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, SemanticEntryKind, SemanticIndexRoot, SemanticTreeNode, State, StateAttachment,
7        StateAttachmentBody, StateAttachmentId, StateAttachmentKind, StateId, 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        /// The attachment's kind, a pure projection of its body
23        /// ([`StateAttachmentBody::kind`]). Carried through the wire so
24        /// descriptors self-describe their kind; the dedup/identity key is
25        /// still `(state, id)`, and kind is coherent under `Eq`/`Hash` because
26        /// it is a deterministic function of the same record.
27        kind: StateAttachmentKind,
28    },
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ObjectInfo {
33    pub id: ObjectId,
34    pub obj_type: ObjectType,
35    pub size: u64,
36    pub delta_base: Option<ContentHash>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
40pub struct PlannedObject {
41    pub id: ObjectId,
42    pub obj_type: ObjectType,
43}
44
45#[derive(Debug, Clone)]
46pub struct StateClosureTransferObjects {
47    pub planned_objects: Vec<PlannedObject>,
48    pub full_objects: Option<Vec<ObjectInfo>>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub enum ObjectType {
53    Blob,
54    Tree,
55    State,
56    Action,
57    /// A `RedactionsBlob` sidecar — the rmp-encoded record(s) declaring
58    /// that a specific blob has been redacted (and possibly purged) by
59    /// an authorized operator. Keyed on the wire by `ObjectId::Hash` of
60    /// the *redacted blob*, since `Repository`'s sidecar store is
61    /// indexed that way.
62    Redaction,
63    /// A `StateVisibilityBlob` sidecar — the rmp-encoded record(s)
64    /// declaring a non-public audience tier for a specific state. Keyed
65    /// on the wire by `ObjectId::StateId` of the state, since the
66    /// per-state sidecar store is indexed that way. Like `Redaction`, it
67    /// is a sidecar record that lives outside the content-addressed pack
68    /// and ships via the per-object transfer path, not the pack.
69    StateVisibility,
70    StateAttachment,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum ObjectTypeBucket {
75    Blob,
76    Tree,
77    State,
78    Action,
79    Redaction,
80    StateVisibility,
81    StateAttachment,
82}
83
84impl ObjectType {
85    pub fn wire_name(self) -> &'static str {
86        match self {
87            ObjectType::Blob => "blob",
88            ObjectType::Tree => "tree",
89            ObjectType::State => "state",
90            ObjectType::Action => "action",
91            ObjectType::Redaction => "redaction",
92            ObjectType::StateVisibility => "state_visibility",
93            ObjectType::StateAttachment => "state_attachment",
94        }
95    }
96
97    pub fn from_wire(value: &str) -> Result<Self> {
98        match value {
99            "blob" => Ok(ObjectType::Blob),
100            "tree" => Ok(ObjectType::Tree),
101            "state" => Ok(ObjectType::State),
102            "action" => Ok(ObjectType::Action),
103            "redaction" => Ok(ObjectType::Redaction),
104            "state_visibility" => Ok(ObjectType::StateVisibility),
105            "state_attachment" => Ok(ObjectType::StateAttachment),
106            _ => Err(ProtocolError::InvalidState(format!(
107                "unknown object type: {value}"
108            ))),
109        }
110    }
111
112    /// Whether this object type can ride the content-addressed native pack in
113    /// general (the historical, direction-agnostic predicate). Sidecar records
114    /// (`Redaction`, `StateVisibility`) live outside `.heddle/objects/` and can
115    /// never be packed; everything else can.
116    ///
117    /// Prefer [`ObjectType::packable_for_push`] /
118    /// [`ObjectType::packable_for_pull`] at transfer sites: packability is
119    /// direction-dependent for `StateAttachment` (see those methods). This
120    /// method retains the pull/general semantics so pack construction,
121    /// have-set, and local-copy planners are unchanged.
122    pub fn packable(self) -> bool {
123        !matches!(self, ObjectType::Redaction | ObjectType::StateVisibility)
124    }
125
126    /// Whether this object type may ride the client→server **push** pack.
127    ///
128    /// `StateAttachment` is deliberately excluded on push even though it is a
129    /// content-addressed object: a deployed server rejects any pack-carried
130    /// attachment as a forgery-prevention measure (weft#549). Pushed
131    /// attachments must instead ride the out-of-pack **sidecar** lane (the same
132    /// lane as `Redaction`/`StateVisibility`), where the server can verify them
133    /// per-kind at finalize while the pack itself stays forgery-sealed. Every
134    /// other type keeps its [`ObjectType::packable`] answer.
135    pub fn packable_for_push(self) -> bool {
136        self.packable() && !matches!(self, ObjectType::StateAttachment)
137    }
138
139    /// Whether this object type may ride the server→client **pull/clone** pack.
140    ///
141    /// Identical to [`ObjectType::packable`]: attachments are carried in the
142    /// pull pack exactly as today. The push/pull split exists solely so the
143    /// push direction can exclude `StateAttachment` without disturbing the
144    /// working pull carriage.
145    pub fn packable_for_pull(self) -> bool {
146        self.packable()
147    }
148
149    pub fn pack_object_type(self) -> Result<PackObjectType> {
150        match self {
151            ObjectType::Blob => Ok(PackObjectType::Blob),
152            ObjectType::Tree => Ok(PackObjectType::Tree),
153            ObjectType::State => Ok(PackObjectType::State),
154            ObjectType::Action => Ok(PackObjectType::Action),
155            ObjectType::StateAttachment => Ok(PackObjectType::StateAttachment),
156            ObjectType::Redaction => Err(ProtocolError::InvalidState(
157                "Redaction sidecar records cannot be packed into the content-addressed object pack"
158                    .to_string(),
159            )),
160            ObjectType::StateVisibility => Err(ProtocolError::InvalidState(
161                "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
162                    .to_string(),
163            )),
164        }
165    }
166
167    pub fn bucket(self) -> ObjectTypeBucket {
168        match self {
169            ObjectType::Blob => ObjectTypeBucket::Blob,
170            ObjectType::Tree => ObjectTypeBucket::Tree,
171            ObjectType::State => ObjectTypeBucket::State,
172            ObjectType::Action => ObjectTypeBucket::Action,
173            ObjectType::Redaction => ObjectTypeBucket::Redaction,
174            ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
175            ObjectType::StateAttachment => ObjectTypeBucket::StateAttachment,
176        }
177    }
178}
179
180#[derive(Debug, Clone, Default)]
181pub struct StateClosureOptions {
182    pub depth: Option<u32>,
183    pub exclude_states: Vec<StateId>,
184}
185
186pub fn enumerate_state_closure(
187    store: &impl ObjectStore,
188    state_id: StateId,
189) -> Result<Vec<ObjectInfo>> {
190    enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
191}
192
193pub fn enumerate_state_closure_with_options(
194    store: &impl ObjectStore,
195    state_id: StateId,
196    options: StateClosureOptions,
197) -> Result<Vec<ObjectInfo>> {
198    let mut out = Vec::new();
199    walk_state_closure(store, state_id, options, |event| {
200        if let Some(info) = object_info_from_event(store, event)? {
201            out.push(info);
202        }
203        Ok(())
204    })?;
205
206    Ok(out)
207}
208
209pub fn enumerate_state_closure_plan(
210    store: &impl ObjectStore,
211    state_id: StateId,
212) -> Result<Vec<PlannedObject>> {
213    enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
214}
215
216pub fn enumerate_state_closure_plan_with_options(
217    store: &impl ObjectStore,
218    state_id: StateId,
219    options: StateClosureOptions,
220) -> Result<Vec<PlannedObject>> {
221    let mut out = Vec::new();
222    walk_state_closure(store, state_id, options, |event| {
223        if let Some(object) = planned_object_from_event(store, event)? {
224            out.push(object);
225        }
226        Ok(())
227    })?;
228
229    Ok(out)
230}
231
232pub fn enumerate_state_closure_transfer_with_options(
233    store: &impl ObjectStore,
234    state_id: StateId,
235    options: StateClosureOptions,
236    full_descriptor_object_threshold: usize,
237) -> Result<StateClosureTransferObjects> {
238    let mut planned_objects = Vec::new();
239    let mut full_objects = Some(Vec::new());
240
241    walk_state_closure(store, state_id, options, |event| {
242        if let Some(object) = planned_object_from_event(store, event)? {
243            planned_objects.push(object);
244        }
245
246        if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
247            full_objects = None;
248        }
249        if let Some(objects) = full_objects.as_mut()
250            && let Some(info) = object_info_from_event(store, event)?
251        {
252            objects.push(info);
253        }
254
255        Ok(())
256    })?;
257
258    Ok(StateClosureTransferObjects {
259        planned_objects,
260        full_objects,
261    })
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265enum BlobSource {
266    Tree,
267    StateMetadata,
268}
269
270#[derive(Debug, Clone, Copy)]
271enum StateClosureEvent<'a> {
272    State {
273        id: StateId,
274        state: &'a State,
275    },
276    Tree {
277        hash: ContentHash,
278        tree: &'a objects::object::Tree,
279    },
280    Blob {
281        hash: ContentHash,
282        source: BlobSource,
283    },
284    Redaction {
285        blob: ContentHash,
286    },
287    StateVisibility {
288        state: StateId,
289    },
290    StateAttachment {
291        state: StateId,
292        attachment: &'a StateAttachment,
293    },
294    ExcludedState {
295        id: StateId,
296    },
297    ExcludedHash {
298        hash: ContentHash,
299    },
300}
301
302fn walk_state_closure(
303    store: &impl ObjectStore,
304    state_id: StateId,
305    options: StateClosureOptions,
306    mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
307) -> Result<()> {
308    let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
309
310    let mut seen_states: HashSet<StateId> = HashSet::new();
311    let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
312    let mut queue: VecDeque<(StateId, u32)> = VecDeque::new();
313    queue.push_back((state_id, 0));
314
315    while let Some((id, depth)) = queue.pop_front() {
316        if excluded_states.contains(&id) {
317            visit(StateClosureEvent::ExcludedState { id })?;
318            continue;
319        }
320        if !seen_states.insert(id) {
321            continue;
322        }
323
324        let state = store
325            .get_state(&id)?
326            .ok_or_else(|| ProtocolError::ObjectNotFound(id.to_string()))?;
327
328        visit(StateClosureEvent::State { id, state: &state })?;
329        if store.has_state_visibility_for_state(&id)? {
330            visit(StateClosureEvent::StateVisibility { state: id })?;
331        }
332        for attachment in store.list_state_attachments(&id)? {
333            visit(StateClosureEvent::StateAttachment {
334                state: id,
335                attachment: &attachment,
336            })?;
337            match attachment.body {
338                StateAttachmentBody::Context(root) => walk_tree_closure_filtered(
339                    store,
340                    root,
341                    &excluded_hashes,
342                    &mut seen_hashes,
343                    &mut visit,
344                )?,
345                StateAttachmentBody::RiskSignals(hash)
346                | StateAttachmentBody::ReviewSignatures(hash)
347                | StateAttachmentBody::Discussions(hash)
348                | StateAttachmentBody::StructuredConflicts(hash) => walk_blob_filtered(
349                    store,
350                    hash,
351                    BlobSource::StateMetadata,
352                    &excluded_hashes,
353                    &mut seen_hashes,
354                    &mut visit,
355                )?,
356                StateAttachmentBody::SemanticIndex(root) => walk_semantic_index_closure(
357                    store,
358                    root,
359                    &excluded_hashes,
360                    &mut seen_hashes,
361                    &mut visit,
362                )?,
363                StateAttachmentBody::Signature(_) => {}
364            }
365        }
366
367        if options.depth.map(|max| depth < max).unwrap_or(true) {
368            for parent in &state.parents {
369                queue.push_back((*parent, depth + 1));
370            }
371        }
372
373        walk_tree_closure_filtered(
374            store,
375            state.tree,
376            &excluded_hashes,
377            &mut seen_hashes,
378            &mut visit,
379        )?;
380        if let Some(provenance_root) = state.provenance {
381            walk_tree_closure_filtered(
382                store,
383                provenance_root,
384                &excluded_hashes,
385                &mut seen_hashes,
386                &mut visit,
387            )?;
388        }
389    }
390
391    Ok(())
392}
393
394fn walk_tree_closure_filtered(
395    store: &impl ObjectStore,
396    tree_hash: ContentHash,
397    excluded: &HashSet<ContentHash>,
398    seen: &mut HashSet<ContentHash>,
399    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
400) -> Result<()> {
401    if excluded.contains(&tree_hash) {
402        visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
403        return Ok(());
404    }
405    if !seen.insert(tree_hash) {
406        return Ok(());
407    }
408
409    let tree = store
410        .get_tree(&tree_hash)?
411        .ok_or_else(|| ProtocolError::ObjectNotFound(tree_hash.to_hex()))?;
412
413    visit(StateClosureEvent::Tree {
414        hash: tree_hash,
415        tree: &tree,
416    })?;
417
418    for entry in tree.entries() {
419        match entry.target() {
420            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
421                walk_blob_filtered(store, *hash, BlobSource::Tree, excluded, seen, visit)?;
422            }
423            TreeEntryTarget::Tree { hash } => {
424                walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
425            }
426            TreeEntryTarget::Gitlink { .. } => {}
427            // Native child-spool edge: its target lives in a separate spool
428            // object graph, not this store, so it is not walked here.
429            TreeEntryTarget::Spoollink { .. } => {}
430        }
431    }
432
433    Ok(())
434}
435
436fn walk_blob_filtered(
437    store: &impl ObjectStore,
438    blob_hash: ContentHash,
439    source: BlobSource,
440    excluded: &HashSet<ContentHash>,
441    seen: &mut HashSet<ContentHash>,
442    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
443) -> Result<()> {
444    if excluded.contains(&blob_hash) {
445        visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
446        return Ok(());
447    }
448    if !seen.insert(blob_hash) {
449        return Ok(());
450    }
451    visit(StateClosureEvent::Blob {
452        hash: blob_hash,
453        source,
454    })?;
455    if store.has_redactions_for_blob(&blob_hash)? {
456        visit(StateClosureEvent::Redaction { blob: blob_hash })?;
457    }
458    Ok(())
459}
460
461/// Walk the merkle semantic-index closure rooted at `root_hash` (a
462/// `SemanticIndexRoot` blob), emitting every reachable semantic node blob.
463///
464/// All semantic-index nodes (root, tree nodes, file nodes) are stored as
465/// ordinary content-addressed blobs, so replication just enumerates them.
466/// Opaque entries point back at raw source blobs already covered by the state's
467/// tree closure, so they are not re-walked here.
468///
469/// Iterative (explicit stack) so a crafted deep `SemanticTreeNode` chain in a
470/// pushed state can't overflow the stack. A missing or undecodable node in the
471/// closure is a HARD failure (`ObjectNotFound`/`Serialization`) — a partial or
472/// corrupt semantic closure must never be shipped silently.
473fn walk_semantic_index_closure(
474    store: &impl ObjectStore,
475    root_hash: ContentHash,
476    excluded: &HashSet<ContentHash>,
477    seen: &mut HashSet<ContentHash>,
478    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
479) -> Result<()> {
480    // Stack of (node_hash, is_tree_node): the root and dir nodes must be decoded
481    // to enumerate their children; file/opaque leaves are emitted only.
482    let mut stack: Vec<ContentHash> = vec![root_hash];
483    while let Some(node_hash) = stack.pop() {
484        if !emit_semantic_blob(store, node_hash, excluded, seen, visit)? {
485            continue; // excluded (present on the far side) or already seen.
486        }
487        let blob = store
488            .get_blob(&node_hash)?
489            .ok_or_else(|| ProtocolError::ObjectNotFound(node_hash.to_hex()))?;
490        // The root and tree nodes decode to the same `SemanticTreeNode.entries`
491        // shape after the root's one indirection; walk uniformly by decoding
492        // every interior node as a tree node, tolerating the root shape.
493        let node = decode_semantic_container(&blob, node_hash)?;
494        for child in node {
495            match child {
496                SemanticChild::Interior(hash) => stack.push(hash),
497                SemanticChild::Leaf(hash) => {
498                    // Emit the leaf (file node) blob; it has no children.
499                    emit_semantic_blob(store, hash, excluded, seen, visit)?;
500                }
501            }
502        }
503    }
504    Ok(())
505}
506
507/// The two child kinds encountered walking the semantic closure: an interior
508/// node to descend into, or a leaf (file node) blob to emit.
509enum SemanticChild {
510    Interior(ContentHash),
511    Leaf(ContentHash),
512}
513
514/// Decode a semantic-closure node — the root (which points at a tree node) or a
515/// tree node — into the child hashes to walk. Missing/corrupt is a hard error.
516fn decode_semantic_container(
517    blob: &objects::object::Blob,
518    node_hash: ContentHash,
519) -> Result<Vec<SemanticChild>> {
520    // Try the root shape first (it has an extra `tree` indirection), then the
521    // tree-node shape. Content-addressed hashes make this unambiguous in
522    // practice; a blob that decodes as neither is corrupt.
523    if let Ok(root) = SemanticIndexRoot::decode(blob.content()) {
524        return Ok(vec![SemanticChild::Interior(root.tree)]);
525    }
526    let node = SemanticTreeNode::decode(blob.content())
527        .map_err(|err| ProtocolError::Serialization(format!("semantic node {node_hash}: {err}")))?;
528    Ok(node
529        .entries
530        .iter()
531        .filter_map(|entry| match entry.kind {
532            SemanticEntryKind::Dir => Some(SemanticChild::Interior(entry.node)),
533            SemanticEntryKind::File => Some(SemanticChild::Leaf(entry.node)),
534            // Opaque `node` is the raw source blob, already in the tree closure.
535            SemanticEntryKind::Opaque => None,
536        })
537        .collect())
538}
539
540/// Emit a semantic node blob as state metadata. Returns `true` when the blob
541/// was newly visited (so the caller may descend into it), `false` when it was
542/// excluded (present on the far side) or already seen. A blob that is neither
543/// excluded nor present is a HARD failure — the closure must be complete.
544fn emit_semantic_blob(
545    store: &impl ObjectStore,
546    hash: ContentHash,
547    excluded: &HashSet<ContentHash>,
548    seen: &mut HashSet<ContentHash>,
549    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
550) -> Result<bool> {
551    if excluded.contains(&hash) {
552        visit(StateClosureEvent::ExcludedHash { hash })?;
553        return Ok(false);
554    }
555    if !seen.insert(hash) {
556        return Ok(false);
557    }
558    if store.get_blob(&hash)?.is_none() {
559        return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
560    }
561    visit(StateClosureEvent::Blob {
562        hash,
563        source: BlobSource::StateMetadata,
564    })?;
565    Ok(true)
566}
567
568/// Collect every semantic-index node blob hash reachable from `root_hash` into
569/// `excluded`, for the have-set computation. Iterative + tolerant (a broken
570/// have-set index is not fatal — it just means fewer objects are marked
571/// already-present, which is safe over-fetching, not corruption).
572fn collect_semantic_hashes(
573    store: &impl ObjectStore,
574    root_hash: ContentHash,
575    excluded: &mut HashSet<ContentHash>,
576) -> Result<()> {
577    let mut stack: Vec<ContentHash> = vec![root_hash];
578    while let Some(node_hash) = stack.pop() {
579        if !excluded.insert(node_hash) {
580            continue;
581        }
582        let Some(blob) = store.get_blob(&node_hash)? else {
583            continue;
584        };
585        let children = match decode_semantic_container(&blob, node_hash) {
586            Ok(children) => children,
587            Err(_) => continue,
588        };
589        for child in children {
590            match child {
591                SemanticChild::Interior(hash) => stack.push(hash),
592                SemanticChild::Leaf(hash) => {
593                    excluded.insert(hash);
594                }
595            }
596        }
597    }
598    Ok(())
599}
600
601fn object_info_from_event(
602    store: &impl ObjectStore,
603    event: StateClosureEvent<'_>,
604) -> Result<Option<ObjectInfo>> {
605    match event {
606        StateClosureEvent::State { id, state } => {
607            let state_bytes = rmp_serde::to_vec_named(state)?;
608            Ok(Some(ObjectInfo {
609                id: ObjectId::StateId(id),
610                obj_type: ObjectType::State,
611                size: state_bytes.len() as u64,
612                delta_base: None,
613            }))
614        }
615        StateClosureEvent::Tree { hash, tree } => {
616            let tree_bytes = rmp_serde::to_vec_named(tree)?;
617            Ok(Some(ObjectInfo {
618                id: ObjectId::Hash(hash),
619                obj_type: ObjectType::Tree,
620                size: tree_bytes.len() as u64,
621                delta_base: None,
622            }))
623        }
624        StateClosureEvent::Blob { hash, .. } => {
625            let blob = store
626                .get_blob(&hash)?
627                .ok_or_else(|| ProtocolError::ObjectNotFound(hash.to_hex()))?;
628            Ok(Some(ObjectInfo {
629                id: ObjectId::Hash(hash),
630                obj_type: ObjectType::Blob,
631                size: blob.size() as u64,
632                delta_base: None,
633            }))
634        }
635        StateClosureEvent::Redaction { blob } => Ok(store
636            .get_redactions_bytes_for_blob(&blob)?
637            .map(|bytes| ObjectInfo {
638                id: ObjectId::Hash(blob),
639                obj_type: ObjectType::Redaction,
640                size: bytes.len() as u64,
641                delta_base: None,
642            })),
643        StateClosureEvent::StateVisibility { state } => Ok(store
644            .get_state_visibility_bytes_for_state(&state)?
645            .map(|bytes| ObjectInfo {
646                id: ObjectId::StateId(state),
647                obj_type: ObjectType::StateVisibility,
648                size: bytes.len() as u64,
649                delta_base: None,
650            })),
651        StateClosureEvent::StateAttachment { state, attachment } => {
652            let bytes = rmp_serde::to_vec_named(attachment)?;
653            Ok(Some(ObjectInfo {
654                id: ObjectId::StateAttachment {
655                    state,
656                    id: attachment.id(),
657                    kind: attachment.body.kind(),
658                },
659                obj_type: ObjectType::StateAttachment,
660                size: bytes.len() as u64,
661                delta_base: None,
662            }))
663        }
664        StateClosureEvent::ExcludedState { id } => {
665            let _ = id;
666            Ok(None)
667        }
668        StateClosureEvent::ExcludedHash { hash } => {
669            let _ = hash;
670            Ok(None)
671        }
672    }
673}
674
675fn planned_object_from_event(
676    store: &impl ObjectStore,
677    event: StateClosureEvent<'_>,
678) -> Result<Option<PlannedObject>> {
679    match event {
680        StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
681            id: ObjectId::StateId(id),
682            obj_type: ObjectType::State,
683        })),
684        StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
685            id: ObjectId::Hash(hash),
686            obj_type: ObjectType::Tree,
687        })),
688        StateClosureEvent::Blob { hash, source } => {
689            if source == BlobSource::StateMetadata && store.get_blob(&hash)?.is_none() {
690                return Err(ProtocolError::ObjectNotFound(hash.to_hex()));
691            }
692            Ok(Some(PlannedObject {
693                id: ObjectId::Hash(hash),
694                obj_type: ObjectType::Blob,
695            }))
696        }
697        StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
698            id: ObjectId::Hash(blob),
699            obj_type: ObjectType::Redaction,
700        })),
701        StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
702            id: ObjectId::StateId(state),
703            obj_type: ObjectType::StateVisibility,
704        })),
705        StateClosureEvent::StateAttachment { state, attachment } => Ok(Some(PlannedObject {
706            id: ObjectId::StateAttachment {
707                state,
708                id: attachment.id(),
709                kind: attachment.body.kind(),
710            },
711            obj_type: ObjectType::StateAttachment,
712        })),
713        StateClosureEvent::ExcludedState { id } => {
714            let _ = id;
715            Ok(None)
716        }
717        StateClosureEvent::ExcludedHash { hash } => {
718            let _ = hash;
719            Ok(None)
720        }
721    }
722}
723
724pub fn missing_blobs_in_tree(
725    store: &impl ObjectStore,
726    tree_hash: ContentHash,
727) -> Result<Vec<ContentHash>> {
728    let mut missing = Vec::new();
729    collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
730    Ok(missing)
731}
732
733fn collect_missing_blobs_recursive(
734    store: &impl ObjectStore,
735    tree_hash: &ContentHash,
736    missing: &mut Vec<ContentHash>,
737) -> Result<()> {
738    let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
739        ProtocolError::InvalidState(format!(
740            "load tree {} while collecting lazy hydration missing blobs: {err}",
741            tree_hash.to_hex()
742        ))
743    })?
744    else {
745        return Ok(());
746    };
747
748    for entry in tree.entries() {
749        match entry.target() {
750            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
751                if !store.has_blob(hash).map_err(|err| {
752                    ProtocolError::InvalidState(format!(
753                        "check blob {} while collecting lazy hydration missing blobs: {err}",
754                        hash.to_hex()
755                    ))
756                })? {
757                    missing.push(*hash);
758                }
759            }
760            TreeEntryTarget::Tree { hash } => {
761                collect_missing_blobs_recursive(store, hash, missing)?;
762            }
763            TreeEntryTarget::Gitlink { .. } => {}
764            // Native child-spool edge: its target lives in a separate spool
765            // object graph, not this store, so it is not walked here.
766            TreeEntryTarget::Spoollink { .. } => {}
767        }
768    }
769    Ok(())
770}
771
772fn collect_excluded(
773    store: &impl ObjectStore,
774    roots: &[StateId],
775) -> Result<(HashSet<StateId>, HashSet<ContentHash>)> {
776    if roots.is_empty() {
777        return Ok((HashSet::new(), HashSet::new()));
778    }
779
780    let mut excluded_states: HashSet<StateId> = HashSet::new();
781    let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
782    let mut queue: VecDeque<StateId> = VecDeque::new();
783
784    for id in roots {
785        queue.push_back(*id);
786    }
787
788    while let Some(id) = queue.pop_front() {
789        if !excluded_states.insert(id) {
790            continue;
791        }
792
793        let state = match store.get_state(&id)? {
794            Some(state) => state,
795            None => continue,
796        };
797
798        for parent in &state.parents {
799            queue.push_back(*parent);
800        }
801
802        collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
803        if let Some(provenance_root) = state.provenance {
804            collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
805        }
806        for attachment in store.list_state_attachments(&id)? {
807            match attachment.body {
808                StateAttachmentBody::Context(root) => {
809                    collect_tree_hashes(store, root, &mut excluded_hashes)?
810                }
811                StateAttachmentBody::RiskSignals(hash)
812                | StateAttachmentBody::ReviewSignatures(hash)
813                | StateAttachmentBody::Discussions(hash)
814                | StateAttachmentBody::StructuredConflicts(hash) => {
815                    excluded_hashes.insert(hash);
816                }
817                StateAttachmentBody::SemanticIndex(root) => {
818                    collect_semantic_hashes(store, root, &mut excluded_hashes)?;
819                }
820                StateAttachmentBody::Signature(_) => {}
821            }
822        }
823    }
824
825    Ok((excluded_states, excluded_hashes))
826}
827
828fn collect_tree_hashes(
829    store: &impl ObjectStore,
830    tree_hash: ContentHash,
831    excluded: &mut HashSet<ContentHash>,
832) -> Result<()> {
833    if !excluded.insert(tree_hash) {
834        return Ok(());
835    }
836
837    let tree = match store.get_tree(&tree_hash)? {
838        Some(tree) => tree,
839        None => return Ok(()),
840    };
841
842    for entry in tree.entries() {
843        match entry.target() {
844            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
845                excluded.insert(*hash);
846            }
847            TreeEntryTarget::Tree { hash } => {
848                collect_tree_hashes(store, *hash, excluded)?;
849            }
850            TreeEntryTarget::Gitlink { .. } => {}
851            // Native child-spool edge: its target lives in a separate spool
852            // object graph, not this store, so it is not walked here.
853            TreeEntryTarget::Spoollink { .. } => {}
854        }
855    }
856
857    Ok(())
858}
859
860pub fn is_ancestor(
861    store: &impl ObjectStore,
862    ancestor: StateId,
863    descendant: StateId,
864) -> Result<bool> {
865    if ancestor == descendant {
866        return Ok(true);
867    }
868
869    let mut seen: HashSet<StateId> = HashSet::new();
870    let mut queue: VecDeque<StateId> = VecDeque::new();
871    queue.push_back(descendant);
872
873    while let Some(id) = queue.pop_front() {
874        if !seen.insert(id) {
875            continue;
876        }
877        let state = match store.get_state(&id)? {
878            Some(s) => s,
879            None => return Ok(false),
880        };
881        for parent in state.parents {
882            if parent == ancestor {
883                return Ok(true);
884            }
885            queue.push_back(parent);
886        }
887    }
888
889    Ok(false)
890}
891
892#[cfg(test)]
893mod tests {
894    use std::{
895        collections::HashSet,
896        sync::atomic::{AtomicUsize, Ordering},
897    };
898
899    use chrono::Utc;
900    use objects::{
901        object::{
902            Action, ActionId, Attribution, Blob, ContentHash, Discussion, DiscussionResolution,
903            DiscussionTurn, DiscussionsBlob, Principal, Redaction, State, StateAttachment,
904            StateAttachmentBody, StateId, StateVisibility, SymbolAnchor, Tree, TreeEntry,
905            VisibilityTier,
906        },
907        store::{ObjectStore, Result as StoreResult},
908    };
909    use repo::Repository;
910    use sley::ObjectId as GitObjectId;
911    use tempfile::TempDir;
912
913    use super::{
914        ObjectId, ObjectInfo, ObjectType, PlannedObject, StateClosureOptions,
915        enumerate_state_closure_plan_with_options, enumerate_state_closure_transfer_with_options,
916        enumerate_state_closure_with_options, missing_blobs_in_tree,
917    };
918
919    fn pairs_from_full(objects: &[ObjectInfo]) -> HashSet<(ObjectId, ObjectType)> {
920        objects
921            .iter()
922            .map(|info| (info.id.clone(), info.obj_type))
923            .collect()
924    }
925
926    fn pairs_from_plan(objects: &[PlannedObject]) -> HashSet<(ObjectId, ObjectType)> {
927        objects
928            .iter()
929            .map(|info| (info.id.clone(), info.obj_type))
930            .collect()
931    }
932
933    fn object_info_fingerprint(
934        objects: &[ObjectInfo],
935    ) -> Vec<(ObjectId, ObjectType, u64, Option<ContentHash>)> {
936        objects
937            .iter()
938            .map(|info| (info.id.clone(), info.obj_type, info.size, info.delta_base))
939            .collect()
940    }
941
942    fn assert_plan_parity(
943        repo: &Repository,
944        state_id: StateId,
945        options: StateClosureOptions,
946    ) -> HashSet<(ObjectId, ObjectType)> {
947        let full =
948            enumerate_state_closure_with_options(repo.store(), state_id, options.clone()).unwrap();
949        let plan =
950            enumerate_state_closure_plan_with_options(repo.store(), state_id, options).unwrap();
951
952        let full_pairs = pairs_from_full(&full);
953        let plan_pairs = pairs_from_plan(&plan);
954        assert_eq!(full_pairs, plan_pairs);
955        full_pairs
956    }
957
958    fn assert_contains_object(
959        objects: &HashSet<(ObjectId, ObjectType)>,
960        id: ObjectId,
961        obj_type: ObjectType,
962    ) {
963        assert!(
964            objects.contains(&(id.clone(), obj_type)),
965            "expected closure to contain {id:?} as {obj_type:?}: {objects:?}"
966        );
967    }
968
969    struct CountingStore<'a, S> {
970        inner: &'a S,
971        state_reads: AtomicUsize,
972    }
973
974    impl<'a, S> CountingStore<'a, S> {
975        fn new(inner: &'a S) -> Self {
976            Self {
977                inner,
978                state_reads: AtomicUsize::new(0),
979            }
980        }
981
982        fn state_reads(&self) -> usize {
983            self.state_reads.load(Ordering::SeqCst)
984        }
985    }
986
987    impl<S: ObjectStore> ObjectStore for CountingStore<'_, S> {
988        fn get_blob(&self, hash: &ContentHash) -> StoreResult<Option<Blob>> {
989            self.inner.get_blob(hash)
990        }
991
992        fn put_blob(&self, blob: &Blob) -> StoreResult<ContentHash> {
993            self.inner.put_blob(blob)
994        }
995
996        fn has_blob(&self, hash: &ContentHash) -> StoreResult<bool> {
997            self.inner.has_blob(hash)
998        }
999
1000        fn get_tree(&self, hash: &ContentHash) -> StoreResult<Option<Tree>> {
1001            self.inner.get_tree(hash)
1002        }
1003
1004        fn put_tree(&self, tree: &Tree) -> StoreResult<ContentHash> {
1005            self.inner.put_tree(tree)
1006        }
1007
1008        fn has_tree(&self, hash: &ContentHash) -> StoreResult<bool> {
1009            self.inner.has_tree(hash)
1010        }
1011
1012        fn get_state(&self, id: &StateId) -> StoreResult<Option<State>> {
1013            self.state_reads.fetch_add(1, Ordering::SeqCst);
1014            self.inner.get_state(id)
1015        }
1016
1017        fn put_state(&self, state: &State) -> StoreResult<()> {
1018            self.inner.put_state(state)
1019        }
1020
1021        fn has_state(&self, id: &StateId) -> StoreResult<bool> {
1022            self.inner.has_state(id)
1023        }
1024
1025        fn list_states(&self) -> StoreResult<Vec<StateId>> {
1026            self.inner.list_states()
1027        }
1028
1029        fn get_action(&self, id: &ActionId) -> StoreResult<Option<Action>> {
1030            self.inner.get_action(id)
1031        }
1032
1033        fn put_action(&self, action: &mut Action) -> StoreResult<ActionId> {
1034            self.inner.put_action(action)
1035        }
1036
1037        fn list_actions(&self) -> StoreResult<Vec<ActionId>> {
1038            self.inner.list_actions()
1039        }
1040
1041        fn list_blobs(&self) -> StoreResult<Vec<ContentHash>> {
1042            self.inner.list_blobs()
1043        }
1044
1045        fn list_trees(&self) -> StoreResult<Vec<ContentHash>> {
1046            self.inner.list_trees()
1047        }
1048    }
1049
1050    fn test_attribution() -> Attribution {
1051        Attribution::human(Principal::new("Graph Tester", "graph@example.com"))
1052    }
1053
1054    #[test]
1055    fn lean_closure_planner_matches_object_info_ids_and_types() {
1056        let temp = TempDir::new().unwrap();
1057        let repo = Repository::init_default(temp.path()).unwrap();
1058        std::fs::create_dir_all(temp.path().join("src")).unwrap();
1059        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1060        std::fs::write(temp.path().join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
1061        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1062
1063        let full = enumerate_state_closure_with_options(
1064            repo.store(),
1065            state.state_id,
1066            StateClosureOptions::default(),
1067        )
1068        .unwrap();
1069        let lean = enumerate_state_closure_plan_with_options(
1070            repo.store(),
1071            state.state_id,
1072            StateClosureOptions::default(),
1073        )
1074        .unwrap();
1075
1076        let full_pairs = full
1077            .into_iter()
1078            .map(|info| (info.id, info.obj_type))
1079            .collect::<std::collections::HashSet<_>>();
1080        let lean_pairs = lean
1081            .into_iter()
1082            .map(|info| (info.id, info.obj_type))
1083            .collect::<std::collections::HashSet<_>>();
1084
1085        assert_eq!(full_pairs, lean_pairs);
1086        assert!(
1087            full_pairs
1088                .iter()
1089                .any(|(id, _)| matches!(id, ObjectId::StateId(_)))
1090        );
1091    }
1092
1093    #[test]
1094    fn transfer_projection_matches_full_and_plan_on_mixed_state_closure_fixture() {
1095        let temp = TempDir::new().unwrap();
1096        let repo = Repository::init_default(temp.path()).unwrap();
1097
1098        let excluded_blob = repo
1099            .store()
1100            .put_blob(&Blob::from("excluded"))
1101            .expect("put excluded blob");
1102        let excluded_tree_hash = repo
1103            .store()
1104            .put_tree(&Tree::from_entries(vec![
1105                TreeEntry::file("excluded.txt", excluded_blob, false).unwrap(),
1106            ]))
1107            .expect("put excluded tree");
1108        let excluded_parent = State::new(excluded_tree_hash, Vec::new(), test_attribution());
1109        repo.store()
1110            .put_state(&excluded_parent)
1111            .expect("put excluded parent");
1112
1113        let redacted_blob = repo
1114            .store()
1115            .put_blob(&Blob::from("secret"))
1116            .expect("put redacted blob");
1117        let nested_blob = repo
1118            .store()
1119            .put_blob(&Blob::from("nested"))
1120            .expect("put nested blob");
1121        let symlink_blob = repo
1122            .store()
1123            .put_blob(&Blob::from("target"))
1124            .expect("put symlink blob");
1125        let context_blob = repo
1126            .store()
1127            .put_blob(&Blob::from("context"))
1128            .expect("put context blob");
1129        let provenance_blob = repo
1130            .store()
1131            .put_blob(&Blob::from("provenance"))
1132            .expect("put provenance blob");
1133        let risk_blob = repo
1134            .store()
1135            .put_blob(&Blob::from("risk"))
1136            .expect("put risk blob");
1137        let review_blob = repo
1138            .store()
1139            .put_blob(&Blob::from("review"))
1140            .expect("put review blob");
1141        let discussions_blob = repo
1142            .store()
1143            .put_blob(&Blob::from("discussion"))
1144            .expect("put discussion blob");
1145        let conflicts_blob = repo
1146            .store()
1147            .put_blob(&Blob::from("conflicts"))
1148            .expect("put conflicts blob");
1149
1150        let nested_tree_hash = repo
1151            .store()
1152            .put_tree(&Tree::from_entries(vec![
1153                TreeEntry::file("nested.txt", nested_blob, false).unwrap(),
1154                TreeEntry::symlink("latest", symlink_blob).unwrap(),
1155            ]))
1156            .expect("put nested tree");
1157        let context_tree_hash = repo
1158            .store()
1159            .put_tree(&Tree::from_entries(vec![
1160                TreeEntry::file("context.txt", context_blob, false).unwrap(),
1161            ]))
1162            .expect("put context tree");
1163        let provenance_tree_hash = repo
1164            .store()
1165            .put_tree(&Tree::from_entries(vec![
1166                TreeEntry::file("lineage.txt", provenance_blob, false).unwrap(),
1167            ]))
1168            .expect("put provenance tree");
1169        let gitlink_target: GitObjectId = "0303030303030303030303030303030303030303"
1170            .parse()
1171            .expect("git oid");
1172        let root_tree_hash = repo
1173            .store()
1174            .put_tree(&Tree::from_entries(vec![
1175                TreeEntry::file("secret.txt", redacted_blob, false).unwrap(),
1176                TreeEntry::directory("nested", nested_tree_hash).unwrap(),
1177                TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
1178            ]))
1179            .expect("put root tree");
1180        let state = State::new(
1181            root_tree_hash,
1182            vec![excluded_parent.state_id],
1183            test_attribution(),
1184        )
1185        .with_provenance(provenance_tree_hash);
1186        repo.store().put_state(&state).expect("put state");
1187        for body in [
1188            StateAttachmentBody::Context(context_tree_hash),
1189            StateAttachmentBody::RiskSignals(risk_blob),
1190            StateAttachmentBody::ReviewSignatures(review_blob),
1191            StateAttachmentBody::Discussions(discussions_blob),
1192            StateAttachmentBody::StructuredConflicts(conflicts_blob),
1193        ] {
1194            repo.put_state_attachment(&StateAttachment {
1195                state_id: state.id(),
1196                body,
1197                attribution: state.attribution.clone(),
1198                created_at: Utc::now(),
1199                supersedes: None,
1200            })
1201            .unwrap();
1202        }
1203
1204        repo.put_redaction(Redaction {
1205            redacted_blob,
1206            state: state.state_id,
1207            path: "secret.txt".to_string(),
1208            reason: "test leak".to_string(),
1209            redactor: Principal::new("Tester", "tester@example.test"),
1210            redacted_at: Utc::now(),
1211            signature: None,
1212            purged_at: None,
1213            supersedes: None,
1214        })
1215        .expect("put redaction");
1216        repo.put_state_visibility(StateVisibility {
1217            state: state.state_id,
1218            tier: VisibilityTier::Restricted {
1219                scope_label: "security".to_string(),
1220            },
1221            embargo_until: None,
1222            declarer: Principal::new("Tester", "tester@example.test"),
1223            declared_at: Utc::now(),
1224            signature: None,
1225            supersedes: None,
1226        })
1227        .expect("put visibility");
1228
1229        let options = StateClosureOptions {
1230            depth: None,
1231            exclude_states: vec![excluded_parent.state_id],
1232        };
1233        let transfer = enumerate_state_closure_transfer_with_options(
1234            repo.store(),
1235            state.state_id,
1236            options.clone(),
1237            512,
1238        )
1239        .expect("transfer projection");
1240
1241        let full =
1242            enumerate_state_closure_with_options(repo.store(), state.state_id, options.clone())
1243                .expect("full closure");
1244        let plan = enumerate_state_closure_plan_with_options(repo.store(), state.state_id, options)
1245            .expect("plan closure");
1246        assert_eq!(
1247            transfer
1248                .full_objects
1249                .as_deref()
1250                .map(object_info_fingerprint),
1251            Some(object_info_fingerprint(&full))
1252        );
1253        assert_eq!(transfer.planned_objects, plan);
1254
1255        let full_pairs = pairs_from_full(&full);
1256        assert_eq!(full_pairs, pairs_from_plan(&plan));
1257        assert_contains_object(
1258            &full_pairs,
1259            ObjectId::StateId(state.state_id),
1260            ObjectType::State,
1261        );
1262        assert_contains_object(
1263            &full_pairs,
1264            ObjectId::StateId(state.state_id),
1265            ObjectType::StateVisibility,
1266        );
1267        assert_contains_object(&full_pairs, ObjectId::Hash(redacted_blob), ObjectType::Blob);
1268        assert_contains_object(
1269            &full_pairs,
1270            ObjectId::Hash(redacted_blob),
1271            ObjectType::Redaction,
1272        );
1273        for hash in [
1274            root_tree_hash,
1275            nested_tree_hash,
1276            context_tree_hash,
1277            provenance_tree_hash,
1278        ] {
1279            assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Tree);
1280        }
1281        for hash in [
1282            nested_blob,
1283            symlink_blob,
1284            context_blob,
1285            provenance_blob,
1286            risk_blob,
1287            review_blob,
1288            discussions_blob,
1289            conflicts_blob,
1290        ] {
1291            assert_contains_object(&full_pairs, ObjectId::Hash(hash), ObjectType::Blob);
1292        }
1293        assert!(!full_pairs.contains(&(
1294            ObjectId::StateId(excluded_parent.state_id),
1295            ObjectType::State
1296        )));
1297        assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_tree_hash), ObjectType::Tree)));
1298        assert!(!full_pairs.contains(&(ObjectId::Hash(excluded_blob), ObjectType::Blob)));
1299    }
1300
1301    #[test]
1302    fn transfer_projection_reads_root_state_once_on_small_transfer() {
1303        let temp = TempDir::new().unwrap();
1304        let repo = Repository::init_default(temp.path()).unwrap();
1305        let blob = repo
1306            .store()
1307            .put_blob(&Blob::from("hello\n"))
1308            .expect("put blob");
1309        let tree_hash = repo
1310            .store()
1311            .put_tree(&Tree::from_entries(vec![
1312                TreeEntry::file("README.md", blob, false).unwrap(),
1313            ]))
1314            .expect("put tree");
1315        let state = State::new(tree_hash, Vec::new(), test_attribution());
1316        repo.store().put_state(&state).expect("put state");
1317        let store = CountingStore::new(repo.store());
1318
1319        let transfer = enumerate_state_closure_transfer_with_options(
1320            &store,
1321            state.state_id,
1322            StateClosureOptions::default(),
1323            512,
1324        )
1325        .expect("transfer projection");
1326
1327        assert!(
1328            !transfer.planned_objects.is_empty(),
1329            "lean projection should be available"
1330        );
1331        assert!(transfer.full_objects.is_some());
1332        assert_eq!(
1333            store.state_reads(),
1334            1,
1335            "small transfer projection must not read the root state through a second closure walk"
1336        );
1337    }
1338
1339    #[test]
1340    fn transfer_projection_drops_full_descriptors_after_threshold() {
1341        let temp = TempDir::new().unwrap();
1342        let repo = Repository::init_default(temp.path()).unwrap();
1343        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1344        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1345
1346        let transfer = enumerate_state_closure_transfer_with_options(
1347            repo.store(),
1348            state.state_id,
1349            StateClosureOptions::default(),
1350            0,
1351        )
1352        .expect("transfer projection");
1353
1354        assert!(
1355            !transfer.planned_objects.is_empty(),
1356            "lean projection should still be available over the threshold"
1357        );
1358        assert!(transfer.full_objects.is_none());
1359    }
1360
1361    #[test]
1362    fn depth_and_exclude_options_match_between_full_and_plan() {
1363        let temp = TempDir::new().unwrap();
1364        let repo = Repository::init_default(temp.path()).unwrap();
1365        let path = temp.path().join("story.txt");
1366
1367        std::fs::write(&path, "base\n").unwrap();
1368        let base = repo.snapshot(Some("base".to_string()), None).unwrap();
1369        std::fs::write(&path, "middle\n").unwrap();
1370        let middle = repo.snapshot(Some("middle".to_string()), None).unwrap();
1371        std::fs::write(&path, "tip\n").unwrap();
1372        let tip = repo.snapshot(Some("tip".to_string()), None).unwrap();
1373
1374        let depth_zero = assert_plan_parity(
1375            &repo,
1376            tip.state_id,
1377            StateClosureOptions {
1378                depth: Some(0),
1379                exclude_states: Vec::new(),
1380            },
1381        );
1382        assert!(depth_zero.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1383        assert!(!depth_zero.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1384        assert!(!depth_zero.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1385
1386        let depth_one = assert_plan_parity(
1387            &repo,
1388            tip.state_id,
1389            StateClosureOptions {
1390                depth: Some(1),
1391                exclude_states: Vec::new(),
1392            },
1393        );
1394        assert!(depth_one.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1395        assert!(depth_one.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1396        assert!(!depth_one.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1397
1398        let exclude_middle = assert_plan_parity(
1399            &repo,
1400            tip.state_id,
1401            StateClosureOptions {
1402                depth: None,
1403                exclude_states: vec![middle.state_id],
1404            },
1405        );
1406        assert!(exclude_middle.contains(&(ObjectId::StateId(tip.state_id), ObjectType::State)));
1407        assert!(!exclude_middle.contains(&(ObjectId::StateId(middle.state_id), ObjectType::State)));
1408        assert!(!exclude_middle.contains(&(ObjectId::StateId(base.state_id), ObjectType::State)));
1409    }
1410
1411    #[test]
1412    fn shared_tree_and_blob_references_are_emitted_once() {
1413        let temp = TempDir::new().unwrap();
1414        let repo = Repository::init_default(temp.path()).unwrap();
1415
1416        let shared_blob = Blob::from("shared contents\n");
1417        let shared_blob_hash = repo.store().put_blob(&shared_blob).unwrap();
1418        let shared_tree = Tree::from_entries(vec![
1419            TreeEntry::file("shared.txt", shared_blob_hash, false).unwrap(),
1420        ]);
1421        let shared_tree_hash = repo.store().put_tree(&shared_tree).unwrap();
1422        let root = Tree::from_entries(vec![
1423            TreeEntry::directory("left", shared_tree_hash).unwrap(),
1424            TreeEntry::directory("right", shared_tree_hash).unwrap(),
1425        ]);
1426        let root_hash = repo.store().put_tree(&root).unwrap();
1427        let state = State::new(root_hash, Vec::new(), test_attribution());
1428        repo.store().put_state(&state).unwrap();
1429
1430        let full = enumerate_state_closure_with_options(
1431            repo.store(),
1432            state.state_id,
1433            StateClosureOptions::default(),
1434        )
1435        .unwrap();
1436        let plan = enumerate_state_closure_plan_with_options(
1437            repo.store(),
1438            state.state_id,
1439            StateClosureOptions::default(),
1440        )
1441        .unwrap();
1442
1443        assert_eq!(
1444            pairs_from_full(&full),
1445            pairs_from_plan(&plan),
1446            "full and lean closure enumerators must dedup the same objects"
1447        );
1448
1449        assert_eq!(
1450            full.iter()
1451                .filter(|info| info.id == ObjectId::Hash(root_hash)
1452                    && info.obj_type == ObjectType::Tree)
1453                .count(),
1454            1
1455        );
1456        assert_eq!(
1457            full.iter()
1458                .filter(|info| info.id == ObjectId::Hash(shared_tree_hash)
1459                    && info.obj_type == ObjectType::Tree)
1460                .count(),
1461            1
1462        );
1463        assert_eq!(
1464            full.iter()
1465                .filter(|info| info.id == ObjectId::Hash(shared_blob_hash)
1466                    && info.obj_type == ObjectType::Blob)
1467                .count(),
1468            1
1469        );
1470    }
1471
1472    #[test]
1473    fn state_closure_skips_gitlink_targets() {
1474        let temp = TempDir::new().unwrap();
1475        let repo = Repository::init_default(temp.path()).unwrap();
1476        let target: GitObjectId = "0303030303030303030303030303030303030303"
1477            .parse()
1478            .expect("git oid");
1479        let root = Tree::from_entries(vec![
1480            TreeEntry::gitlink("vendor", target).expect("gitlink entry"),
1481        ]);
1482        let root_hash = repo.store().put_tree(&root).unwrap();
1483        let state = State::new(root_hash, Vec::new(), test_attribution());
1484        repo.store().put_state(&state).unwrap();
1485
1486        let full = enumerate_state_closure_with_options(
1487            repo.store(),
1488            state.state_id,
1489            StateClosureOptions::default(),
1490        )
1491        .unwrap();
1492        let plan = enumerate_state_closure_plan_with_options(
1493            repo.store(),
1494            state.state_id,
1495            StateClosureOptions::default(),
1496        )
1497        .unwrap();
1498
1499        assert_eq!(pairs_from_full(&full), pairs_from_plan(&plan));
1500        assert!(
1501            !full.iter().any(|info| info.obj_type == ObjectType::Blob),
1502            "gitlinks carry foreign Git commit ids, not Heddle blob dependencies: {full:?}"
1503        );
1504        assert!(full.iter().any(|info| {
1505            info.id == ObjectId::Hash(root_hash) && info.obj_type == ObjectType::Tree
1506        }));
1507    }
1508
1509    #[test]
1510    fn missing_blobs_in_tree_skips_gitlinks_and_walks_nested_side_paths() {
1511        let temp = TempDir::new().unwrap();
1512        let repo = Repository::init_default(temp.path()).unwrap();
1513        let present_blob = repo
1514            .store()
1515            .put_blob(&Blob::from("already local"))
1516            .expect("put present blob");
1517        let missing_nested = ContentHash::from_bytes([7; 32]);
1518        let missing_symlink = ContentHash::from_bytes([8; 32]);
1519        let nested_tree = Tree::from_entries(vec![
1520            TreeEntry::file("remote.txt", missing_nested, false).unwrap(),
1521            TreeEntry::symlink("remote-link", missing_symlink).unwrap(),
1522        ]);
1523        let nested_tree_hash = repo
1524            .store()
1525            .put_tree(&nested_tree)
1526            .expect("put nested tree");
1527        let gitlink_target: GitObjectId = "0404040404040404040404040404040404040404"
1528            .parse()
1529            .expect("git oid");
1530        let root = Tree::from_entries(vec![
1531            TreeEntry::file("local.txt", present_blob, false).unwrap(),
1532            TreeEntry::directory("nested", nested_tree_hash).unwrap(),
1533            TreeEntry::gitlink("vendor", gitlink_target).unwrap(),
1534        ]);
1535        let root_hash = repo.store().put_tree(&root).expect("put root tree");
1536
1537        let missing = missing_blobs_in_tree(repo.store(), root_hash).expect("missing blobs");
1538
1539        assert_eq!(
1540            missing.into_iter().collect::<HashSet<_>>(),
1541            HashSet::from([missing_nested, missing_symlink])
1542        );
1543    }
1544
1545    /// Once a redaction is declared for a blob in a snapshot, the
1546    /// state closure must include an `ObjectType::Redaction` entry
1547    /// keyed on that blob's hash — that's the wire-side signal the
1548    /// receiver replays.
1549    #[test]
1550    fn enumerate_state_closure_emits_redaction_for_redacted_blob() {
1551        let temp = TempDir::new().unwrap();
1552        let repo = Repository::init_default(temp.path()).unwrap();
1553        std::fs::write(temp.path().join("secret.toml"), "api_token = \"x\"\n").unwrap();
1554        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1555
1556        // Find the blob hash for secret.toml by walking the snapshot's tree.
1557        let tree = repo
1558            .store()
1559            .get_tree(&state.tree)
1560            .unwrap()
1561            .expect("tree present");
1562        let blob_hash = tree
1563            .iter()
1564            .find(|e| e.name() == "secret.toml")
1565            .expect("entry present")
1566            .blob_hash()
1567            .expect("secret.toml is a blob");
1568
1569        let redaction = Redaction {
1570            redacted_blob: blob_hash,
1571            state: state.state_id,
1572            path: "secret.toml".to_string(),
1573            reason: "test leak".to_string(),
1574            redactor: Principal {
1575                name: "Tester".into(),
1576                email: "tester@heddle.sh".into(),
1577            },
1578            redacted_at: Utc::now(),
1579            signature: None,
1580            purged_at: None,
1581            supersedes: None,
1582        };
1583        repo.put_redaction(redaction).unwrap();
1584
1585        let full = enumerate_state_closure_with_options(
1586            repo.store(),
1587            state.state_id,
1588            StateClosureOptions::default(),
1589        )
1590        .unwrap();
1591        let plan = enumerate_state_closure_plan_with_options(
1592            repo.store(),
1593            state.state_id,
1594            StateClosureOptions::default(),
1595        )
1596        .unwrap();
1597
1598        assert!(
1599            full.iter()
1600                .any(|info| info.obj_type == ObjectType::Redaction
1601                    && info.id == ObjectId::Hash(blob_hash)),
1602            "full closure must include a Redaction entry for the redacted blob"
1603        );
1604        assert!(
1605            plan.iter()
1606                .any(|p| p.obj_type == ObjectType::Redaction && p.id == ObjectId::Hash(blob_hash)),
1607            "plan closure must include a Redaction entry for the redacted blob"
1608        );
1609    }
1610
1611    #[test]
1612    fn enumerate_state_closure_emits_state_visibility_for_visible_state() {
1613        let temp = TempDir::new().unwrap();
1614        let repo = Repository::init_default(temp.path()).unwrap();
1615        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1616        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1617
1618        repo.put_state_visibility(StateVisibility {
1619            state: state.state_id,
1620            tier: VisibilityTier::Restricted {
1621                scope_label: "security-embargo".into(),
1622            },
1623            embargo_until: None,
1624            declarer: Principal {
1625                name: "Tester".into(),
1626                email: "tester@heddle.sh".into(),
1627            },
1628            declared_at: Utc::now(),
1629            signature: None,
1630            supersedes: None,
1631        })
1632        .unwrap();
1633
1634        let full = enumerate_state_closure_with_options(
1635            repo.store(),
1636            state.state_id,
1637            StateClosureOptions::default(),
1638        )
1639        .unwrap();
1640        let plan = enumerate_state_closure_plan_with_options(
1641            repo.store(),
1642            state.state_id,
1643            StateClosureOptions::default(),
1644        )
1645        .unwrap();
1646
1647        assert!(
1648            full.iter()
1649                .any(|info| info.obj_type == ObjectType::StateVisibility
1650                    && info.id == ObjectId::StateId(state.state_id)),
1651            "full closure must include a StateVisibility entry for the visible state"
1652        );
1653        assert!(
1654            plan.iter()
1655                .any(|p| p.obj_type == ObjectType::StateVisibility
1656                    && p.id == ObjectId::StateId(state.state_id)),
1657            "plan closure must include a StateVisibility entry for the visible state"
1658        );
1659    }
1660
1661    #[test]
1662    fn enumerate_state_closure_emits_state_metadata_blobs() {
1663        let temp = TempDir::new().unwrap();
1664        let repo = Repository::init_default(temp.path()).unwrap();
1665        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1666        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1667
1668        let principal = Principal::new("Tester", "tester@example.test");
1669        let discussion_bytes = DiscussionsBlob::new(vec![Discussion {
1670            id: "disc-1".to_string(),
1671            anchor: SymbolAnchor::new("src/lib.rs", "answer"),
1672            opened_against_state: state.state_id,
1673            opened_at: 1_782_400_000,
1674            thread_ref: None,
1675            turns: vec![DiscussionTurn {
1676                author: principal,
1677                body: "Should this sync?".to_string(),
1678                posted_at: 1_782_400_000,
1679            }],
1680            resolution: DiscussionResolution::Open,
1681            body_changed_since_open: false,
1682            orphaned: false,
1683            visibility: VisibilityTier::default(),
1684            resolved_annotation_id: None,
1685        }])
1686        .encode()
1687        .expect("encode discussions");
1688        let discussion_hash = repo
1689            .store()
1690            .put_blob(&Blob::new(discussion_bytes))
1691            .expect("put discussions blob");
1692        let risk_hash = repo
1693            .store()
1694            .put_blob(&Blob::from_slice(b"risk signals"))
1695            .expect("put risk blob");
1696        let review_hash = repo
1697            .store()
1698            .put_blob(&Blob::from_slice(b"review signatures"))
1699            .expect("put review blob");
1700        let conflicts_hash = repo
1701            .store()
1702            .put_blob(&Blob::from_slice(b"structured conflicts"))
1703            .expect("put conflicts blob");
1704        for body in [
1705            StateAttachmentBody::RiskSignals(risk_hash),
1706            StateAttachmentBody::ReviewSignatures(review_hash),
1707            StateAttachmentBody::Discussions(discussion_hash),
1708            StateAttachmentBody::StructuredConflicts(conflicts_hash),
1709        ] {
1710            repo.put_state_attachment(&StateAttachment {
1711                state_id: state.id(),
1712                body,
1713                attribution: state.attribution.clone(),
1714                created_at: Utc::now(),
1715                supersedes: None,
1716            })
1717            .unwrap();
1718        }
1719
1720        let full = enumerate_state_closure_with_options(
1721            repo.store(),
1722            state.state_id,
1723            StateClosureOptions::default(),
1724        )
1725        .unwrap();
1726        let plan = enumerate_state_closure_plan_with_options(
1727            repo.store(),
1728            state.state_id,
1729            StateClosureOptions::default(),
1730        )
1731        .unwrap();
1732
1733        for metadata_hash in [risk_hash, review_hash, discussion_hash, conflicts_hash] {
1734            assert!(
1735                full.iter().any(|info| info.obj_type == ObjectType::Blob
1736                    && info.id == ObjectId::Hash(metadata_hash)),
1737                "full closure must include state metadata blob {metadata_hash}"
1738            );
1739            assert!(
1740                plan.iter().any(
1741                    |p| p.obj_type == ObjectType::Blob && p.id == ObjectId::Hash(metadata_hash)
1742                ),
1743                "plan closure must include state metadata blob {metadata_hash}"
1744            );
1745        }
1746    }
1747
1748    /// The push/pull packability split routes `StateAttachment` off the push
1749    /// pack (weft#549 forgery seal) while leaving pull carriage and every other
1750    /// type untouched.
1751    #[test]
1752    fn packable_predicates_split_state_attachment_by_direction() {
1753        // Sidecar records are never packable in either direction.
1754        for sidecar in [ObjectType::Redaction, ObjectType::StateVisibility] {
1755            assert!(!sidecar.packable_for_push(), "{sidecar:?} push");
1756            assert!(!sidecar.packable_for_pull(), "{sidecar:?} pull");
1757        }
1758        // Content-addressed objects ride the pack in both directions.
1759        for packable in [
1760            ObjectType::Blob,
1761            ObjectType::Tree,
1762            ObjectType::State,
1763            ObjectType::Action,
1764        ] {
1765            assert!(packable.packable_for_push(), "{packable:?} push");
1766            assert!(packable.packable_for_pull(), "{packable:?} pull");
1767        }
1768        // The attachment record: excluded from the push pack, kept on pull.
1769        assert!(!ObjectType::StateAttachment.packable_for_push());
1770        assert!(ObjectType::StateAttachment.packable_for_pull());
1771    }
1772
1773    /// A pushed state's semantic-index attachment RECORD must be excluded from
1774    /// the push pack (it rides the sidecar lane) while its semantic-index
1775    /// content blobs still ride the pack in both directions, and the same
1776    /// record stays packable server->client on pull.
1777    #[test]
1778    fn semantic_index_attachment_excluded_from_push_pack_but_kept_for_pull() {
1779        use std::collections::BTreeMap;
1780
1781        use objects::object::{SemanticIndexRoot, SemanticTreeNode};
1782
1783        let temp = TempDir::new().unwrap();
1784        let repo = Repository::init_default(temp.path()).unwrap();
1785        std::fs::write(temp.path().join("README.md"), "hello\n").unwrap();
1786        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1787
1788        // Minimal valid semantic-index fixture: an empty tree node under a root.
1789        let (node, node_digest) = SemanticTreeNode::new(Vec::new());
1790        let node_hash = repo
1791            .store()
1792            .put_blob(&Blob::new(node.encode().unwrap()))
1793            .expect("put semantic tree node");
1794        let root = SemanticIndexRoot::new(1, BTreeMap::new(), node_hash, node_digest);
1795        let root_hash = repo
1796            .store()
1797            .put_blob(&Blob::new(root.encode().unwrap()))
1798            .expect("put semantic index root");
1799        repo.put_state_attachment(&StateAttachment {
1800            state_id: state.state_id,
1801            body: StateAttachmentBody::SemanticIndex(root_hash),
1802            attribution: test_attribution(),
1803            created_at: Utc::now(),
1804            supersedes: None,
1805        })
1806        .unwrap();
1807
1808        let plan = enumerate_state_closure_plan_with_options(
1809            repo.store(),
1810            state.state_id,
1811            StateClosureOptions::default(),
1812        )
1813        .unwrap();
1814
1815        // Every StateAttachment record (at least the semantic index authored
1816        // above) is push-excluded and pull-included.
1817        let attachments: Vec<_> = plan
1818            .iter()
1819            .filter(|p| p.obj_type == ObjectType::StateAttachment)
1820            .collect();
1821        assert!(
1822            !attachments.is_empty(),
1823            "closure must contain the semantic-index attachment record"
1824        );
1825        for attachment in &attachments {
1826            assert!(matches!(attachment.id, ObjectId::StateAttachment { .. }));
1827            // Push: excluded from the pack (sidecar lane). Pull: kept in pack.
1828            assert!(
1829                !attachment.obj_type.packable_for_push(),
1830                "attachment record must be excluded from the push pack"
1831            );
1832            assert!(
1833                attachment.obj_type.packable_for_pull(),
1834                "attachment record must stay in the pull pack"
1835            );
1836        }
1837
1838        // The semantic-index CONTENT blobs are ordinary content-addressed
1839        // objects and still ride the pack in both directions — only the
1840        // attachment record is sidecar'd on push.
1841        for content in [root_hash, node_hash] {
1842            let obj = plan
1843                .iter()
1844                .find(|p| p.id == ObjectId::Hash(content))
1845                .unwrap_or_else(|| panic!("semantic content blob {content} in closure"));
1846            assert_eq!(obj.obj_type, ObjectType::Blob);
1847            assert!(obj.obj_type.packable_for_push());
1848            assert!(obj.obj_type.packable_for_pull());
1849        }
1850
1851        // Partitioning the plan by push-packability puts the record on the
1852        // sidecar side and never in the pack side.
1853        let (push_pack, push_sidecar): (Vec<_>, Vec<_>) = plan
1854            .iter()
1855            .partition(|p| p.obj_type.packable_for_push());
1856        assert!(
1857            push_sidecar
1858                .iter()
1859                .any(|p| p.obj_type == ObjectType::StateAttachment),
1860            "attachment record routed to the push sidecar partition"
1861        );
1862        assert!(
1863            !push_pack
1864                .iter()
1865                .any(|p| p.obj_type == ObjectType::StateAttachment),
1866            "attachment record must not be in the push pack partition"
1867        );
1868    }
1869}