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