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}