1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6use rskit_codec::{Codec, TomlCodec, Value};
7use rskit_errors::{AppError, AppResult};
8use rskit_fs::sync_io::file;
9use rskit_util::SecretString;
10
11use super::ConfigSink;
12
13pub type ConfigTable = BTreeMap<String, String>;
18
19const MAX_FILE_BYTES: u64 = 1024 * 1024;
23
24const TEMP_PREFIX: &str = "config";
26
27#[derive(Debug, Clone)]
45pub struct FileConfigSink {
46 path: PathBuf,
47 codec: Arc<dyn Codec>,
48 mutation_lock: Arc<Mutex<()>>,
50}
51
52impl FileConfigSink {
53 pub fn new(path: impl Into<PathBuf>) -> Self {
57 Self {
58 path: path.into(),
59 codec: Arc::new(TomlCodec),
60 mutation_lock: Arc::new(Mutex::new(())),
61 }
62 }
63
64 pub fn with_codec(path: impl Into<PathBuf>, codec: Arc<dyn Codec>) -> Self {
68 Self {
69 path: path.into(),
70 codec,
71 mutation_lock: Arc::new(Mutex::new(())),
72 }
73 }
74
75 #[must_use]
77 pub fn path(&self) -> &Path {
78 &self.path
79 }
80
81 #[must_use]
83 pub fn codec(&self) -> &dyn Codec {
84 self.codec.as_ref()
85 }
86
87 fn read_table(&self) -> AppResult<ConfigTable> {
89 if !file::exists(&self.path)? {
90 return Ok(ConfigTable::new());
91 }
92 let contents = file::read_string_bounded(&self.path, MAX_FILE_BYTES)?;
93 let value = self.codec.decode_value(&contents).map_err(|err| {
94 AppError::invalid_input(
95 "config",
96 format!("failed to parse config file '{}'", self.path.display()),
97 )
98 .with_cause(err)
99 })?;
100 value_into_table(&value).ok_or_else(|| {
101 AppError::invalid_input(
102 "config",
103 format!(
104 "config file '{}' must be a flat table of string values",
105 self.path.display()
106 ),
107 )
108 })
109 }
110
111 fn write_table(&self, table: &ConfigTable) -> AppResult<()> {
113 let value = table_into_value(table);
114 let rendered = self.codec.encode_value(&value).map_err(|err| {
115 AppError::invalid_input(
116 "config",
117 format!("failed to encode config file '{}'", self.path.display()),
118 )
119 .with_cause(err)
120 })?;
121 file::write_atomic_replace(&self.path, rendered.as_bytes(), TEMP_PREFIX)
122 }
123}
124
125fn table_into_value(table: &ConfigTable) -> Value {
127 Value::Object(
128 table
129 .iter()
130 .map(|(key, value)| (key.clone(), Value::String(value.clone())))
131 .collect(),
132 )
133}
134
135fn value_into_table(value: &Value) -> Option<ConfigTable> {
137 let object = value.as_object()?;
138 let mut table = ConfigTable::new();
139 for (key, entry) in object {
140 table.insert(key.clone(), entry.as_str()?.to_string());
141 }
142 Some(table)
143}
144
145impl ConfigSink for FileConfigSink {
146 fn set(&self, key: &str, value: SecretString) -> AppResult<()> {
147 let _guard = self.mutation_lock.lock();
148 let mut table = self.read_table()?;
149 table.insert(key.to_string(), value.expose().to_string());
150 self.write_table(&table)
151 }
152
153 fn remove(&self, key: &str) -> AppResult<()> {
154 let _guard = self.mutation_lock.lock();
155 let mut table = self.read_table()?;
156 if table.remove(key).is_none() {
159 return Ok(());
160 }
161 self.write_table(&table)
162 }
163
164 fn set_many(&self, entries: Vec<(String, SecretString)>) -> AppResult<()> {
165 let _guard = self.mutation_lock.lock();
166 let mut table = self.read_table()?;
167 for (key, value) in entries {
168 table.insert(key, value.expose().to_string());
169 }
170 self.write_table(&table)
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use tempfile::tempdir;
178
179 fn read_back(sink: &FileConfigSink, key: &str) -> Option<String> {
180 sink.read_table().unwrap().get(key).cloned()
181 }
182
183 #[test]
184 fn set_persists_and_survives_new_handle() {
185 let dir = tempdir().unwrap();
186 let path = dir.path().join("config.toml");
187 let sink = FileConfigSink::new(&path);
188 sink.set("api_token", SecretString::new("s3cret")).unwrap();
189
190 let reopened = FileConfigSink::new(&path);
191 assert_eq!(read_back(&reopened, "api_token").as_deref(), Some("s3cret"));
192 }
193
194 #[test]
195 fn missing_file_reads_as_empty() {
196 let dir = tempdir().unwrap();
197 let path = dir.path().join("absent.toml");
198 let sink = FileConfigSink::new(&path);
199 assert_eq!(sink.path(), path.as_path());
200 assert_eq!(sink.codec().name(), "toml");
201 assert!(sink.read_table().unwrap().is_empty());
202 sink.remove("anything").unwrap();
204 assert!(!path.exists());
205 }
206
207 #[test]
208 fn invalid_backing_files_are_rejected() {
209 let dir = tempdir().unwrap();
210 let malformed = dir.path().join("malformed.toml");
211 std::fs::write(&malformed, "not = [").unwrap();
212 assert!(FileConfigSink::new(&malformed).read_table().is_err());
213
214 let non_table = dir.path().join("array.toml");
215 std::fs::write(&non_table, "[[items]]\nname = 'x'\n").unwrap();
216 assert!(FileConfigSink::new(&non_table).read_table().is_err());
217 }
218
219 #[test]
220 fn remove_absent_key_does_not_rewrite_file() {
221 let dir = tempdir().unwrap();
222 let path = dir.path().join("config.toml");
223 let sink = FileConfigSink::new(&path);
224 sink.set("keep", SecretString::new("v")).unwrap();
225 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
226
227 sink.remove("missing").unwrap();
229 let after = std::fs::metadata(&path).unwrap().modified().unwrap();
230
231 assert_eq!(before, after);
232 assert_eq!(read_back(&sink, "keep").as_deref(), Some("v"));
233 }
234
235 #[test]
236 fn remove_deletes_persisted_key() {
237 let dir = tempdir().unwrap();
238 let sink = FileConfigSink::new(dir.path().join("config.toml"));
239 sink.set("a", SecretString::new("1")).unwrap();
240 sink.set("b", SecretString::new("2")).unwrap();
241 sink.remove("a").unwrap();
242 assert!(read_back(&sink, "a").is_none());
243 assert_eq!(read_back(&sink, "b").as_deref(), Some("2"));
244 }
245
246 #[test]
247 fn set_many_persists_all_entries() {
248 let dir = tempdir().unwrap();
249 let sink = FileConfigSink::new(dir.path().join("config.toml"));
250 sink.set_many(vec![
251 ("a".to_string(), SecretString::new("1")),
252 ("b".to_string(), SecretString::new("2")),
253 ])
254 .unwrap();
255 assert_eq!(read_back(&sink, "a").as_deref(), Some("1"));
256 assert_eq!(read_back(&sink, "b").as_deref(), Some("2"));
257 }
258
259 #[test]
260 fn concurrent_set_does_not_lose_updates() {
261 use std::thread;
262
263 let dir = tempdir().unwrap();
264 let sink = FileConfigSink::new(dir.path().join("config.toml"));
265
266 let handles: Vec<_> = (0..16)
270 .map(|i| {
271 let sink = sink.clone();
272 thread::spawn(move || {
273 sink.set(&format!("k{i}"), SecretString::new(i.to_string()))
274 .unwrap();
275 })
276 })
277 .collect();
278 for handle in handles {
279 handle.join().unwrap();
280 }
281
282 let table = sink.read_table().unwrap();
283 assert_eq!(table.len(), 16);
284 for i in 0..16 {
285 assert_eq!(
286 table.get(&format!("k{i}")).map(String::as_str),
287 Some(i.to_string().as_str())
288 );
289 }
290 }
291
292 #[test]
293 fn keys_with_dots_round_trip() {
294 let dir = tempdir().unwrap();
295 let sink = FileConfigSink::new(dir.path().join("config.toml"));
296 sink.set("a.b.c", SecretString::new("v")).unwrap();
297 assert_eq!(read_back(&sink, "a.b.c").as_deref(), Some("v"));
298 }
299
300 #[test]
301 fn oversized_file_is_rejected() {
302 let dir = tempdir().unwrap();
303 let path = dir.path().join("big.toml");
304 std::fs::write(&path, vec![b'#'; (MAX_FILE_BYTES + 1) as usize]).unwrap();
305 let sink = FileConfigSink::new(&path);
306 let err = sink.set("k", SecretString::new("v")).unwrap_err();
307 assert!(err.to_string().contains("exceeding limit"));
308 }
309
310 #[test]
311 fn custom_codec_is_used_for_persistence() {
312 #[derive(Debug)]
314 struct LineCodec;
315
316 impl Codec for LineCodec {
317 fn name(&self) -> &'static str {
318 "lines"
319 }
320
321 fn encode_value(&self, value: &Value) -> AppResult<String> {
322 let object = value
323 .as_object()
324 .ok_or_else(|| AppError::invalid_input("config", "expected an object"))?;
325 let mut out = String::new();
326 for (k, v) in object {
327 let v = v
328 .as_str()
329 .ok_or_else(|| AppError::invalid_input("config", "expected a string"))?;
330 out.push_str(k);
331 out.push('=');
332 out.push_str(v);
333 out.push('\n');
334 }
335 Ok(out)
336 }
337
338 fn decode_value(&self, contents: &str) -> AppResult<Value> {
339 let mut object = serde_json::Map::new();
340 for line in contents.lines().filter(|l| !l.is_empty()) {
341 let (k, v) = line
342 .split_once('=')
343 .ok_or_else(|| AppError::invalid_input("config", "malformed line entry"))?;
344 object.insert(k.to_string(), Value::String(v.to_string()));
345 }
346 Ok(Value::Object(object))
347 }
348 }
349
350 let dir = tempdir().unwrap();
351 let path = dir.path().join("config.lines");
352 let sink = FileConfigSink::with_codec(&path, Arc::new(LineCodec));
353 sink.set("k", SecretString::new("v")).unwrap();
354
355 let raw = std::fs::read_to_string(&path).unwrap();
356 assert_eq!(raw, "k=v\n");
357 assert_eq!(sink.codec().name(), "lines");
358
359 let reopened = FileConfigSink::with_codec(&path, Arc::new(LineCodec));
360 assert_eq!(read_back(&reopened, "k").as_deref(), Some("v"));
361 }
362}