heddle_object_model/object/
entry_visibility.rs1use serde::{Deserialize, Serialize};
23
24use crate::object::{ChangeId, ContentHash, VisibilityTier};
25
26pub const ENTRY_VISIBILITY_FORMAT_VERSION: u8 = 1;
28
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct EntryVisibilityEntry {
32 pub tree_id: ContentHash,
34 pub leaf_hash: ContentHash,
37 pub tier: VisibilityTier,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub struct EntryVisibility {
47 pub format_version: u8,
49 pub change_id: ChangeId,
51 pub tree_root: ContentHash,
53 pub entries: Vec<EntryVisibilityEntry>,
56}
57
58impl EntryVisibility {
59 pub fn new(
68 change_id: ChangeId,
69 tree_root: ContentHash,
70 mut entries: Vec<EntryVisibilityEntry>,
71 ) -> Result<Self, EntryVisibilityError> {
72 entries.sort_by(|a, b| {
73 a.tree_id
74 .as_bytes()
75 .cmp(b.tree_id.as_bytes())
76 .then_with(|| a.leaf_hash.as_bytes().cmp(b.leaf_hash.as_bytes()))
77 });
78 let mut deduped: Vec<EntryVisibilityEntry> = Vec::with_capacity(entries.len());
81 for entry in entries {
82 if let Some(last) = deduped.last()
83 && last.tree_id == entry.tree_id
84 && last.leaf_hash == entry.leaf_hash
85 {
86 if last.tier != entry.tier {
87 return Err(EntryVisibilityError::ConflictingDuplicate {
88 tree_id: entry.tree_id.to_hex(),
89 leaf_hash: entry.leaf_hash.to_hex(),
90 });
91 }
92 continue;
93 }
94 deduped.push(entry);
95 }
96 Ok(Self {
97 format_version: ENTRY_VISIBILITY_FORMAT_VERSION,
98 change_id,
99 tree_root,
100 entries: deduped,
101 })
102 }
103
104 pub fn has_records(&self) -> bool {
107 !self.entries.is_empty()
108 }
109
110 pub fn encode(&self) -> Result<Vec<u8>, EntryVisibilityError> {
112 rmp_serde::to_vec_named(self).map_err(|e| EntryVisibilityError::Codec(e.to_string()))
113 }
114
115 pub fn decode(bytes: &[u8]) -> Result<Self, EntryVisibilityError> {
117 let value: Self =
118 rmp_serde::from_slice(bytes).map_err(|e| EntryVisibilityError::Codec(e.to_string()))?;
119 if value.format_version != ENTRY_VISIBILITY_FORMAT_VERSION {
120 return Err(EntryVisibilityError::UnsupportedVersion(
121 value.format_version,
122 ));
123 }
124 Ok(value)
125 }
126
127 pub fn content_hash(&self) -> Result<ContentHash, EntryVisibilityError> {
130 let bytes = self.encode()?;
131 Ok(ContentHash::from_bytes(*blake3::hash(&bytes).as_bytes()))
132 }
133}
134
135#[derive(Debug, thiserror::Error)]
137pub enum EntryVisibilityError {
138 #[error("unsupported entry-visibility format version {0}")]
139 UnsupportedVersion(u8),
140 #[error("entry-visibility codec error: {0}")]
141 Codec(String),
142 #[error(
143 "conflicting entry-visibility overrides for the same entry (tree {tree_id}, leaf {leaf_hash})"
144 )]
145 ConflictingDuplicate { tree_id: String, leaf_hash: String },
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn hash(seed: &str) -> ContentHash {
153 ContentHash::compute_typed("test", seed.as_bytes())
154 }
155
156 #[test]
157 fn round_trips_and_normalizes_order() {
158 let change = ChangeId::generate();
159 let root = hash("root");
160 let unordered = vec![
161 EntryVisibilityEntry {
162 tree_id: hash("t2"),
163 leaf_hash: hash("l2"),
164 tier: VisibilityTier::Internal,
165 },
166 EntryVisibilityEntry {
167 tree_id: hash("t1"),
168 leaf_hash: hash("l1"),
169 tier: VisibilityTier::Private {
170 scope_label: "secret".into(),
171 },
172 },
173 ];
174 let a = EntryVisibility::new(change, root, unordered.clone()).unwrap();
175 let mut reversed = unordered;
176 reversed.reverse();
177 let b = EntryVisibility::new(change, root, reversed).unwrap();
178 assert_eq!(
179 a.encode().unwrap(),
180 b.encode().unwrap(),
181 "order must normalize"
182 );
183
184 let decoded = EntryVisibility::decode(&a.encode().unwrap()).unwrap();
185 assert_eq!(decoded, a);
186 assert_eq!(decoded.content_hash().unwrap(), a.content_hash().unwrap());
187 }
188
189 #[test]
190 fn rejects_unsupported_version() {
191 let change = ChangeId::generate();
192 let mut sidecar = EntryVisibility::new(change, hash("root"), Vec::new()).unwrap();
193 sidecar.format_version = 99;
194 let bytes = rmp_serde::to_vec_named(&sidecar).unwrap();
195 assert!(matches!(
196 EntryVisibility::decode(&bytes),
197 Err(EntryVisibilityError::UnsupportedVersion(99))
198 ));
199 }
200
201 #[test]
202 fn rejects_conflicting_duplicate_leaf() {
203 let change = ChangeId::generate();
204 let root = hash("root");
205 let conflicting = vec![
206 EntryVisibilityEntry {
207 tree_id: root,
208 leaf_hash: hash("leaf"),
209 tier: VisibilityTier::Internal,
210 },
211 EntryVisibilityEntry {
212 tree_id: root,
213 leaf_hash: hash("leaf"),
214 tier: VisibilityTier::Private {
215 scope_label: "secret".into(),
216 },
217 },
218 ];
219 assert!(matches!(
220 EntryVisibility::new(change, root, conflicting),
221 Err(EntryVisibilityError::ConflictingDuplicate { .. })
222 ));
223
224 let exact = vec![
226 EntryVisibilityEntry {
227 tree_id: root,
228 leaf_hash: hash("leaf"),
229 tier: VisibilityTier::Internal,
230 },
231 EntryVisibilityEntry {
232 tree_id: root,
233 leaf_hash: hash("leaf"),
234 tier: VisibilityTier::Internal,
235 },
236 ];
237 let sidecar = EntryVisibility::new(change, root, exact).unwrap();
238 assert_eq!(sidecar.entries.len(), 1);
239 }
240}