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::{
6        ContentHash, State, StateAttachment, StateAttachmentBody, StateAttachmentId, StateId,
7        TreeEntryTarget,
8    },
9    store::{ObjectStore, pack::ObjectType as PackObjectType},
10};
11use serde::{Deserialize, Serialize};
12
13use crate::{ProtocolError, Result};
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum ObjectId {
17    Hash(ContentHash),
18    StateId(StateId),
19    StateAttachment {
20        state: StateId,
21        id: StateAttachmentId,
22    },
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ObjectInfo {
27    pub id: ObjectId,
28    pub obj_type: ObjectType,
29    pub size: u64,
30    pub delta_base: Option<ContentHash>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
34pub struct PlannedObject {
35    pub id: ObjectId,
36    pub obj_type: ObjectType,
37}
38
39#[derive(Debug, Clone)]
40pub struct StateClosureTransferObjects {
41    pub planned_objects: Vec<PlannedObject>,
42    pub full_objects: Option<Vec<ObjectInfo>>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub enum ObjectType {
47    Blob,
48    Tree,
49    State,
50    Action,
51    /// A `RedactionsBlob` sidecar — the rmp-encoded record(s) declaring
52    /// that a specific blob has been redacted (and possibly purged) by
53    /// an authorized operator. Keyed on the wire by `ObjectId::Hash` of
54    /// the *redacted blob*, since `Repository`'s sidecar store is
55    /// indexed that way.
56    Redaction,
57    /// A `StateVisibilityBlob` sidecar — the rmp-encoded record(s)
58    /// declaring a non-public audience tier for a specific state. Keyed
59    /// on the wire by `ObjectId::StateId` of the state, since the
60    /// per-state sidecar store is indexed that way. Like `Redaction`, it
61    /// is a sidecar record that lives outside the content-addressed pack
62    /// and ships via the per-object transfer path, not the pack.
63    StateVisibility,
64    StateAttachment,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum ObjectTypeBucket {
69    Blob,
70    Tree,
71    State,
72    Action,
73    Redaction,
74    StateVisibility,
75    StateAttachment,
76}
77
78impl ObjectType {
79    pub fn wire_name(self) -> &'static str {
80        match self {
81            ObjectType::Blob => "blob",
82            ObjectType::Tree => "tree",
83            ObjectType::State => "state",
84            ObjectType::Action => "action",
85            ObjectType::Redaction => "redaction",
86            ObjectType::StateVisibility => "state_visibility",
87            ObjectType::StateAttachment => "state_attachment",
88        }
89    }
90
91    pub fn from_wire(value: &str) -> Result<Self> {
92        match value {
93            "blob" => Ok(ObjectType::Blob),
94            "tree" => Ok(ObjectType::Tree),
95            "state" => Ok(ObjectType::State),
96            "action" => Ok(ObjectType::Action),
97            "redaction" => Ok(ObjectType::Redaction),
98            "state_visibility" => Ok(ObjectType::StateVisibility),
99            "state_attachment" => Ok(ObjectType::StateAttachment),
100            _ => Err(ProtocolError::InvalidState(format!(
101                "unknown object type: {value}"
102            ))),
103        }
104    }
105
106    pub fn packable(self) -> bool {
107        !matches!(self, ObjectType::Redaction | ObjectType::StateVisibility)
108    }
109
110    pub fn pack_object_type(self) -> Result<PackObjectType> {
111        match self {
112            ObjectType::Blob => Ok(PackObjectType::Blob),
113            ObjectType::Tree => Ok(PackObjectType::Tree),
114            ObjectType::State => Ok(PackObjectType::State),
115            ObjectType::Action => Ok(PackObjectType::Action),
116            ObjectType::StateAttachment => Ok(PackObjectType::StateAttachment),
117            ObjectType::Redaction => Err(ProtocolError::InvalidState(
118                "Redaction sidecar records cannot be packed into the content-addressed object pack"
119                    .to_string(),
120            )),
121            ObjectType::StateVisibility => Err(ProtocolError::InvalidState(
122                "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
123                    .to_string(),
124            )),
125        }
126    }
127
128    pub fn bucket(self) -> ObjectTypeBucket {
129        match self {
130            ObjectType::Blob => ObjectTypeBucket::Blob,
131            ObjectType::Tree => ObjectTypeBucket::Tree,
132            ObjectType::State => ObjectTypeBucket::State,
133            ObjectType::Action => ObjectTypeBucket::Action,
134            ObjectType::Redaction => ObjectTypeBucket::Redaction,
135            ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
136            ObjectType::StateAttachment => ObjectTypeBucket::StateAttachment,
137        }
138    }
139}
140
141#[derive(Debug, Clone, Default)]
142pub struct StateClosureOptions {
143    pub depth: Option<u32>,
144    pub exclude_states: Vec<StateId>,
145}
146
147pub fn enumerate_state_closure(
148    store: &impl ObjectStore,
149    state_id: StateId,
150) -> Result<Vec<ObjectInfo>> {
151    enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
152}
153
154pub fn enumerate_state_closure_with_options(
155    store: &impl ObjectStore,
156    state_id: StateId,
157    options: StateClosureOptions,
158) -> Result<Vec<ObjectInfo>> {
159    let mut out = Vec::new();
160    walk_state_closure(store, state_id, options, |event| {
161        if let Some(info) = object_info_from_event(store, event)? {
162            out.push(info);
163        }
164        Ok(())
165    })?;
166
167    Ok(out)
168}
169
170pub fn enumerate_state_closure_plan(
171    store: &impl ObjectStore,
172    state_id: StateId,
173) -> Result<Vec<PlannedObject>> {
174    enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
175}
176
177pub fn enumerate_state_closure_plan_with_options(
178    store: &impl ObjectStore,
179    state_id: StateId,
180    options: StateClosureOptions,
181) -> Result<Vec<PlannedObject>> {
182    let mut out = Vec::new();
183    walk_state_closure(store, state_id, options, |event| {
184        if let Some(object) = planned_object_from_event(store, event)? {
185            out.push(object);
186        }
187        Ok(())
188    })?;
189
190    Ok(out)
191}
192
193pub fn enumerate_state_closure_transfer_with_options(
194    store: &impl ObjectStore,
195    state_id: StateId,
196    options: StateClosureOptions,
197    full_descriptor_object_threshold: usize,
198) -> Result<StateClosureTransferObjects> {
199    let mut planned_objects = Vec::new();
200    let mut full_objects = Some(Vec::new());
201
202    walk_state_closure(store, state_id, options, |event| {
203        if let Some(object) = planned_object_from_event(store, event)? {
204            planned_objects.push(object);
205        }
206
207        if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
208            full_objects = None;
209        }
210        if let Some(objects) = full_objects.as_mut()
211            && let Some(info) = object_info_from_event(store, event)?
212        {
213            objects.push(info);
214        }
215
216        Ok(())
217    })?;
218
219    Ok(StateClosureTransferObjects {
220        planned_objects,
221        full_objects,
222    })
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226enum BlobSource {
227    Tree,
228    StateMetadata,
229}
230
231#[derive(Debug, Clone, Copy)]
232enum StateClosureEvent<'a> {
233    State {
234        id: StateId,
235        state: &'a State,
236    },
237    Tree {
238        hash: ContentHash,
239        tree: &'a objects::object::Tree,
240    },
241    Blob {
242        hash: ContentHash,
243        source: BlobSource,
244    },
245    Redaction {
246        blob: ContentHash,
247    },
248    StateVisibility {
249        state: StateId,
250    },
251    StateAttachment {
252        state: StateId,
253        attachment: &'a StateAttachment,
254    },
255    ExcludedState {
256        id: StateId,
257    },
258    ExcludedHash {
259        hash: ContentHash,
260    },
261}
262
263fn walk_state_closure(
264    store: &impl ObjectStore,
265    state_id: StateId,
266    options: StateClosureOptions,
267    mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
268) -> Result<()> {
269    let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
270
271    let mut seen_states: HashSet<StateId> = HashSet::new();
272    let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
273    let mut queue: VecDeque<(StateId, u32)> = VecDeque::new();
274    queue.push_back((state_id, 0));
275
276    while let Some((id, depth)) = queue.pop_front() {
277        if excluded_states.contains(&id) {
278            visit(StateClosureEvent::ExcludedState { id })?;
279            continue;
280        }
281        if !seen_states.insert(id) {
282            continue;
283        }
284
285        let state = store
286            .get_state(&id)?
287            .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
288
289        visit(StateClosureEvent::State { id, state: &state })?;
290        if store.has_state_visibility_for_state(&id)? {
291            visit(StateClosureEvent::StateVisibility { state: id })?;
292        }
293        for attachment in store.list_state_attachments(&id)? {
294            visit(StateClosureEvent::StateAttachment {
295                state: id,
296                attachment: &attachment,
297            })?;
298            match attachment.body {
299                StateAttachmentBody::Context(root) => walk_tree_closure_filtered(
300                    store,
301                    root,
302                    &excluded_hashes,
303                    &mut seen_hashes,
304                    &mut visit,
305                )?,
306                StateAttachmentBody::RiskSignals(hash)
307                | StateAttachmentBody::ReviewSignatures(hash)
308                | StateAttachmentBody::Discussions(hash)
309                | StateAttachmentBody::StructuredConflicts(hash) => walk_blob_filtered(
310                    store,
311                    hash,
312                    BlobSource::StateMetadata,
313                    &excluded_hashes,
314                    &mut seen_hashes,
315                    &mut visit,
316                )?,
317                StateAttachmentBody::Signature(_) => {}
318            }
319        }
320
321        if options.depth.map(|max| depth < max).unwrap_or(true) {
322            for parent in &state.parents {
323                queue.push_back((*parent, depth + 1));
324            }
325        }
326
327        walk_tree_closure_filtered(
328            store,
329            state.tree,
330            &excluded_hashes,
331            &mut seen_hashes,
332            &mut visit,
333        )?;
334        if let Some(provenance_root) = state.provenance {
335            walk_tree_closure_filtered(
336                store,
337                provenance_root,
338                &excluded_hashes,
339                &mut seen_hashes,
340                &mut visit,
341            )?;
342        }
343    }
344
345    Ok(())
346}
347
348fn walk_tree_closure_filtered(
349    store: &impl ObjectStore,
350    tree_hash: ContentHash,
351    excluded: &HashSet<ContentHash>,
352    seen: &mut HashSet<ContentHash>,
353    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
354) -> Result<()> {
355    if excluded.contains(&tree_hash) {
356        visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
357        return Ok(());
358    }
359    if !seen.insert(tree_hash) {
360        return Ok(());
361    }
362
363    let tree = store
364        .get_tree(&tree_hash)?
365        .ok_or_else(|| ProtocolError::ObjectNotFound(tree_hash.to_hex()))?;
366
367    visit(StateClosureEvent::Tree {
368        hash: tree_hash,
369        tree: &tree,
370    })?;
371
372    for entry in tree.entries() {
373        match entry.target() {
374            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
375                walk_blob_filtered(store, *hash, BlobSource::Tree, excluded, seen, visit)?;
376            }
377            TreeEntryTarget::Tree { hash } => {
378                walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
379            }
380            TreeEntryTarget::Gitlink { .. } => {}
381            // Native child-spool edge: its target lives in a separate spool
382            // object graph, not this store, so it is not walked here.
383            TreeEntryTarget::Spoollink { .. } => {}
384        }
385    }
386
387    Ok(())
388}
389
390fn walk_blob_filtered(
391    store: &impl ObjectStore,
392    blob_hash: ContentHash,
393    source: BlobSource,
394    excluded: &HashSet<ContentHash>,
395    seen: &mut HashSet<ContentHash>,
396    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
397) -> Result<()> {
398    if excluded.contains(&blob_hash) {
399        visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
400        return Ok(());
401    }
402    if !seen.insert(blob_hash) {
403        return Ok(());
404    }
405    visit(StateClosureEvent::Blob {
406        hash: blob_hash,
407        source,
408    })?;
409    if store.has_redactions_for_blob(&blob_hash)? {
410        visit(StateClosureEvent::Redaction { blob: blob_hash })?;
411    }
412    Ok(())
413}
414
415fn object_info_from_event(
416    store: &impl ObjectStore,
417    event: StateClosureEvent<'_>,
418) -> Result<Option<ObjectInfo>> {
419    match event {
420        StateClosureEvent::State { id, state } => {
421            let state_bytes = rmp_serde::to_vec_named(state)?;
422            Ok(Some(ObjectInfo {
423                id: ObjectId::StateId(id),
424                obj_type: ObjectType::State,
425                size: state_bytes.len() as u64,
426                delta_base: None,
427            }))
428        }
429        StateClosureEvent::Tree { hash, tree } => {
430            let tree_bytes = rmp_serde::to_vec_named(tree)?;
431            Ok(Some(ObjectInfo {
432                id: ObjectId::Hash(hash),
433                obj_type: ObjectType::Tree,
434                size: tree_bytes.len() as u64,
435                delta_base: None,
436            }))
437        }
438        StateClosureEvent::Blob { hash, .. } => {
439            let blob = store
440                .get_blob(&hash)?
441                .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
442            Ok(Some(ObjectInfo {
443                id: ObjectId::Hash(hash),
444                obj_type: ObjectType::Blob,
445                size: blob.size() as u64,
446                delta_base: None,
447            }))
448        }
449        StateClosureEvent::Redaction { blob } => Ok(store
450            .get_redactions_bytes_for_blob(&blob)?
451            .map(|bytes| ObjectInfo {
452                id: ObjectId::Hash(blob),
453                obj_type: ObjectType::Redaction,
454                size: bytes.len() as u64,
455                delta_base: None,
456            })),
457        StateClosureEvent::StateVisibility { state } => Ok(store
458            .get_state_visibility_bytes_for_state(&state)?
459            .map(|bytes| ObjectInfo {
460                id: ObjectId::StateId(state),
461                obj_type: ObjectType::StateVisibility,
462                size: bytes.len() as u64,
463                delta_base: None,
464            })),
465        StateClosureEvent::StateAttachment { state, attachment } => {
466            let bytes = rmp_serde::to_vec_named(attachment)?;
467            Ok(Some(ObjectInfo {
468                id: ObjectId::StateAttachment {
469                    state,
470                    id: attachment.id(),
471                },
472                obj_type: ObjectType::StateAttachment,
473                size: bytes.len() as u64,
474                delta_base: None,
475            }))
476        }
477        StateClosureEvent::ExcludedState { id } => {
478            let _ = id;
479            Ok(None)
480        }
481        StateClosureEvent::ExcludedHash { hash } => {
482            let _ = hash;
483            Ok(None)
484        }
485    }
486}
487
488fn planned_object_from_event(
489    store: &impl ObjectStore,
490    event: StateClosureEvent<'_>,
491) -> Result<Option<PlannedObject>> {
492    match event {
493        StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
494            id: ObjectId::StateId(id),
495            obj_type: ObjectType::State,
496        })),
497        StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
498            id: ObjectId::Hash(hash),
499            obj_type: ObjectType::Tree,
500        })),
501        StateClosureEvent::Blob { hash, source } => {
502            if source == BlobSource::StateMetadata && store.get_blob(&hash)?.is_none() {
503                return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
504            }
505            Ok(Some(PlannedObject {
506                id: ObjectId::Hash(hash),
507                obj_type: ObjectType::Blob,
508            }))
509        }
510        StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
511            id: ObjectId::Hash(blob),
512            obj_type: ObjectType::Redaction,
513        })),
514        StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
515            id: ObjectId::StateId(state),
516            obj_type: ObjectType::StateVisibility,
517        })),
518        StateClosureEvent::StateAttachment { state, attachment } => Ok(Some(PlannedObject {
519            id: ObjectId::StateAttachment {
520                state,
521                id: attachment.id(),
522            },
523            obj_type: ObjectType::StateAttachment,
524        })),
525        StateClosureEvent::ExcludedState { id } => {
526            let _ = id;
527            Ok(None)
528        }
529        StateClosureEvent::ExcludedHash { hash } => {
530            let _ = hash;
531            Ok(None)
532        }
533    }
534}
535
536pub fn missing_blobs_in_tree(
537    store: &impl ObjectStore,
538    tree_hash: ContentHash,
539) -> Result<Vec<ContentHash>> {
540    let mut missing = Vec::new();
541    collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
542    Ok(missing)
543}
544
545fn collect_missing_blobs_recursive(
546    store: &impl ObjectStore,
547    tree_hash: &ContentHash,
548    missing: &mut Vec<ContentHash>,
549) -> Result<()> {
550    let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
551        ProtocolError::InvalidState(format!(
552            "load tree {} while collecting lazy hydration missing blobs: {err}",
553            tree_hash.to_hex()
554        ))
555    })?
556    else {
557        return Ok(());
558    };
559
560    for entry in tree.entries() {
561        match entry.target() {
562            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
563                if !store.has_blob(hash).map_err(|err| {
564                    ProtocolError::InvalidState(format!(
565                        "check blob {} while collecting lazy hydration missing blobs: {err}",
566                        hash.to_hex()
567                    ))
568                })? {
569                    missing.push(*hash);
570                }
571            }
572            TreeEntryTarget::Tree { hash } => {
573                collect_missing_blobs_recursive(store, hash, missing)?;
574            }
575            TreeEntryTarget::Gitlink { .. } => {}
576            // Native child-spool edge: its target lives in a separate spool
577            // object graph, not this store, so it is not walked here.
578            TreeEntryTarget::Spoollink { .. } => {}
579        }
580    }
581    Ok(())
582}
583
584fn collect_excluded(
585    store: &impl ObjectStore,
586    roots: &[StateId],
587) -> Result<(HashSet<StateId>, HashSet<ContentHash>)> {
588    if roots.is_empty() {
589        return Ok((HashSet::new(), HashSet::new()));
590    }
591
592    let mut excluded_states: HashSet<StateId> = HashSet::new();
593    let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
594    let mut queue: VecDeque<StateId> = VecDeque::new();
595
596    for id in roots {
597        queue.push_back(*id);
598    }
599
600    while let Some(id) = queue.pop_front() {
601        if !excluded_states.insert(id) {
602            continue;
603        }
604
605        let state = match store.get_state(&id)? {
606            Some(state) => state,
607            None => continue,
608        };
609
610        for parent in &state.parents {
611            queue.push_back(*parent);
612        }
613
614        collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
615        if let Some(provenance_root) = state.provenance {
616            collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
617        }
618        for attachment in store.list_state_attachments(&id)? {
619            match attachment.body {
620                StateAttachmentBody::Context(root) => {
621                    collect_tree_hashes(store, root, &mut excluded_hashes)?
622                }
623                StateAttachmentBody::RiskSignals(hash)
624                | StateAttachmentBody::ReviewSignatures(hash)
625                | StateAttachmentBody::Discussions(hash)
626                | StateAttachmentBody::StructuredConflicts(hash) => {
627                    excluded_hashes.insert(hash);
628                }
629                StateAttachmentBody::Signature(_) => {}
630            }
631        }
632    }
633
634    Ok((excluded_states, excluded_hashes))
635}
636
637fn collect_tree_hashes(
638    store: &impl ObjectStore,
639    tree_hash: ContentHash,
640    excluded: &mut HashSet<ContentHash>,
641) -> Result<()> {
642    if !excluded.insert(tree_hash) {
643        return Ok(());
644    }
645
646    let tree = match store.get_tree(&tree_hash)? {
647        Some(tree) => tree,
648        None => return Ok(()),
649    };
650
651    for entry in tree.entries() {
652        match entry.target() {
653            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
654                excluded.insert(*hash);
655            }
656            TreeEntryTarget::Tree { hash } => {
657                collect_tree_hashes(store, *hash, excluded)?;
658            }
659            TreeEntryTarget::Gitlink { .. } => {}
660            // Native child-spool edge: its target lives in a separate spool
661            // object graph, not this store, so it is not walked here.
662            TreeEntryTarget::Spoollink { .. } => {}
663        }
664    }
665
666    Ok(())
667}
668
669pub fn is_ancestor(
670    store: &impl ObjectStore,
671    ancestor: StateId,
672    descendant: StateId,
673) -> Result<bool> {
674    if ancestor == descendant {
675        return Ok(true);
676    }
677
678    let mut seen: HashSet<StateId> = HashSet::new();
679    let mut queue: VecDeque<StateId> = VecDeque::new();
680    queue.push_back(descendant);
681
682    while let Some(id) = queue.pop_front() {
683        if !seen.insert(id) {
684            continue;
685        }
686        let state = match store.get_state(&id)? {
687            Some(s) => s,
688            None => return Ok(false),
689        };
690        for parent in state.parents {
691            if parent == ancestor {
692                return Ok(true);
693            }
694            queue.push_back(parent);
695        }
696    }
697
698    Ok(false)
699}
700
701#[cfg(test)]
702mod tests {
703    use std::{
704        collections::HashSet,
705        sync::atomic::{AtomicUsize, Ordering},
706    };
707
708    use chrono::Utc;
709    use objects::{
710        object::{
711            Action, ActionId, Attribution, Blob, ContentHash, Discussion, DiscussionResolution,
712            DiscussionTurn, DiscussionsBlob, Principal, Redaction, State, StateAttachment,
713            StateAttachmentBody, StateId, StateVisibility, SymbolAnchor, Tree, TreeEntry,
714            VisibilityTier,
715        },
716        store::{ObjectStore, Result as StoreResult},
717    };
718    use repo::Repository;
719    use sley::ObjectId as GitObjectId;
720    use tempfile::TempDir;
721
722    use super::{
723        ObjectId, ObjectInfo, ObjectType, PlannedObject, StateClosureOptions,
724        enumerate_state_closure_plan_with_options, enumerate_state_closure_transfer_with_options,
725        enumerate_state_closure_with_options, missing_blobs_in_tree,
726    };
727
728    fn pairs_from_full(objects: &[ObjectInfo]) -> HashSet<(ObjectId, ObjectType)> {
729        objects
730            .iter()
731            .map(|info| (info.id.clone(), info.obj_type))
732            .collect()
733    }
734
735    fn pairs_from_plan(objects: &[PlannedObject]) -> HashSet<(ObjectId, ObjectType)> {
736        objects
737            .iter()
738            .map(|info| (info.id.clone(), info.obj_type))
739            .collect()
740    }
741
742    fn object_info_fingerprint(
743        objects: &[ObjectInfo],
744    ) -> Vec<(ObjectId, ObjectType, u64, Option<ContentHash>)> {
745        objects
746            .iter()
747            .map(|info| (info.id.clone(), info.obj_type, info.size, info.delta_base))
748            .collect()
749    }
750
751    fn assert_plan_parity(
752        repo: &Repository,
753        state_id: StateId,
754        options: StateClosureOptions,
755    ) -> HashSet<(ObjectId, ObjectType)> {
756        let full =
757            enumerate_state_closure_with_options(repo.store(), state_id, options.clone()).unwrap();
758        let plan =
759            enumerate_state_closure_plan_with_options(repo.store(), state_id, options).unwrap();
760
761        let full_pairs = pairs_from_full(&full);
762        let plan_pairs = pairs_from_plan(&plan);
763        assert_eq!(full_pairs, plan_pairs);
764        full_pairs
765    }
766
767    fn assert_contains_object(
768        objects: &HashSet<(ObjectId, ObjectType)>,
769        id: ObjectId,
770        obj_type: ObjectType,
771    ) {
772        assert!(
773            objects.contains(&(id.clone(), obj_type)),
774            "expected closure to contain {id:?} as {obj_type:?}: {objects:?}"
775        );
776    }
777
778    struct CountingStore<'a, S> {
779        inner: &'a S,
780        state_reads: AtomicUsize,
781    }
782
783    impl<'a, S> CountingStore<'a, S> {
784        fn new(inner: &'a S) -> Self {
785            Self {
786                inner,
787                state_reads: AtomicUsize::new(0),
788            }
789        }
790
791        fn state_reads(&self) -> usize {
792            self.state_reads.load(Ordering::SeqCst)
793        }
794    }
795
796    impl<S: ObjectStore> ObjectStore for CountingStore<'_, S> {
797        fn get_blob(&self, hash: &ContentHash) -> StoreResult<Option<Blob>> {
798            self.inner.get_blob(hash)
799        }
800
801        fn put_blob(&self, blob: &Blob) -> StoreResult<ContentHash> {
802            self.inner.put_blob(blob)
803        }
804
805        fn has_blob(&self, hash: &ContentHash) -> StoreResult<bool> {
806            self.inner.has_blob(hash)
807        }
808
809        fn get_tree(&self, hash: &ContentHash) -> StoreResult<Option<Tree>> {
810            self.inner.get_tree(hash)
811        }
812
813        fn put_tree(&self, tree: &Tree) -> StoreResult<ContentHash> {
814            self.inner.put_tree(tree)
815        }
816
817        fn has_tree(&self, hash: &ContentHash) -> StoreResult<bool> {
818            self.inner.has_tree(hash)
819        }
820
821        fn get_state(&self, id: &StateId) -> StoreResult<Option<State>> {
822            self.state_reads.fetch_add(1, Ordering::SeqCst);
823            self.inner.get_state(id)
824        }
825
826        fn put_state(&self, state: &State) -> StoreResult<()> {
827            self.inner.put_state(state)
828        }
829
830        fn has_state(&self, id: &StateId) -> StoreResult<bool> {
831            self.inner.has_state(id)
832        }
833
834        fn list_states(&self) -> StoreResult<Vec<StateId>> {
835            self.inner.list_states()
836        }
837
838        fn get_action(&self, id: &ActionId) -> StoreResult<Option<Action>> {
839            self.inner.get_action(id)
840        }
841
842        fn put_action(&self, action: &mut Action) -> StoreResult<ActionId> {
843            self.inner.put_action(action)
844        }
845
846        fn list_actions(&self) -> StoreResult<Vec<ActionId>> {
847            self.inner.list_actions()
848        }
849
850        fn list_blobs(&self) -> StoreResult<Vec<ContentHash>> {
851            self.inner.list_blobs()
852        }
853
854        fn list_trees(&self) -> StoreResult<Vec<ContentHash>> {
855            self.inner.list_trees()
856        }
857    }
858
859    fn test_attribution() -> Attribution {
860        Attribution::human(Principal::new("Graph Tester", "graph@example.com"))
861    }
862
863    #[test]
864    fn lean_closure_planner_matches_object_info_ids_and_types() {
865        let temp = TempDir::new().unwrap();
866        let repo = Repository::init_default(temp.path()).unwrap();
867        std::fs::create_dir_all(temp.path().join("src")).unwrap();
868        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
869        std::fs::write(temp.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
870        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
871
872        let full = enumerate_state_closure_with_options(
873            repo.store(),
874            state.state_id,
875            StateClosureOptions::default(),
876        )
877        .unwrap();
878        let lean = enumerate_state_closure_plan_with_options(
879            repo.store(),
880            state.state_id,
881            StateClosureOptions::default(),
882        )
883        .unwrap();
884
885        let full_pairs = full
886            .into_iter()
887            .map(|info| (info.id, info.obj_type))
888            .collect::<std::collections::HashSet<_>>();
889        let lean_pairs = lean
890            .into_iter()
891            .map(|info| (info.id, info.obj_type))
892            .collect::<std::collections::HashSet<_>>();
893
894        assert_eq!(full_pairs, lean_pairs);
895        assert!(
896            full_pairs
897                .iter()
898                .any(|(id, _)| matches!(id, ObjectId::StateId(_)))
899        );
900    }
901
902    #[test]
903    fn transfer_projection_matches_full_and_plan_on_mixed_state_closure_fixture() {
904        let temp = TempDir::new().unwrap();
905        let repo = Repository::init_default(temp.path()).unwrap();
906
907        let excluded_blob = repo
908            .store()
909            .put_blob(&Blob::from("excluded"))
910            .expect("put excluded blob");
911        let excluded_tree_hash = repo
912            .store()
913            .put_tree(&Tree::from_entries(vec![
914                TreeEntry::file("excluded.txt", excluded_blob, false).unwrap(),
915            ]))
916            .expect("put excluded tree");
917        let excluded_parent = State::new(excluded_tree_hash, Vec::new(), test_attribution());
918        repo.store()
919            .put_state(&excluded_parent)
920            .expect("put excluded parent");
921
922        let redacted_blob = repo
923            .store()
924            .put_blob(&Blob::from("secret"))
925            .expect("put redacted blob");
926        let nested_blob = repo
927            .store()
928            .put_blob(&Blob::from("nested"))
929            .expect("put nested blob");
930        let symlink_blob = repo
931            .store()
932            .put_blob(&Blob::from("target"))
933            .expect("put symlink blob");
934        let context_blob = repo
935            .store()
936            .put_blob(&Blob::from("context"))
937            .expect("put context blob");
938        let provenance_blob = repo
939            .store()
940            .put_blob(&Blob::from("provenance"))
941            .expect("put provenance blob");
942        let risk_blob = repo
943            .store()
944            .put_blob(&Blob::from("risk"))
945            .expect("put risk blob");
946        let review_blob = repo
947            .store()
948            .put_blob(&Blob::from("review"))
949            .expect("put review blob");
950        let discussions_blob = repo
951            .store()
952            .put_blob(&Blob::from("discussion"))
953            .expect("put discussion blob");
954        let conflicts_blob = repo
955            .store()
956            .put_blob(&Blob::from("conflicts"))
957            .expect("put conflicts blob");
958
959        let nested_tree_hash = repo
960            .store()
961            .put_tree(&Tree::from_entries(vec![
962                TreeEntry::file("nested.txt", nested_blob, false).unwrap(),
963                TreeEntry::symlink("latest", symlink_blob).unwrap(),
964            ]))
965            .expect("put nested tree");
966        let context_tree_hash = repo
967            .store()
968            .put_tree(&Tree::from_entries(vec![
969                TreeEntry::file("context.txt", context_blob, false).unwrap(),
970            ]))
971            .expect("put context tree");
972        let provenance_tree_hash = repo
973            .store()
974            .put_tree(&Tree::from_entries(vec![
975                TreeEntry::file("lineage.txt", provenance_blob, false).unwrap(),
976            ]))
977            .expect("put provenance tree");
978        let gitlink_target: GitObjectId = "0303030303030303030303030303030303030303"
979            .parse()
980            .expect("git oid");
981        let root_tree_hash = repo
982            .store()
983            .put_tree(&Tree::from_entries(vec![
984                TreeEntry::file("secret.txt", redacted_blob, false).unwrap(),
985                TreeEntry::directory("nested", nested_tree_hash).unwrap(),
986                TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
987            ]))
988            .expect("put root tree");
989        let state = State::new(
990            root_tree_hash,
991            vec![excluded_parent.state_id],
992            test_attribution(),
993        )
994        .with_provenance(provenance_tree_hash);
995        repo.store().put_state(&state).expect("put state");
996        for body in [
997            StateAttachmentBody::Context(context_tree_hash),
998            StateAttachmentBody::RiskSignals(risk_blob),
999            StateAttachmentBody::ReviewSignatures(review_blob),
1000            StateAttachmentBody::Discussions(discussions_blob),
1001            StateAttachmentBody::StructuredConflicts(conflicts_blob),
1002        ] {
1003            repo.put_state_attachment(&StateAttachment {
1004                state_id: state.id(),
1005                body,
1006                attribution: state.attribution.clone(),
1007                created_at: Utc::now(),
1008                supersedes: None,
1009            })
1010            .unwrap();
1011        }
1012
1013        repo.put_redaction(Redaction {
1014            redacted_blob,
1015            state: state.state_id,
1016            path: "secret.txt".to_string(),
1017            reason: "test leak".to_string(),
1018            redactor: Principal::new("Tester", "tester@example.test"),
1019            redacted_at: Utc::now(),
1020            signature: None,
1021            purged_at: None,
1022            supersedes: None,
1023        })
1024        .expect("put redaction");
1025        repo.put_state_visibility(StateVisibility {
1026            state: state.state_id,
1027            tier: VisibilityTier::Restricted {
1028                scope_label: "security".to_string(),
1029            },
1030            embargo_until: None,
1031            declarer: Principal::new("Tester", "tester@example.test"),
1032            declared_at: Utc::now(),
1033            signature: None,
1034            supersedes: None,
1035        })
1036        .expect("put visibility");
1037
1038        let options = StateClosureOptions {
1039            depth: None,
1040            exclude_states: vec![excluded_parent.state_id],
1041        };
1042        let transfer = enumerate_state_closure_transfer_with_options(
1043            repo.store(),
1044            state.state_id,
1045            options.clone(),
1046            512,
1047        )
1048        .expect("transfer projection");
1049
1050        let full =
1051            enumerate_state_closure_with_options(repo.store(), state.state_id, options.clone())
1052                .expect("full closure");
1053        let plan = enumerate_state_closure_plan_with_options(repo.store(), state.state_id, options)
1054            .expect("plan closure");
1055        assert_eq!(
1056            transfer
1057                .full_objects
1058                .as_deref()
1059                .map(object_info_fingerprint),
1060            Some(object_info_fingerprint(&full))
1061        );
1062        assert_eq!(transfer.planned_objects, plan);
1063
1064        let full_pairs = pairs_from_full(&full);
1065        assert_eq!(full_pairs, pairs_from_plan(&plan));
1066        assert_contains_object(
1067            &full_pairs,
1068            ObjectId::StateId(state.state_id),
1069            ObjectType::State,
1070        );
1071        assert_contains_object(
1072            &full_pairs,
1073            ObjectId::StateId(state.state_id),
1074            ObjectType::StateVisibility,
1075        );
1076        assert_contains_object(&full_pairs, ObjectId::Hash(redacted_blob), ObjectType::Blob);
1077        assert_contains_object(
1078            &full_pairs,
1079            ObjectId::Hash(redacted_blob),
1080            ObjectType::Redaction,
1081        );
1082        for hash in [
1083            root_tree_hash,
1084            nested_tree_hash,
1085            context_tree_hash,
1086            provenance_tree_hash,
1087        ] {
1088            assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Tree);
1089        }
1090        for hash in [
1091            nested_blob,
1092            symlink_blob,
1093            context_blob,
1094            provenance_blob,
1095            risk_blob,
1096            review_blob,
1097            discussions_blob,
1098            conflicts_blob,
1099        ] {
1100            assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Blob);
1101        }
1102        assert!(!full_pairs.contains(&(
1103            ObjectId::StateId(excluded_parent.state_id),
1104            ObjectType::State
1105        )));
1106        assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_tree_hash), ObjectType::Tree)));
1107        assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_blob), ObjectType::Blob)));
1108    }
1109
1110    #[test]
1111    fn transfer_projection_reads_root_state_once_on_small_transfer() {
1112        let temp = TempDir::new().unwrap();
1113        let repo = Repository::init_default(temp.path()).unwrap();
1114        let blob = repo
1115            .store()
1116            .put_blob(&Blob::from("hello\n"))
1117            .expect("put blob");
1118        let tree_hash = repo
1119            .store()
1120            .put_tree(&Tree::from_entries(vec![
1121                TreeEntry::file("README.md", blob, false).unwrap(),
1122            ]))
1123            .expect("put tree");
1124        let state = State::new(tree_hash, Vec::new(), test_attribution());
1125        repo.store().put_state(&state).expect("put state");
1126        let store = CountingStore::new(repo.store());
1127
1128        let transfer = enumerate_state_closure_transfer_with_options(
1129            &store,
1130            state.state_id,
1131            StateClosureOptions::default(),
1132            512,
1133        )
1134        .expect("transfer projection");
1135
1136        assert!(
1137            !transfer.planned_objects.is_empty(),
1138            "lean projection should be available"
1139        );
1140        assert!(transfer.full_objects.is_some());
1141        assert_eq!(
1142            store.state_reads(),
1143            1,
1144            "small transfer projection must not read the root state through a second closure walk"
1145        );
1146    }
1147
1148    #[test]
1149    fn transfer_projection_drops_full_descriptors_after_threshold() {
1150        let temp = TempDir::new().unwrap();
1151        let repo = Repository::init_default(temp.path()).unwrap();
1152        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1153        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1154
1155        let transfer = enumerate_state_closure_transfer_with_options(
1156            repo.store(),
1157            state.state_id,
1158            StateClosureOptions::default(),
1159            0,
1160        )
1161        .expect("transfer projection");
1162
1163        assert!(
1164            !transfer.planned_objects.is_empty(),
1165            "lean projection should still be available over the threshold"
1166        );
1167        assert!(transfer.full_objects.is_none());
1168    }
1169
1170    #[test]
1171    fn depth_and_exclude_options_match_between_full_and_plan() {
1172        let temp = TempDir::new().unwrap();
1173        let repo = Repository::init_default(temp.path()).unwrap();
1174        let path = temp.path().join("story.txt");
1175
1176        std::fs::write(&path, "base\n").unwrap();
1177        let base = repo.snapshot(Some("base".to_string()), None).unwrap();
1178        std::fs::write(&path, "middle\n").unwrap();
1179        let middle = repo.snapshot(Some("middle".to_string()), None).unwrap();
1180        std::fs::write(&path, "tip\n").unwrap();
1181        let tip = repo.snapshot(Some("tip".to_string()), None).unwrap();
1182
1183        let depth_zero = assert_plan_parity(
1184            &repo,
1185            tip.state_id,
1186            StateClosureOptions {
1187                depth: Some(0),
1188                exclude_states: Vec::new(),
1189            },
1190        );
1191        assert!(depth_zero.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1192        assert!(!depth_zero.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1193        assert!(!depth_zero.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1194
1195        let depth_one = assert_plan_parity(
1196            &repo,
1197            tip.state_id,
1198            StateClosureOptions {
1199                depth: Some(1),
1200                exclude_states: Vec::new(),
1201            },
1202        );
1203        assert!(depth_one.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1204        assert!(depth_one.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1205        assert!(!depth_one.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1206
1207        let exclude_middle = assert_plan_parity(
1208            &repo,
1209            tip.state_id,
1210            StateClosureOptions {
1211                depth: None,
1212                exclude_states: vec![middle.state_id],
1213            },
1214        );
1215        assert!(exclude_middle.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1216        assert!(!exclude_middle.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1217        assert!(!exclude_middle.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1218    }
1219
1220    #[test]
1221    fn shared_tree_and_blob_references_are_emitted_once() {
1222        let temp = TempDir::new().unwrap();
1223        let repo = Repository::init_default(temp.path()).unwrap();
1224
1225        let shared_blob = Blob::from("shared contents\n");
1226        let shared_blob_hash = repo.store().put_blob(&shared_blob).unwrap();
1227        let shared_tree = Tree::from_entries(vec![
1228            TreeEntry::file("shared.txt", shared_blob_hash, false).unwrap(),
1229        ]);
1230        let shared_tree_hash = repo.store().put_tree(&shared_tree).unwrap();
1231        let root = Tree::from_entries(vec![
1232            TreeEntry::directory("left", shared_tree_hash).unwrap(),
1233            TreeEntry::directory("right", shared_tree_hash).unwrap(),
1234        ]);
1235        let root_hash = repo.store().put_tree(&root).unwrap();
1236        let state = State::new(root_hash, Vec::new(), test_attribution());
1237        repo.store().put_state(&state).unwrap();
1238
1239        let full = enumerate_state_closure_with_options(
1240            repo.store(),
1241            state.state_id,
1242            StateClosureOptions::default(),
1243        )
1244        .unwrap();
1245        let plan = enumerate_state_closure_plan_with_options(
1246            repo.store(),
1247            state.state_id,
1248            StateClosureOptions::default(),
1249        )
1250        .unwrap();
1251
1252        assert_eq!(
1253            pairs_from_full(&full),
1254            pairs_from_plan(&plan),
1255            "full and lean closure enumerators must dedup the same objects"
1256        );
1257
1258        assert_eq!(
1259            full.iter()
1260                .filter(|info| info.id == ObjectId::Hash(root_hash)
1261                    && info.obj_type == ObjectType::Tree)
1262                .count(),
1263            1
1264        );
1265        assert_eq!(
1266            full.iter()
1267                .filter(|info| info.id == ObjectId::Hash(shared_tree_hash)
1268                    && info.obj_type == ObjectType::Tree)
1269                .count(),
1270            1
1271        );
1272        assert_eq!(
1273            full.iter()
1274                .filter(|info| info.id == ObjectId::Hash(shared_blob_hash)
1275                    && info.obj_type == ObjectType::Blob)
1276                .count(),
1277            1
1278        );
1279    }
1280
1281    #[test]
1282    fn state_closure_skips_gitlink_targets() {
1283        let temp = TempDir::new().unwrap();
1284        let repo = Repository::init_default(temp.path()).unwrap();
1285        let target: GitObjectId = "0303030303030303030303030303030303030303"
1286            .parse()
1287            .expect("git oid");
1288        let root = Tree::from_entries(vec![
1289            TreeEntry::gitlink("vendor", target).expect("gitlink entry"),
1290        ]);
1291        let root_hash = repo.store().put_tree(&root).unwrap();
1292        let state = State::new(root_hash, Vec::new(), test_attribution());
1293        repo.store().put_state(&state).unwrap();
1294
1295        let full = enumerate_state_closure_with_options(
1296            repo.store(),
1297            state.state_id,
1298            StateClosureOptions::default(),
1299        )
1300        .unwrap();
1301        let plan = enumerate_state_closure_plan_with_options(
1302            repo.store(),
1303            state.state_id,
1304            StateClosureOptions::default(),
1305        )
1306        .unwrap();
1307
1308        assert_eq!(pairs_from_full(&full), pairs_from_plan(&plan));
1309        assert!(
1310            !full.iter().any(|info| info.obj_type == ObjectType::Blob),
1311            "gitlinks carry foreign Git commit ids, not Heddle blob dependencies: {full:?}"
1312        );
1313        assert!(full.iter().any(|info| {
1314            info.id == ObjectId::Hash(root_hash) && info.obj_type == ObjectType::Tree
1315        }));
1316    }
1317
1318    #[test]
1319    fn missing_blobs_in_tree_skips_gitlinks_and_walks_nested_side_paths() {
1320        let temp = TempDir::new().unwrap();
1321        let repo = Repository::init_default(temp.path()).unwrap();
1322        let present_blob = repo
1323            .store()
1324            .put_blob(&Blob::from("already local"))
1325            .expect("put present blob");
1326        let missing_nested = ContentHash::from_bytes([7; 32]);
1327        let missing_symlink = ContentHash::from_bytes([8; 32]);
1328        let nested_tree = Tree::from_entries(vec![
1329            TreeEntry::file("remote.txt", missing_nested, false).unwrap(),
1330            TreeEntry::symlink("remote-link", missing_symlink).unwrap(),
1331        ]);
1332        let nested_tree_hash = repo
1333            .store()
1334            .put_tree(&nested_tree)
1335            .expect("put nested tree");
1336        let gitlink_target: GitObjectId = "0404040404040404040404040404040404040404"
1337            .parse()
1338            .expect("git oid");
1339        let root = Tree::from_entries(vec![
1340            TreeEntry::file("local.txt", present_blob, false).unwrap(),
1341            TreeEntry::directory("nested", nested_tree_hash).unwrap(),
1342            TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
1343        ]);
1344        let root_hash = repo.store().put_tree(&root).expect("put root tree");
1345
1346        let missing = missing_blobs_in_tree(repo.store(), root_hash).expect("missing blobs");
1347
1348        assert_eq!(
1349            missing.into_iter().collect::<HashSet<_>>(),
1350            HashSet::from([missing_nested, missing_symlink])
1351        );
1352    }
1353
1354    /// Once a redaction is declared for a blob in a snapshot, the
1355    /// state closure must include an `ObjectType::Redaction` entry
1356    /// keyed on that blob's hash — that's the wire-side signal the
1357    /// receiver replays.
1358    #[test]
1359    fn enumerate_state_closure_emits_redaction_for_redacted_blob() {
1360        let temp = TempDir::new().unwrap();
1361        let repo = Repository::init_default(temp.path()).unwrap();
1362        std::fs::write(temp.path().join("secret.toml"), "api_token = \"x\"\n").unwrap();
1363        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1364
1365        // Find the blob hash for secret.toml by walking the snapshot's tree.
1366        let tree = repo
1367            .store()
1368            .get_tree(&state.tree)
1369            .unwrap()
1370            .expect("tree present");
1371        let blob_hash = tree
1372            .iter()
1373            .find(|e| e.name() == "secret.toml")
1374            .expect("entry present")
1375            .blob_hash()
1376            .expect("secret.toml is a blob");
1377
1378        let redaction = Redaction {
1379            redacted_blob: blob_hash,
1380            state: state.state_id,
1381            path: "secret.toml".to_string(),
1382            reason: "test leak".to_string(),
1383            redactor: Principal {
1384                name: "Tester".into(),
1385                email: "tester@heddle.sh".into(),
1386            },
1387            redacted_at: Utc::now(),
1388            signature: None,
1389            purged_at: None,
1390            supersedes: None,
1391        };
1392        repo.put_redaction(redaction).unwrap();
1393
1394        let full = enumerate_state_closure_with_options(
1395            repo.store(),
1396            state.state_id,
1397            StateClosureOptions::default(),
1398        )
1399        .unwrap();
1400        let plan = enumerate_state_closure_plan_with_options(
1401            repo.store(),
1402            state.state_id,
1403            StateClosureOptions::default(),
1404        )
1405        .unwrap();
1406
1407        assert!(
1408            full.iter()
1409                .any(|info| info.obj_type == ObjectType::Redaction
1410                    && info.id == ObjectId::Hash(blob_hash)),
1411            "full closure must include a Redaction entry for the redacted blob"
1412        );
1413        assert!(
1414            plan.iter()
1415                .any(|p| p.obj_type == ObjectType::Redaction && p.id == ObjectId::Hash(blob_hash)),
1416            "plan closure must include a Redaction entry for the redacted blob"
1417        );
1418    }
1419
1420    #[test]
1421    fn enumerate_state_closure_emits_state_visibility_for_visible_state() {
1422        let temp = TempDir::new().unwrap();
1423        let repo = Repository::init_default(temp.path()).unwrap();
1424        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1425        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1426
1427        repo.put_state_visibility(StateVisibility {
1428            state: state.state_id,
1429            tier: VisibilityTier::Restricted {
1430                scope_label: "security-embargo".into(),
1431            },
1432            embargo_until: None,
1433            declarer: Principal {
1434                name: "Tester".into(),
1435                email: "tester@heddle.sh".into(),
1436            },
1437            declared_at: Utc::now(),
1438            signature: None,
1439            supersedes: None,
1440        })
1441        .unwrap();
1442
1443        let full = enumerate_state_closure_with_options(
1444            repo.store(),
1445            state.state_id,
1446            StateClosureOptions::default(),
1447        )
1448        .unwrap();
1449        let plan = enumerate_state_closure_plan_with_options(
1450            repo.store(),
1451            state.state_id,
1452            StateClosureOptions::default(),
1453        )
1454        .unwrap();
1455
1456        assert!(
1457            full.iter()
1458                .any(|info| info.obj_type == ObjectType::StateVisibility
1459                    && info.id == ObjectId::StateId(state.state_id)),
1460            "full closure must include a StateVisibility entry for the visible state"
1461        );
1462        assert!(
1463            plan.iter()
1464                .any(|p| p.obj_type == ObjectType::StateVisibility
1465                    && p.id == ObjectId::StateId(state.state_id)),
1466            "plan closure must include a StateVisibility entry for the visible state"
1467        );
1468    }
1469
1470    #[test]
1471    fn enumerate_state_closure_emits_state_metadata_blobs() {
1472        let temp = TempDir::new().unwrap();
1473        let repo = Repository::init_default(temp.path()).unwrap();
1474        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1475        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1476
1477        let principal = Principal::new("Tester", "tester@example.test");
1478        let discussion_bytes = DiscussionsBlob::new(vec![Discussion {
1479            id: "disc-1".to_string(),
1480            anchor: SymbolAnchor::new("src/lib.rs", "answer"),
1481            opened_against_state: state.state_id,
1482            opened_at: 1_782_400_000,
1483            thread_ref: None,
1484            turns: vec![DiscussionTurn {
1485                author: principal,
1486                body: "Should this sync?".to_string(),
1487                posted_at: 1_782_400_000,
1488            }],
1489            resolution: DiscussionResolution::Open,
1490            body_changed_since_open: false,
1491            orphaned: false,
1492            visibility: VisibilityTier::default(),
1493            resolved_annotation_id: None,
1494        }])
1495        .encode()
1496        .expect("encode discussions");
1497        let discussion_hash = repo
1498            .store()
1499            .put_blob(&Blob::new(discussion_bytes))
1500            .expect("put discussions blob");
1501        let risk_hash = repo
1502            .store()
1503            .put_blob(&Blob::from_slice(b"risk signals"))
1504            .expect("put risk blob");
1505        let review_hash = repo
1506            .store()
1507            .put_blob(&Blob::from_slice(b"review signatures"))
1508            .expect("put review blob");
1509        let conflicts_hash = repo
1510            .store()
1511            .put_blob(&Blob::from_slice(b"structured conflicts"))
1512            .expect("put conflicts blob");
1513        for body in [
1514            StateAttachmentBody::RiskSignals(risk_hash),
1515            StateAttachmentBody::ReviewSignatures(review_hash),
1516            StateAttachmentBody::Discussions(discussion_hash),
1517            StateAttachmentBody::StructuredConflicts(conflicts_hash),
1518        ] {
1519            repo.put_state_attachment(&StateAttachment {
1520                state_id: state.id(),
1521                body,
1522                attribution: state.attribution.clone(),
1523                created_at: Utc::now(),
1524                supersedes: None,
1525            })
1526            .unwrap();
1527        }
1528
1529        let full = enumerate_state_closure_with_options(
1530            repo.store(),
1531            state.state_id,
1532            StateClosureOptions::default(),
1533        )
1534        .unwrap();
1535        let plan = enumerate_state_closure_plan_with_options(
1536            repo.store(),
1537            state.state_id,
1538            StateClosureOptions::default(),
1539        )
1540        .unwrap();
1541
1542        for metadata_hash in [risk_hash, review_hash, discussion_hash, conflicts_hash] {
1543            assert!(
1544                full.iter().any(|info| info.obj_type == ObjectType::Blob
1545                    && info.id == ObjectId::Hash(metadata_hash)),
1546                "full closure must include state metadata blob {metadata_hash}"
1547            );
1548            assert!(
1549                plan.iter().any(
1550                    |p| p.obj_type == ObjectType::Blob && p.id == ObjectId::Hash(metadata_hash)
1551                ),
1552                "plan closure must include state metadata blob {metadata_hash}"
1553            );
1554        }
1555    }
1556}