Skip to main content

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}