Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-07-26 09:01:36 -04:00
commit 01fda8ab87
2 changed files with 155 additions and 32 deletions

View file

@ -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);
}
}

View file

@ -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();