Skip to main content

objects/transfer/
graph.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::{HashSet, VecDeque};
3
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    error::{HeddleError, Result},
8    object::{
9        AnnotatedTag, BindingDelta, ContentHash, RedactionsBlob, ReverseDependencyIndex,
10        SemanticEntryKind, SemanticIndexRoot, SemanticTreeNode, State, StateAttachment,
11        StateAttachmentBody, StateAttachmentId, StateAttachmentKind, StateId, TreeEntryTarget,
12    },
13    store::{ObjectStore, pack::ObjectType as PackObjectType},
14};
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum ObjectId {
18    Hash(ContentHash),
19    StateId(StateId),
20    StateAttachment {
21        state: StateId,
22        id: StateAttachmentId,
23        /// The attachment's kind, a pure projection of its body
24        /// ([`StateAttachmentBody::kind`]). Carried through the wire so
25        /// descriptors self-describe their kind; the dedup/identity key is
26        /// still `(state, id)`, and kind is coherent under `Eq`/`Hash` because
27        /// it is a deterministic function of the same record.
28        kind: StateAttachmentKind,
29    },
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ObjectInfo {
34    pub id: ObjectId,
35    pub obj_type: ObjectType,
36    pub size: u64,
37    pub delta_base: Option<ContentHash>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41pub struct PlannedObject {
42    pub id: ObjectId,
43    pub obj_type: ObjectType,
44}
45
46#[derive(Debug, Clone)]
47pub struct StateClosureTransferObjects {
48    pub planned_objects: Vec<PlannedObject>,
49    pub full_objects: Option<Vec<ObjectInfo>>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53pub enum ObjectType {
54    Blob,
55    Tree,
56    State,
57    Action,
58    AnnotatedTag,
59    /// A `RedactionsBlob` sidecar — the rmp-encoded record(s) declaring
60    /// that a specific blob has been redacted by a writer. Keyed on the
61    /// wire by `ObjectId::Hash` of
62    /// the *redacted blob*, since `Repository`'s sidecar store is
63    /// indexed that way.
64    Redaction,
65    /// An owner-authorized purge sidecar. It carries the same encoded
66    /// `RedactionsBlob` as `Redaction`, but is a distinct operation because
67    /// receiving it can irreversibly erase blob bytes.
68    Purge,
69    /// A `StateVisibilityBlob` sidecar — the rmp-encoded record(s)
70    /// declaring a non-public audience tier for a specific state. Keyed
71    /// on the wire by `ObjectId::StateId` of the state, since the
72    /// per-state sidecar store is indexed that way. Like `Redaction`, it
73    /// is a sidecar record that lives outside the content-addressed pack
74    /// and ships via the per-object transfer path, not the pack.
75    StateVisibility,
76    StateAttachment,
77    /// A content-addressed `KeyBindingRegistry` together with each binding's
78    /// revocation/liveness overlay. Hosted materializers append this object to
79    /// a closure and carry it out of pack because the native pack format has no
80    /// key-binding record kind.
81    KeyBinding,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
85pub enum ObjectTypeBucket {
86    Blob,
87    Tree,
88    State,
89    Action,
90    AnnotatedTag,
91    Redaction,
92    Purge,
93    StateVisibility,
94    StateAttachment,
95    KeyBinding,
96}
97
98impl ObjectType {
99    pub fn wire_name(self) -> &'static str {
100        match self {
101            ObjectType::Blob => "blob",
102            ObjectType::Tree => "tree",
103            ObjectType::State => "state",
104            ObjectType::Action => "action",
105            ObjectType::AnnotatedTag => "annotated_tag",
106            ObjectType::Redaction => "redaction",
107            ObjectType::Purge => "purge",
108            ObjectType::StateVisibility => "state_visibility",
109            ObjectType::StateAttachment => "state_attachment",
110            ObjectType::KeyBinding => "key_binding",
111        }
112    }
113
114    pub fn from_wire(value: &str) -> Result<Self> {
115        match value {
116            "blob" => Ok(ObjectType::Blob),
117            "tree" => Ok(ObjectType::Tree),
118            "state" => Ok(ObjectType::State),
119            "action" => Ok(ObjectType::Action),
120            "annotated_tag" => Ok(ObjectType::AnnotatedTag),
121            "redaction" => Ok(ObjectType::Redaction),
122            "purge" => Ok(ObjectType::Purge),
123            "state_visibility" => Ok(ObjectType::StateVisibility),
124            "state_attachment" => Ok(ObjectType::StateAttachment),
125            "key_binding" => Ok(ObjectType::KeyBinding),
126            _ => Err(HeddleError::InvalidObject(format!(
127                "unknown object type: {value}"
128            ))),
129        }
130    }
131
132    /// Whether this object type can ride the content-addressed native pack in
133    /// general (the historical, direction-agnostic predicate). Sidecar records
134    /// (`Redaction`, `StateVisibility`) live outside `.heddle/objects/` and can
135    /// never be packed; everything else can.
136    ///
137    /// Prefer [`ObjectType::packable_for_push`] /
138    /// [`ObjectType::packable_for_pull`] at transfer sites: packability is
139    /// direction-dependent for `StateAttachment` (see those methods). This
140    /// method retains the pull/general semantics so pack construction,
141    /// have-set, and local-copy planners are unchanged.
142    pub fn packable(self) -> bool {
143        !matches!(
144            self,
145            ObjectType::Redaction
146                | ObjectType::Purge
147                | ObjectType::StateVisibility
148                | ObjectType::KeyBinding
149        )
150    }
151
152    /// Whether this object type may ride the client→server **push** pack.
153    ///
154    /// `StateAttachment` is deliberately excluded on push even though it is a
155    /// content-addressed object: a deployed server rejects any pack-carried
156    /// attachment as a forgery-prevention measure (weft#549). Pushed
157    /// attachments must instead ride the out-of-pack **sidecar** lane (the same
158    /// lane as `Redaction`/`StateVisibility`), where the server can verify them
159    /// per-kind at finalize while the pack itself stays forgery-sealed. Every
160    /// other type keeps its [`ObjectType::packable`] answer.
161    pub fn packable_for_push(self) -> bool {
162        self.packable() && !matches!(self, ObjectType::StateAttachment)
163    }
164
165    /// Whether this object type may ride the server→client **pull/clone** pack.
166    ///
167    /// Identical to [`ObjectType::packable`]: attachments are carried in the
168    /// pull pack exactly as today. The push/pull split exists solely so the
169    /// push direction can exclude `StateAttachment` without disturbing the
170    /// working pull carriage.
171    pub fn packable_for_pull(self) -> bool {
172        self.packable()
173    }
174
175    pub fn pack_object_type(self) -> Result<PackObjectType> {
176        match self {
177            ObjectType::Blob => Ok(PackObjectType::Blob),
178            ObjectType::Tree => Ok(PackObjectType::Tree),
179            ObjectType::State => Ok(PackObjectType::State),
180            ObjectType::Action => Ok(PackObjectType::Action),
181            ObjectType::AnnotatedTag => Ok(PackObjectType::AnnotatedTag),
182            ObjectType::StateAttachment => Ok(PackObjectType::StateAttachment),
183            ObjectType::Redaction => Err(HeddleError::InvalidObject(
184                "Redaction sidecar records cannot be packed into the content-addressed object pack"
185                    .to_string(),
186            )),
187            ObjectType::Purge => Err(HeddleError::InvalidObject(
188                "Purge sidecar records cannot be packed into the content-addressed object pack"
189                    .to_string(),
190            )),
191            ObjectType::StateVisibility => Err(HeddleError::InvalidObject(
192                "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
193                    .to_string(),
194            )),
195            ObjectType::KeyBinding => Err(HeddleError::InvalidObject(
196                "KeyBinding registry objects cannot be packed into the content-addressed object pack"
197                    .to_string(),
198            )),
199        }
200    }
201
202    pub fn bucket(self) -> ObjectTypeBucket {
203        match self {
204            ObjectType::Blob => ObjectTypeBucket::Blob,
205            ObjectType::Tree => ObjectTypeBucket::Tree,
206            ObjectType::State => ObjectTypeBucket::State,
207            ObjectType::Action => ObjectTypeBucket::Action,
208            ObjectType::AnnotatedTag => ObjectTypeBucket::AnnotatedTag,
209            ObjectType::Redaction => ObjectTypeBucket::Redaction,
210            ObjectType::Purge => ObjectTypeBucket::Purge,
211            ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
212            ObjectType::StateAttachment => ObjectTypeBucket::StateAttachment,
213            ObjectType::KeyBinding => ObjectTypeBucket::KeyBinding,
214        }
215    }
216}
217
218#[derive(Debug, Clone, Default)]
219pub struct StateClosureOptions {
220    pub depth: Option<u32>,
221    pub exclude_states: Vec<StateId>,
222}
223
224pub fn enumerate_state_closure(
225    store: &impl ObjectStore,
226    state_id: StateId,
227) -> Result<Vec<ObjectInfo>> {
228    enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
229}
230
231pub fn enumerate_state_closure_with_options(
232    store: &impl ObjectStore,
233    state_id: StateId,
234    options: StateClosureOptions,
235) -> Result<Vec<ObjectInfo>> {
236    let mut out = Vec::new();
237    walk_state_closure(store, state_id, options, |event| {
238        if let Some(info) = object_info_from_event(store, event)? {
239            out.push(info);
240        }
241        Ok(())
242    })?;
243    for (hash, tag) in annotated_tags_for_state(store, state_id)? {
244        out.push(annotated_tag_info(hash, &tag));
245    }
246
247    Ok(out)
248}
249
250pub fn enumerate_state_closure_plan(
251    store: &impl ObjectStore,
252    state_id: StateId,
253) -> Result<Vec<PlannedObject>> {
254    enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
255}
256
257pub fn enumerate_state_closure_plan_with_options(
258    store: &impl ObjectStore,
259    state_id: StateId,
260    options: StateClosureOptions,
261) -> Result<Vec<PlannedObject>> {
262    let mut out = Vec::new();
263    walk_state_closure(store, state_id, options, |event| {
264        if let Some(object) = planned_object_from_event(store, event)? {
265            out.push(object);
266        }
267        Ok(())
268    })?;
269    out.extend(
270        annotated_tags_for_state(store, state_id)?
271            .into_iter()
272            .map(|(hash, _)| PlannedObject {
273                id: ObjectId::Hash(hash),
274                obj_type: ObjectType::AnnotatedTag,
275            }),
276    );
277
278    Ok(out)
279}
280
281pub fn enumerate_state_closure_transfer_with_options(
282    store: &impl ObjectStore,
283    state_id: StateId,
284    options: StateClosureOptions,
285    full_descriptor_object_threshold: usize,
286) -> Result<StateClosureTransferObjects> {
287    let mut planned_objects = Vec::new();
288    let mut full_objects = Some(Vec::new());
289
290    walk_state_closure(store, state_id, options, |event| {
291        if let Some(object) = planned_object_from_event(store, event)? {
292            planned_objects.push(object);
293        }
294
295        if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
296            full_objects = None;
297        }
298        if let Some(objects) = full_objects.as_mut()
299            && let Some(info) = object_info_from_event(store, event)?
300        {
301            objects.push(info);
302        }
303
304        Ok(())
305    })?;
306    let tags = annotated_tags_for_state(store, state_id)?;
307    planned_objects.extend(tags.iter().map(|(hash, _)| PlannedObject {
308        id: ObjectId::Hash(*hash),
309        obj_type: ObjectType::AnnotatedTag,
310    }));
311    if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
312        full_objects = None;
313    }
314    if let Some(objects) = full_objects.as_mut() {
315        objects.extend(
316            tags.iter()
317                .map(|(hash, tag)| annotated_tag_info(*hash, tag)),
318        );
319    }
320
321    Ok(StateClosureTransferObjects {
322        planned_objects,
323        full_objects,
324    })
325}
326
327fn annotated_tags_for_state(
328    store: &impl ObjectStore,
329    state_id: StateId,
330) -> Result<Vec<(ContentHash, AnnotatedTag)>> {
331    let mut roots = Vec::new();
332    for hash in store.list_annotated_tags()? {
333        let Some(tag) = store.get_annotated_tag(&hash)? else {
334            continue;
335        };
336        if tag
337            .marker()
338            .is_some_and(|marker| marker.peeled_state == state_id)
339        {
340            roots.push((hash, tag));
341        }
342    }
343
344    let mut tags = Vec::new();
345    let mut seen = HashSet::new();
346    let mut stack = roots;
347    while let Some((hash, tag)) = stack.pop() {
348        if !seen.insert(hash) {
349            continue;
350        }
351        if let Some(inner_hash) = tag.target_tag() {
352            let inner = store.get_annotated_tag(&inner_hash)?.ok_or_else(|| {
353                HeddleError::NotFound(format!(
354                    "annotated tag {hash} references missing inner tag {inner_hash}"
355                ))
356            })?;
357            stack.push((inner_hash, inner));
358        }
359        tags.push((hash, tag));
360    }
361    Ok(tags)
362}
363
364fn annotated_tag_info(hash: ContentHash, tag: &AnnotatedTag) -> ObjectInfo {
365    ObjectInfo {
366        id: ObjectId::Hash(hash),
367        obj_type: ObjectType::AnnotatedTag,
368        size: tag.encode_current_msgpack().len() as u64,
369        delta_base: None,
370    }
371}
372
373/// Enumerate a transfer delta while treating `boundary_states` as complete
374/// server-held roots. Unlike [`StateClosureOptions::exclude_states`], this does
375/// not expand each boundary's tree/history to build a hash exclusion set: the
376/// walk stops as soon as it reaches the boundary state. Objects reused by the
377/// new tip may still be advertised, and the receiver's have-set filters them.
378/// This keeps incremental push planning proportional to the new history.
379pub fn enumerate_state_closure_transfer_from_boundaries(
380    store: &impl ObjectStore,
381    state_id: StateId,
382    boundary_states: &[StateId],
383    full_descriptor_object_threshold: usize,
384) -> Result<StateClosureTransferObjects> {
385    let mut planned_objects = Vec::new();
386    let mut full_objects = Some(Vec::new());
387    let excluded_states = boundary_states.iter().copied().collect();
388
389    walk_state_closure_with_exclusions(
390        store,
391        state_id,
392        None,
393        excluded_states,
394        HashSet::new(),
395        |event| {
396            if let Some(object) = planned_object_from_event(store, event)? {
397                planned_objects.push(object);
398            }
399
400            if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
401                full_objects = None;
402            }
403            if let Some(objects) = full_objects.as_mut()
404                && let Some(info) = object_info_from_event(store, event)?
405            {
406                objects.push(info);
407            }
408
409            Ok(())
410        },
411    )?;
412
413    Ok(StateClosureTransferObjects {
414        planned_objects,
415        full_objects,
416    })
417}
418
419#[derive(Debug, Clone, Copy)]
420enum StateClosureEvent<'a> {
421    State {
422        id: StateId,
423        state: &'a State,
424    },
425    Tree {
426        hash: ContentHash,
427        tree: &'a crate::object::Tree,
428    },
429    Blob {
430        hash: ContentHash,
431    },
432    Redaction {
433        blob: ContentHash,
434    },
435    StateVisibility {
436        state: StateId,
437    },
438    StateAttachment {
439        state: StateId,
440        attachment: &'a StateAttachment,
441    },
442    ExcludedState {
443        id: StateId,
444    },
445    ExcludedHash {
446        hash: ContentHash,
447    },
448}
449
450fn walk_state_closure(
451    store: &impl ObjectStore,
452    state_id: StateId,
453    options: StateClosureOptions,
454    visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
455) -> Result<()> {
456    let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
457
458    walk_state_closure_with_exclusions(
459        store,
460        state_id,
461        options.depth,
462        excluded_states,
463        excluded_hashes,
464        visit,
465    )
466}
467
468fn walk_state_closure_with_exclusions(
469    store: &impl ObjectStore,
470    state_id: StateId,
471    max_depth: Option<u32>,
472    excluded_states: HashSet<StateId>,
473    excluded_hashes: HashSet<ContentHash>,
474    mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
475) -> Result<()> {
476    let mut seen_states: HashSet<StateId> = HashSet::new();
477    let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
478    let mut queue: VecDeque<(StateId, u32)> = VecDeque::new();
479    queue.push_back((state_id, 0));
480
481    while let Some((id, depth)) = queue.pop_front() {
482        if excluded_states.contains(&id) {
483            visit(StateClosureEvent::ExcludedState { id })?;
484            continue;
485        }
486        if !seen_states.insert(id) {
487            continue;
488        }
489
490        let state = store
491            .get_state(&id)?
492            .ok_or_else(|| HeddleError::MissingObject {
493                object_type: "state".to_string(),
494                id: id.to_string(),
495            })?;
496
497        visit(StateClosureEvent::State { id, state: &state })?;
498        if store.has_state_visibility_for_state(&id)? {
499            visit(StateClosureEvent::StateVisibility { state: id })?;
500        }
501        for attachment in store.list_state_attachments(&id)? {
502            visit(StateClosureEvent::StateAttachment {
503                state: id,
504                attachment: &attachment,
505            })?;
506            match attachment.body {
507                StateAttachmentBody::Context(root) => walk_tree_closure_filtered(
508                    store,
509                    root,
510                    &excluded_hashes,
511                    &mut seen_hashes,
512                    &mut visit,
513                )?,
514                StateAttachmentBody::RiskSignals(hash)
515                | StateAttachmentBody::ReviewSignatures(hash)
516                | StateAttachmentBody::Discussions(hash)
517                | StateAttachmentBody::StructuredConflicts(hash) => {
518                    walk_blob_filtered(store, hash, &excluded_hashes, &mut seen_hashes, &mut visit)?
519                }
520                StateAttachmentBody::SemanticIndex(root) => walk_semantic_index_closure(
521                    store,
522                    root,
523                    &excluded_hashes,
524                    &mut seen_hashes,
525                    &mut visit,
526                )?,
527                StateAttachmentBody::Signature(_) => {}
528            }
529        }
530
531        if max_depth.map(|max| depth < max).unwrap_or(true) {
532            for parent in &state.parents {
533                queue.push_back((*parent, depth + 1));
534            }
535        }
536
537        walk_tree_closure_filtered(
538            store,
539            state.tree,
540            &excluded_hashes,
541            &mut seen_hashes,
542            &mut visit,
543        )?;
544        if let Some(provenance_root) = state.provenance {
545            walk_tree_closure_filtered(
546                store,
547                provenance_root,
548                &excluded_hashes,
549                &mut seen_hashes,
550                &mut visit,
551            )?;
552        }
553    }
554
555    Ok(())
556}
557
558fn walk_tree_closure_filtered(
559    store: &impl ObjectStore,
560    tree_hash: ContentHash,
561    excluded: &HashSet<ContentHash>,
562    seen: &mut HashSet<ContentHash>,
563    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
564) -> Result<()> {
565    if excluded.contains(&tree_hash) {
566        visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
567        return Ok(());
568    }
569    if !seen.insert(tree_hash) {
570        return Ok(());
571    }
572
573    let tree = store
574        .get_tree(&tree_hash)?
575        .ok_or_else(|| HeddleError::MissingObject {
576            object_type: "tree".to_string(),
577            id: tree_hash.to_hex(),
578        })?;
579
580    visit(StateClosureEvent::Tree {
581        hash: tree_hash,
582        tree: &tree,
583    })?;
584
585    for entry in tree.entries() {
586        match entry.target() {
587            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
588                walk_blob_filtered(store, *hash, excluded, seen, visit)?;
589            }
590            TreeEntryTarget::Tree { hash } => {
591                walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
592            }
593            TreeEntryTarget::Gitlink { .. } => {}
594            // Native child-spool edge: its target lives in a separate spool
595            // object graph, not this store, so it is not walked here.
596            TreeEntryTarget::Spoollink { .. } => {}
597        }
598    }
599
600    Ok(())
601}
602
603fn walk_blob_filtered(
604    store: &impl ObjectStore,
605    blob_hash: ContentHash,
606    excluded: &HashSet<ContentHash>,
607    seen: &mut HashSet<ContentHash>,
608    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
609) -> Result<()> {
610    if excluded.contains(&blob_hash) {
611        visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
612        return Ok(());
613    }
614    if !seen.insert(blob_hash) {
615        return Ok(());
616    }
617    visit(StateClosureEvent::Blob { hash: blob_hash })?;
618    if store.has_redactions_for_blob(&blob_hash)? {
619        visit(StateClosureEvent::Redaction { blob: blob_hash })?;
620    }
621    Ok(())
622}
623
624/// Walk the merkle semantic-index closure rooted at `root_hash` (a
625/// `SemanticIndexRoot` blob), emitting every reachable semantic node blob.
626///
627/// All semantic-index nodes (root, tree nodes, file nodes) are stored as
628/// ordinary content-addressed blobs, so replication just enumerates them.
629/// Opaque entries point back at raw source blobs already covered by the state's
630/// tree closure, so they are not re-walked here.
631///
632/// Iterative (explicit stack) so a crafted deep `SemanticTreeNode` chain in a
633/// pushed state can't overflow the stack. A missing or undecodable node in the
634/// closure is a HARD failure (`ObjectNotFound`/`Serialization`) — a partial or
635/// corrupt semantic closure must never be shipped silently.
636fn walk_semantic_index_closure(
637    store: &impl ObjectStore,
638    root_hash: ContentHash,
639    excluded: &HashSet<ContentHash>,
640    seen: &mut HashSet<ContentHash>,
641    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
642) -> Result<()> {
643    // Stack of (node_hash, is_tree_node): the root and dir nodes must be decoded
644    // to enumerate their children; file/opaque leaves are emitted only.
645    let mut stack: Vec<ContentHash> = vec![root_hash];
646    while let Some(node_hash) = stack.pop() {
647        if !emit_semantic_blob(store, node_hash, excluded, seen, visit)? {
648            continue; // excluded (present on the far side) or already seen.
649        }
650        let blob = store
651            .get_blob(&node_hash)?
652            .ok_or_else(|| missing_blob(node_hash))?;
653        // The root and tree nodes decode to the same `SemanticTreeNode.entries`
654        // shape after the root's one indirection; walk uniformly by decoding
655        // every interior node as a tree node, tolerating the root shape.
656        let node = decode_semantic_container(&blob, node_hash)?;
657        for child in node {
658            match child {
659                SemanticChild::Interior(hash) => stack.push(hash),
660                SemanticChild::Leaf(hash) => {
661                    // Emit the leaf (file node) blob; it has no children.
662                    emit_semantic_blob(store, hash, excluded, seen, visit)?;
663                }
664                SemanticChild::BindingDelta(hash) => {
665                    // Binding deltas are state-scoped attachments. Emit this
666                    // state's direct delta; ancestor states contribute their
667                    // own deltas when (and only when) the state walk reaches
668                    // them. Following `delta.parent` here would cross a
669                    // shallow-clone boundary.
670                    emit_binding_delta(store, hash, excluded, seen, visit)?;
671                }
672                SemanticChild::ImporterIndex(hash) => {
673                    emit_importer_index(store, hash, excluded, seen, visit)?;
674                }
675            }
676        }
677    }
678    Ok(())
679}
680
681/// Child kinds encountered while walking a state's semantic closure.
682enum SemanticChild {
683    Interior(ContentHash),
684    Leaf(ContentHash),
685    BindingDelta(ContentHash),
686    ImporterIndex(ContentHash),
687}
688
689/// Decode a semantic-closure node — the root (which points at a tree node) or a
690/// tree node — into the child hashes to walk. Missing/corrupt is a hard error.
691fn decode_semantic_container(
692    blob: &crate::object::Blob,
693    node_hash: ContentHash,
694) -> Result<Vec<SemanticChild>> {
695    // Try the root shape first (it has an extra `tree` indirection), then the
696    // tree-node shape. Content-addressed hashes make this unambiguous in
697    // practice; a blob that decodes as neither is corrupt.
698    if let Ok(root) = SemanticIndexRoot::decode(blob.content()) {
699        let mut children = vec![SemanticChild::Interior(root.tree)];
700        if let Some(binding_delta) = root.binding_delta {
701            children.push(SemanticChild::BindingDelta(binding_delta));
702        }
703        if let Some(importer_index) = root.importer_index {
704            children.push(SemanticChild::ImporterIndex(importer_index));
705        }
706        return Ok(children);
707    }
708    let node = SemanticTreeNode::decode(blob.content())
709        .map_err(|err| HeddleError::Serialization(format!("semantic node {node_hash}: {err}")))?;
710    Ok(node
711        .entries
712        .iter()
713        .filter_map(|entry| match entry.kind {
714            SemanticEntryKind::Dir => Some(SemanticChild::Interior(entry.node)),
715            SemanticEntryKind::File => Some(SemanticChild::Leaf(entry.node)),
716            // Opaque `node` is the raw source blob, already in the tree closure.
717            SemanticEntryKind::Opaque => None,
718        })
719        .collect())
720}
721
722fn emit_binding_delta(
723    store: &impl ObjectStore,
724    hash: ContentHash,
725    excluded: &HashSet<ContentHash>,
726    seen: &mut HashSet<ContentHash>,
727    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
728) -> Result<()> {
729    if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
730        return Ok(());
731    }
732    let blob = store.get_blob(&hash)?.ok_or_else(|| missing_blob(hash))?;
733    BindingDelta::decode(blob.content()).map_err(|err| {
734        HeddleError::Serialization(format!("semantic binding delta {hash}: {err}"))
735    })?;
736    Ok(())
737}
738
739fn emit_importer_index(
740    store: &impl ObjectStore,
741    hash: ContentHash,
742    excluded: &HashSet<ContentHash>,
743    seen: &mut HashSet<ContentHash>,
744    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
745) -> Result<()> {
746    if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
747        return Ok(());
748    }
749    let blob = store.get_blob(&hash)?.ok_or_else(|| missing_blob(hash))?;
750    ReverseDependencyIndex::decode(blob.content()).map_err(|err| {
751        HeddleError::Serialization(format!("semantic reverse-dependency index {hash}: {err}"))
752    })?;
753    Ok(())
754}
755
756/// Emit a semantic node blob as state metadata. Returns `true` when the blob
757/// was newly visited (so the caller may descend into it), `false` when it was
758/// excluded (present on the far side) or already seen. A blob that is neither
759/// excluded nor present is a HARD failure — the closure must be complete.
760fn emit_semantic_blob(
761    store: &impl ObjectStore,
762    hash: ContentHash,
763    excluded: &HashSet<ContentHash>,
764    seen: &mut HashSet<ContentHash>,
765    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
766) -> Result<bool> {
767    if excluded.contains(&hash) {
768        visit(StateClosureEvent::ExcludedHash { hash })?;
769        return Ok(false);
770    }
771    if !seen.insert(hash) {
772        return Ok(false);
773    }
774    if store.get_blob(&hash)?.is_none() {
775        return Err(missing_blob(hash));
776    }
777    visit(StateClosureEvent::Blob { hash })?;
778    Ok(true)
779}
780
781/// Collect every semantic-index node blob hash reachable from `root_hash` into
782/// `excluded`, for the have-set computation. Iterative + tolerant (a broken
783/// have-set index is not fatal — it just means fewer objects are marked
784/// already-present, which is safe over-fetching, not corruption).
785fn collect_semantic_hashes(
786    store: &impl ObjectStore,
787    root_hash: ContentHash,
788    excluded: &mut HashSet<ContentHash>,
789) -> Result<()> {
790    let mut stack: Vec<ContentHash> = vec![root_hash];
791    while let Some(node_hash) = stack.pop() {
792        if !excluded.insert(node_hash) {
793            continue;
794        }
795        let Some(blob) = store.get_blob(&node_hash)? else {
796            continue;
797        };
798        let children = match decode_semantic_container(&blob, node_hash) {
799            Ok(children) => children,
800            Err(_) => continue,
801        };
802        for child in children {
803            match child {
804                SemanticChild::Interior(hash) => stack.push(hash),
805                SemanticChild::Leaf(hash) => {
806                    excluded.insert(hash);
807                }
808                SemanticChild::BindingDelta(hash) | SemanticChild::ImporterIndex(hash) => {
809                    excluded.insert(hash);
810                }
811            }
812        }
813    }
814    Ok(())
815}
816
817fn object_info_from_event(
818    store: &impl ObjectStore,
819    event: StateClosureEvent<'_>,
820) -> Result<Option<ObjectInfo>> {
821    match event {
822        StateClosureEvent::State { id, state } => {
823            let state_bytes = rmp_serde::to_vec_named(state)?;
824            Ok(Some(ObjectInfo {
825                id: ObjectId::StateId(id),
826                obj_type: ObjectType::State,
827                size: state_bytes.len() as u64,
828                delta_base: None,
829            }))
830        }
831        StateClosureEvent::Tree { hash, tree } => {
832            let tree_bytes = tree.encode_canonical().map_err(HeddleError::from)?;
833            Ok(Some(ObjectInfo {
834                id: ObjectId::Hash(hash),
835                obj_type: ObjectType::Tree,
836                size: tree_bytes.len() as u64,
837                delta_base: None,
838            }))
839        }
840        StateClosureEvent::Blob { hash, .. } => {
841            let Some(blob) = store.get_blob(&hash)? else {
842                if blob_has_purge_evidence(store, &hash)? {
843                    return Ok(None);
844                }
845                return Err(missing_blob(hash));
846            };
847            Ok(Some(ObjectInfo {
848                id: ObjectId::Hash(hash),
849                obj_type: ObjectType::Blob,
850                size: blob.size() as u64,
851                delta_base: None,
852            }))
853        }
854        StateClosureEvent::Redaction { blob } => Ok(store
855            .get_redactions_bytes_for_blob(&blob)?
856            .map(|bytes| ObjectInfo {
857                id: ObjectId::Hash(blob),
858                obj_type: ObjectType::Redaction,
859                size: bytes.len() as u64,
860                delta_base: None,
861            })),
862        StateClosureEvent::StateVisibility { state } => Ok(store
863            .get_state_visibility_bytes_for_state(&state)?
864            .map(|bytes| ObjectInfo {
865                id: ObjectId::StateId(state),
866                obj_type: ObjectType::StateVisibility,
867                size: bytes.len() as u64,
868                delta_base: None,
869            })),
870        StateClosureEvent::StateAttachment { state, attachment } => {
871            let bytes = rmp_serde::to_vec_named(attachment)?;
872            Ok(Some(ObjectInfo {
873                id: ObjectId::StateAttachment {
874                    state,
875                    id: attachment.id(),
876                    kind: attachment.body.kind(),
877                },
878                obj_type: ObjectType::StateAttachment,
879                size: bytes.len() as u64,
880                delta_base: None,
881            }))
882        }
883        StateClosureEvent::ExcludedState { id } => {
884            let _ = id;
885            Ok(None)
886        }
887        StateClosureEvent::ExcludedHash { hash } => {
888            let _ = hash;
889            Ok(None)
890        }
891    }
892}
893
894fn planned_object_from_event(
895    store: &impl ObjectStore,
896    event: StateClosureEvent<'_>,
897) -> Result<Option<PlannedObject>> {
898    match event {
899        StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
900            id: ObjectId::StateId(id),
901            obj_type: ObjectType::State,
902        })),
903        StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
904            id: ObjectId::Hash(hash),
905            obj_type: ObjectType::Tree,
906        })),
907        StateClosureEvent::Blob { hash, .. } => {
908            if store.get_blob(&hash)?.is_none() {
909                if blob_has_purge_evidence(store, &hash)? {
910                    return Ok(None);
911                }
912                return Err(missing_blob(hash));
913            }
914            Ok(Some(PlannedObject {
915                id: ObjectId::Hash(hash),
916                obj_type: ObjectType::Blob,
917            }))
918        }
919        StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
920            id: ObjectId::Hash(blob),
921            obj_type: ObjectType::Redaction,
922        })),
923        StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
924            id: ObjectId::StateId(state),
925            obj_type: ObjectType::StateVisibility,
926        })),
927        StateClosureEvent::StateAttachment { state, attachment } => Ok(Some(PlannedObject {
928            id: ObjectId::StateAttachment {
929                state,
930                id: attachment.id(),
931                kind: attachment.body.kind(),
932            },
933            obj_type: ObjectType::StateAttachment,
934        })),
935        StateClosureEvent::ExcludedState { id } => {
936            let _ = id;
937            Ok(None)
938        }
939        StateClosureEvent::ExcludedHash { hash } => {
940            let _ = hash;
941            Ok(None)
942        }
943    }
944}
945
946/// A purged blob is intentionally absent from the object closure; its sidecar
947/// remains and is verified by the receiver before the absence is accepted.
948/// Mere redaction never excuses a missing blob.
949fn missing_blob(hash: ContentHash) -> HeddleError {
950    HeddleError::MissingObject {
951        object_type: "blob".to_string(),
952        id: hash.to_hex(),
953    }
954}
955
956fn blob_has_purge_evidence(
957    store: &impl ObjectStore,
958    hash: &ContentHash,
959) -> Result<bool> {
960    let Some(bytes) = store.get_redactions_bytes_for_blob(hash)? else {
961        return Ok(false);
962    };
963    let redactions = RedactionsBlob::decode(&bytes).map_err(|error| {
964        HeddleError::InvalidObject(format!(
965            "invalid redaction sidecar for missing blob {}: {error}",
966            hash.to_hex()
967        ))
968    })?;
969    Ok(redactions
970        .redactions
971        .iter()
972        .any(|redaction| redaction.redacted_blob == *hash && redaction.is_purged()))
973}
974
975pub fn missing_blobs_in_tree(
976    store: &impl ObjectStore,
977    tree_hash: ContentHash,
978) -> Result<Vec<ContentHash>> {
979    let mut missing = Vec::new();
980    collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
981    Ok(missing)
982}
983
984fn collect_missing_blobs_recursive(
985    store: &impl ObjectStore,
986    tree_hash: &ContentHash,
987    missing: &mut Vec<ContentHash>,
988) -> Result<()> {
989    let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
990        HeddleError::InvalidObject(format!(
991            "load tree {} while collecting lazy hydration missing blobs: {err}",
992            tree_hash.to_hex()
993        ))
994    })?
995    else {
996        return Ok(());
997    };
998
999    for entry in tree.entries() {
1000        match entry.target() {
1001            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1002                if !store.has_blob(hash).map_err(|err| {
1003                    HeddleError::InvalidObject(format!(
1004                        "check blob {} while collecting lazy hydration missing blobs: {err}",
1005                        hash.to_hex()
1006                    ))
1007                })? {
1008                    missing.push(*hash);
1009                }
1010            }
1011            TreeEntryTarget::Tree { hash } => {
1012                collect_missing_blobs_recursive(store, hash, missing)?;
1013            }
1014            TreeEntryTarget::Gitlink { .. } => {}
1015            // Native child-spool edge: its target lives in a separate spool
1016            // object graph, not this store, so it is not walked here.
1017            TreeEntryTarget::Spoollink { .. } => {}
1018        }
1019    }
1020    Ok(())
1021}
1022
1023fn collect_excluded(
1024    store: &impl ObjectStore,
1025    roots: &[StateId],
1026) -> Result<(HashSet<StateId>, HashSet<ContentHash>)> {
1027    if roots.is_empty() {
1028        return Ok((HashSet::new(), HashSet::new()));
1029    }
1030
1031    let mut excluded_states: HashSet<StateId> = HashSet::new();
1032    let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
1033    let mut queue: VecDeque<StateId> = VecDeque::new();
1034
1035    for id in roots {
1036        queue.push_back(*id);
1037    }
1038
1039    while let Some(id) = queue.pop_front() {
1040        if !excluded_states.insert(id) {
1041            continue;
1042        }
1043
1044        let state = match store.get_state(&id)? {
1045            Some(state) => state,
1046            None => continue,
1047        };
1048
1049        for parent in &state.parents {
1050            queue.push_back(*parent);
1051        }
1052
1053        collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
1054        if let Some(provenance_root) = state.provenance {
1055            collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
1056        }
1057        for attachment in store.list_state_attachments(&id)? {
1058            match attachment.body {
1059                StateAttachmentBody::Context(root) => {
1060                    collect_tree_hashes(store, root, &mut excluded_hashes)?
1061                }
1062                StateAttachmentBody::RiskSignals(hash)
1063                | StateAttachmentBody::ReviewSignatures(hash)
1064                | StateAttachmentBody::Discussions(hash)
1065                | StateAttachmentBody::StructuredConflicts(hash) => {
1066                    excluded_hashes.insert(hash);
1067                }
1068                StateAttachmentBody::SemanticIndex(root) => {
1069                    collect_semantic_hashes(store, root, &mut excluded_hashes)?;
1070                }
1071                StateAttachmentBody::Signature(_) => {}
1072            }
1073        }
1074    }
1075
1076    Ok((excluded_states, excluded_hashes))
1077}
1078
1079fn collect_tree_hashes(
1080    store: &impl ObjectStore,
1081    tree_hash: ContentHash,
1082    excluded: &mut HashSet<ContentHash>,
1083) -> Result<()> {
1084    if !excluded.insert(tree_hash) {
1085        return Ok(());
1086    }
1087
1088    let tree = match store.get_tree(&tree_hash)? {
1089        Some(tree) => tree,
1090        None => return Ok(()),
1091    };
1092
1093    for entry in tree.entries() {
1094        match entry.target() {
1095            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1096                excluded.insert(*hash);
1097            }
1098            TreeEntryTarget::Tree { hash } => {
1099                collect_tree_hashes(store, *hash, excluded)?;
1100            }
1101            TreeEntryTarget::Gitlink { .. } => {}
1102            // Native child-spool edge: its target lives in a separate spool
1103            // object graph, not this store, so it is not walked here.
1104            TreeEntryTarget::Spoollink { .. } => {}
1105        }
1106    }
1107
1108    Ok(())
1109}
1110
1111pub fn is_ancestor(
1112    store: &impl ObjectStore,
1113    ancestor: StateId,
1114    descendant: StateId,
1115) -> Result<bool> {
1116    if ancestor == descendant {
1117        return Ok(true);
1118    }
1119
1120    let mut seen: HashSet<StateId> = HashSet::new();
1121    let mut queue: VecDeque<StateId> = VecDeque::new();
1122    queue.push_back(descendant);
1123
1124    while let Some(id) = queue.pop_front() {
1125        if !seen.insert(id) {
1126            continue;
1127        }
1128        let state = match store.get_state(&id)? {
1129            Some(s) => s,
1130            None => return Ok(false),
1131        };
1132        for parent in state.parents {
1133            if parent == ancestor {
1134                return Ok(true);
1135            }
1136            queue.push_back(parent);
1137        }
1138    }
1139
1140    Ok(false)
1141}