Skip to main content

heddle_object_model/object/
entry_redactions.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared, name-free per-entry redaction for an already admitted state.
3//!
4//! Only explicit overrides contribute leaves. Unioning ancestor overrides
5//! carries a restriction while the salted entry commitment remains unchanged;
6//! a changed entry has a fresh salt and does not inherit content taint.
7
8use std::collections::HashSet;
9
10use super::{ContentHash, EntryVisibilityEntry, Tree, TreeScheme, VisibilityTier};
11
12/// Explicit salted entry commitments hidden from one admitted reader.
13///
14/// This is not a state authorization proof. Callers must first admit the
15/// governing Thread and apply the whole-tip and own-tier gate. Directory
16/// descent, hash reachability, listings and diffs then use the same predicate.
17#[derive(Clone, Debug, Default)]
18pub struct EntryRedactions {
19    leaves: HashSet<ContentHash>,
20}
21
22impl EntryRedactions {
23    /// Fold explicit overrides whose tier the caller's authorized audience
24    /// cannot read. For ancestor carry-forward, evaluate the override tier
25    /// alone: the served state's baseline is checked at the whole-tip gate.
26    pub fn extend_overrides(
27        &mut self,
28        entries: &[EntryVisibilityEntry],
29        can_read: impl Fn(&VisibilityTier) -> bool,
30    ) {
31        self.leaves.extend(
32            entries
33                .iter()
34                .filter(|entry| !can_read(&entry.tier))
35                .map(|entry| entry.leaf_hash),
36        );
37    }
38
39    /// Union restrictions from another endpoint, such as the base of a diff.
40    pub fn extend(&mut self, other: &Self) {
41        self.leaves.extend(other.leaves.iter().copied());
42    }
43
44    /// Opaque commitments used by [`super::PartialTree::project`].
45    pub fn leaves(&self) -> &HashSet<ContentHash> {
46        &self.leaves
47    }
48
49    /// Whether no explicit override is hidden for this reader.
50    pub fn is_empty(&self) -> bool {
51        self.leaves.is_empty()
52    }
53
54    /// Whether one occurrence is visible. A denied directory must not be
55    /// descended into, even to resolve a caller-supplied path or object hash.
56    /// Invalid indices and malformed salted leaves fail closed.
57    pub fn entry_visible(&self, tree: &Tree, index: usize) -> bool {
58        if index >= tree.entries().len() {
59            return false;
60        }
61        match tree.scheme() {
62            TreeScheme::V3Flat => true,
63            TreeScheme::V4Salted => tree
64                .v4_leaf_hash_at(index)
65                .is_some_and(|leaf| !self.leaves.contains(&leaf)),
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::object::{AudienceTier, PartialTree, PartialTreeLeaf, TreeEntry, visible};
74
75    #[test]
76    fn explicit_leaf_carries_across_tree_roots_but_not_a_fresh_salt() {
77        let file = TreeEntry::file("secret.txt", ContentHash::compute(b"secret"), false)
78            .expect("valid file");
79        let parent =
80            Tree::from_entries_salted_v4(vec![file.clone()], vec![[1; 32]]).expect("salted parent");
81        let inherited = Tree::from_entries_salted_v4(
82            vec![
83                file.clone(),
84                TreeEntry::file("visible.txt", ContentHash::compute(b"ok"), false)
85                    .expect("valid sibling"),
86            ],
87            vec![[1; 32], [2; 32]],
88        )
89        .expect("salted descendant");
90        let changed =
91            Tree::from_entries_salted_v4(vec![file], vec![[3; 32]]).expect("fresh commitment");
92        let overrides = [EntryVisibilityEntry {
93            tree_id: parent.hash(),
94            leaf_hash: parent.v4_leaf_hash_at(0).expect("parent leaf"),
95            tier: VisibilityTier::Private {
96                scope_label: "security".into(),
97            },
98        }];
99        let mut redacted = EntryRedactions::default();
100        redacted.extend_overrides(&overrides, |tier| visible(tier, &AudienceTier::Internal));
101        assert_ne!(parent.hash(), inherited.hash());
102        assert!(!redacted.entry_visible(&parent, 0));
103        assert!(
104            !redacted.entry_visible(&inherited, 0),
105            "unchanged leaf retains override"
106        );
107        assert!(redacted.entry_visible(&inherited, 1));
108        assert!(
109            redacted.entry_visible(&changed, 0),
110            "fresh salt does not inherit taint"
111        );
112        assert!(
113            !redacted.entry_visible(&changed, 1),
114            "invalid index is not visible"
115        );
116        let partial = PartialTree::project(&inherited, redacted.leaves()).expect("projection");
117        partial
118            .verify()
119            .expect("original Merkle root remains provable");
120        assert_eq!(partial.declared_root(), inherited.hash());
121        assert!(
122            partial
123                .leaves()
124                .iter()
125                .any(|leaf| matches!(leaf, PartialTreeLeaf::Redacted { .. }))
126        );
127        assert!(!partial.leaves().iter().any(|leaf| matches!(leaf,
128            PartialTreeLeaf::Visible { entry, .. } if entry.name() == "secret.txt")));
129
130        let mut labelled = EntryRedactions::default();
131        labelled.extend_overrides(&overrides, |tier| {
132            visible(tier, &AudienceTier::Restricted("security".into()))
133        });
134        assert!(labelled.entry_visible(&inherited, 0));
135    }
136}