Skip to main content

structfs_core_store/
combinators.rs

1//! Composable store wrappers: capability restriction, layering, sharing,
2//! path confinement, and redaction.
3
4use std::sync::{Arc, Mutex};
5
6use crate::{Error, Path, PathPattern, Reader, Record, Value, Writer};
7
8/// A read-only view of a store: reads pass through, writes are rejected
9/// with a `PermissionDenied` error.
10///
11/// Useful for handing a store to code that should only observe it (display
12/// layers, documentation consumers).
13pub struct ReadOnly<S>(S);
14
15impl<S> ReadOnly<S> {
16    /// Wrap a store in a read-only view.
17    pub fn new(inner: S) -> Self {
18        Self(inner)
19    }
20
21    /// Unwrap, returning the inner store.
22    pub fn into_inner(self) -> S {
23        self.0
24    }
25}
26
27impl<S: Reader> Reader for ReadOnly<S> {
28    fn read(&mut self, from: &Path) -> Result<Option<Record>, Error> {
29        self.0.read(from)
30    }
31
32    fn read_children(&mut self, from: &Path) -> Result<Option<Vec<String>>, Error> {
33        self.0.read_children(from)
34    }
35}
36
37impl<S: Reader> Writer for ReadOnly<S> {
38    fn write(&mut self, to: &Path, _data: Record) -> Result<Path, Error> {
39        Err(Error::permission_denied(format!(
40            "store is read-only (write to {})",
41            to
42        )))
43    }
44}
45
46/// A layered store: reads try the primary first, then fall back to the
47/// secondary; writes always go to the primary.
48///
49/// This is layering (like an overlay filesystem), distinct from
50/// `OverlayStore`, which *routes* by path prefix. Typical use: runtime
51/// overrides cascading over immutable defaults.
52pub struct Cascade<A, B> {
53    primary: A,
54    fallback: B,
55}
56
57impl<A, B> Cascade<A, B> {
58    /// Layer `primary` over `fallback`.
59    pub fn new(primary: A, fallback: B) -> Self {
60        Self { primary, fallback }
61    }
62
63    /// Unwrap, returning `(primary, fallback)`.
64    pub fn into_inner(self) -> (A, B) {
65        (self.primary, self.fallback)
66    }
67}
68
69impl<A: Reader, B: Reader> Reader for Cascade<A, B> {
70    fn read(&mut self, from: &Path) -> Result<Option<Record>, Error> {
71        match self.primary.read(from)? {
72            Some(record) => Ok(Some(record)),
73            None => self.fallback.read(from),
74        }
75    }
76
77    fn read_children(&mut self, from: &Path) -> Result<Option<Vec<String>>, Error> {
78        match self.primary.read_children(from)? {
79            Some(children) => Ok(Some(children)),
80            None => self.fallback.read_children(from),
81        }
82    }
83}
84
85impl<A: Writer, B: Send + Sync> Writer for Cascade<A, B> {
86    fn write(&mut self, to: &Path, data: Record) -> Result<Path, Error> {
87        self.primary.write(to, data)
88    }
89}
90
91/// A cloneable, shareable handle to a store.
92///
93/// `Reader`/`Writer` take `&mut self`, so sharing a store between owners
94/// requires a lock. `Shared` is that lock, packaged: it implements the
95/// store traits over `Arc<Mutex<S>>` so callers don't hand-roll the
96/// wrapper. Lock poisoning is recovered from (the store may be mid-update,
97/// but path-level operations are individually atomic).
98pub struct Shared<S> {
99    inner: Arc<Mutex<S>>,
100}
101
102impl<S> Shared<S> {
103    /// Wrap a store for shared access.
104    pub fn new(inner: S) -> Self {
105        Self {
106            inner: Arc::new(Mutex::new(inner)),
107        }
108    }
109
110    /// Access the underlying store directly.
111    pub fn lock(&self) -> std::sync::MutexGuard<'_, S> {
112        self.inner
113            .lock()
114            .unwrap_or_else(|poisoned| poisoned.into_inner())
115    }
116}
117
118impl<S> Clone for Shared<S> {
119    fn clone(&self) -> Self {
120        Self {
121            inner: self.inner.clone(),
122        }
123    }
124}
125
126impl<S: Reader> Reader for Shared<S> {
127    fn read(&mut self, from: &Path) -> Result<Option<Record>, Error> {
128        self.lock().read(from)
129    }
130
131    fn read_children(&mut self, from: &Path) -> Result<Option<Vec<String>>, Error> {
132        self.lock().read_children(from)
133    }
134}
135
136impl<S: Writer> Writer for Shared<S> {
137    fn write(&mut self, to: &Path, data: Record) -> Result<Path, Error> {
138        self.lock().write(to, data)
139    }
140}
141
142/// A store that redacts sensitive paths on read.
143///
144/// Paths matching any pattern read back as the mask value instead of
145/// their contents; existence is preserved (a masked path that exists
146/// reads `Some(mask)`, a missing one reads `None`). Writes pass through
147/// unchanged — masking is a read-side lens, not write protection (wrap
148/// in [`ReadOnly`] for that).
149///
150/// Matching is **component-wise** via [`PathPattern`]: masking
151/// `gate/api_key` does not mask `gate/api_key_other`, which a string
152/// prefix check would.
153pub struct Masked<S> {
154    inner: S,
155    patterns: Vec<PathPattern>,
156    mask: Value,
157}
158
159impl<S> Masked<S> {
160    /// Mask paths matching `patterns` with the default `"[masked]"`.
161    pub fn new(inner: S, patterns: Vec<PathPattern>) -> Self {
162        Self::with_mask(inner, patterns, Value::from("[masked]"))
163    }
164
165    /// Mask with a custom mask value.
166    pub fn with_mask(inner: S, patterns: Vec<PathPattern>, mask: Value) -> Self {
167        Self {
168            inner,
169            patterns,
170            mask,
171        }
172    }
173
174    /// Unwrap, returning the inner store.
175    pub fn into_inner(self) -> S {
176        self.inner
177    }
178
179    fn is_masked(&self, path: &Path) -> bool {
180        self.patterns.iter().any(|pattern| pattern.matches(path))
181    }
182}
183
184impl<S: Reader> Reader for Masked<S> {
185    fn read(&mut self, from: &Path) -> Result<Option<Record>, Error> {
186        if self.is_masked(from) {
187            // Preserve existence, redact content.
188            return Ok(self
189                .inner
190                .read(from)?
191                .map(|_| Record::parsed(self.mask.clone())));
192        }
193        self.inner.read(from)
194    }
195
196    fn read_children(&mut self, from: &Path) -> Result<Option<Vec<String>>, Error> {
197        // Child names are structure, not content; they stay visible even
198        // under a masked prefix.
199        self.inner.read_children(from)
200    }
201}
202
203impl<S: Writer> Writer for Masked<S> {
204    fn write(&mut self, to: &Path, data: Record) -> Result<Path, Error> {
205        self.inner.write(to, data)
206    }
207}
208
209/// A store confined to a subtree of another store.
210///
211/// Incoming paths are joined under `root` before reaching the inner store,
212/// and result paths from writes have the root stripped (component-wise)
213/// before being returned, so the root never leaks to callers. A write
214/// result that escapes the root is an error rather than a leak.
215pub struct Rooted<S> {
216    root: Path,
217    inner: S,
218}
219
220impl<S> Rooted<S> {
221    /// Confine `inner` to the subtree at `root`.
222    pub fn new(root: Path, inner: S) -> Self {
223        Self { root, inner }
224    }
225
226    /// Unwrap, returning the inner store.
227    pub fn into_inner(self) -> S {
228        self.inner
229    }
230}
231
232impl<S: Reader> Reader for Rooted<S> {
233    fn read(&mut self, from: &Path) -> Result<Option<Record>, Error> {
234        self.inner.read(&self.root.join(from))
235    }
236
237    fn read_children(&mut self, from: &Path) -> Result<Option<Vec<String>>, Error> {
238        self.inner.read_children(&self.root.join(from))
239    }
240}
241
242impl<S: Writer> Writer for Rooted<S> {
243    fn write(&mut self, to: &Path, data: Record) -> Result<Path, Error> {
244        let result = self.inner.write(&self.root.join(to), data)?;
245        result.strip_prefix(&self.root).ok_or_else(|| {
246            Error::store(
247                "rooted",
248                "write",
249                format!("inner store returned path outside root: {}", result),
250            )
251        })
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::{path, Value};
259    use std::collections::HashMap;
260
261    struct MapStore {
262        data: HashMap<Path, Record>,
263    }
264
265    impl MapStore {
266        fn new() -> Self {
267            Self {
268                data: HashMap::new(),
269            }
270        }
271    }
272
273    impl Reader for MapStore {
274        fn read(&mut self, from: &Path) -> Result<Option<Record>, Error> {
275            Ok(self.data.get(from).cloned())
276        }
277    }
278
279    impl Writer for MapStore {
280        fn write(&mut self, to: &Path, data: Record) -> Result<Path, Error> {
281            self.data.insert(to.clone(), data);
282            Ok(to.clone())
283        }
284    }
285
286    #[test]
287    fn read_only_passes_reads_rejects_writes() {
288        let mut inner = MapStore::new();
289        inner
290            .write(&path!("key"), Record::parsed(Value::from("v")))
291            .unwrap();
292
293        let mut ro = ReadOnly::new(inner);
294        assert!(ro.read(&path!("key")).unwrap().is_some());
295
296        let err = ro
297            .write(&path!("key"), Record::parsed(Value::from("w")))
298            .unwrap_err();
299        assert!(matches!(err, Error::PermissionDenied { .. }));
300
301        // Inner store unchanged
302        let mut inner = ro.into_inner();
303        assert_eq!(
304            inner.read(&path!("key")).unwrap().unwrap().as_value(),
305            Some(&Value::from("v"))
306        );
307    }
308
309    #[test]
310    fn cascade_layers_reads_and_writes_to_primary() {
311        let mut fallback = MapStore::new();
312        fallback
313            .write(&path!("base"), Record::parsed(Value::from("default")))
314            .unwrap();
315        fallback
316            .write(&path!("both"), Record::parsed(Value::from("under")))
317            .unwrap();
318
319        let mut primary = MapStore::new();
320        primary
321            .write(&path!("both"), Record::parsed(Value::from("over")))
322            .unwrap();
323
324        let mut cascade = Cascade::new(primary, fallback);
325
326        // Fallback shows through where primary has nothing
327        assert_eq!(
328            cascade.read(&path!("base")).unwrap().unwrap().as_value(),
329            Some(&Value::from("default"))
330        );
331        // Primary wins where both exist
332        assert_eq!(
333            cascade.read(&path!("both")).unwrap().unwrap().as_value(),
334            Some(&Value::from("over"))
335        );
336        // Missing everywhere
337        assert!(cascade.read(&path!("missing")).unwrap().is_none());
338
339        // Writes land in primary only
340        cascade
341            .write(&path!("new"), Record::parsed(Value::from("x")))
342            .unwrap();
343        let (mut primary, mut fallback) = cascade.into_inner();
344        assert!(primary.read(&path!("new")).unwrap().is_some());
345        assert!(fallback.read(&path!("new")).unwrap().is_none());
346    }
347
348    #[test]
349    fn shared_clones_access_same_store() {
350        let shared = Shared::new(MapStore::new());
351        let mut a = shared.clone();
352        let mut b = shared;
353
354        a.write(&path!("key"), Record::parsed(Value::from("v")))
355            .unwrap();
356        assert!(b.read(&path!("key")).unwrap().is_some());
357    }
358
359    #[test]
360    fn shared_is_send_and_usable_across_threads() {
361        let shared = Shared::new(MapStore::new());
362        let mut clone = shared.clone();
363        let handle = std::thread::spawn(move || {
364            clone
365                .write(&path!("from_thread"), Record::parsed(Value::from(1i64)))
366                .unwrap();
367        });
368        handle.join().unwrap();
369        assert!(shared.lock().read(&path!("from_thread")).unwrap().is_some());
370    }
371
372    #[test]
373    fn masked_redacts_component_wise() {
374        let mut inner = MapStore::new();
375        inner
376            .write(
377                &path!("gate/api_key"),
378                Record::parsed(Value::from("s3cret")),
379            )
380            .unwrap();
381        inner
382            .write(
383                &path!("gate/api_key_other"),
384                Record::parsed(Value::from("visible")),
385            )
386            .unwrap();
387        inner
388            .write(&path!("gate/model"), Record::parsed(Value::from("gpt-oss")))
389            .unwrap();
390
391        let mut masked = Masked::new(inner, vec![PathPattern::prefix(path!("gate/api_key"))]);
392
393        // The secret reads as the mask; existence is preserved.
394        assert_eq!(
395            masked
396                .read(&path!("gate/api_key"))
397                .unwrap()
398                .unwrap()
399                .as_value(),
400            Some(&Value::from("[masked]"))
401        );
402        // The byte-prefix bug: a component-wise sibling stays visible.
403        assert_eq!(
404            masked
405                .read(&path!("gate/api_key_other"))
406                .unwrap()
407                .unwrap()
408                .as_value(),
409            Some(&Value::from("visible"))
410        );
411        // Unmasked paths pass through.
412        assert_eq!(
413            masked
414                .read(&path!("gate/model"))
415                .unwrap()
416                .unwrap()
417                .as_value(),
418            Some(&Value::from("gpt-oss"))
419        );
420        // Missing masked paths stay absent — no fabricated existence.
421        assert!(masked.read(&path!("gate/api_key/sub")).unwrap().is_none());
422    }
423
424    #[test]
425    fn masked_passes_writes_through() {
426        let inner = MapStore::new();
427        let mut masked = Masked::with_mask(
428            inner,
429            vec![PathPattern::exact(path!("secret"))],
430            Value::Null,
431        );
432        masked
433            .write(&path!("secret"), Record::parsed(Value::from("v")))
434            .unwrap();
435        // Read of the freshly written secret is masked (custom mask).
436        assert_eq!(
437            masked.read(&path!("secret")).unwrap().unwrap().as_value(),
438            Some(&Value::Null)
439        );
440        // The inner store holds the real value.
441        let mut inner = masked.into_inner();
442        assert_eq!(
443            inner.read(&path!("secret")).unwrap().unwrap().as_value(),
444            Some(&Value::from("v"))
445        );
446    }
447
448    #[test]
449    fn rooted_confines_and_strips() {
450        let mut rooted = Rooted::new(path!("export/v1"), MapStore::new());
451
452        let result = rooted
453            .write(&path!("users/alice"), Record::parsed(Value::from("a")))
454            .unwrap();
455        // Root is stripped from the result path
456        assert_eq!(result, path!("users/alice"));
457
458        // Data actually lives under the root
459        let mut inner = rooted.into_inner();
460        assert!(inner
461            .read(&path!("export/v1/users/alice"))
462            .unwrap()
463            .is_some());
464    }
465
466    #[test]
467    fn rooted_reads_under_root() {
468        let mut inner = MapStore::new();
469        inner
470            .write(&path!("jail/key"), Record::parsed(Value::from("v")))
471            .unwrap();
472
473        let mut rooted = Rooted::new(path!("jail"), inner);
474        assert!(rooted.read(&path!("key")).unwrap().is_some());
475        // Sibling paths outside the root are unreachable
476        assert!(rooted.read(&path!("jail/key")).unwrap().is_none());
477    }
478
479    #[test]
480    fn rooted_escaping_write_result_is_error() {
481        /// Store whose write returns a path outside the requested subtree.
482        struct EscapingStore;
483
484        impl Reader for EscapingStore {
485            fn read(&mut self, _from: &Path) -> Result<Option<Record>, Error> {
486                Ok(None)
487            }
488        }
489
490        impl Writer for EscapingStore {
491            fn write(&mut self, _to: &Path, _data: Record) -> Result<Path, Error> {
492                Ok(path!("elsewhere/entirely"))
493            }
494        }
495
496        let mut rooted = Rooted::new(path!("jail"), EscapingStore);
497        let err = rooted
498            .write(&path!("key"), Record::parsed(Value::Null))
499            .unwrap_err();
500        assert!(err.to_string().contains("outside root"));
501    }
502}