sessiond: persist the device-state policy
SetPolicy only mutated memory. Every lock-screen timer set in Settings reverted to the built-in 15s on the next restart, silently — the page was honest about reading the daemon, the daemon was forgetting. Saved to $XDG_CONFIG_HOME/souveraine/device-state-policy.json, write-then- rename. serde(default) on the container so an older file loads with new fields defaulted instead of dropping the user's settings on upgrade. A failed save returns ok:false rather than a success-shaped switch.
This commit is contained in:
parent
862d1d7bba
commit
01fda8ab87
2 changed files with 155 additions and 32 deletions
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{info, warn};
|
||||
|
|
@ -88,7 +89,14 @@ pub const LOCK_ACK_BUDGET: Duration = Duration::from_secs(2);
|
|||
/// option for the desk-clock case. The Settings control center (TASK-19) is
|
||||
/// meant to be a view over this struct, so the values live here rather than
|
||||
/// being baked into the rule.
|
||||
/// `serde(default)` at the container level, so a policy file written by an
|
||||
/// older sessiond loads with the new fields filled from `Default` instead of
|
||||
/// failing outright. Without it, adding one field here would make every
|
||||
/// existing device fall back to built-in timers on the next upgrade and lose
|
||||
/// whatever the user had set — silently, which is the failure mode this whole
|
||||
/// area keeps producing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DeviceStatePolicy {
|
||||
/// Locked, panel lit, no input for this long → blank. `None` = never.
|
||||
pub lock_blank_after: Option<Duration>,
|
||||
|
|
@ -113,6 +121,74 @@ pub struct DeviceStatePolicy {
|
|||
pub source_down_after: Duration,
|
||||
}
|
||||
|
||||
impl DeviceStatePolicy {
|
||||
/// Where the user's choices live between runs.
|
||||
///
|
||||
/// They did not live anywhere until 2026-07-26. `SetPolicy` mutated the
|
||||
/// in-memory struct and nothing wrote it down, so every lock-screen timer
|
||||
/// set in Settings survived exactly until the next restart and then
|
||||
/// silently reverted to the built-in 15 s. The Settings page was honest
|
||||
/// about reading the daemon — the daemon was the one forgetting.
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
|
||||
Some(base.join("souveraine").join("device-state-policy.json"))
|
||||
}
|
||||
|
||||
/// Load the saved policy, or the defaults.
|
||||
///
|
||||
/// A file that exists but cannot be read or parsed is LOUD and then
|
||||
/// ignored: continuing on built-in timers is the only thing a session
|
||||
/// authority can do at startup, but doing it quietly would present
|
||||
/// defaults as if they were the user's settings.
|
||||
pub fn load() -> Self {
|
||||
let Some(path) = Self::path() else {
|
||||
warn!(
|
||||
"[device-state] no HOME or XDG_CONFIG_HOME — policy cannot be persisted this run"
|
||||
);
|
||||
return Self::default();
|
||||
};
|
||||
let raw = match std::fs::read_to_string(&path) {
|
||||
Ok(raw) => raw,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
|
||||
Err(e) => {
|
||||
warn!("[device-state] policy at {} is unreadable ({e}) — RUNNING ON DEFAULTS, not on your settings", path.display());
|
||||
return Self::default();
|
||||
}
|
||||
};
|
||||
match serde_json::from_str(&raw) {
|
||||
Ok(p) => {
|
||||
info!("[device-state] policy loaded from {}", path.display());
|
||||
p
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[device-state] policy at {} is malformed ({e}) — RUNNING ON DEFAULTS, not on your settings", path.display());
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the policy out. Errors are returned, never swallowed — the caller
|
||||
/// tells the Settings page, so a control that appears to have taken effect
|
||||
/// has actually taken effect past the next reboot.
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let path = Self::path().ok_or_else(|| "no HOME or XDG_CONFIG_HOME".to_string())?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
|
||||
}
|
||||
let body = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| format!("serializing the policy: {e}"))?;
|
||||
// Write-then-rename, so a crash mid-write cannot leave a truncated
|
||||
// file that the next boot reads as malformed and discards.
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, body).map_err(|e| format!("writing {}: {e}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
.map_err(|e| format!("renaming into {}: {e}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DeviceStatePolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
|
@ -600,7 +676,9 @@ impl DeviceStateMachine {
|
|||
blank_requested: false,
|
||||
dimmed: false,
|
||||
pending_blank: None,
|
||||
policy: DeviceStatePolicy::default(),
|
||||
// Loaded, not defaulted: the user's Auto-Lock choices are settings,
|
||||
// and a setting that reverts on reboot is not a setting.
|
||||
policy: DeviceStatePolicy::load(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2321,4 +2399,31 @@ mod tests {
|
|||
"an unchanged reading must still be re-evaluated against the new state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_policy_round_trips_through_json() {
|
||||
// The bug this closes: SetPolicy mutated memory and nothing wrote it
|
||||
// down, so every timer set in Settings reverted on the next restart.
|
||||
let mut p = DeviceStatePolicy::default();
|
||||
p.lock_blank_after = Some(Duration::from_secs(300));
|
||||
p.dim_warning = false;
|
||||
let raw = serde_json::to_string(&p).expect("policy serializes");
|
||||
let back: DeviceStatePolicy = serde_json::from_str(&raw).expect("policy parses");
|
||||
assert_eq!(back.lock_blank_after, Some(Duration::from_secs(300)));
|
||||
assert!(!back.dim_warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_policy_file_missing_new_fields_still_loads() {
|
||||
// An older sessiond's file must not make the device fall back to
|
||||
// built-in timers on upgrade. `serde(default)` at the container level
|
||||
// is what guarantees it; this is the test that keeps it there.
|
||||
let raw = r#"{"lock_blank_after":{"secs":300,"nanos":0},"dim_warning":true}"#;
|
||||
let p: DeviceStatePolicy = serde_json::from_str(raw).expect("partial policy parses");
|
||||
assert_eq!(p.lock_blank_after, Some(Duration::from_secs(300)));
|
||||
// Filled from Default, not left at zero.
|
||||
assert_eq!(p.lock_ack_budget, LOCK_ACK_BUDGET);
|
||||
assert_eq!(p.source_down_after, SOURCE_DOWN_AFTER);
|
||||
assert_eq!(p.evidence_ttl, EVIDENCE_TTL);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,11 +283,8 @@ fn request_session_lock(shared: &Arc<Shared>) {
|
|||
}
|
||||
Err(e) => {
|
||||
warn!("could not push lock directive to the shell: {e}");
|
||||
d.device_state.record_error(
|
||||
"device-state",
|
||||
"lock-directive",
|
||||
&e.to_string(),
|
||||
);
|
||||
d.device_state
|
||||
.record_error("device-state", "lock-directive", &e.to_string());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -360,7 +357,11 @@ fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
|
|||
match outcome {
|
||||
Ok(SessionOutcome::Unlocked) => {
|
||||
info!("session unlocked at fallback surface");
|
||||
d.phase = if d.shell_alive { Phase::Released } else { Phase::Idle };
|
||||
d.phase = if d.shell_alive {
|
||||
Phase::Released
|
||||
} else {
|
||||
Phase::Idle
|
||||
};
|
||||
// PAM succeeded. Only this path may leave the locked tier.
|
||||
set_tier(&mut d, DeviceState::Active, "unlocked at fallback surface");
|
||||
}
|
||||
|
|
@ -374,11 +375,19 @@ fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
|
|||
}
|
||||
Ok(SessionOutcome::Denied) => {
|
||||
info!("lock denied/revoked — another locker owns the session");
|
||||
d.phase = if d.shell_alive { Phase::Released } else { Phase::Idle };
|
||||
d.phase = if d.shell_alive {
|
||||
Phase::Released
|
||||
} else {
|
||||
Phase::Idle
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("lock session failed: {e:#}");
|
||||
d.phase = if d.shell_alive { Phase::Released } else { Phase::Idle };
|
||||
d.phase = if d.shell_alive {
|
||||
Phase::Released
|
||||
} else {
|
||||
Phase::Idle
|
||||
};
|
||||
}
|
||||
}
|
||||
drop(d);
|
||||
|
|
@ -545,7 +554,9 @@ fn handle_request(
|
|||
// The handoff is a deliberate connection drop, but Hyprland
|
||||
// announces any dying lock client as a crashed lockscreen.
|
||||
// The shell's lock is confirmed, so clear that banner.
|
||||
let _ = std::process::Command::new("hyprctl").arg("dismissnotify").spawn();
|
||||
let _ = std::process::Command::new("hyprctl")
|
||||
.arg("dismissnotify")
|
||||
.spawn();
|
||||
serde_json::json!({ "ok": true })
|
||||
}
|
||||
Request::Lock => {
|
||||
|
|
@ -613,7 +624,8 @@ fn handle_request(
|
|||
input.source, input.value, conf, suppress, promote
|
||||
);
|
||||
// Let the state machine evaluate Observed transitions.
|
||||
d.device_state.update_sensors_from(input.source, evidence_copy);
|
||||
d.device_state
|
||||
.update_sensors_from(input.source, evidence_copy);
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"confidence": conf,
|
||||
|
|
@ -714,9 +726,7 @@ fn handle_request(
|
|||
if let Some(budget) = p.lock_blank_after {
|
||||
if p.dim_warning && p.dim_grace >= budget {
|
||||
drop(d);
|
||||
return refusal(
|
||||
"dim_grace_secs must be shorter than lock_blank_after_secs",
|
||||
);
|
||||
return refusal("dim_grace_secs must be shorter than lock_blank_after_secs");
|
||||
}
|
||||
}
|
||||
let applied = serde_json::json!({
|
||||
|
|
@ -731,8 +741,29 @@ fn handle_request(
|
|||
p.unlocked_blank_after.map(|v| v.as_secs()).unwrap_or(0),
|
||||
});
|
||||
info!("[device-state] policy set: {applied}");
|
||||
d.device_state
|
||||
.record_decision("policy-set", applied.clone(), "settings changed the policy");
|
||||
d.device_state.record_decision(
|
||||
"policy-set",
|
||||
applied.clone(),
|
||||
"settings changed the policy",
|
||||
);
|
||||
|
||||
// Persist, and say so if it fails. A setting that took effect for
|
||||
// this run but will not survive a reboot is a success-shaped
|
||||
// switch (TASK-19's rule), so the failure goes back to the caller
|
||||
// rather than only into the log.
|
||||
if let Err(e) = d.device_state.policy.save() {
|
||||
warn!("[device-state] policy applied but NOT saved: {e}");
|
||||
d.device_state.record_error(
|
||||
"device-state",
|
||||
"policy-save-failed",
|
||||
&format!("the policy is live for this run only: {e}"),
|
||||
);
|
||||
return serde_json::json!({
|
||||
"ok": false,
|
||||
"reason": format!("applied for this session, but not saved: {e}"),
|
||||
"policy": applied,
|
||||
});
|
||||
}
|
||||
serde_json::json!({ "ok": true, "policy": applied })
|
||||
}
|
||||
Request::ForensicLog { count } => {
|
||||
|
|
@ -770,27 +801,14 @@ mod tests {
|
|||
fn second_shell_ready_cannot_steal_authority_lease() {
|
||||
let shared = idle_shared();
|
||||
let mut authority_heartbeat = None;
|
||||
let first = handle_request(
|
||||
Request::ShellReady,
|
||||
&shared,
|
||||
None,
|
||||
&mut authority_heartbeat,
|
||||
);
|
||||
let first = handle_request(Request::ShellReady, &shared, None, &mut authority_heartbeat);
|
||||
assert_eq!(first["ok"], true);
|
||||
assert_eq!(authority_heartbeat, Some(1));
|
||||
|
||||
let mut utility_heartbeat = None;
|
||||
let second = handle_request(
|
||||
Request::ShellReady,
|
||||
&shared,
|
||||
None,
|
||||
&mut utility_heartbeat,
|
||||
);
|
||||
let second = handle_request(Request::ShellReady, &shared, None, &mut utility_heartbeat);
|
||||
assert_eq!(second["ok"], false);
|
||||
assert_eq!(
|
||||
second["reason"],
|
||||
"shell authority is already registered"
|
||||
);
|
||||
assert_eq!(second["reason"], "shell authority is already registered");
|
||||
assert_eq!(utility_heartbeat, None);
|
||||
|
||||
let state = shared.lock();
|
||||
|
|
|
|||
Loading…
Reference in a new issue