Skip to main content

koi_config/
state.rs

1//! Runtime state file management (Phase 1+).
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use koi_common::paths;
8use koi_common::persist;
9
10/// DNS static entry stored in the local state file.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
12pub struct DnsEntry {
13    pub name: String,
14    pub ip: String,
15    #[serde(default)]
16    pub ttl: Option<u32>,
17}
18
19/// DNS state persisted on disk.
20#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
21pub struct DnsState {
22    #[serde(default)]
23    pub entries: Vec<DnsEntry>,
24}
25
26/// Path to the DNS state file.
27pub fn dns_state_path() -> PathBuf {
28    paths::koi_state_dir().join("dns.json")
29}
30
31/// Load DNS state from disk. Returns default state if missing.
32pub fn load_dns_state() -> Result<DnsState, std::io::Error> {
33    let path = dns_state_path();
34    persist::read_json_or_default(&path)
35}
36
37/// Save DNS state to disk, creating the state directory if needed.
38pub fn save_dns_state(state: &DnsState) -> Result<(), std::io::Error> {
39    let path = dns_state_path();
40    persist::write_json_pretty(&path, state)
41}
42
43/// A CA root that Koi installed into the OS trust store.
44///
45/// Koi tracks *only* the roots it installed so `koi trust list` / `remove` manage
46/// Koi's own footprint and never enumerate or mutate the OS store wholesale.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48pub struct TrustEntry {
49    /// Name used as the install marker (filename on Linux, store name elsewhere).
50    pub name: String,
51    /// RFC 3339 timestamp of when Koi installed it.
52    pub installed_at: String,
53    /// Lowercase hex SHA-256 fingerprint of the certificate (DER).
54    pub fingerprint: String,
55    /// Where the cert came from (e.g. a file path, or "certmesh-ca").
56    pub source: String,
57}
58
59/// Trust state persisted on disk (`state/trust.json`).
60#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
61pub struct TrustState {
62    #[serde(default)]
63    pub roots: Vec<TrustEntry>,
64}
65
66/// Path to the trust state file.
67pub fn trust_state_path() -> PathBuf {
68    paths::koi_state_dir().join("trust.json")
69}
70
71/// Load trust state from disk. Returns default (empty) state if missing.
72pub fn load_trust_state() -> Result<TrustState, std::io::Error> {
73    let path = trust_state_path();
74    persist::read_json_or_default(&path)
75}
76
77/// Save trust state to disk, creating the state directory if needed.
78pub fn save_trust_state(state: &TrustState) -> Result<(), std::io::Error> {
79    let path = trust_state_path();
80    persist::write_json_pretty(&path, state)
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn dns_state_round_trip() {
89        let state = DnsState {
90            entries: vec![DnsEntry {
91                name: "grafana.lan".to_string(),
92                ip: "192.168.1.50".to_string(),
93                ttl: Some(60),
94            }],
95        };
96        let json = serde_json::to_string(&state).unwrap();
97        let parsed: DnsState = serde_json::from_str(&json).unwrap();
98        assert_eq!(state, parsed);
99    }
100
101    #[test]
102    fn load_dns_state_missing_returns_default() {
103        let _ = koi_common::test::ensure_data_dir("koi-config-state-tests");
104        let state = load_dns_state().unwrap();
105        assert!(state.entries.is_empty());
106    }
107
108    #[test]
109    fn trust_state_round_trip() {
110        let state = TrustState {
111            roots: vec![TrustEntry {
112                name: "step-ca-root".to_string(),
113                installed_at: "2026-06-15T00:00:00Z".to_string(),
114                fingerprint: "abcd1234".to_string(),
115                source: "./root.pem".to_string(),
116            }],
117        };
118        let json = serde_json::to_string(&state).unwrap();
119        let parsed: TrustState = serde_json::from_str(&json).unwrap();
120        assert_eq!(state, parsed);
121    }
122
123    #[test]
124    fn trust_state_default_is_empty() {
125        assert!(TrustState::default().roots.is_empty());
126    }
127}