Skip to main content

wire/
object_graph.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::{HashSet, VecDeque};
3
4use objects::{
5    object::{ChangeId, ContentHash, EntryType},
6    store::ObjectStore,
7};
8use serde::{Deserialize, Serialize};
9
10use crate::{ProtocolError, Result};
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum ObjectId {
14    Hash(ContentHash),
15    ChangeId(ChangeId),
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ObjectInfo {
20    pub id: ObjectId,
21    pub obj_type: ObjectType,
22    pub size: u64,
23    pub delta_base: Option<ContentHash>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct PlannedObject {
28    pub id: ObjectId,
29    pub obj_type: ObjectType,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33pub enum ObjectType {
34    Blob,
35    Tree,
36    State,
37    Action,
38    /// A `RedactionsBlob` sidecar — the rmp-encoded record(s) declaring
39    /// that a specific blob has been redacted (and possibly purged) by
40    /// an authorized operator. Keyed on the wire by `ObjectId::Hash` of
41    /// the *redacted blob*, since `Repository`'s sidecar store is
42    /// indexed that way.
43    Redaction,
44    /// A `StateVisibilityBlob` sidecar — the rmp-encoded record(s)
45    /// declaring a non-public audience tier for a specific state. Keyed
46    /// on the wire by `ObjectId::ChangeId` of the *state*, since the
47    /// per-state sidecar store is indexed that way. Like `Redaction`, it
48    /// is a sidecar record that lives outside the content-addressed pack
49    /// and ships via the per-object transfer path, not the pack.
50    StateVisibility,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct StateClosureOptions {
55    pub depth: Option<u32>,
56    pub exclude_states: Vec<ChangeId>,
57}
58
59pub fn enumerate_state_closure(
60    store: &impl ObjectStore,
61    state_id: ChangeId,
62) -> Result<Vec<ObjectInfo>> {
63    enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
64}
65
66pub fn enumerate_state_closure_with_options(
67    store: &impl ObjectStore,
68    state_id: ChangeId,
69    options: StateClosureOptions,
70) -> Result<Vec<ObjectInfo>> {
71    let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
72
73    let mut out = Vec::new();
74    let mut seen_states: HashSet<ChangeId> = HashSet::new();
75    let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
76    let mut queue: VecDeque<(ChangeId, u32)> = VecDeque::new();
77    queue.push_back((state_id, 0));
78
79    while let Some((id, depth)) = queue.pop_front() {
80        if excluded_states.contains(&id) {
81            continue;
82        }
83        if !seen_states.insert(id) {
84            continue;
85        }
86
87        let state = store
88            .get_state(&id)?
89            .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
90
91        let state_bytes = rmp_serde::to_vec_named(&state)?;
92        out.push(ObjectInfo {
93            id: ObjectId::ChangeId(id),
94            obj_type: ObjectType::State,
95            size: state_bytes.len() as u64,
96            delta_base: None,
97        });
98        emit_state_visibility_info(store, &id, &mut out)?;
99
100        if options.depth.map(|max| depth < max).unwrap_or(true) {
101            for parent in &state.parents {
102                queue.push_back((*parent, depth + 1));
103            }
104        }
105
106        enumerate_tree_closure_filtered(
107            store,
108            state.tree,
109            &excluded_hashes,
110            &mut seen_hashes,
111            &mut out,
112        )?;
113        if let Some(provenance_root) = state.provenance {
114            enumerate_tree_closure_filtered(
115                store,
116                provenance_root,
117                &excluded_hashes,
118                &mut seen_hashes,
119                &mut out,
120            )?;
121        }
122        if let Some(context_root) = state.context {
123            enumerate_tree_closure_filtered(
124                store,
125                context_root,
126                &excluded_hashes,
127                &mut seen_hashes,
128                &mut out,
129            )?;
130        }
131    }
132
133    Ok(out)
134}
135
136pub fn enumerate_state_closure_plan(
137    store: &impl ObjectStore,
138    state_id: ChangeId,
139) -> Result<Vec<PlannedObject>> {
140    enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
141}
142
143pub fn enumerate_state_closure_plan_with_options(
144    store: &impl ObjectStore,
145    state_id: ChangeId,
146    options: StateClosureOptions,
147) -> Result<Vec<PlannedObject>> {
148    let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
149
150    let mut out = Vec::new();
151    let mut seen_states: HashSet<ChangeId> = HashSet::new();
152    let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
153    let mut queue: VecDeque<(ChangeId, u32)> = VecDeque::new();
154    queue.push_back((state_id, 0));
155
156    while let Some((id, depth)) = queue.pop_front() {
157        if excluded_states.contains(&id) {
158            continue;
159        }
160        if !seen_states.insert(id) {
161            continue;
162        }
163
164        let state = store
165            .get_state(&id)?
166            .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
167
168        out.push(PlannedObject {
169            id: ObjectId::ChangeId(id),
170            obj_type: ObjectType::State,
171        });
172        emit_state_visibility_plan(store, &id, &mut out)?;
173
174        if options.depth.map(|max| depth < max).unwrap_or(true) {
175            for parent in &state.parents {
176                queue.push_back((*parent, depth + 1));
177            }
178        }
179
180        enumerate_tree_plan_filtered(
181            store,
182            state.tree,
183            &excluded_hashes,
184            &mut seen_hashes,
185            &mut out,
186        )?;
187        if let Some(provenance_root) = state.provenance {
188            enumerate_tree_plan_filtered(
189                store,
190                provenance_root,
191                &excluded_hashes,
192                &mut seen_hashes,
193                &mut out,
194            )?;
195        }
196        if let Some(context_root) = state.context {
197            enumerate_tree_plan_filtered(
198                store,
199                context_root,
200                &excluded_hashes,
201                &mut seen_hashes,
202                &mut out,
203            )?;
204        }
205    }
206
207    Ok(out)
208}
209
210fn enumerate_tree_closure_filtered(
211    store: &impl ObjectStore,
212    tree_hash: ContentHash,
213    excluded: &HashSet<ContentHash>,
214    seen: &mut HashSet<ContentHash>,
215    out: &mut Vec<ObjectInfo>,
216) -> Result<()> {
217    if excluded.contains(&tree_hash) {
218        return Ok(());
219    }
220    if !seen.insert(tree_hash) {
221        return Ok(());
222    }
223
224    let tree = store
225        .get_tree(&tree_hash)?
226        .ok_or_else(|| ProtocolError::ObjectNotFound(tree_hash.to_hex()))?;
227
228    let tree_bytes = rmp_serde::to_vec_named(&tree)?;
229    out.push(ObjectInfo {
230        id: ObjectId::Hash(tree_hash),
231        obj_type: ObjectType::Tree,
232        size: tree_bytes.len() as u64,
233        delta_base: None,
234    });
235
236    for entry in tree.entries() {
237        match entry.entry_type {
238            EntryType::Blob => {
239                if excluded.contains(&entry.hash) {
240                    continue;
241                }
242                if !seen.insert(entry.hash) {
243                    continue;
244                }
245                let blob = store
246                    .get_blob(&entry.hash)?
247                    .ok_or_else(|| ProtocolError::ObjectNotFound(entry.hash.to_hex()))?;
248                out.push(ObjectInfo {
249                    id: ObjectId::Hash(entry.hash),
250                    obj_type: ObjectType::Blob,
251                    size: blob.size() as u64,
252                    delta_base: None,
253                });
254                emit_redaction_info(store, &entry.hash, out)?;
255            }
256            EntryType::Tree => {
257                enumerate_tree_closure_filtered(store, entry.hash, excluded, seen, out)?;
258            }
259            EntryType::Symlink => {
260                if excluded.contains(&entry.hash) {
261                    continue;
262                }
263                if !seen.insert(entry.hash) {
264                    continue;
265                }
266                let blob = store
267                    .get_blob(&entry.hash)?
268                    .ok_or_else(|| ProtocolError::ObjectNotFound(entry.hash.to_hex()))?;
269                out.push(ObjectInfo {
270                    id: ObjectId::Hash(entry.hash),
271                    obj_type: ObjectType::Blob,
272                    size: blob.size() as u64,
273                    delta_base: None,
274                });
275                emit_redaction_info(store, &entry.hash, out)?;
276            }
277        }
278    }
279
280    Ok(())
281}
282
283/// If `state` carries a state-visibility sidecar, push a StateVisibility
284/// `ObjectInfo` keyed by the state id. No-op when the state is public by
285/// absence.
286fn emit_state_visibility_info(
287    store: &impl ObjectStore,
288    state: &ChangeId,
289    out: &mut Vec<ObjectInfo>,
290) -> Result<()> {
291    if let Some(bytes) = store.get_state_visibility_bytes_for_state(state)? {
292        out.push(ObjectInfo {
293            id: ObjectId::ChangeId(*state),
294            obj_type: ObjectType::StateVisibility,
295            size: bytes.len() as u64,
296            delta_base: None,
297        });
298    }
299    Ok(())
300}
301
302fn emit_state_visibility_plan(
303    store: &impl ObjectStore,
304    state: &ChangeId,
305    out: &mut Vec<PlannedObject>,
306) -> Result<()> {
307    if store.has_state_visibility_for_state(state)? {
308        out.push(PlannedObject {
309            id: ObjectId::ChangeId(*state),
310            obj_type: ObjectType::StateVisibility,
311        });
312    }
313    Ok(())
314}
315
316/// If `blob` carries a redaction sidecar, push a Redaction `ObjectInfo`
317/// keyed by the blob hash. No-op when the blob has no redactions.
318///
319/// Redactions are not deduped via the `seen: HashSet<ContentHash>` used
320/// for blob/tree dedup because the `ObjectId` for a redaction is the
321/// *redacted blob's* hash — and that hash is already inserted into
322/// `seen` by the blob's own emission. A blob can only appear once in
323/// the closure (dedup'd by hash), so its redaction can only be emitted
324/// once too.
325fn emit_redaction_info(
326    store: &impl ObjectStore,
327    blob: &ContentHash,
328    out: &mut Vec<ObjectInfo>,
329) -> Result<()> {
330    if let Some(bytes) = store.get_redactions_bytes_for_blob(blob)? {
331        out.push(ObjectInfo {
332            id: ObjectId::Hash(*blob),
333            obj_type: ObjectType::Redaction,
334            size: bytes.len() as u64,
335            delta_base: None,
336        });
337    }
338    Ok(())
339}
340
341fn enumerate_tree_plan_filtered(
342    store: &impl ObjectStore,
343    tree_hash: ContentHash,
344    excluded: &HashSet<ContentHash>,
345    seen: &mut HashSet<ContentHash>,
346    out: &mut Vec<PlannedObject>,
347) -> Result<()> {
348    if excluded.contains(&tree_hash) {
349        return Ok(());
350    }
351    if !seen.insert(tree_hash) {
352        return Ok(());
353    }
354
355    let tree = store
356        .get_tree(&tree_hash)?
357        .ok_or_else(|| ProtocolError::ObjectNotFound(tree_hash.to_hex()))?;
358
359    out.push(PlannedObject {
360        id: ObjectId::Hash(tree_hash),
361        obj_type: ObjectType::Tree,
362    });
363
364    for entry in tree.entries() {
365        match entry.entry_type {
366            EntryType::Blob | EntryType::Symlink => {
367                if excluded.contains(&entry.hash) {
368                    continue;
369                }
370                if !seen.insert(entry.hash) {
371                    continue;
372                }
373                out.push(PlannedObject {
374                    id: ObjectId::Hash(entry.hash),
375                    obj_type: ObjectType::Blob,
376                });
377                emit_redaction_plan(store, &entry.hash, out)?;
378            }
379            EntryType::Tree => {
380                enumerate_tree_plan_filtered(store, entry.hash, excluded, seen, out)?;
381            }
382        }
383    }
384
385    Ok(())
386}
387
388fn emit_redaction_plan(
389    store: &impl ObjectStore,
390    blob: &ContentHash,
391    out: &mut Vec<PlannedObject>,
392) -> Result<()> {
393    if store.has_redactions_for_blob(blob)? {
394        out.push(PlannedObject {
395            id: ObjectId::Hash(*blob),
396            obj_type: ObjectType::Redaction,
397        });
398    }
399    Ok(())
400}
401
402fn collect_excluded(
403    store: &impl ObjectStore,
404    roots: &[ChangeId],
405) -> Result<(HashSet<ChangeId>, HashSet<ContentHash>)> {
406    if roots.is_empty() {
407        return Ok((HashSet::new(), HashSet::new()));
408    }
409
410    let mut excluded_states: HashSet<ChangeId> = HashSet::new();
411    let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
412    let mut queue: VecDeque<ChangeId> = VecDeque::new();
413
414    for id in roots {
415        queue.push_back(*id);
416    }
417
418    while let Some(id) = queue.pop_front() {
419        if !excluded_states.insert(id) {
420            continue;
421        }
422
423        let state = match store.get_state(&id)? {
424            Some(state) => state,
425            None => continue,
426        };
427
428        for parent in &state.parents {
429            queue.push_back(*parent);
430        }
431
432        collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
433        if let Some(provenance_root) = state.provenance {
434            collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
435        }
436        if let Some(context_root) = state.context {
437            collect_tree_hashes(store, context_root, &mut excluded_hashes)?;
438        }
439    }
440
441    Ok((excluded_states, excluded_hashes))
442}
443
444fn collect_tree_hashes(
445    store: &impl ObjectStore,
446    tree_hash: ContentHash,
447    excluded: &mut HashSet<ContentHash>,
448) -> Result<()> {
449    if !excluded.insert(tree_hash) {
450        return Ok(());
451    }
452
453    let tree = match store.get_tree(&tree_hash)? {
454        Some(tree) => tree,
455        None => return Ok(()),
456    };
457
458    for entry in tree.entries() {
459        match entry.entry_type {
460            EntryType::Blob | EntryType::Symlink => {
461                excluded.insert(entry.hash);
462            }
463            EntryType::Tree => {
464                collect_tree_hashes(store, entry.hash, excluded)?;
465            }
466        }
467    }
468
469    Ok(())
470}
471
472pub fn is_ancestor(
473    store: &impl ObjectStore,
474    ancestor: ChangeId,
475    descendant: ChangeId,
476) -> Result<bool> {
477    if ancestor == descendant {
478        return Ok(true);
479    }
480
481    let mut seen: HashSet<ChangeId> = HashSet::new();
482    let mut queue: VecDeque<ChangeId> = VecDeque::new();
483    queue.push_back(descendant);
484
485    while let Some(id) = queue.pop_front() {
486        if !seen.insert(id) {
487            continue;
488        }
489        let state = match store.get_state(&id)? {
490            Some(s) => s,
491            None => return Ok(false),
492        };
493        for parent in state.parents {
494            if parent == ancestor {
495                return Ok(true);
496            }
497            queue.push_back(parent);
498        }
499    }
500
501    Ok(false)
502}
503
504#[cfg(test)]
505mod tests {
506    use std::collections::HashSet;
507
508    use chrono::Utc;
509    use objects::{
510        object::{
511            Attribution, Blob, ChangeId, Principal, Redaction, State, StateVisibility, Tree,
512            TreeEntry, VisibilityTier,
513        },
514        store::ObjectStore,
515    };
516    use repo::Repository;
517    use tempfile::TempDir;
518
519    use super::{
520        ObjectId, ObjectInfo, ObjectType, PlannedObject, StateClosureOptions,
521        enumerate_state_closure_plan_with_options, enumerate_state_closure_with_options,
522    };
523
524    fn pairs_from_full(objects: &[ObjectInfo]) -> HashSet<(ObjectId, ObjectType)> {
525        objects
526            .iter()
527            .map(|info| (info.id.clone(), info.obj_type))
528            .collect()
529    }
530
531    fn pairs_from_plan(objects: &[PlannedObject]) -> HashSet<(ObjectId, ObjectType)> {
532        objects
533            .iter()
534            .map(|info| (info.id.clone(), info.obj_type))
535            .collect()
536    }
537
538    fn assert_plan_parity(
539        repo: &Repository,
540        state_id: ChangeId,
541        options: StateClosureOptions,
542    ) -> HashSet<(ObjectId, ObjectType)> {
543        let full =
544            enumerate_state_closure_with_options(repo.store(), state_id, options.clone()).unwrap();
545        let plan =
546            enumerate_state_closure_plan_with_options(repo.store(), state_id, options).unwrap();
547
548        let full_pairs = pairs_from_full(&full);
549        let plan_pairs = pairs_from_plan(&plan);
550        assert_eq!(full_pairs, plan_pairs);
551        full_pairs
552    }
553
554    fn test_attribution() -> Attribution {
555        Attribution::human(Principal::new("Graph Tester", "graph@example.com"))
556    }
557
558    #[test]
559    fn lean_closure_planner_matches_object_info_ids_and_types() {
560        let temp = TempDir::new().unwrap();
561        let repo = Repository::init_default(temp.path()).unwrap();
562        std::fs::create_dir_all(temp.path().join("src")).unwrap();
563        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
564        std::fs::write(temp.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
565        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
566
567        let full = enumerate_state_closure_with_options(
568            repo.store(),
569            state.change_id,
570            StateClosureOptions::default(),
571        )
572        .unwrap();
573        let lean = enumerate_state_closure_plan_with_options(
574            repo.store(),
575            state.change_id,
576            StateClosureOptions::default(),
577        )
578        .unwrap();
579
580        let full_pairs = full
581            .into_iter()
582            .map(|info| (info.id, info.obj_type))
583            .collect::<std::collections::HashSet<_>>();
584        let lean_pairs = lean
585            .into_iter()
586            .map(|info| (info.id, info.obj_type))
587            .collect::<std::collections::HashSet<_>>();
588
589        assert_eq!(full_pairs, lean_pairs);
590        assert!(
591            full_pairs
592                .iter()
593                .any(|(id, _)| matches!(id, ObjectId::ChangeId(_)))
594        );
595    }
596
597    #[test]
598    fn depth_and_exclude_options_match_between_full_and_plan() {
599        let temp = TempDir::new().unwrap();
600        let repo = Repository::init_default(temp.path()).unwrap();
601        let path = temp.path().join("story.txt");
602
603        std::fs::write(&path, "base\n").unwrap();
604        let base = repo.snapshot(Some("base".to_string()), None).unwrap();
605        std::fs::write(&path, "middle\n").unwrap();
606        let middle = repo.snapshot(Some("middle".to_string()), None).unwrap();
607        std::fs::write(&path, "tip\n").unwrap();
608        let tip = repo.snapshot(Some("tip".to_string()), None).unwrap();
609
610        let depth_zero = assert_plan_parity(
611            &repo,
612            tip.change_id,
613            StateClosureOptions {
614                depth: Some(0),
615                exclude_states: Vec::new(),
616            },
617        );
618        assert!(depth_zero.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
619        assert!(!depth_zero.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State)));
620        assert!(!depth_zero.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
621
622        let depth_one = assert_plan_parity(
623            &repo,
624            tip.change_id,
625            StateClosureOptions {
626                depth: Some(1),
627                exclude_states: Vec::new(),
628            },
629        );
630        assert!(depth_one.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
631        assert!(depth_one.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State)));
632        assert!(!depth_one.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
633
634        let exclude_middle = assert_plan_parity(
635            &repo,
636            tip.change_id,
637            StateClosureOptions {
638                depth: None,
639                exclude_states: vec![middle.change_id],
640            },
641        );
642        assert!(exclude_middle.contains(&(ObjectId::ChangeId(tip.change_id), ObjectType::State)));
643        assert!(
644            !exclude_middle.contains(&(ObjectId::ChangeId(middle.change_id), ObjectType::State))
645        );
646        assert!(!exclude_middle.contains(&(ObjectId::ChangeId(base.change_id), ObjectType::State)));
647    }
648
649    #[test]
650    fn shared_tree_and_blob_references_are_emitted_once() {
651        let temp = TempDir::new().unwrap();
652        let repo = Repository::init_default(temp.path()).unwrap();
653
654        let shared_blob = Blob::from("shared contents\n");
655        let shared_blob_hash = repo.store().put_blob(&shared_blob).unwrap();
656        let shared_tree = Tree::from_entries(vec![
657            TreeEntry::file("shared.txt", shared_blob_hash, false).unwrap(),
658        ]);
659        let shared_tree_hash = repo.store().put_tree(&shared_tree).unwrap();
660        let root = Tree::from_entries(vec![
661            TreeEntry::directory("left", shared_tree_hash).unwrap(),
662            TreeEntry::directory("right", shared_tree_hash).unwrap(),
663        ]);
664        let root_hash = repo.store().put_tree(&root).unwrap();
665        let state = State::new(root_hash, Vec::new(), test_attribution());
666        repo.store().put_state(&state).unwrap();
667
668        let full = enumerate_state_closure_with_options(
669            repo.store(),
670            state.change_id,
671            StateClosureOptions::default(),
672        )
673        .unwrap();
674        let plan = enumerate_state_closure_plan_with_options(
675            repo.store(),
676            state.change_id,
677            StateClosureOptions::default(),
678        )
679        .unwrap();
680
681        assert_eq!(
682            pairs_from_full(&full),
683            pairs_from_plan(&plan),
684            "full and lean closure enumerators must dedup the same objects"
685        );
686
687        assert_eq!(
688            full.iter()
689                .filter(|info| info.id == ObjectId::Hash(root_hash)
690                    && info.obj_type == ObjectType::Tree)
691                .count(),
692            1
693        );
694        assert_eq!(
695            full.iter()
696                .filter(|info| info.id == ObjectId::Hash(shared_tree_hash)
697                    && info.obj_type == ObjectType::Tree)
698                .count(),
699            1
700        );
701        assert_eq!(
702            full.iter()
703                .filter(|info| info.id == ObjectId::Hash(shared_blob_hash)
704                    && info.obj_type == ObjectType::Blob)
705                .count(),
706            1
707        );
708    }
709
710    /// Once a redaction is declared for a blob in a snapshot, the
711    /// state closure must include an `ObjectType::Redaction` entry
712    /// keyed on that blob's hash — that's the wire-side signal the
713    /// receiver replays.
714    #[test]
715    fn enumerate_state_closure_emits_redaction_for_redacted_blob() {
716        let temp = TempDir::new().unwrap();
717        let repo = Repository::init_default(temp.path()).unwrap();
718        std::fs::write(temp.path().join("secret.toml"), "api_token = \"x\"\n").unwrap();
719        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
720
721        // Find the blob hash for secret.toml by walking the snapshot's tree.
722        let tree = repo
723            .store()
724            .get_tree(&state.tree)
725            .unwrap()
726            .expect("tree present");
727        let blob_hash = tree
728            .iter()
729            .find(|e| e.name == "secret.toml")
730            .expect("entry present")
731            .hash;
732
733        let redaction = Redaction {
734            redacted_blob: blob_hash,
735            state: state.change_id,
736            path: "secret.toml".to_string(),
737            reason: "test leak".to_string(),
738            redactor: Principal {
739                name: "Tester".into(),
740                email: "tester@heddle.sh".into(),
741            },
742            redacted_at: Utc::now(),
743            signature: None,
744            purged_at: None,
745            supersedes: None,
746        };
747        repo.put_redaction(redaction).unwrap();
748
749        let full = enumerate_state_closure_with_options(
750            repo.store(),
751            state.change_id,
752            StateClosureOptions::default(),
753        )
754        .unwrap();
755        let plan = enumerate_state_closure_plan_with_options(
756            repo.store(),
757            state.change_id,
758            StateClosureOptions::default(),
759        )
760        .unwrap();
761
762        assert!(
763            full.iter()
764                .any(|info| info.obj_type == ObjectType::Redaction
765                    && info.id == ObjectId::Hash(blob_hash)),
766            "full closure must include a Redaction entry for the redacted blob"
767        );
768        assert!(
769            plan.iter()
770                .any(|p| p.obj_type == ObjectType::Redaction && p.id == ObjectId::Hash(blob_hash)),
771            "plan closure must include a Redaction entry for the redacted blob"
772        );
773    }
774
775    #[test]
776    fn enumerate_state_closure_emits_state_visibility_for_visible_state() {
777        let temp = TempDir::new().unwrap();
778        let repo = Repository::init_default(temp.path()).unwrap();
779        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
780        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
781
782        repo.put_state_visibility(StateVisibility {
783            state: state.change_id,
784            tier: VisibilityTier::Restricted {
785                scope_label: "security-embargo".into(),
786            },
787            embargo_until: None,
788            declarer: Principal {
789                name: "Tester".into(),
790                email: "tester@heddle.sh".into(),
791            },
792            declared_at: Utc::now(),
793            signature: None,
794            supersedes: None,
795        })
796        .unwrap();
797
798        let full = enumerate_state_closure_with_options(
799            repo.store(),
800            state.change_id,
801            StateClosureOptions::default(),
802        )
803        .unwrap();
804        let plan = enumerate_state_closure_plan_with_options(
805            repo.store(),
806            state.change_id,
807            StateClosureOptions::default(),
808        )
809        .unwrap();
810
811        assert!(
812            full.iter()
813                .any(|info| info.obj_type == ObjectType::StateVisibility
814                    && info.id == ObjectId::ChangeId(state.change_id)),
815            "full closure must include a StateVisibility entry for the visible state"
816        );
817        assert!(
818            plan.iter()
819                .any(|p| p.obj_type == ObjectType::StateVisibility
820                    && p.id == ObjectId::ChangeId(state.change_id)),
821            "plan closure must include a StateVisibility entry for the visible state"
822        );
823    }
824}