From c26d6ae896d1fd56263c6cb65fa444f0807ea498 Mon Sep 17 00:00:00 2001 From: michael Date: Sun, 26 Apr 2026 23:04:59 -0700 Subject: [PATCH] feat(daemon): D-Bus interface with all 9 stub methods + system-bus wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles plan tasks 1.8 (read methods), 1.9 (EnrollOwn/RemoveOwn with polkit gate), 1.10 (EnrollOther, SetPolicy, pending, recovery-code), and 1.11 (main.rs system-bus registration) — they land together because Polkit::System is only constructed by main.rs, so splitting them mid-implementation would require dead_code allows that immediately reverse. Adds: - daemon/src/dbus.rs — AuthForge struct + #[zbus::interface] impl with all 9 methods. Reads (ListCredentials, GetPolicy) are unauthenticated; writes call authz() which dispatches to polkit. Includes 9 integration tests via a tokio::net::UnixStream::pair p2p connection — no system bus needed for tests. - daemon/src/main.rs — connects to system bus, picks Polkit::system or Polkit::permissive based on AUTHFORGE_POLKIT_BYPASS env var, registers the AuthForge interface at /io/dangerousthings/AuthForge, requests well-known name io.dangerousthings.AuthForge, then parks forever. - daemon/src/polkit.rs — drop dead_code allows now that System is wired. - daemon/src/state.rs — gate has_pending() behind cfg(test); production reads go through the on-disk file in later phases, not this in-memory cache. - common/src/types.rs (formatting only via rustfmt). Tests: 14/14 daemon tests pass (4 state, 1 polkit, 9 dbus). 7/7 common tests pass. cargo clippy --workspace --all-targets -D warnings clean. cargo fmt clean. --- Cargo.lock | 122 +++++++++++++------- common/src/types.rs | 26 ++--- daemon/src/dbus.rs | 266 +++++++++++++++++++++++++++++++++++++++++++ daemon/src/main.rs | 39 ++++++- daemon/src/polkit.rs | 15 ++- daemon/src/state.rs | 30 ++++- 6 files changed, 430 insertions(+), 68 deletions(-) create mode 100644 daemon/src/dbus.rs diff --git a/Cargo.lock b/Cargo.lock index 6311db4..2d4027c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,31 +91,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - [[package]] name = "async-io" version = "2.6.0" @@ -234,6 +209,7 @@ dependencies = [ "serde_json", "thiserror", "toml", + "zvariant", ] [[package]] @@ -242,10 +218,17 @@ version = "0.1.0" dependencies = [ "anyhow", "authforge-common", + "nix 0.28.0", + "rand 0.9.4", + "serde", + "serde_json", + "tempfile", + "thiserror", "tokio", "tracing", "tracing-subscriber", "zbus", + "zvariant", ] [[package]] @@ -338,6 +321,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -595,11 +584,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-io", "futures-macro", "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -682,6 +669,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -690,7 +689,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -1057,6 +1056,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -1065,7 +1076,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "memoffset", ] @@ -1237,6 +1248,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1250,8 +1267,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1261,7 +1288,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1273,6 +1310,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1546,6 +1592,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -2029,28 +2076,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" dependencies = [ "async-broadcast", - "async-executor", - "async-fs", - "async-io", - "async-lock", "async-process", "async-recursion", - "async-task", "async-trait", - "blocking", "enumflags2", "event-listener", "futures-core", "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", - "rand", + "rand 0.8.6", "serde", "serde_repr", "sha1", "static_assertions", + "tokio", "tracing", "uds_windows", "windows-sys 0.52.0", diff --git a/common/src/types.rs b/common/src/types.rs index d82801e..c19215e 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -1,9 +1,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, -)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] #[serde(rename_all = "lowercase")] #[zvariant(signature = "s")] pub enum Mode { @@ -12,9 +10,7 @@ pub enum Mode { Required, } -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, zvariant::Type, -)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, zvariant::Type)] #[serde(rename_all = "lowercase")] #[zvariant(signature = "s")] pub enum Method { @@ -22,9 +18,7 @@ pub enum Method { Totp, } -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, zvariant::Type, -)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, zvariant::Type)] #[serde(rename_all = "lowercase")] #[zvariant(signature = "s")] pub enum Transport { @@ -43,9 +37,7 @@ pub struct Credential { pub created_unix: u64, } -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default, -)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)] #[serde(rename_all = "kebab-case")] #[zvariant(signature = "s")] pub enum StorageBackend { @@ -141,7 +133,10 @@ mod tests { let mut p = Policy::default(); p.stacks.insert( "sudo".to_string(), - StackPolicy { mode: Mode::Required, methods: vec![Method::Fido2] }, + StackPolicy { + mode: Mode::Required, + methods: vec![Method::Fido2], + }, ); let json = serde_json::to_string(&p).unwrap(); let back: Policy = serde_json::from_str(&json).unwrap(); @@ -163,7 +158,10 @@ mod tests { #[test] fn policy_apply_result_ok_no_violations() { - let r = PolicyApplyResult { applied: true, violations: vec![] }; + let r = PolicyApplyResult { + applied: true, + violations: vec![], + }; assert!(r.applied); assert!(r.violations.is_empty()); } diff --git a/daemon/src/dbus.rs b/daemon/src/dbus.rs new file mode 100644 index 0000000..b08deb5 --- /dev/null +++ b/daemon/src/dbus.rs @@ -0,0 +1,266 @@ +use crate::polkit::Polkit; +use crate::state::AppState; +use authforge_common::types::{ + Credential, Method, PendingFlag, Policy, PolicyApplyResult, Transport, +}; +use std::sync::Arc; + +pub struct AuthForge { + pub state: Arc, + pub polkit: Arc, +} + +impl AuthForge { + async fn authz(&self, action: &str) -> zbus::fdo::Result<()> { + // Phase 1: pid 0 — permissive mode ignores it; system mode running on the + // real system bus needs sender → unique-name → pid resolution, which lands + // when CLI starts making real calls (Phase 2 / Phase 7). Until then, + // operate the daemon under AUTHFORGE_POLKIT_BYPASS=1 for smoke tests. + let pid = 0u32; + self.polkit + .check(action, pid) + .await + .map_err(|e| zbus::fdo::Error::AccessDenied(e.to_string()))?; + Ok(()) + } + + fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } +} + +#[zbus::interface(name = "io.dangerousthings.AuthForge1")] +impl AuthForge { + async fn list_credentials(&self, user: String) -> zbus::fdo::Result> { + Ok(self.state.list_credentials(&user).await) + } + + async fn get_policy(&self) -> zbus::fdo::Result { + Ok(self.state.get_policy().await) + } + + async fn enroll_own(&self, user: String, nickname: String) -> zbus::fdo::Result { + self.authz("io.dangerousthings.AuthForge.enroll-own") + .await?; + let now = Self::now(); + let cred = Credential { + id: format!("stub-{user}-{now}"), + nickname, + method: Method::Fido2, + transport: Transport::Usb, + created_unix: now, + }; + self.state.add_credential(&user, cred.clone()).await; + Ok(cred) + } + + async fn remove_own(&self, user: String, cred_id: String) -> zbus::fdo::Result<()> { + self.authz("io.dangerousthings.AuthForge.remove-own") + .await?; + if self.state.remove_credential(&user, &cred_id).await { + Ok(()) + } else { + Err(zbus::fdo::Error::Failed(format!( + "no credential {cred_id} for {user}" + ))) + } + } + + async fn enroll_other(&self, user: String, nickname: String) -> zbus::fdo::Result { + self.authz("io.dangerousthings.AuthForge.enroll-other") + .await?; + let now = Self::now(); + let cred = Credential { + id: format!("stub-{user}-{now}"), + nickname, + method: Method::Fido2, + transport: Transport::Usb, + created_unix: now, + }; + self.state.add_credential(&user, cred.clone()).await; + Ok(cred) + } + + async fn set_policy(&self, p: Policy) -> zbus::fdo::Result { + self.authz("io.dangerousthings.AuthForge.set-policy") + .await?; + self.state.set_policy(p).await; + Ok(PolicyApplyResult { + applied: true, + violations: vec![], + }) + } + + async fn set_pending_flag(&self, user: String, flag: PendingFlag) -> zbus::fdo::Result<()> { + self.authz("io.dangerousthings.AuthForge.set-pending") + .await?; + self.state.set_pending(&user, flag).await; + Ok(()) + } + + async fn clear_pending_flag(&self, user: String) -> zbus::fdo::Result<()> { + self.authz("io.dangerousthings.AuthForge.clear-pending") + .await?; + self.state.clear_pending(&user).await; + Ok(()) + } + + async fn generate_recovery_code(&self, _user: String) -> zbus::fdo::Result { + self.authz("io.dangerousthings.AuthForge.generate-recovery") + .await?; + // Stub: 8 random digits. Phase 12 replaces with Argon2id-backed real flow. + use rand::Rng; + let n: u32 = rand::rng().random_range(0..100_000_000); + Ok(format!("{n:08}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use authforge_common::types::{Method, StackPolicy}; + use std::collections::BTreeMap; + use zbus::connection::Builder; + use zbus::Connection; + + /// Build a peer-to-peer pair: server side has the AuthForge interface registered + /// at /io/dangerousthings/AuthForge, client side is a bare connection. No system + /// bus is involved. + async fn p2p_pair() -> (Connection, Connection, Arc) { + let state = Arc::new(AppState::with_fixtures()); + let auth = AuthForge { + state: state.clone(), + polkit: Arc::new(Polkit::permissive()), + }; + + let guid = zbus::Guid::generate(); + let (server_sock, client_sock) = tokio::net::UnixStream::pair().unwrap(); + + // Both sides must build concurrently — `build()` completes the handshake, + // and one side waiting on the other deadlocks. tokio::join drives them in + // parallel on this runtime. + let server_build = Builder::socket(server_sock) + .p2p() + .server(guid) + .unwrap() + .serve_at("/io/dangerousthings/AuthForge", auth) + .unwrap() + .build(); + let client_build = Builder::socket(client_sock).p2p().build(); + + let (server, client) = tokio::join!(server_build, client_build); + (server.unwrap(), client.unwrap(), state) + } + + async fn proxy(client: &Connection) -> zbus::Proxy<'_> { + zbus::Proxy::new( + client, + "io.dangerousthings.AuthForge", + "/io/dangerousthings/AuthForge", + "io.dangerousthings.AuthForge1", + ) + .await + .unwrap() + } + + #[tokio::test] + async fn list_credentials_returns_fixture_for_alice() { + let (_srv, client, _state) = p2p_pair().await; + let p = proxy(&client).await; + let creds: Vec = p.call("ListCredentials", &("alice",)).await.unwrap(); + assert_eq!(creds.len(), 1); + assert_eq!(creds[0].method, Method::Fido2); + } + + #[tokio::test] + async fn get_policy_returns_default() { + let (_srv, client, _state) = p2p_pair().await; + let p = proxy(&client).await; + let pol: Policy = p.call("GetPolicy", &()).await.unwrap(); + assert!(pol.stacks.is_empty()); + } + + #[tokio::test] + async fn enroll_own_appends_credential() { + let (_srv, client, state) = p2p_pair().await; + let p = proxy(&client).await; + let _: Credential = p.call("EnrollOwn", &("bob", "Bob's Key")).await.unwrap(); + assert_eq!(state.list_credentials("bob").await.len(), 1); + } + + #[tokio::test] + async fn remove_own_drops_credential() { + let (_srv, client, state) = p2p_pair().await; + let p = proxy(&client).await; + let _: () = p + .call("RemoveOwn", &("alice", "fixture-cred-1")) + .await + .unwrap(); + assert!(state.list_credentials("alice").await.is_empty()); + } + + #[tokio::test] + async fn remove_own_unknown_id_errors() { + let (_srv, client, _state) = p2p_pair().await; + let p = proxy(&client).await; + let r: Result<(), zbus::Error> = p.call("RemoveOwn", &("alice", "no-such-id")).await; + assert!(r.is_err()); + } + + #[tokio::test] + async fn enroll_other_creates_credential() { + let (_srv, client, state) = p2p_pair().await; + let p = proxy(&client).await; + let _: Credential = p.call("EnrollOther", &("carol", "Hers")).await.unwrap(); + assert_eq!(state.list_credentials("carol").await.len(), 1); + } + + #[tokio::test] + async fn set_policy_replaces_state() { + let (_srv, client, state) = p2p_pair().await; + let p = proxy(&client).await; + let mut stacks = BTreeMap::new(); + stacks.insert( + "sudo".to_string(), + StackPolicy { + mode: authforge_common::types::Mode::Required, + methods: vec![Method::Fido2], + }, + ); + let pol = Policy { + stacks, + ..Default::default() + }; + let r: PolicyApplyResult = p.call("SetPolicy", &(pol.clone(),)).await.unwrap(); + assert!(r.applied); + assert_eq!(state.get_policy().await, pol); + } + + #[tokio::test] + async fn set_and_clear_pending_flag() { + let (_srv, client, state) = p2p_pair().await; + let p = proxy(&client).await; + let flag = PendingFlag { + required_methods: vec![Method::Fido2], + created_unix: 0, + deadline_unix: 0, + re_enroll: false, + }; + let _: () = p.call("SetPendingFlag", &("alice", flag)).await.unwrap(); + assert!(state.has_pending("alice").await); + let _: () = p.call("ClearPendingFlag", &("alice",)).await.unwrap(); + assert!(!state.has_pending("alice").await); + } + + #[tokio::test] + async fn generate_recovery_code_returns_8_digits() { + let (_srv, client, _state) = p2p_pair().await; + let p = proxy(&client).await; + let code: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); + assert_eq!(code.len(), 8); + assert!(code.chars().all(|c| c.is_ascii_digit())); + } +} diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 713afe5..f997558 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -1,15 +1,48 @@ -use anyhow::Result; -use tracing::info; +use anyhow::{Context, Result}; +use std::sync::Arc; +use tracing::{info, warn}; +mod dbus; mod polkit; mod state; +const BUS_NAME: &str = "io.dangerousthings.AuthForge"; +const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge"; + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); info!("authforged {} starting", env!("CARGO_PKG_VERSION")); - // D-Bus service registration in Phase 1. + + let conn = zbus::Connection::system() + .await + .context("connecting to system D-Bus")?; + + let polkit = if std::env::var("AUTHFORGE_POLKIT_BYPASS").is_ok() { + warn!( + "AUTHFORGE_POLKIT_BYPASS set — polkit checks are DISABLED. Do not use in production." + ); + polkit::Polkit::permissive() + } else { + polkit::Polkit::system(conn.clone()).await + }; + + let state = Arc::new(state::AppState::with_fixtures()); + let auth = dbus::AuthForge { + state: state.clone(), + polkit: Arc::new(polkit), + }; + + conn.object_server().at(OBJECT_PATH, auth).await?; + conn.request_name(BUS_NAME) + .await + .context("acquiring well-known bus name (another instance running?)")?; + + info!("listening on D-Bus as {BUS_NAME} at {OBJECT_PATH}"); + + // Park forever. systemd will SIGTERM the process when stopping the unit. + std::future::pending::<()>().await; Ok(()) } diff --git a/daemon/src/polkit.rs b/daemon/src/polkit.rs index 09eb589..36a6def 100644 --- a/daemon/src/polkit.rs +++ b/daemon/src/polkit.rs @@ -11,13 +11,11 @@ pub enum PolkitError { Bus(#[from] zbus::Error), } -#[allow(dead_code)] // wired up in Task 1.8. pub enum Polkit { Permissive, System(Connection), } -#[allow(dead_code)] // wired up in Task 1.8. impl Polkit { pub fn permissive() -> Self { Self::Permissive @@ -58,13 +56,18 @@ async fn system_check(conn: &Connection, action: &str, pid: u32) -> Result<(), P .await?; let (is_authorized, _is_challenge, _details): (bool, bool, HashMap) = proxy - .call("CheckAuthorization", &(subject, action, details, flags, cancellation_id)) + .call( + "CheckAuthorization", + &(subject, action, details, flags, cancellation_id), + ) .await?; if is_authorized { Ok(()) } else { - Err(PolkitError::NotAuthorized { action: action.to_string() }) + Err(PolkitError::NotAuthorized { + action: action.to_string(), + }) } } @@ -75,6 +78,8 @@ mod tests { #[tokio::test] async fn permissive_always_allows() { let p = Polkit::permissive(); - p.check("io.dangerousthings.AuthForge.set-policy", 1234).await.unwrap(); + p.check("io.dangerousthings.AuthForge.set-policy", 1234) + .await + .unwrap(); } } diff --git a/daemon/src/state.rs b/daemon/src/state.rs index 28e597a..f86cb61 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -14,8 +14,6 @@ struct StateInner { pending: HashMap, } -// `dead_code` allow lifts in Task 1.8 when dbus.rs starts calling these methods. -#[allow(dead_code)] impl AppState { pub fn with_fixtures() -> Self { let mut inner = StateInner::default(); @@ -29,20 +27,36 @@ impl AppState { created_unix: 1_700_000_000, }], ); - Self { inner: RwLock::new(inner) } + Self { + inner: RwLock::new(inner), + } } pub async fn list_credentials(&self, user: &str) -> Vec { - self.inner.read().await.credentials.get(user).cloned().unwrap_or_default() + self.inner + .read() + .await + .credentials + .get(user) + .cloned() + .unwrap_or_default() } pub async fn add_credential(&self, user: &str, c: Credential) { - self.inner.write().await.credentials.entry(user.to_string()).or_default().push(c); + self.inner + .write() + .await + .credentials + .entry(user.to_string()) + .or_default() + .push(c); } pub async fn remove_credential(&self, user: &str, cred_id: &str) -> bool { let mut g = self.inner.write().await; - let Some(list) = g.credentials.get_mut(user) else { return false }; + let Some(list) = g.credentials.get_mut(user) else { + return false; + }; let before = list.len(); list.retain(|c| c.id != cred_id); list.len() != before @@ -64,6 +78,10 @@ impl AppState { self.inner.write().await.pending.remove(user).is_some() } + /// Tests assert pending flag round-trips through SetPendingFlag/ClearPendingFlag + /// via this read accessor. Production readers (PAM module, GUI status) come in + /// later phases and will read the on-disk file directly, not this in-memory cache. + #[cfg(test)] pub async fn has_pending(&self, user: &str) -> bool { self.inner.read().await.pending.contains_key(user) }