heddle_object_model/object/
entry_redactions.rs1use std::collections::HashSet;
9
10use super::{ContentHash, EntryVisibilityEntry, Tree, TreeScheme, VisibilityTier};
11
12#[derive(Clone, Debug, Default)]
18pub struct EntryRedactions {
19 leaves: HashSet<ContentHash>,
20}
21
22impl EntryRedactions {
23 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 pub fn extend(&mut self, other: &Self) {
41 self.leaves.extend(other.leaves.iter().copied());
42 }
43
44 pub fn leaves(&self) -> &HashSet<ContentHash> {
46 &self.leaves
47 }
48
49 pub fn is_empty(&self) -> bool {
51 self.leaves.is_empty()
52 }
53
54 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}