Skip to main content

rskit_config/sink/
file.rs

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
13/// Flat `key -> value` table that backs a [`FileConfigSink`].
14///
15/// Keys are opaque strings (dots are not nesting);
16/// values are plaintext secret material persisted verbatim by the sink.
17pub type ConfigTable = BTreeMap<String, String>;
18
19/// Upper bound on the backing file size accepted on read (1 MiB).
20///
21/// A larger file is rejected rather than buffered unbounded.
22const MAX_FILE_BYTES: u64 = 1024 * 1024;
23
24/// Temp-file prefix used for atomic replacement.
25const TEMP_PREFIX: &str = "config";
26
27/// File-backed writable config store.
28///
29/// A reference [`ConfigSink`] that persists keys to a flat table on disk.
30/// The on-disk representation is pluggable via [`Codec`]:
31/// TOML is the built-in default ([`FileConfigSink::new`]),
32/// and any other format (JSON, …) drops in through [`FileConfigSink::with_codec`] without changing the sink.
33///
34/// Filesystem access goes through `rskit-fs` (bounded reads + atomic replacement),
35/// so a concurrent reader never observes a partial write.
36///
37/// Mutations (`set`/`remove`/`set_many`) are read-modify-write sequences serialized by a shared in-process lock,
38/// so concurrent writers — including separate clones, which share the same lock —
39/// never lose each other's updates. Cross-process coordination is out of scope;
40/// protect the file with OS-level mechanisms if multiple processes write it.
41///
42/// Persisting writes the plaintext value to disk — this is the sink's explicit, intended persistence.
43/// Protect the file with appropriate permissions; the plaintext is never logged.
44#[derive(Debug, Clone)]
45pub struct FileConfigSink {
46    path: PathBuf,
47    codec: Arc<dyn Codec>,
48    /// Serializes read-modify-write mutations across all clones of this sink.
49    mutation_lock: Arc<Mutex<()>>,
50}
51
52impl FileConfigSink {
53    /// Create a file sink backed by `path`, using the built-in TOML codec.
54    ///
55    /// The file is created on first write; a missing file reads as empty.
56    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    /// Create a file sink backed by `path` with an explicit [`Codec`].
65    ///
66    /// Use this to persist as JSON or any user-supplied format.
67    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    /// Return the backing file path.
76    #[must_use]
77    pub fn path(&self) -> &Path {
78        &self.path
79    }
80
81    /// Return the codec used to persist this sink.
82    #[must_use]
83    pub fn codec(&self) -> &dyn Codec {
84        self.codec.as_ref()
85    }
86
87    /// Read the current table, treating a missing file as empty.
88    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    /// Atomically replace the backing file with `table`.
112    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
125/// Encode a flat table as a JSON object of string values.
126fn 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
135/// Decode a flat table from a value, requiring an object of string values.
136fn 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        // Removing an absent key is an idempotent no-op:
157        // avoid rewriting the file (which would create an empty file / bump mtime and wake watchers).
158        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        // Removing from an absent file is a no-op success that does not create the backing file.
203        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        // Removing a key that isn't present must not touch the file.
228        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        // Many clones share the same backing file and mutation lock;
267        // each writes a distinct key concurrently. Without serialization,
268        // read-modify-write races would drop some keys.
269        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        // A trivial line-based codec proves the sink is format-neutral.
313        #[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}