1use 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#[derive(Debug, Deserialize)]
13struct FileStateConfig {
14 path: PathBuf,
15 #[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 #[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#[cfg(feature = "state-postgres")]
57const DEFAULT_PG_POOL_SIZE: u32 = 5;
58
59pub 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
118pub 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 #[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}