Skip to main content

core_api/
mask.rs

1use core_query::visible::VisibleSet;
2use core_storage::fs::Fs;
3use core_storage::Result;
4use std::collections::{HashMap, HashSet};
5use std::sync::{Arc, Mutex};
6
7use crate::db::GraphDb;
8
9/// Controls how hidden nodes are rendered when a [`NodeMask`] is used in
10/// [`GraphDb::node_info_masked`], [`GraphDb::node_edges_masked`], and
11/// [`GraphDb::neighborhood_masked`].
12///
13/// The default is [`MaskMode::Omit`], which preserves byte-identical behaviour
14/// with all pre-existing masked paths.  [`MaskMode::Stub`] is an explicit
15/// opt-in that discloses node *existence* — suitable only for full-token
16/// client masks.  Role-token paths are hard-coded to `Omit`.
17///
18/// **Existence-disclosure warning**: `Stub` mode intentionally tells the caller
19/// whether a node exists, even if its contents are hidden.  Only use this on
20/// client-mask (full-token) paths where the caller already has that knowledge
21/// implicitly.
22#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
23pub enum MaskMode {
24    /// Hidden nodes are silently omitted from every result — behaviour is
25    /// byte-identical to the pre-existing masked-query paths.  This is the
26    /// default.
27    #[default]
28    Omit,
29    /// Hidden nodes' existence is acknowledged via a restricted stub:
30    /// `{"key": "<key>", "restricted": true}`.  No label, props, or other
31    /// fields are included in the stub.
32    Stub,
33}
34
35/// Query-scoped node visibility filter (ACL primitive).
36///
37/// When a `NodeMask` is passed to `query_masked`, only nodes whose dense id
38/// appears in `visible` will be returned by label scans, key lookups, and
39/// neighbor expansions. Edges where either endpoint is hidden are silently
40/// dropped from the result.
41///
42/// Unknown keys in `from_keys` are silently ignored (they resolve to no id).
43/// An empty mask hides every node.
44#[derive(Clone, Debug)]
45pub struct NodeMask {
46    /// The allow-list, in whichever shape [`VisibleSet`]'s density rule picked
47    /// for it at construction. Every masked read probes this once per
48    /// candidate, which is what makes the shape worth choosing.
49    pub(crate) visible: VisibleSet,
50    mode: MaskMode,
51}
52
53impl NodeMask {
54    /// Resolve string keys to dense ids and build a mask.
55    ///
56    /// Keys that do not exist in the database are ignored.
57    /// The mask mode defaults to [`MaskMode::Omit`]; call [`NodeMask::with_mode`]
58    /// to opt into [`MaskMode::Stub`].
59    pub fn from_keys<'a, F: Fs>(db: &GraphDb<F>, keys: impl IntoIterator<Item = &'a str>) -> Self {
60        let visible = keys.into_iter().filter_map(|k| db.ids().get(k)).collect();
61        NodeMask {
62            visible,
63            mode: MaskMode::default(),
64        }
65    }
66
67    /// Build a mask from an already-resolved iterator of dense node ids.
68    ///
69    /// Used by `ReaderSnapshot` handlers that resolve keys against the frozen
70    /// state without a `GraphDb` reference.
71    pub fn from_ids(ids: impl IntoIterator<Item = u32>) -> Self {
72        NodeMask {
73            visible: ids.into_iter().collect(),
74            mode: MaskMode::default(),
75        }
76    }
77
78    /// Set the rendering mode, consuming `self` and returning a new mask.
79    ///
80    /// **SECURITY**: never call with [`MaskMode::Stub`] on role-token paths.
81    pub fn with_mode(self, mode: MaskMode) -> Self {
82        NodeMask { mode, ..self }
83    }
84
85    /// Return the current rendering mode.
86    pub fn mode(&self) -> MaskMode {
87        self.mode
88    }
89
90    pub fn len(&self) -> usize {
91        self.visible.len()
92    }
93
94    pub fn is_empty(&self) -> bool {
95        self.visible.is_empty()
96    }
97
98    /// Return a new mask that is the intersection of `self` and `other`.
99    ///
100    /// The result contains only nodes visible in both masks.  Used to enforce
101    /// the never-widen rule when a role token also supplies a client mask:
102    /// `effective = role_mask.intersect(&client_mask)`.
103    ///
104    /// The result always carries [`MaskMode::Omit`] — the role-path invariant
105    /// means stubs must never slip through an intersection.
106    pub fn intersect(&self, other: &NodeMask) -> NodeMask {
107        NodeMask {
108            visible: self.visible.intersect(&other.visible),
109            mode: MaskMode::Omit,
110        }
111    }
112
113    /// Return `true` if the dense node id is visible in this mask.
114    ///
115    /// Used by `ReaderSnapshot` handlers where the key has already been resolved
116    /// to a dense id (avoids a second lookup into a `GraphDb`).
117    #[inline]
118    pub fn contains_id(&self, id: u32) -> bool {
119        self.visible.contains(id)
120    }
121
122    /// Return `true` if the node identified by `key` is visible in this mask.
123    ///
124    /// Returns `false` for keys that do not exist in the database (unknown keys
125    /// are never visible), as well as for keys that exist but are not in the
126    /// visible set.  Used by node-endpoint handlers to produce the same
127    /// absent-key response for both missing and hidden nodes.
128    pub fn contains_node<F: core_storage::fs::Fs>(
129        &self,
130        db: &crate::db::GraphDb<F>,
131        key: &str,
132    ) -> bool {
133        db.ids()
134            .get(key)
135            .is_some_and(|id| self.visible.contains(id))
136    }
137}
138
139// ── Store identity ────────────────────────────────────────────────────────────
140
141/// Identifies one *loaded* store within this process.
142///
143/// # Where an id is minted, and where one is not
144///
145/// A [`GraphDb`] mints a fresh id at **two** points, both of which replace the
146/// graph behind the handle:
147///
148/// 1. `GraphDb::new_empty` — the handle is constructed, so there is no earlier
149///    state anything could have memoised against.
150/// 2. `GraphDb::reset_for_reload` — the store is reloaded from disk. This is
151///    the one that matters: `commit_seq` is zeroed and reseeded from
152///    `max(last_change)`, which a delete-only commit leaves where it was, so a
153///    reload can land back on a sequence a caller's [`Scope`] already cached a
154///    mask at.
155///
156/// A fresh [`RoleMaskCache`] is installed at **three** points — those two, plus
157/// `GraphDb::commit_roles`, which rewrites `roles.json` without committing, so
158/// `commit_seq` does not move and a memoised role mask would still match its
159/// version. The counts differ on purpose: `commit_roles` changes what a *role*
160/// name resolves to, and the only memo a `StoreStamp` guards is the `keys` leg
161/// of a [`Scope`], which is dense ids for literal key strings and does not
162/// depend on role definitions at all. Minting there would evict a live entry
163/// for nothing.
164///
165/// Read that as the rule: **the stamp tracks the identity of the graph, the
166/// cache tracks the identity of the answers.** A change that replaces the graph
167/// does both; a change that replaces only role definitions does one.
168///
169/// Ids come from a process-wide counter and are never reused, so two ids are
170/// equal only when they name the same store at the same load.
171#[derive(Clone, Copy, PartialEq, Eq, Debug)]
172pub(crate) struct StoreId(u64);
173
174impl StoreId {
175    /// Mint an id no other store has held.
176    pub(crate) fn next() -> StoreId {
177        static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
178        StoreId(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
179    }
180}
181
182/// Everything a resolved mask depends on: which store, and which commit of it.
183///
184/// # The invariant, stated where the cache lives
185///
186/// A dense node id means something only inside one loaded store. A memo of
187/// dense ids is therefore valid only while **both** halves of this stamp still
188/// match: a bare `commit_seq` is not enough on either axis.
189///
190/// - *Across stores*: two unrelated stores of the same age carry the same
191///   `commit_seq`, and [`Scope::resolve`] accepts any `&GraphDb`, so a `Scope`
192///   the caller owns can meet a store its mask was never built for.
193/// - *Across a reload*: `reset_for_reload` zeroes `commit_seq` and
194///   `load_from_disk` reseeds it from `max(last_change)`. `DeleteNode` records
195///   no `last_change` entry, so a store snapshotted after delete-only commits
196///   comes back at a sequence it already held, with a different graph behind it.
197///
198/// [`RoleMaskCache`] is immune to both because the `GraphDb` **owns** it and
199/// replaces it on reload; a `Scope` is owned by the caller and outlives any
200/// store it is handed to. The stamp is how that ownership is carried into the
201/// entry instead.
202///
203/// Every field here is part of the validity test, because the test is
204/// `entry.stamp == StoreStamp::of(db)` on a derived `PartialEq` and
205/// [`StoreStamp::of`] is the only place a stamp is built. A field added to this
206/// struct joins the comparison automatically and will not compile until `of`
207/// fills it in.
208#[derive(Clone, Copy, PartialEq, Eq, Debug)]
209pub(crate) struct StoreStamp {
210    store: StoreId,
211    commit_seq: u64,
212}
213
214impl StoreStamp {
215    /// The stamp `db` carries at this instant.
216    fn of<F: Fs>(db: &GraphDb<F>) -> StoreStamp {
217        StoreStamp {
218            store: db.store_id(),
219            commit_seq: db.commit_seq(),
220        }
221    }
222}
223
224// ── Scope ─────────────────────────────────────────────────────────────────────
225
226// Test-only: counts how many times the `keys` leg was actually rebuilt, as
227// opposed to served from `keys_cache`. Thread-local because the test harness
228// gives each test its own thread, so a parallel test's resolve cannot be
229// mistaken for this one's.
230#[cfg(test)]
231thread_local! {
232    static SCOPE_KEYS_RESOLVES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
233}
234
235/// A read scope: what a handle may see, as a descriptor rather than a mask.
236///
237/// A `Scope` names its legs — a role, a namespace, an explicit key allow-list —
238/// and resolves them to a [`NodeMask`] **per read**. It is the live-read
239/// analogue of [`AsOfScope`](crate::db::AsOfScope), and resolves its role leg
240/// through the same [`GraphDb::mask_for_role`], so a role name means one thing
241/// on both.
242///
243/// # Never widens
244///
245/// Present legs are **intersected**; an absent leg contributes nothing. A scope
246/// with no legs at all is refused by [`Scope::new`] rather than treated as
247/// unscoped — an empty scope must never be the accident that widens a caller to
248/// everything.
249///
250/// # Never stale
251///
252/// Resolution happens on every read, not once at construction. The role leg
253/// goes through [`RoleMaskCache`], keyed on `commit_seq`; the `keys` leg is
254/// cached on this `Scope` under a [`StoreStamp`], which is `commit_seq` plus
255/// the identity of the store that commit belongs to — a `Scope` is the caller's
256/// and can be carried to another store or held across a reload, neither of
257/// which a sequence number can detect. Either way a read after a write
258/// rebuilds, so a scoped handle held across a write cannot serve the allow-list
259/// it had before — a key created since is visible, a key deleted since is not.
260/// That is a security property, not a freshness nicety.
261///
262/// Mode is hard-coded [`MaskMode::Omit`]. [`MaskMode::Stub`] discloses node
263/// existence and belongs only to full-token client masks.
264pub struct Scope {
265    /// Role legs, intersected. `new` sets at most one; [`Scope::intersect`]
266    /// appends, because two roles cannot be collapsed into one name without
267    /// resolving them against a store.
268    roles: Vec<String>,
269    /// Namespace legs, intersected on the same terms as `roles`.
270    namespaces: Vec<String>,
271    /// The explicit allow-list, already intersected across every scope that
272    /// contributed one: unknown keys resolve to nothing in every leg, so the
273    /// intersection of two key lists is exact as strings.
274    keys: Option<Vec<String>>,
275    /// The `keys` leg resolved, stamped with the store *and* the commit it was
276    /// resolved against — see [`StoreStamp`] for why neither half alone is
277    /// enough, and why this `Scope`-owned memo needs a stamp at all when the
278    /// `GraphDb`-owned [`RoleMaskCache`] does not.
279    ///
280    /// [`NodeMask::from_keys`] is one hash lookup per key, so re-resolving a
281    /// 50,000-key allow-list on every read would make a handle scope slower
282    /// than the per-call `mask=` it replaces — for exactly the caller who needs
283    /// it most. A read carrying the same stamp reuses the entry; anything else
284    /// rebuilds. Steady-state cost is one comparison of two integers.
285    keys_cache: Mutex<Option<(StoreStamp, NodeMask)>>,
286}
287
288impl Scope {
289    /// Build a scope from the legs that are present.
290    ///
291    /// At least one leg is required: `Scope::new(None, None, None)` is
292    /// [`GraphError::QueryError`](core_storage::GraphError::QueryError), never
293    /// an unscoped handle.
294    ///
295    /// `keys: Some(vec![])` *is* a leg — it narrows to nothing, which is safe.
296    /// An unknown role is not detected here; it surfaces from
297    /// [`Scope::resolve`], which is where a store exists to check it against.
298    pub fn new(
299        role: Option<String>,
300        namespace: Option<String>,
301        keys: Option<Vec<String>>,
302    ) -> Result<Scope> {
303        if role.is_none() && namespace.is_none() && keys.is_none() {
304            return Err(core_storage::GraphError::QueryError {
305                detail: "a scope needs at least one of role, namespace or keys; \
306                         an empty scope is refused rather than read as unscoped"
307                    .into(),
308            });
309        }
310        Ok(Scope {
311            roles: role.into_iter().collect(),
312            namespaces: namespace.into_iter().collect(),
313            keys,
314            keys_cache: Mutex::new(None),
315        })
316    }
317
318    /// Resolve every present leg against `db` and intersect the results.
319    ///
320    /// Returns `Err` when a role leg names no defined role, or when
321    /// `roles.json` was corrupt at open — the same refusals
322    /// [`GraphDb::mask_for_role`] makes, unchanged.
323    pub fn resolve<F: Fs>(&self, db: &GraphDb<F>) -> Result<NodeMask> {
324        self.resolve_with(db, true)
325    }
326
327    /// Resolve against `db` without reading or writing the `keys` cache.
328    ///
329    /// A temporal handle from [`GraphDb::open_at`] is a distinct store with its
330    /// own [`StoreId`], so the entry could not be *mistaken* between the two —
331    /// the stamp settles that. What it would still do is evict: a per-call
332    /// temporal handle is thrown away immediately, so caching against it buys
333    /// nothing and costs the live handle its entry.
334    ///
335    /// Time-travel reads therefore resolve cold, in both directions: they do
336    /// not consult the entry and they do not leave one behind.
337    pub(crate) fn resolve_uncached<F: Fs>(&self, db: &GraphDb<F>) -> Result<NodeMask> {
338        self.resolve_with(db, false)
339    }
340
341    /// The body of [`Scope::resolve`] and [`Scope::resolve_uncached`].
342    fn resolve_with<F: Fs>(&self, db: &GraphDb<F>, cached: bool) -> Result<NodeMask> {
343        let mut out: Option<NodeMask> = None;
344        let mut narrow = |mask: NodeMask| {
345            out = Some(match out.take() {
346                Some(acc) => acc.intersect(&mask),
347                None => mask,
348            });
349        };
350
351        for role in &self.roles {
352            narrow(db.mask_for_role(role)?);
353        }
354        for namespace in &self.namespaces {
355            narrow(db.mask_for_namespace(namespace));
356        }
357        if self.keys.is_some() {
358            narrow(self.resolve_keys(db, cached));
359        }
360
361        // `new` refuses a legless scope, so at least one leg ran.
362        Ok(out
363            .expect("a Scope always has at least one leg")
364            .with_mode(MaskMode::Omit))
365    }
366
367    /// Return a scope seeing only what both `self` and `other` see.
368    ///
369    /// Legs accumulate rather than replace: two role legs are both resolved and
370    /// intersected, and two key lists are intersected as strings. The result
371    /// starts with a cold cache, which costs one rebuild and cannot be wrong.
372    pub fn intersect(&self, other: &Scope) -> Scope {
373        let keys = match (&self.keys, &other.keys) {
374            (Some(a), Some(b)) => {
375                let b: HashSet<&str> = b.iter().map(String::as_str).collect();
376                Some(
377                    a.iter()
378                        .filter(|k| b.contains(k.as_str()))
379                        .cloned()
380                        .collect(),
381                )
382            }
383            (Some(a), None) => Some(a.clone()),
384            (None, b) => b.clone(),
385        };
386        Scope {
387            roles: [self.roles.clone(), other.roles.clone()].concat(),
388            namespaces: [self.namespaces.clone(), other.namespaces.clone()].concat(),
389            keys,
390            keys_cache: Mutex::new(None),
391        }
392    }
393
394    /// The `keys` leg, from the cache when it was resolved at this commit.
395    ///
396    /// Callers check `self.keys.is_some()` first; an absent leg is not a leg
397    /// resolving to the empty mask, which would hide everything.
398    ///
399    /// `cached = false` skips the memo entirely — see
400    /// [`Scope::resolve_uncached`] for why a temporal handle must.
401    fn resolve_keys<F: Fs>(&self, db: &GraphDb<F>, cached: bool) -> NodeMask {
402        let keys = self.keys.as_deref().unwrap_or_default();
403        let stamp = StoreStamp::of(db);
404
405        if cached {
406            if let Ok(cache) = self.keys_cache.lock() {
407                if let Some((at, mask)) = cache.as_ref() {
408                    if *at == stamp {
409                        return mask.clone();
410                    }
411                }
412            }
413        }
414
415        #[cfg(test)]
416        SCOPE_KEYS_RESOLVES.with(|c| c.set(c.get() + 1));
417        let mask = NodeMask::from_keys(db, keys.iter().map(String::as_str));
418
419        if cached {
420            if let Ok(mut cache) = self.keys_cache.lock() {
421                *cache = Some((stamp, mask.clone()));
422            }
423        }
424        mask
425    }
426}
427
428impl Clone for Scope {
429    /// Carries the resolved `keys` leg across, cache included: it is stamped
430    /// with the store and the commit it was built against, so a clone can serve
431    /// it only against that same store while that commit is still current.
432    fn clone(&self) -> Scope {
433        Scope {
434            roles: self.roles.clone(),
435            namespaces: self.namespaces.clone(),
436            keys: self.keys.clone(),
437            keys_cache: Mutex::new(self.keys_cache.lock().ok().and_then(|cache| cache.clone())),
438        }
439    }
440}
441
442impl std::fmt::Debug for Scope {
443    /// Omits the resolved cache: it is a derived value, and printing a
444    /// 50,000-id mask in a log line helps nobody.
445    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
446        f.debug_struct("Scope")
447            .field("roles", &self.roles)
448            .field("namespaces", &self.namespaces)
449            .field("keys", &self.keys.as_ref().map(Vec::len))
450            .finish()
451    }
452}
453
454// ── Role → mask memo ──────────────────────────────────────────────────────────
455
456/// Role → resolved mask, valid for exactly one commit sequence.
457///
458/// Resolving a role is a full scan of the label vector, and with a
459/// [`visible_where`](crate::roles::RoleDef::visible_where) predicate it is also
460/// a property read per candidate node. A scoped reader pays that on every
461/// request, and between two writes the answer cannot have changed — so it is
462/// paid once and remembered.
463///
464/// **Never stale**: an entry records the store's `commit_seq` at the moment it
465/// was built and is served only when that is still the current one. Any write
466/// bumps `commit_seq` and the entry simply stops matching. The cache can be
467/// cold, but it cannot be wrong.
468///
469/// `commit_seq` does not move when a role *definition* changes — `roles.json`
470/// is a sidecar, not a WAL record — so the owner of the cache installs a fresh
471/// one whenever roles are rewritten or the store is reloaded. That also leaves
472/// any reader snapshot holding the old `Arc` with a private cache, so a
473/// snapshot frozen against the old definitions can never publish an answer the
474/// live handle would read back.
475#[derive(Default)]
476pub struct RoleMaskCache {
477    entries: Mutex<HashMap<String, (u64, Arc<NodeMask>)>>,
478}
479
480impl RoleMaskCache {
481    pub fn new() -> Self {
482        Self::default()
483    }
484
485    /// Return the memoised mask for `role` at `version`, building it if the
486    /// entry is absent or was built against a different commit sequence.
487    ///
488    /// `build` runs outside the lock: it reads the store, and the cache must
489    /// never be a lock ordering between two readers.
490    pub fn get_or_build(
491        &self,
492        role: &str,
493        version: u64,
494        build: impl FnOnce() -> Result<NodeMask>,
495    ) -> Result<Arc<NodeMask>> {
496        if let Ok(entries) = self.entries.lock() {
497            if let Some((v, mask)) = entries.get(role) {
498                if *v == version {
499                    return Ok(Arc::clone(mask));
500                }
501            }
502        }
503        let mask = Arc::new(build()?);
504        if let Ok(mut entries) = self.entries.lock() {
505            entries.insert(role.to_string(), (version, Arc::clone(&mask)));
506        }
507        Ok(mask)
508    }
509
510    /// Drop every entry. Correctness never depends on this — a mismatched
511    /// version is already ignored — but the owner calls it when the role
512    /// definitions themselves change, which `commit_seq` does not record.
513    pub fn clear(&self) {
514        if let Ok(mut entries) = self.entries.lock() {
515            entries.clear();
516        }
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::roles::RoleDef;
524    use crate::schema::Schema;
525
526    fn tmp_dir(name: &str) -> std::path::PathBuf {
527        let d =
528            std::env::temp_dir().join(format!("graphdb-mask-unit-{}-{}", name, std::process::id()));
529        let _ = std::fs::remove_dir_all(&d);
530        d
531    }
532
533    /// A scope with nothing in it is not "unscoped" — it is a mistake, and it
534    /// is refused where it is made rather than where it would have widened.
535    #[test]
536    fn scope_with_no_legs_is_refused() {
537        let err = Scope::new(None, None, None).expect_err("an empty scope must not be built");
538        match err {
539            core_storage::GraphError::QueryError { detail } => {
540                for arg in ["role", "namespace", "keys"] {
541                    assert!(
542                        detail.contains(arg),
543                        "the refusal must name `{arg}`; got {detail:?}"
544                    );
545                }
546            }
547            other => panic!("expected QueryError, got {other:?}"),
548        }
549    }
550
551    /// Two legs are an intersection, never a union: the key leg cannot hand a
552    /// role a node the role could not already see.
553    #[test]
554    fn scope_legs_intersect_and_never_widen() {
555        let dir = tmp_dir("scope-intersect");
556        let mut db = GraphDb::open(&dir).unwrap();
557        db.insert_node("Doc", "a", vec![]).unwrap();
558        db.insert_node("Doc", "b", vec![]).unwrap();
559        db.insert_node("Secret", "c", vec![]).unwrap();
560        db.apply_schema(&Schema {
561            roles: vec![RoleDef {
562                name: "reader".into(),
563                keys: vec![],
564                labels: vec!["Doc".into()],
565                visible_where: None,
566                namespaces: None,
567                write: None,
568            }],
569            ..Default::default()
570        })
571        .unwrap();
572
573        let scope = Scope::new(
574            Some("reader".into()),
575            None,
576            Some(vec!["b".into(), "c".into()]),
577        )
578        .unwrap();
579        let mask = scope.resolve(&db).unwrap();
580
581        let b = db.ids().get("b").unwrap();
582        assert_eq!(mask.len(), 1, "only `b` is in both legs");
583        assert!(mask.contains_id(b));
584        assert_eq!(mask.mode(), MaskMode::Omit);
585    }
586
587    /// The key leg is re-resolved, not frozen: a key that names nothing when
588    /// the scope is built is visible once it exists.
589    #[test]
590    fn scope_keys_leg_sees_a_key_created_after_construction() {
591        let dir = tmp_dir("scope-late-key");
592        let mut db = GraphDb::open(&dir).unwrap();
593        db.insert_node("Doc", "early", vec![]).unwrap();
594
595        let scope = Scope::new(None, None, Some(vec!["late".into()])).unwrap();
596        assert!(
597            scope.resolve(&db).unwrap().is_empty(),
598            "`late` does not exist yet"
599        );
600
601        db.insert_node("Doc", "late", vec![]).unwrap();
602
603        let mask = scope.resolve(&db).unwrap();
604        let late = db.ids().get("late").unwrap();
605        assert!(
606            mask.contains_id(late),
607            "the key leg must be re-resolved after the write"
608        );
609        assert_eq!(mask.len(), 1);
610    }
611
612    /// Re-resolving a key leg is one hash lookup per key, so it is remembered
613    /// for the commit it was resolved at — and only for that commit.
614    #[test]
615    fn scope_keys_leg_is_cached_within_a_commit() {
616        let dir = tmp_dir("scope-keys-cache");
617        let mut db = GraphDb::open(&dir).unwrap();
618        db.insert_node("Doc", "a", vec![]).unwrap();
619
620        let scope = Scope::new(None, None, Some(vec!["a".into()])).unwrap();
621        let before = SCOPE_KEYS_RESOLVES.with(|c| c.get());
622
623        scope.resolve(&db).unwrap();
624        scope.resolve(&db).unwrap();
625        assert_eq!(
626            SCOPE_KEYS_RESOLVES.with(|c| c.get()) - before,
627            1,
628            "two resolves at one commit rebuild the key leg once"
629        );
630
631        db.insert_node("Doc", "b", vec![]).unwrap();
632        scope.resolve(&db).unwrap();
633        assert_eq!(
634            SCOPE_KEYS_RESOLVES.with(|c| c.get()) - before,
635            2,
636            "a write invalidates the cached key leg"
637        );
638    }
639
640    /// A time-travel read resolves cold, and leaves the cache as it found it.
641    ///
642    /// The cache is keyed on `commit_seq`, which identifies a graph state only
643    /// *within one handle*: a store reopened from a snapshot seeds its
644    /// `commit_seq` from `max(last_change)`, which underestimates the WAL
645    /// length (`db.rs`'s own note at the archive rename says so). So a
646    /// temporal handle from `open_at` can carry the same sequence as the live
647    /// one while holding a different graph — and a shared `Scope` would serve
648    /// one's allow-list to the other. `resolve_uncached` is the way out, and
649    /// it has to be cold in both directions to work.
650    #[test]
651    fn scope_resolve_uncached_neither_reads_nor_fills_the_cache() {
652        let dir = tmp_dir("scope-uncached");
653        let mut db = GraphDb::open(&dir).unwrap();
654        db.insert_node("Doc", "a", vec![]).unwrap();
655
656        let scope = Scope::new(None, None, Some(vec!["a".into()])).unwrap();
657        let before = SCOPE_KEYS_RESOLVES.with(|c| c.get());
658
659        scope.resolve_uncached(&db).unwrap();
660        scope.resolve_uncached(&db).unwrap();
661        assert_eq!(
662            SCOPE_KEYS_RESOLVES.with(|c| c.get()) - before,
663            2,
664            "an uncached resolve never serves the cached entry"
665        );
666
667        scope.resolve(&db).unwrap();
668        scope.resolve(&db).unwrap();
669        assert_eq!(
670            SCOPE_KEYS_RESOLVES.with(|c| c.get()) - before,
671            3,
672            "and never fills it either: the first cached resolve still rebuilds"
673        );
674    }
675
676    /// A resolved key leg belongs to the store it was resolved against.
677    ///
678    /// Dense ids are store-local, so serving store B an allow-list built in
679    /// store A does not merely go stale — it hands B whichever of *its* nodes
680    /// happen to hold those ids. `commit_seq` alone cannot tell the two apart:
681    /// two stores of the same age carry the same one.
682    #[test]
683    fn scope_keys_cache_never_crosses_stores() {
684        let dir_a = tmp_dir("scope-store-a");
685        let dir_b = tmp_dir("scope-store-b");
686        let mut a = GraphDb::open(&dir_a).unwrap();
687        a.insert_node("Doc", "filler", vec![]).unwrap();
688        a.insert_node("Doc", "target", vec![]).unwrap();
689
690        let mut b = GraphDb::open(&dir_b).unwrap();
691        b.insert_node("Doc", "other", vec![]).unwrap();
692        b.insert_node("Doc", "secret", vec![]).unwrap();
693
694        assert_eq!(
695            a.commit_seq(),
696            b.commit_seq(),
697            "the two stores must collide on commit_seq for this to test anything"
698        );
699
700        let scope = Scope::new(None, None, Some(vec!["target".into()])).unwrap();
701        let mask_a = scope.resolve(&a).unwrap();
702        assert!(mask_a.contains_id(a.ids().get("target").unwrap()));
703
704        let mask_b = scope.resolve(&b).unwrap();
705        for key in ["other", "secret"] {
706            let id = b.ids().get(key).unwrap();
707            assert!(
708                !mask_b.contains_id(id),
709                "store B's mask admits `{key}`, a node this scope never named"
710            );
711        }
712        assert!(
713            mask_b.is_empty(),
714            "store B has no `target`, so the key leg resolves to nothing there"
715        );
716    }
717
718    /// A reload is a new store as far as a resolved mask is concerned.
719    ///
720    /// `reset_for_reload` zeroes `commit_seq` and `load_from_disk` reseeds it
721    /// from `max(last_change)`. `DeleteNode` writes no `last_change` entry, so
722    /// a store snapshotted after a delete-only commit comes back at a sequence
723    /// it already held — with a different graph behind it.
724    #[test]
725    fn scope_keys_cache_is_dropped_when_the_store_reloads() {
726        let dir = tmp_dir("scope-reload");
727        let mut w = GraphDb::open(&dir).unwrap();
728        w.insert_node("Doc", "filler", vec![]).unwrap();
729        w.insert_node("Doc", "target", vec![]).unwrap();
730        w.insert_node("Doc", "keep", vec![]).unwrap();
731
732        // A read-only handle takes no lock, so it can follow the writer.
733        let mut r = GraphDb::open_with_options(
734            &dir,
735            crate::db::OpenOptions {
736                read_only: true,
737                ..Default::default()
738            },
739        )
740        .unwrap();
741        let seq_before = r.commit_seq();
742
743        let scope = Scope::new(None, None, Some(vec!["target".into()])).unwrap();
744        assert_eq!(
745            scope.resolve(&r).unwrap().len(),
746            1,
747            "`target` is visible before the delete"
748        );
749
750        // A delete-only commit: it moves the graph but writes no `last_change`.
751        w.delete_node("target").unwrap();
752        w.snapshot().unwrap();
753        r.refresh().unwrap();
754        assert_eq!(
755            r.commit_seq(),
756            seq_before,
757            "the reseed must land back on the sequence the mask was cached at"
758        );
759
760        let mask = scope.resolve(&r).unwrap();
761        for key in ["filler", "keep"] {
762            let id = r.ids().get(key).unwrap();
763            assert!(
764                !mask.contains_id(id),
765                "after the reload the stale mask admits `{key}`, which the scope never named"
766            );
767        }
768        assert!(
769            mask.is_empty(),
770            "`target` is gone, so the key leg must resolve to nothing"
771        );
772    }
773
774    // ── Scope::intersect ──────────────────────────────────────────────────────
775
776    /// Build a two-node store and a `reader` role that sees only `Doc`.
777    fn intersect_fixture(name: &str) -> (std::path::PathBuf, GraphDb<core_storage::fs::RealFs>) {
778        let dir = tmp_dir(name);
779        let mut db = GraphDb::open(&dir).unwrap();
780        db.insert_node("Doc", "a", vec![]).unwrap();
781        db.insert_node("Doc", "b", vec![]).unwrap();
782        db.insert_node("Secret", "c", vec![]).unwrap();
783        db.apply_schema(&Schema {
784            roles: vec![RoleDef {
785                name: "reader".into(),
786                keys: vec![],
787                labels: vec!["Doc".into()],
788                visible_where: None,
789                namespaces: None,
790                write: None,
791            }],
792            ..Default::default()
793        })
794        .unwrap();
795        (dir, db)
796    }
797
798    fn visible_keys<F: Fs>(db: &GraphDb<F>, mask: &NodeMask, keys: &[&str]) -> Vec<String> {
799        keys.iter()
800            .filter(|k| mask.contains_node(db, k))
801            .map(|k| (*k).to_string())
802            .collect()
803    }
804
805    /// Two key legs meet as an intersection: the result names only keys both
806    /// sides named.
807    #[test]
808    fn intersect_of_two_key_legs_keeps_only_the_keys_in_both() {
809        let (_dir, db) = intersect_fixture("intersect-both-keys");
810        let left = Scope::new(None, None, Some(vec!["a".into(), "b".into()])).unwrap();
811        let right = Scope::new(None, None, Some(vec!["b".into(), "c".into()])).unwrap();
812
813        let mask = left.intersect(&right).resolve(&db).unwrap();
814        assert_eq!(visible_keys(&db, &mask, &["a", "b", "c"]), vec!["b"]);
815        // The operation is symmetric.
816        let mask = right.intersect(&left).resolve(&db).unwrap();
817        assert_eq!(visible_keys(&db, &mask, &["a", "b", "c"]), vec!["b"]);
818    }
819
820    /// The asymmetric arms: a side with no key leg contributes no keys, and
821    /// must not be read as "every key". The surviving list is the other side's,
822    /// whichever side that is.
823    #[test]
824    fn intersect_carries_a_lone_key_leg_from_either_side() {
825        let (_dir, db) = intersect_fixture("intersect-one-key");
826        let keyed = Scope::new(None, None, Some(vec!["b".into()])).unwrap();
827        let roled = Scope::new(Some("reader".into()), None, None).unwrap();
828
829        // (Some, None)
830        let mask = keyed.intersect(&roled).resolve(&db).unwrap();
831        assert_eq!(visible_keys(&db, &mask, &["a", "b", "c"]), vec!["b"]);
832        // (None, Some)
833        let mask = roled.intersect(&keyed).resolve(&db).unwrap();
834        assert_eq!(visible_keys(&db, &mask, &["a", "b", "c"]), vec!["b"]);
835    }
836
837    /// (None, None): neither side named keys, so the result has no key leg —
838    /// not an empty one, which would hide everything.
839    #[test]
840    fn intersect_of_two_keyless_scopes_has_no_key_leg() {
841        let (_dir, db) = intersect_fixture("intersect-no-keys");
842        let roled = Scope::new(Some("reader".into()), None, None).unwrap();
843        let namespaced = Scope::new(None, Some("default".into()), None).unwrap();
844
845        let both = roled.intersect(&namespaced);
846        assert!(
847            both.keys.is_none(),
848            "an absent key leg must stay absent, not become `Some(vec![])`"
849        );
850        let mask = both.resolve(&db).unwrap();
851        assert_eq!(
852            visible_keys(&db, &mask, &["a", "b", "c"]),
853            vec!["a", "b"],
854            "the role leg still decides; the missing key leg narrows nothing"
855        );
856    }
857
858    /// Role and namespace legs accumulate rather than replace: an intersection
859    /// resolves both and narrows by each.
860    #[test]
861    fn intersect_accumulates_role_and_namespace_legs() {
862        let (_dir, db) = intersect_fixture("intersect-legs");
863        let reader = Scope::new(Some("reader".into()), None, None).unwrap();
864        let elsewhere = Scope::new(None, Some("other".into()), None).unwrap();
865
866        let both = reader.intersect(&elsewhere);
867        assert_eq!(both.roles, vec!["reader".to_string()]);
868        assert_eq!(both.namespaces, vec!["other".to_string()]);
869        let mask = both.resolve(&db).unwrap();
870        assert!(
871            mask.is_empty(),
872            "every node is in the `default` namespace, so the two legs share nobody"
873        );
874    }
875
876    /// The property the whole handle-scoping story rests on: whatever two
877    /// scopes are combined, the result sees no node either one could not.
878    #[test]
879    fn intersect_can_never_widen_either_side() {
880        let (_dir, db) = intersect_fixture("intersect-never-widens");
881        let all = ["a", "b", "c"];
882        let scopes = || {
883            vec![
884                Scope::new(Some("reader".into()), None, None).unwrap(),
885                Scope::new(None, Some("default".into()), None).unwrap(),
886                Scope::new(None, None, Some(vec!["b".into(), "c".into()])).unwrap(),
887                Scope::new(None, None, Some(vec![])).unwrap(),
888                Scope::new(Some("reader".into()), None, Some(vec!["a".into()])).unwrap(),
889            ]
890        };
891        for left in scopes() {
892            for right in scopes() {
893                let l = visible_keys(&db, &left.resolve(&db).unwrap(), &all);
894                let r = visible_keys(&db, &right.resolve(&db).unwrap(), &all);
895                let both = visible_keys(&db, &left.intersect(&right).resolve(&db).unwrap(), &all);
896                for key in &both {
897                    assert!(
898                        l.contains(key) && r.contains(key),
899                        "{left:?} ∩ {right:?} sees `{key}`, which one side alone does not"
900                    );
901                }
902            }
903        }
904    }
905
906    #[test]
907    fn a_version_change_rebuilds_and_clear_empties() {
908        let cache = RoleMaskCache::new();
909        let built = std::cell::Cell::new(0u32);
910        let build = |ids: Vec<u32>| {
911            built.set(built.get() + 1);
912            Ok(NodeMask::from_ids(ids))
913        };
914
915        let m = cache.get_or_build("r", 1, || build(vec![1])).unwrap();
916        assert_eq!(m.len(), 1);
917        assert_eq!(built.get(), 1);
918
919        // Same version → memo hit, `build` never runs.
920        let m = cache.get_or_build("r", 1, || build(vec![1, 2])).unwrap();
921        assert_eq!(m.len(), 1, "the memoised mask is returned unchanged");
922        assert_eq!(built.get(), 1);
923
924        // New version → rebuild.
925        let m = cache.get_or_build("r", 2, || build(vec![1, 2])).unwrap();
926        assert_eq!(m.len(), 2);
927        assert_eq!(built.get(), 2);
928
929        // A different role is a different entry.
930        let m = cache.get_or_build("other", 2, || build(vec![9])).unwrap();
931        assert_eq!(m.len(), 1);
932        assert_eq!(built.get(), 3);
933
934        cache.clear();
935        let _ = cache.get_or_build("r", 2, || build(vec![1, 2])).unwrap();
936        assert_eq!(built.get(), 4, "clear drops the entry, so it rebuilds");
937    }
938
939    #[test]
940    fn a_failed_build_is_not_cached() {
941        let cache = RoleMaskCache::new();
942        assert!(cache
943            .get_or_build("r", 1, || Err(core_storage::GraphError::KeyNotFound {
944                key: "role:r".into()
945            }))
946            .is_err());
947        let m = cache
948            .get_or_build("r", 1, || Ok(NodeMask::from_ids(vec![7])))
949            .unwrap();
950        assert_eq!(m.len(), 1);
951    }
952}