Skip to main content

faucet_cli/
state.rs

1//! Build a `StateStore` from a `(kind, config)` pair, with feature-gated
2//! backends for Redis and PostgreSQL.
3
4use crate::config::StateStoreSpec;
5use crate::error::{CliError, CliResult};
6use faucet_core::{FileStateStore, MemoryStateStore, StateStore};
7use serde::Deserialize;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11/// Config block for the built-in file backend.
12#[derive(Debug, Deserialize)]
13struct FileStateConfig {
14    path: PathBuf,
15    /// Encryption at rest for bookmark files (#207). Parsed as a raw value so
16    /// a build without the `encryption` feature can reject it with a clear
17    /// error instead of a generic unknown-field failure.
18    #[serde(default)]
19    encryption: Option<serde_json::Value>,
20}
21
22#[cfg(feature = "state-redis")]
23#[derive(Debug, Deserialize)]
24struct RedisStateConfig {
25    url: String,
26    #[serde(default = "default_redis_namespace")]
27    namespace: String,
28}
29
30#[cfg(feature = "state-redis")]
31fn default_redis_namespace() -> String {
32    "faucet".to_owned()
33}
34
35#[cfg(feature = "state-postgres")]
36#[derive(Debug, Deserialize)]
37struct PostgresStateConfig {
38    url: String,
39    #[serde(default = "default_pg_table")]
40    table: String,
41    #[serde(default)]
42    ensure_table: bool,
43    /// Size of the connection pool backing the state store. Defaults to
44    /// [`DEFAULT_PG_POOL_SIZE`] when omitted. Must be greater than zero.
45    #[serde(default)]
46    max_connections: Option<u32>,
47}
48
49#[cfg(feature = "state-postgres")]
50fn default_pg_table() -> String {
51    "faucet_state".to_owned()
52}
53
54/// Default Postgres state-store pool size, matching the connector's own
55/// `PostgresStateStore::connect` default. Used when `max_connections` is absent.
56#[cfg(feature = "state-postgres")]
57const DEFAULT_PG_POOL_SIZE: u32 = 5;
58
59/// Construct a state store from the parsed `state:` block.
60pub async fn build_state_store(spec: &StateStoreSpec) -> CliResult<Arc<dyn StateStore>> {
61    match spec.kind.as_str() {
62        "memory" => Ok(Arc::new(MemoryStateStore::new())),
63        "file" => {
64            let cfg = decode::<FileStateConfig>("file", spec.config.clone())?;
65            match cfg.encryption {
66                None => Ok(Arc::new(FileStateStore::new(cfg.path))),
67                #[cfg(feature = "encryption")]
68                Some(raw) => {
69                    let enc_spec: faucet_core::EncryptionSpec = serde_json::from_value(raw)
70                        .map_err(|e| CliError::Config(format!("state.config.encryption: {e}")))?;
71                    let compiled = faucet_core::CompiledEncryption::compile(&enc_spec)?;
72                    Ok(Arc::new(
73                        FileStateStore::new(cfg.path).with_encryption(compiled),
74                    ))
75                }
76                #[cfg(not(feature = "encryption"))]
77                Some(_) => Err(CliError::Config(
78                    "state.config.encryption requires a faucet build with the `encryption` \
79                     feature (cargo install faucet-cli --features encryption)"
80                        .into(),
81                )),
82            }
83        }
84        #[cfg(feature = "state-redis")]
85        "redis" => {
86            let cfg = decode::<RedisStateConfig>("redis", spec.config.clone())?;
87            Ok(Arc::new(
88                faucet_state_redis::RedisStateStore::connect(&cfg.url, &cfg.namespace).await?,
89            ))
90        }
91        #[cfg(feature = "state-postgres")]
92        "postgres" => {
93            let cfg = decode::<PostgresStateConfig>("postgres", spec.config.clone())?;
94            let max_connections = cfg.max_connections.unwrap_or(DEFAULT_PG_POOL_SIZE);
95            if max_connections == 0 {
96                return Err(CliError::Config(
97                    "state.config.max_connections must be greater than 0".to_owned(),
98                ));
99            }
100            let store = faucet_state_postgres::PostgresStateStore::connect_with(
101                &cfg.url,
102                max_connections,
103                &cfg.table,
104            )
105            .await?;
106            if cfg.ensure_table {
107                store.ensure_table().await?;
108            }
109            Ok(Arc::new(store))
110        }
111        other => Err(CliError::UnknownStateStore {
112            name: other.to_owned(),
113            available: available_state_kinds().join(", "),
114        }),
115    }
116}
117
118/// Names of every state-store backend compiled into this build.
119pub fn available_state_kinds() -> Vec<&'static str> {
120    let mut v = vec!["memory", "file"];
121    #[cfg(feature = "state-redis")]
122    v.push("redis");
123    #[cfg(feature = "state-postgres")]
124    v.push("postgres");
125    v
126}
127
128fn decode<T: serde::de::DeserializeOwned>(name: &str, config: serde_json::Value) -> CliResult<T> {
129    serde_json::from_value(config).map_err(|e| CliError::InvalidConnectorConfig {
130        kind: "state",
131        name: name.to_owned(),
132        message: e.to_string(),
133    })
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use serde_json::json;
140
141    #[tokio::test]
142    async fn builds_memory_store() {
143        let spec = StateStoreSpec {
144            kind: "memory".into(),
145            config: json!({}),
146        };
147        let store = build_state_store(&spec).await.unwrap();
148        store.put("k", &json!(1)).await.unwrap();
149        assert_eq!(store.get("k").await.unwrap(), Some(json!(1)));
150    }
151
152    #[tokio::test]
153    async fn builds_file_store() {
154        let dir = tempfile::tempdir().unwrap();
155        let spec = StateStoreSpec {
156            kind: "file".into(),
157            config: json!({"path": dir.path().to_str().unwrap()}),
158        };
159        let store = build_state_store(&spec).await.unwrap();
160        store.put("k", &json!("v")).await.unwrap();
161    }
162
163    // `max_connections: 0` is rejected at config-load time with a typed
164    // `Config` error, before any connection attempt — so the assertion holds
165    // offline without a running Postgres.
166    #[cfg(feature = "state-postgres")]
167    #[tokio::test]
168    async fn postgres_state_rejects_zero_max_connections() {
169        let spec = StateStoreSpec {
170            kind: "postgres".into(),
171            config: json!({
172                "url": "postgres://user:pass@localhost/faucet",
173                "max_connections": 0,
174            }),
175        };
176        let err = build_state_store(&spec).await.err().expect("should fail");
177        match err {
178            CliError::Config(msg) => assert!(msg.contains("max_connections"), "{msg}"),
179            other => panic!("expected Config error, got {other:?}"),
180        }
181    }
182
183    #[cfg(feature = "encryption")]
184    #[tokio::test]
185    async fn builds_encrypted_file_store_and_seals_bookmarks() {
186        let dir = tempfile::tempdir().unwrap();
187        let spec = StateStoreSpec {
188            kind: "file".into(),
189            config: json!({
190                "path": dir.path().to_str().unwrap(),
191                "encryption": { "key": "test-key" },
192            }),
193        };
194        let store = build_state_store(&spec).await.unwrap();
195        store.put("bk", &json!({"pos": 7})).await.unwrap();
196        assert_eq!(store.get("bk").await.unwrap(), Some(json!({"pos": 7})));
197        let raw = std::fs::read(dir.path().join("bk.json")).unwrap();
198        assert!(raw.starts_with(b"FCT1"), "bookmark must be ciphertext");
199    }
200
201    #[cfg(feature = "encryption")]
202    #[tokio::test]
203    async fn encrypted_file_store_rejects_bad_block() {
204        let spec = StateStoreSpec {
205            kind: "file".into(),
206            config: json!({"path": "/tmp/x", "encryption": {"key": ""}}),
207        };
208        assert!(build_state_store(&spec).await.is_err());
209        let spec = StateStoreSpec {
210            kind: "file".into(),
211            config: json!({"path": "/tmp/x", "encryption": {"key": "k", "nope": 1}}),
212        };
213        assert!(build_state_store(&spec).await.is_err());
214    }
215
216    #[tokio::test]
217    async fn unknown_kind_errors() {
218        let spec = StateStoreSpec {
219            kind: "nope".into(),
220            config: json!({}),
221        };
222        let err = build_state_store(&spec).await.err().expect("should fail");
223        match err {
224            CliError::UnknownStateStore { name, .. } => assert_eq!(name, "nope"),
225            other => panic!("expected UnknownStateStore, got {other:?}"),
226        }
227    }
228}