nixfleet_control_plane/db/
allowed_nonces.rs

1//! In-memory view of the signed bootstrap-nonces allowlist. Lives in
2//! AppState behind a RwLock so the polling task replaces it wholesale
3//! per successful verify; readers (enrolment handler) take a read
4//! lock and look up by nonce.
5//!
6//! Not a `db` module in the strict sense (no SQLite), but lives here
7//! for namespace symmetry with `revocations` (which IS sqlite-backed).
8
9use std::collections::HashMap;
10
11use chrono::{DateTime, Utc};
12use nixfleet_proto::{BootstrapNonceEntry, BootstrapNonces};
13
14/// Lookup by nonce. Empty by default; replaced wholesale per poll.
15#[derive(Debug, Default, Clone)]
16pub struct AllowedNoncesView {
17    /// `nonce` -> entry. Read by the enrolment handler.
18    by_nonce: HashMap<String, BootstrapNonceEntry>,
19}
20
21impl AllowedNoncesView {
22    pub fn from_artifact(artifact: BootstrapNonces) -> Self {
23        let by_nonce = artifact
24            .bootstrap_nonces
25            .into_iter()
26            .map(|e| (e.nonce.clone(), e))
27            .collect();
28        Self { by_nonce }
29    }
30
31    pub fn lookup(&self, nonce: &str) -> Option<&BootstrapNonceEntry> {
32        self.by_nonce.get(nonce)
33    }
34
35    pub fn len(&self) -> usize {
36        self.by_nonce.len()
37    }
38
39    pub fn is_empty(&self) -> bool {
40        self.by_nonce.is_empty()
41    }
42
43    /// True iff `entry.expires_at >= now`. Defense-in-depth: the release
44    /// tool prunes expired entries at sign time, but clock skew between
45    /// CP and CI could let one through.
46    pub fn entry_is_live(entry: &BootstrapNonceEntry, now: DateTime<Utc>) -> bool {
47        entry.expires_at >= now
48    }
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54    use nixfleet_proto::Meta;
55
56    fn meta_v1() -> Meta {
57        Meta {
58            schema_version: 1,
59            signed_at: Some("2026-05-13T10:00:00Z".parse().unwrap()),
60            ci_commit: None,
61            signature_algorithm: Some("ecdsa-p256".into()),
62        }
63    }
64
65    #[test]
66    fn lookup_finds_declared_nonce() {
67        let view = AllowedNoncesView::from_artifact(BootstrapNonces {
68            schema_version: 1,
69            bootstrap_nonces: vec![BootstrapNonceEntry {
70                nonce: "abc".into(),
71                hostname: "agent-01".into(),
72                expires_at: "2026-05-14T00:00:00Z".parse().unwrap(),
73                minted_at: None,
74                minted_by: None,
75            }],
76            meta: meta_v1(),
77        });
78        let entry = view.lookup("abc").expect("declared nonce found");
79        assert_eq!(entry.hostname, "agent-01");
80    }
81
82    #[test]
83    fn lookup_returns_none_for_unknown_nonce() {
84        let view = AllowedNoncesView::default();
85        assert!(view.lookup("unknown").is_none());
86    }
87
88    #[test]
89    fn entry_is_live_strict_inequality() {
90        let entry = BootstrapNonceEntry {
91            nonce: "abc".into(),
92            hostname: "agent-01".into(),
93            expires_at: "2026-05-13T10:00:00Z".parse().unwrap(),
94            minted_at: None,
95            minted_by: None,
96        };
97        assert!(AllowedNoncesView::entry_is_live(
98            &entry,
99            "2026-05-13T10:00:00Z".parse().unwrap()
100        ));
101        assert!(AllowedNoncesView::entry_is_live(
102            &entry,
103            "2026-05-13T09:59:59Z".parse().unwrap()
104        ));
105        assert!(!AllowedNoncesView::entry_is_live(
106            &entry,
107            "2026-05-13T10:00:01Z".parse().unwrap()
108        ));
109    }
110}