core_api/mask.rs
1use core_storage::fs::Fs;
2use core_storage::Result;
3use std::collections::{HashMap, HashSet};
4use std::sync::{Arc, Mutex};
5
6use crate::db::GraphDb;
7
8/// Controls how hidden nodes are rendered when a [`NodeMask`] is used in
9/// [`GraphDb::node_info_masked`], [`GraphDb::node_edges_masked`], and
10/// [`GraphDb::neighborhood_masked`].
11///
12/// The default is [`MaskMode::Omit`], which preserves byte-identical behaviour
13/// with all pre-existing masked paths. [`MaskMode::Stub`] is an explicit
14/// opt-in that discloses node *existence* — suitable only for full-token
15/// client masks. Role-token paths are hard-coded to `Omit`.
16///
17/// **Existence-disclosure warning**: `Stub` mode intentionally tells the caller
18/// whether a node exists, even if its contents are hidden. Only use this on
19/// client-mask (full-token) paths where the caller already has that knowledge
20/// implicitly.
21#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
22pub enum MaskMode {
23 /// Hidden nodes are silently omitted from every result — behaviour is
24 /// byte-identical to the pre-existing masked-query paths. This is the
25 /// default.
26 #[default]
27 Omit,
28 /// Hidden nodes' existence is acknowledged via a restricted stub:
29 /// `{"key": "<key>", "restricted": true}`. No label, props, or other
30 /// fields are included in the stub.
31 Stub,
32}
33
34/// Query-scoped node visibility filter (ACL primitive).
35///
36/// When a `NodeMask` is passed to `query_masked`, only nodes whose dense id
37/// appears in `visible` will be returned by label scans, key lookups, and
38/// neighbor expansions. Edges where either endpoint is hidden are silently
39/// dropped from the result.
40///
41/// Unknown keys in `from_keys` are silently ignored (they resolve to no id).
42/// An empty mask hides every node.
43#[derive(Clone, Debug)]
44pub struct NodeMask {
45 pub(crate) visible: HashSet<u32>,
46 mode: MaskMode,
47}
48
49impl NodeMask {
50 /// Resolve string keys to dense ids and build a mask.
51 ///
52 /// Keys that do not exist in the database are ignored.
53 /// The mask mode defaults to [`MaskMode::Omit`]; call [`NodeMask::with_mode`]
54 /// to opt into [`MaskMode::Stub`].
55 pub fn from_keys<'a, F: Fs>(db: &GraphDb<F>, keys: impl IntoIterator<Item = &'a str>) -> Self {
56 let visible = keys.into_iter().filter_map(|k| db.ids().get(k)).collect();
57 NodeMask {
58 visible,
59 mode: MaskMode::default(),
60 }
61 }
62
63 /// Build a mask from an already-resolved iterator of dense node ids.
64 ///
65 /// Used by `ReaderSnapshot` handlers that resolve keys against the frozen
66 /// state without a `GraphDb` reference.
67 pub fn from_ids(ids: impl IntoIterator<Item = u32>) -> Self {
68 NodeMask {
69 visible: ids.into_iter().collect(),
70 mode: MaskMode::default(),
71 }
72 }
73
74 /// Set the rendering mode, consuming `self` and returning a new mask.
75 ///
76 /// **SECURITY**: never call with [`MaskMode::Stub`] on role-token paths.
77 pub fn with_mode(self, mode: MaskMode) -> Self {
78 NodeMask { mode, ..self }
79 }
80
81 /// Return the current rendering mode.
82 pub fn mode(&self) -> MaskMode {
83 self.mode
84 }
85
86 pub fn len(&self) -> usize {
87 self.visible.len()
88 }
89
90 pub fn is_empty(&self) -> bool {
91 self.visible.is_empty()
92 }
93
94 /// Return a new mask that is the intersection of `self` and `other`.
95 ///
96 /// The result contains only nodes visible in both masks. Used to enforce
97 /// the never-widen rule when a role token also supplies a client mask:
98 /// `effective = role_mask.intersect(&client_mask)`.
99 ///
100 /// The result always carries [`MaskMode::Omit`] — the role-path invariant
101 /// means stubs must never slip through an intersection.
102 pub fn intersect(&self, other: &NodeMask) -> NodeMask {
103 NodeMask {
104 visible: self.visible.intersection(&other.visible).copied().collect(),
105 mode: MaskMode::Omit,
106 }
107 }
108
109 /// Return `true` if the dense node id is visible in this mask.
110 ///
111 /// Used by `ReaderSnapshot` handlers where the key has already been resolved
112 /// to a dense id (avoids a second lookup into a `GraphDb`).
113 pub fn contains_id(&self, id: u32) -> bool {
114 self.visible.contains(&id)
115 }
116
117 /// Return `true` if the node identified by `key` is visible in this mask.
118 ///
119 /// Returns `false` for keys that do not exist in the database (unknown keys
120 /// are never visible), as well as for keys that exist but are not in the
121 /// visible set. Used by node-endpoint handlers to produce the same
122 /// absent-key response for both missing and hidden nodes.
123 pub fn contains_node<F: core_storage::fs::Fs>(
124 &self,
125 db: &crate::db::GraphDb<F>,
126 key: &str,
127 ) -> bool {
128 db.ids()
129 .get(key)
130 .is_some_and(|id| self.visible.contains(&id))
131 }
132}
133
134// ── Role → mask memo ──────────────────────────────────────────────────────────
135
136/// Role → resolved mask, valid for exactly one commit sequence.
137///
138/// Resolving a role is a full scan of the label vector, and with a
139/// [`visible_where`](crate::roles::RoleDef::visible_where) predicate it is also
140/// a property read per candidate node. A scoped reader pays that on every
141/// request, and between two writes the answer cannot have changed — so it is
142/// paid once and remembered.
143///
144/// **Never stale**: an entry records the store's `commit_seq` at the moment it
145/// was built and is served only when that is still the current one. Any write
146/// bumps `commit_seq` and the entry simply stops matching. The cache can be
147/// cold, but it cannot be wrong.
148///
149/// `commit_seq` does not move when a role *definition* changes — `roles.json`
150/// is a sidecar, not a WAL record — so the owner of the cache installs a fresh
151/// one whenever roles are rewritten or the store is reloaded. That also leaves
152/// any reader snapshot holding the old `Arc` with a private cache, so a
153/// snapshot frozen against the old definitions can never publish an answer the
154/// live handle would read back.
155#[derive(Default)]
156pub struct RoleMaskCache {
157 entries: Mutex<HashMap<String, (u64, Arc<NodeMask>)>>,
158}
159
160impl RoleMaskCache {
161 pub fn new() -> Self {
162 Self::default()
163 }
164
165 /// Return the memoised mask for `role` at `version`, building it if the
166 /// entry is absent or was built against a different commit sequence.
167 ///
168 /// `build` runs outside the lock: it reads the store, and the cache must
169 /// never be a lock ordering between two readers.
170 pub fn get_or_build(
171 &self,
172 role: &str,
173 version: u64,
174 build: impl FnOnce() -> Result<NodeMask>,
175 ) -> Result<Arc<NodeMask>> {
176 if let Ok(entries) = self.entries.lock() {
177 if let Some((v, mask)) = entries.get(role) {
178 if *v == version {
179 return Ok(Arc::clone(mask));
180 }
181 }
182 }
183 let mask = Arc::new(build()?);
184 if let Ok(mut entries) = self.entries.lock() {
185 entries.insert(role.to_string(), (version, Arc::clone(&mask)));
186 }
187 Ok(mask)
188 }
189
190 /// Drop every entry. Correctness never depends on this — a mismatched
191 /// version is already ignored — but the owner calls it when the role
192 /// definitions themselves change, which `commit_seq` does not record.
193 pub fn clear(&self) {
194 if let Ok(mut entries) = self.entries.lock() {
195 entries.clear();
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn a_version_change_rebuilds_and_clear_empties() {
206 let cache = RoleMaskCache::new();
207 let built = std::cell::Cell::new(0u32);
208 let build = |ids: Vec<u32>| {
209 built.set(built.get() + 1);
210 Ok(NodeMask::from_ids(ids))
211 };
212
213 let m = cache.get_or_build("r", 1, || build(vec![1])).unwrap();
214 assert_eq!(m.len(), 1);
215 assert_eq!(built.get(), 1);
216
217 // Same version → memo hit, `build` never runs.
218 let m = cache.get_or_build("r", 1, || build(vec![1, 2])).unwrap();
219 assert_eq!(m.len(), 1, "the memoised mask is returned unchanged");
220 assert_eq!(built.get(), 1);
221
222 // New version → rebuild.
223 let m = cache.get_or_build("r", 2, || build(vec![1, 2])).unwrap();
224 assert_eq!(m.len(), 2);
225 assert_eq!(built.get(), 2);
226
227 // A different role is a different entry.
228 let m = cache.get_or_build("other", 2, || build(vec![9])).unwrap();
229 assert_eq!(m.len(), 1);
230 assert_eq!(built.get(), 3);
231
232 cache.clear();
233 let _ = cache.get_or_build("r", 2, || build(vec![1, 2])).unwrap();
234 assert_eq!(built.get(), 4, "clear drops the entry, so it rebuilds");
235 }
236
237 #[test]
238 fn a_failed_build_is_not_cached() {
239 let cache = RoleMaskCache::new();
240 assert!(cache
241 .get_or_build("r", 1, || Err(core_storage::GraphError::KeyNotFound {
242 key: "role:r".into()
243 }))
244 .is_err());
245 let m = cache
246 .get_or_build("r", 1, || Ok(NodeMask::from_ids(vec![7])))
247 .unwrap();
248 assert_eq!(m.len(), 1);
249 }
250}