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
6#[cfg(feature = "async-source")]
7use crate::store::AsyncObjectSource;
8#[cfg(feature = "async-source")]
9use std::sync::{
10    Arc,
11    atomic::{AtomicBool, Ordering},
12};
13#[cfg(feature = "async-source")]
14use std::time::Instant;
15
16use crate::{
17    error::{HeddleError, Result},
18    object::{
19        AnnotatedTag, BindingDelta, ContentHash, RedactionsBlob, ReverseDependencyIndex,
20        SemanticEntryKind, SemanticIndexRoot, SemanticTreeNode, State, StateAttachment,
21        StateAttachmentBody, StateAttachmentId, StateAttachmentKind, StateId, TreeEntryTarget,
22        decode_tree_delta_header, is_delta_tree,
23    },
24    store::{ObjectSource, ObjectStore, pack::ObjectType as PackObjectType},
25};
26
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub enum ObjectId {
29    Hash(ContentHash),
30    StateId(StateId),
31    StateAttachment {
32        state: StateId,
33        id: StateAttachmentId,
34        /// The attachment's kind, a pure projection of its body
35        /// ([`StateAttachmentBody::kind`]). Carried through the wire so
36        /// descriptors self-describe their kind; the dedup/identity key is
37        /// still `(state, id)`, and kind is coherent under `Eq`/`Hash` because
38        /// it is a deterministic function of the same record.
39        kind: StateAttachmentKind,
40    },
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ObjectInfo {
45    pub id: ObjectId,
46    pub obj_type: ObjectType,
47    pub size: u64,
48    pub delta_base: Option<ContentHash>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub struct PlannedObject {
53    pub id: ObjectId,
54    pub obj_type: ObjectType,
55}
56
57#[derive(Debug, Clone)]
58pub struct StateClosureTransferObjects {
59    pub planned_objects: Vec<PlannedObject>,
60    pub full_objects: Option<Vec<ObjectInfo>>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64pub enum ObjectType {
65    Blob,
66    Tree,
67    State,
68    Action,
69    AnnotatedTag,
70    /// A `RedactionsBlob` sidecar — the rmp-encoded record(s) declaring
71    /// that a specific blob has been redacted by a writer. Keyed on the
72    /// wire by `ObjectId::Hash` of
73    /// the *redacted blob*, since `Repository`'s sidecar store is
74    /// indexed that way.
75    Redaction,
76    /// An owner-authorized purge sidecar. It carries the same encoded
77    /// `RedactionsBlob` as `Redaction`, but is a distinct operation because
78    /// receiving it can irreversibly erase blob bytes.
79    Purge,
80    /// A `StateVisibilityBlob` sidecar — the rmp-encoded record(s)
81    /// declaring a non-public audience tier for a specific state. Keyed
82    /// on the wire by `ObjectId::StateId` of the state, since the
83    /// per-state sidecar store is indexed that way. Like `Redaction`, it
84    /// is a sidecar record that lives outside the content-addressed pack
85    /// and ships via the per-object transfer path, not the pack.
86    StateVisibility,
87    StateAttachment,
88    /// A content-addressed `KeyBindingRegistry` together with each binding's
89    /// revocation/liveness overlay. Hosted materializers append this object to
90    /// a closure and carry it out of pack because the native pack format has no
91    /// key-binding record kind.
92    KeyBinding,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub enum ObjectTypeBucket {
97    Blob,
98    Tree,
99    State,
100    Action,
101    AnnotatedTag,
102    Redaction,
103    Purge,
104    StateVisibility,
105    StateAttachment,
106    KeyBinding,
107}
108
109impl ObjectType {
110    pub fn wire_name(self) -> &'static str {
111        match self {
112            ObjectType::Blob => "blob",
113            ObjectType::Tree => "tree",
114            ObjectType::State => "state",
115            ObjectType::Action => "action",
116            ObjectType::AnnotatedTag => "annotated_tag",
117            ObjectType::Redaction => "redaction",
118            ObjectType::Purge => "purge",
119            ObjectType::StateVisibility => "state_visibility",
120            ObjectType::StateAttachment => "state_attachment",
121            ObjectType::KeyBinding => "key_binding",
122        }
123    }
124
125    pub fn from_wire(value: &str) -> Result<Self> {
126        match value {
127            "blob" => Ok(ObjectType::Blob),
128            "tree" => Ok(ObjectType::Tree),
129            "state" => Ok(ObjectType::State),
130            "action" => Ok(ObjectType::Action),
131            "annotated_tag" => Ok(ObjectType::AnnotatedTag),
132            "redaction" => Ok(ObjectType::Redaction),
133            "purge" => Ok(ObjectType::Purge),
134            "state_visibility" => Ok(ObjectType::StateVisibility),
135            "state_attachment" => Ok(ObjectType::StateAttachment),
136            "key_binding" => Ok(ObjectType::KeyBinding),
137            _ => Err(HeddleError::InvalidObject(format!(
138                "unknown object type: {value}"
139            ))),
140        }
141    }
142
143    /// Whether this object type can ride the content-addressed native pack in
144    /// general (the historical, direction-agnostic predicate). Sidecar records
145    /// (`Redaction`, `StateVisibility`) live outside `.heddle/objects/` and can
146    /// never be packed; everything else can.
147    ///
148    /// Prefer [`ObjectType::packable_for_push`] /
149    /// [`ObjectType::packable_for_pull`] at transfer sites: packability is
150    /// direction-dependent for `StateAttachment` (see those methods). This
151    /// method retains the pull/general semantics so pack construction,
152    /// have-set, and local-copy planners are unchanged.
153    pub fn packable(self) -> bool {
154        !matches!(
155            self,
156            ObjectType::Redaction
157                | ObjectType::Purge
158                | ObjectType::StateVisibility
159                | ObjectType::KeyBinding
160        )
161    }
162
163    /// Whether this object type may ride the client→server **push** pack.
164    ///
165    /// `StateAttachment` is deliberately excluded on push even though it is a
166    /// content-addressed object: a deployed server rejects any pack-carried
167    /// attachment as a forgery-prevention measure (weft#549). Pushed
168    /// attachments must instead ride the out-of-pack **sidecar** lane (the same
169    /// lane as `Redaction`/`StateVisibility`), where the server can verify them
170    /// per-kind at finalize while the pack itself stays forgery-sealed. Every
171    /// other type keeps its [`ObjectType::packable`] answer.
172    pub fn packable_for_push(self) -> bool {
173        self.packable() && !matches!(self, ObjectType::StateAttachment)
174    }
175
176    /// Whether this object type may ride the server→client **pull/clone** pack.
177    ///
178    /// Same boolean as [`ObjectType::packable`] so planners still list
179    /// `StateAttachment`. Weft does not put those records in the native pack:
180    /// it streams `PullServerFrame::StateAttachment`. Discussions attachments
181    /// are not the live transport (v2 chain vs this client's v1 blob); clone
182    /// treats an advertised-but-unconsumable pack as a ListByState fallback.
183    pub fn packable_for_pull(self) -> bool {
184        self.packable()
185    }
186
187    pub fn pack_object_type(self) -> Result<PackObjectType> {
188        match self {
189            ObjectType::Blob => Ok(PackObjectType::Blob),
190            ObjectType::Tree => Ok(PackObjectType::Tree),
191            ObjectType::State => Ok(PackObjectType::State),
192            ObjectType::Action => Ok(PackObjectType::Action),
193            ObjectType::AnnotatedTag => Ok(PackObjectType::AnnotatedTag),
194            ObjectType::StateAttachment => Ok(PackObjectType::StateAttachment),
195            ObjectType::Redaction => Err(HeddleError::InvalidObject(
196                "Redaction sidecar records cannot be packed into the content-addressed object pack"
197                    .to_string(),
198            )),
199            ObjectType::Purge => Err(HeddleError::InvalidObject(
200                "Purge sidecar records cannot be packed into the content-addressed object pack"
201                    .to_string(),
202            )),
203            ObjectType::StateVisibility => Err(HeddleError::InvalidObject(
204                "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
205                    .to_string(),
206            )),
207            ObjectType::KeyBinding => Err(HeddleError::InvalidObject(
208                "KeyBinding registry objects cannot be packed into the content-addressed object pack"
209                    .to_string(),
210            )),
211        }
212    }
213
214    pub fn bucket(self) -> ObjectTypeBucket {
215        match self {
216            ObjectType::Blob => ObjectTypeBucket::Blob,
217            ObjectType::Tree => ObjectTypeBucket::Tree,
218            ObjectType::State => ObjectTypeBucket::State,
219            ObjectType::Action => ObjectTypeBucket::Action,
220            ObjectType::AnnotatedTag => ObjectTypeBucket::AnnotatedTag,
221            ObjectType::Redaction => ObjectTypeBucket::Redaction,
222            ObjectType::Purge => ObjectTypeBucket::Purge,
223            ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
224            ObjectType::StateAttachment => ObjectTypeBucket::StateAttachment,
225            ObjectType::KeyBinding => ObjectTypeBucket::KeyBinding,
226        }
227    }
228}
229
230#[derive(Debug, Clone, Default)]
231pub struct StateClosureOptions {
232    pub depth: Option<u32>,
233    pub exclude_states: Vec<StateId>,
234}
235
236pub fn enumerate_state_closure(
237    store: &impl ObjectStore,
238    state_id: StateId,
239) -> Result<Vec<ObjectInfo>> {
240    enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
241}
242
243pub fn enumerate_state_closure_with_options(
244    store: &impl ObjectStore,
245    state_id: StateId,
246    options: StateClosureOptions,
247) -> Result<Vec<ObjectInfo>> {
248    let mut out = Vec::new();
249    walk_state_closure(store, state_id, options, |event| {
250        if let Some(info) = object_info_from_event(store, event)? {
251            out.push(info);
252        }
253        Ok(())
254    })?;
255    for (hash, tag) in annotated_tags_for_state(store, state_id)? {
256        out.push(annotated_tag_info(hash, &tag));
257    }
258
259    Ok(out)
260}
261
262pub fn enumerate_state_closure_plan(
263    store: &impl ObjectStore,
264    state_id: StateId,
265) -> Result<Vec<PlannedObject>> {
266    enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
267}
268
269pub fn enumerate_state_closure_plan_with_options(
270    store: &impl ObjectStore,
271    state_id: StateId,
272    options: StateClosureOptions,
273) -> Result<Vec<PlannedObject>> {
274    let mut out = Vec::new();
275    walk_state_closure(store, state_id, options, |event| {
276        if let Some(object) = planned_object_from_event(store, event)? {
277            out.push(object);
278        }
279        Ok(())
280    })?;
281    out.extend(
282        annotated_tags_for_state(store, state_id)?
283            .into_iter()
284            .map(|(hash, _)| PlannedObject {
285                id: ObjectId::Hash(hash),
286                obj_type: ObjectType::AnnotatedTag,
287            }),
288    );
289
290    Ok(out)
291}
292
293pub fn enumerate_state_closure_transfer_with_options(
294    store: &impl ObjectStore,
295    state_id: StateId,
296    options: StateClosureOptions,
297    full_descriptor_object_threshold: usize,
298) -> Result<StateClosureTransferObjects> {
299    let mut planned_objects = Vec::new();
300    let mut full_objects = Some(Vec::new());
301
302    walk_state_closure(store, state_id, options, |event| {
303        if let Some(object) = planned_object_from_event(store, event)? {
304            planned_objects.push(object);
305        }
306
307        if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
308            full_objects = None;
309        }
310        if let Some(objects) = full_objects.as_mut()
311            && let Some(info) = object_info_from_event(store, event)?
312        {
313            objects.push(info);
314        }
315
316        Ok(())
317    })?;
318    let tags = annotated_tags_for_state(store, state_id)?;
319    planned_objects.extend(tags.iter().map(|(hash, _)| PlannedObject {
320        id: ObjectId::Hash(*hash),
321        obj_type: ObjectType::AnnotatedTag,
322    }));
323    if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
324        full_objects = None;
325    }
326    if let Some(objects) = full_objects.as_mut() {
327        objects.extend(
328            tags.iter()
329                .map(|(hash, tag)| annotated_tag_info(*hash, tag)),
330        );
331    }
332
333    Ok(StateClosureTransferObjects {
334        planned_objects,
335        full_objects,
336    })
337}
338
339fn annotated_tags_for_state(
340    store: &impl ObjectStore,
341    state_id: StateId,
342) -> Result<Vec<(ContentHash, AnnotatedTag)>> {
343    let mut roots = Vec::new();
344    for hash in store.list_annotated_tags()? {
345        let Some(tag) = store.get_annotated_tag(&hash)? else {
346            continue;
347        };
348        if tag
349            .marker()
350            .is_some_and(|marker| marker.peeled_state == state_id)
351        {
352            roots.push((hash, tag));
353        }
354    }
355
356    let mut tags = Vec::new();
357    let mut seen = HashSet::new();
358    let mut stack = roots;
359    while let Some((hash, tag)) = stack.pop() {
360        if !seen.insert(hash) {
361            continue;
362        }
363        if let Some(inner_hash) = tag.target_tag() {
364            let inner = store.get_annotated_tag(&inner_hash)?.ok_or_else(|| {
365                HeddleError::NotFound(format!(
366                    "annotated tag {hash} references missing inner tag {inner_hash}"
367                ))
368            })?;
369            stack.push((inner_hash, inner));
370        }
371        tags.push((hash, tag));
372    }
373    Ok(tags)
374}
375
376fn annotated_tag_info(hash: ContentHash, tag: &AnnotatedTag) -> ObjectInfo {
377    ObjectInfo {
378        id: ObjectId::Hash(hash),
379        obj_type: ObjectType::AnnotatedTag,
380        size: tag.encode_current_msgpack().len() as u64,
381        delta_base: None,
382    }
383}
384
385/// Enumerate a transfer delta while treating `boundary_states` as complete
386/// server-held roots. Unlike [`StateClosureOptions::exclude_states`], this does
387/// not expand each boundary's tree/history to build a hash exclusion set: the
388/// walk stops as soon as it reaches the boundary state. Objects reused by the
389/// new tip may still be advertised, and the receiver's have-set filters them.
390/// This keeps incremental push planning proportional to the new history.
391pub fn enumerate_state_closure_transfer_from_boundaries(
392    store: &impl ObjectStore,
393    state_id: StateId,
394    boundary_states: &[StateId],
395    full_descriptor_object_threshold: usize,
396) -> Result<StateClosureTransferObjects> {
397    let mut planned_objects = Vec::new();
398    let mut full_objects = Some(Vec::new());
399    let excluded_states = boundary_states.iter().copied().collect();
400
401    walk_state_closure_with_exclusions(
402        store,
403        state_id,
404        None,
405        excluded_states,
406        HashSet::new(),
407        |event| {
408            if let Some(object) = planned_object_from_event(store, event)? {
409                planned_objects.push(object);
410            }
411
412            if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
413                full_objects = None;
414            }
415            if let Some(objects) = full_objects.as_mut()
416                && let Some(info) = object_info_from_event(store, event)?
417            {
418                objects.push(info);
419            }
420
421            Ok(())
422        },
423    )?;
424
425    Ok(StateClosureTransferObjects {
426        planned_objects,
427        full_objects,
428    })
429}
430
431#[derive(Debug, Clone, Copy)]
432enum StateClosureEvent<'a> {
433    State {
434        id: StateId,
435        state: &'a State,
436    },
437    Tree {
438        hash: ContentHash,
439        storage_size: u64,
440        delta_base: Option<ContentHash>,
441    },
442    Blob {
443        hash: ContentHash,
444    },
445    Redaction {
446        blob: ContentHash,
447    },
448    StateVisibility {
449        state: StateId,
450    },
451    StateAttachment {
452        state: StateId,
453        attachment: &'a StateAttachment,
454    },
455    ExcludedState {
456        id: StateId,
457    },
458    ExcludedHash {
459        hash: ContentHash,
460    },
461}
462
463fn walk_state_closure(
464    store: &impl ObjectStore,
465    state_id: StateId,
466    options: StateClosureOptions,
467    visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
468) -> Result<()> {
469    let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
470
471    walk_state_closure_with_exclusions(
472        store,
473        state_id,
474        options.depth,
475        excluded_states,
476        excluded_hashes,
477        visit,
478    )
479}
480
481fn walk_state_closure_with_exclusions(
482    store: &impl ObjectStore,
483    state_id: StateId,
484    max_depth: Option<u32>,
485    excluded_states: HashSet<StateId>,
486    excluded_hashes: HashSet<ContentHash>,
487    mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
488) -> Result<()> {
489    let mut seen_states: HashSet<StateId> = HashSet::new();
490    let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
491    let mut queue: VecDeque<(StateId, u32)> = VecDeque::new();
492    queue.push_back((state_id, 0));
493
494    while let Some((id, depth)) = queue.pop_front() {
495        if excluded_states.contains(&id) {
496            visit(StateClosureEvent::ExcludedState { id })?;
497            continue;
498        }
499        if !seen_states.insert(id) {
500            continue;
501        }
502
503        let state = store
504            .get_state(&id)?
505            .ok_or_else(|| HeddleError::MissingObject {
506                object_type: "state".to_string(),
507                id: id.to_string(),
508            })?;
509
510        visit(StateClosureEvent::State { id, state: &state })?;
511        if store.has_state_visibility_for_state(&id)? {
512            visit(StateClosureEvent::StateVisibility { state: id })?;
513        }
514        for attachment in store.list_state_attachments(&id)? {
515            visit(StateClosureEvent::StateAttachment {
516                state: id,
517                attachment: &attachment,
518            })?;
519            match attachment.body {
520                StateAttachmentBody::Context(root) => walk_tree_closure_filtered(
521                    store,
522                    root,
523                    &excluded_hashes,
524                    &mut seen_hashes,
525                    &mut visit,
526                )?,
527                StateAttachmentBody::RiskSignals(hash)
528                | StateAttachmentBody::ReviewSignatures(hash)
529                | StateAttachmentBody::Discussions(hash)
530                | StateAttachmentBody::StructuredConflicts(hash) => {
531                    walk_blob_filtered(store, hash, &excluded_hashes, &mut seen_hashes, &mut visit)?
532                }
533                StateAttachmentBody::SemanticIndex(root) => walk_semantic_index_closure(
534                    store,
535                    root,
536                    &excluded_hashes,
537                    &mut seen_hashes,
538                    &mut visit,
539                )?,
540                StateAttachmentBody::Signature(_) => {}
541            }
542        }
543
544        if max_depth.map(|max| depth < max).unwrap_or(true) {
545            for parent in &state.parents {
546                queue.push_back((*parent, depth + 1));
547            }
548        }
549
550        walk_tree_closure_filtered(
551            store,
552            state.tree,
553            &excluded_hashes,
554            &mut seen_hashes,
555            &mut visit,
556        )?;
557        if let Some(provenance_root) = state.provenance {
558            walk_tree_closure_filtered(
559                store,
560                provenance_root,
561                &excluded_hashes,
562                &mut seen_hashes,
563                &mut visit,
564            )?;
565        }
566    }
567
568    Ok(())
569}
570
571fn walk_tree_closure_filtered(
572    store: &impl ObjectStore,
573    tree_hash: ContentHash,
574    excluded: &HashSet<ContentHash>,
575    seen: &mut HashSet<ContentHash>,
576    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
577) -> Result<()> {
578    if excluded.contains(&tree_hash) {
579        visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
580        return Ok(());
581    }
582    if !seen.insert(tree_hash) {
583        return Ok(());
584    }
585
586    let tree = store
587        .get_tree(&tree_hash)?
588        .ok_or_else(|| HeddleError::MissingObject {
589            object_type: "tree".to_string(),
590            id: tree_hash.to_hex(),
591        })?;
592
593    let (storage_size, delta_base) = tree_storage_metadata(store, tree_hash)?;
594    visit(StateClosureEvent::Tree {
595        hash: tree_hash,
596        storage_size,
597        delta_base,
598    })?;
599
600    if let Some(anchor) = delta_base {
601        walk_tree_storage_anchor(store, anchor, seen, visit)?;
602    }
603
604    for entry in tree.entries() {
605        match entry.target() {
606            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
607                walk_blob_filtered(store, *hash, excluded, seen, visit)?;
608            }
609            TreeEntryTarget::Tree { hash } => {
610                walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
611            }
612            TreeEntryTarget::Gitlink { .. } => {}
613            // Native child-spool edge: its target lives in a separate spool
614            // object graph, not this store, so it is not walked here.
615            TreeEntryTarget::Spoollink { .. } => {}
616        }
617    }
618
619    Ok(())
620}
621
622fn tree_storage_metadata(
623    store: &impl ObjectStore,
624    tree_hash: ContentHash,
625) -> Result<(u64, Option<ContentHash>)> {
626    let body =
627        store
628            .get_tree_serialized(&tree_hash)?
629            .ok_or_else(|| HeddleError::MissingObject {
630                object_type: "tree".to_string(),
631                id: tree_hash.to_hex(),
632            })?;
633    let delta_base = if is_delta_tree(&body) {
634        let header = decode_tree_delta_header(&body)?;
635        if header.anchor == tree_hash {
636            return Err(HeddleError::InvalidObject(
637                "HDC1 result id must differ from its anchor id".to_string(),
638            ));
639        }
640        Some(header.anchor)
641    } else {
642        None
643    };
644    Ok((body.len() as u64, delta_base))
645}
646
647fn walk_tree_storage_anchor(
648    store: &impl ObjectStore,
649    anchor_hash: ContentHash,
650    seen: &mut HashSet<ContentHash>,
651    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
652) -> Result<()> {
653    if !seen.insert(anchor_hash) {
654        return Ok(());
655    }
656    if store.get_tree(&anchor_hash)?.is_none() {
657        return Err(HeddleError::MissingObject {
658            object_type: "tree delta anchor".to_string(),
659            id: anchor_hash.to_hex(),
660        });
661    }
662    let (storage_size, delta_base) = tree_storage_metadata(store, anchor_hash)?;
663    if delta_base.is_some() {
664        return Err(HeddleError::InvalidObject(
665            "HDC1 anchor must be materialized; delta chains are forbidden".to_string(),
666        ));
667    }
668    visit(StateClosureEvent::Tree {
669        hash: anchor_hash,
670        storage_size,
671        delta_base: None,
672    })
673}
674
675fn walk_blob_filtered(
676    store: &impl ObjectStore,
677    blob_hash: ContentHash,
678    excluded: &HashSet<ContentHash>,
679    seen: &mut HashSet<ContentHash>,
680    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
681) -> Result<()> {
682    if excluded.contains(&blob_hash) {
683        visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
684        return Ok(());
685    }
686    if !seen.insert(blob_hash) {
687        return Ok(());
688    }
689    visit(StateClosureEvent::Blob { hash: blob_hash })?;
690    if store.has_redactions_for_blob(&blob_hash)? {
691        visit(StateClosureEvent::Redaction { blob: blob_hash })?;
692    }
693    Ok(())
694}
695
696/// Walk the merkle semantic-index closure rooted at `root_hash` (a
697/// `SemanticIndexRoot` blob), emitting every reachable semantic node blob.
698///
699/// All semantic-index nodes (root, tree nodes, file nodes) are stored as
700/// ordinary content-addressed blobs, so replication just enumerates them.
701/// Opaque entries point back at raw source blobs already covered by the state's
702/// tree closure, so they are not re-walked here.
703///
704/// Iterative (explicit stack) so a crafted deep `SemanticTreeNode` chain in a
705/// pushed state can't overflow the stack. A missing or undecodable node in the
706/// closure is a HARD failure (`ObjectNotFound`/`Serialization`) — a partial or
707/// corrupt semantic closure must never be shipped silently.
708fn walk_semantic_index_closure(
709    store: &impl ObjectStore,
710    root_hash: ContentHash,
711    excluded: &HashSet<ContentHash>,
712    seen: &mut HashSet<ContentHash>,
713    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
714) -> Result<()> {
715    // Stack of (node_hash, is_tree_node): the root and dir nodes must be decoded
716    // to enumerate their children; file/opaque leaves are emitted only.
717    let mut stack: Vec<ContentHash> = vec![root_hash];
718    while let Some(node_hash) = stack.pop() {
719        if !emit_semantic_blob(store, node_hash, excluded, seen, visit)? {
720            continue; // excluded (present on the far side) or already seen.
721        }
722        let blob = store
723            .get_blob(&node_hash)?
724            .ok_or_else(|| missing_blob(node_hash))?;
725        // The root and tree nodes decode to the same `SemanticTreeNode.entries`
726        // shape after the root's one indirection; walk uniformly by decoding
727        // every interior node as a tree node, tolerating the root shape.
728        let node = decode_semantic_container(&blob, node_hash)?;
729        for child in node {
730            match child {
731                SemanticChild::Interior(hash) => stack.push(hash),
732                SemanticChild::Leaf(hash) => {
733                    // Emit the leaf (file node) blob; it has no children.
734                    emit_semantic_blob(store, hash, excluded, seen, visit)?;
735                }
736                SemanticChild::BindingDelta(hash) => {
737                    // Binding deltas are state-scoped attachments. Emit this
738                    // state's direct delta; ancestor states contribute their
739                    // own deltas when (and only when) the state walk reaches
740                    // them. Following `delta.parent` here would cross a
741                    // shallow-clone boundary.
742                    emit_binding_delta(store, hash, excluded, seen, visit)?;
743                }
744                SemanticChild::ImporterIndex(hash) => {
745                    emit_importer_index(store, hash, excluded, seen, visit)?;
746                }
747            }
748        }
749    }
750    Ok(())
751}
752
753/// Child kinds encountered while walking a state's semantic closure.
754enum SemanticChild {
755    Interior(ContentHash),
756    Leaf(ContentHash),
757    BindingDelta(ContentHash),
758    ImporterIndex(ContentHash),
759}
760
761/// Decode a semantic-closure node — the root (which points at a tree node) or a
762/// tree node — into the child hashes to walk. Missing/corrupt is a hard error.
763fn decode_semantic_container(
764    blob: &crate::object::Blob,
765    node_hash: ContentHash,
766) -> Result<Vec<SemanticChild>> {
767    // Try the root shape first (it has an extra `tree` indirection), then the
768    // tree-node shape. Content-addressed hashes make this unambiguous in
769    // practice; a blob that decodes as neither is corrupt.
770    if let Ok(root) = SemanticIndexRoot::decode(blob.content()) {
771        let mut children = vec![SemanticChild::Interior(root.tree)];
772        if let Some(binding_delta) = root.binding_delta {
773            children.push(SemanticChild::BindingDelta(binding_delta));
774        }
775        if let Some(importer_index) = root.importer_index {
776            children.push(SemanticChild::ImporterIndex(importer_index));
777        }
778        return Ok(children);
779    }
780    let node = SemanticTreeNode::decode(blob.content())
781        .map_err(|err| HeddleError::Serialization(format!("semantic node {node_hash}: {err}")))?;
782    Ok(node
783        .entries
784        .iter()
785        .filter_map(|entry| match entry.kind {
786            SemanticEntryKind::Dir => Some(SemanticChild::Interior(entry.node)),
787            SemanticEntryKind::File => Some(SemanticChild::Leaf(entry.node)),
788            // Opaque `node` is the raw source blob, already in the tree closure.
789            SemanticEntryKind::Opaque => None,
790        })
791        .collect())
792}
793
794fn emit_binding_delta(
795    store: &impl ObjectStore,
796    hash: ContentHash,
797    excluded: &HashSet<ContentHash>,
798    seen: &mut HashSet<ContentHash>,
799    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
800) -> Result<()> {
801    if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
802        return Ok(());
803    }
804    let blob = store.get_blob(&hash)?.ok_or_else(|| missing_blob(hash))?;
805    BindingDelta::decode(blob.content()).map_err(|err| {
806        HeddleError::Serialization(format!("semantic binding delta {hash}: {err}"))
807    })?;
808    Ok(())
809}
810
811fn emit_importer_index(
812    store: &impl ObjectStore,
813    hash: ContentHash,
814    excluded: &HashSet<ContentHash>,
815    seen: &mut HashSet<ContentHash>,
816    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
817) -> Result<()> {
818    if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
819        return Ok(());
820    }
821    let blob = store.get_blob(&hash)?.ok_or_else(|| missing_blob(hash))?;
822    ReverseDependencyIndex::decode(blob.content()).map_err(|err| {
823        HeddleError::Serialization(format!("semantic reverse-dependency index {hash}: {err}"))
824    })?;
825    Ok(())
826}
827
828/// Emit a semantic node blob as state metadata. Returns `true` when the blob
829/// was newly visited (so the caller may descend into it), `false` when it was
830/// excluded (present on the far side) or already seen. A blob that is neither
831/// excluded nor present is a HARD failure — the closure must be complete.
832fn emit_semantic_blob(
833    store: &impl ObjectStore,
834    hash: ContentHash,
835    excluded: &HashSet<ContentHash>,
836    seen: &mut HashSet<ContentHash>,
837    visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
838) -> Result<bool> {
839    if excluded.contains(&hash) {
840        visit(StateClosureEvent::ExcludedHash { hash })?;
841        return Ok(false);
842    }
843    if !seen.insert(hash) {
844        return Ok(false);
845    }
846    if store.get_blob(&hash)?.is_none() {
847        return Err(missing_blob(hash));
848    }
849    visit(StateClosureEvent::Blob { hash })?;
850    Ok(true)
851}
852
853/// Collect every semantic-index node blob hash reachable from `root_hash` into
854/// `excluded`, for the have-set computation. Iterative + tolerant (a broken
855/// have-set index is not fatal — it just means fewer objects are marked
856/// already-present, which is safe over-fetching, not corruption).
857fn collect_semantic_hashes(
858    store: &impl ObjectStore,
859    root_hash: ContentHash,
860    excluded: &mut HashSet<ContentHash>,
861) -> Result<()> {
862    let mut stack: Vec<ContentHash> = vec![root_hash];
863    while let Some(node_hash) = stack.pop() {
864        if !excluded.insert(node_hash) {
865            continue;
866        }
867        let Some(blob) = store.get_blob(&node_hash)? else {
868            continue;
869        };
870        let children = match decode_semantic_container(&blob, node_hash) {
871            Ok(children) => children,
872            Err(_) => continue,
873        };
874        for child in children {
875            match child {
876                SemanticChild::Interior(hash) => stack.push(hash),
877                SemanticChild::Leaf(hash) => {
878                    excluded.insert(hash);
879                }
880                SemanticChild::BindingDelta(hash) | SemanticChild::ImporterIndex(hash) => {
881                    excluded.insert(hash);
882                }
883            }
884        }
885    }
886    Ok(())
887}
888
889fn object_info_from_event(
890    store: &impl ObjectStore,
891    event: StateClosureEvent<'_>,
892) -> Result<Option<ObjectInfo>> {
893    match event {
894        StateClosureEvent::State { id, state } => {
895            let state_bytes = state.encode_current_msgpack()?;
896            Ok(Some(ObjectInfo {
897                id: ObjectId::StateId(id),
898                obj_type: ObjectType::State,
899                size: state_bytes.len() as u64,
900                delta_base: None,
901            }))
902        }
903        StateClosureEvent::Tree {
904            hash,
905            storage_size,
906            delta_base,
907        } => Ok(Some(ObjectInfo {
908            id: ObjectId::Hash(hash),
909            obj_type: ObjectType::Tree,
910            size: storage_size,
911            delta_base,
912        })),
913        StateClosureEvent::Blob { hash, .. } => {
914            let Some(blob) = store.get_blob(&hash)? else {
915                if blob_has_purge_evidence(store, &hash)? {
916                    return Ok(None);
917                }
918                return Err(missing_blob(hash));
919            };
920            Ok(Some(ObjectInfo {
921                id: ObjectId::Hash(hash),
922                obj_type: ObjectType::Blob,
923                size: blob.size() as u64,
924                delta_base: None,
925            }))
926        }
927        StateClosureEvent::Redaction { blob } => Ok(store
928            .get_redactions_bytes_for_blob(&blob)?
929            .map(|bytes| ObjectInfo {
930                id: ObjectId::Hash(blob),
931                obj_type: ObjectType::Redaction,
932                size: bytes.len() as u64,
933                delta_base: None,
934            })),
935        StateClosureEvent::StateVisibility { state } => Ok(store
936            .get_state_visibility_bytes_for_state(&state)?
937            .map(|bytes| ObjectInfo {
938                id: ObjectId::StateId(state),
939                obj_type: ObjectType::StateVisibility,
940                size: bytes.len() as u64,
941                delta_base: None,
942            })),
943        StateClosureEvent::StateAttachment { state, attachment } => {
944            let bytes = attachment.encode_current_msgpack()?;
945            Ok(Some(ObjectInfo {
946                id: ObjectId::StateAttachment {
947                    state,
948                    id: attachment.id(),
949                    kind: attachment.body.kind(),
950                },
951                obj_type: ObjectType::StateAttachment,
952                size: bytes.len() as u64,
953                delta_base: None,
954            }))
955        }
956        StateClosureEvent::ExcludedState { id } => {
957            let _ = id;
958            Ok(None)
959        }
960        StateClosureEvent::ExcludedHash { hash } => {
961            let _ = hash;
962            Ok(None)
963        }
964    }
965}
966
967fn planned_object_from_event(
968    store: &impl ObjectStore,
969    event: StateClosureEvent<'_>,
970) -> Result<Option<PlannedObject>> {
971    match event {
972        StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
973            id: ObjectId::StateId(id),
974            obj_type: ObjectType::State,
975        })),
976        StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
977            id: ObjectId::Hash(hash),
978            obj_type: ObjectType::Tree,
979        })),
980        StateClosureEvent::Blob { hash, .. } => {
981            if store.get_blob(&hash)?.is_none() {
982                if blob_has_purge_evidence(store, &hash)? {
983                    return Ok(None);
984                }
985                return Err(missing_blob(hash));
986            }
987            Ok(Some(PlannedObject {
988                id: ObjectId::Hash(hash),
989                obj_type: ObjectType::Blob,
990            }))
991        }
992        StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
993            id: ObjectId::Hash(blob),
994            obj_type: ObjectType::Redaction,
995        })),
996        StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
997            id: ObjectId::StateId(state),
998            obj_type: ObjectType::StateVisibility,
999        })),
1000        StateClosureEvent::StateAttachment { state, attachment } => Ok(Some(PlannedObject {
1001            id: ObjectId::StateAttachment {
1002                state,
1003                id: attachment.id(),
1004                kind: attachment.body.kind(),
1005            },
1006            obj_type: ObjectType::StateAttachment,
1007        })),
1008        StateClosureEvent::ExcludedState { id } => {
1009            let _ = id;
1010            Ok(None)
1011        }
1012        StateClosureEvent::ExcludedHash { hash } => {
1013            let _ = hash;
1014            Ok(None)
1015        }
1016    }
1017}
1018
1019/// A purged blob is intentionally absent from the object closure; its sidecar
1020/// remains and is verified by the receiver before the absence is accepted.
1021/// Mere redaction never excuses a missing blob.
1022fn missing_blob(hash: ContentHash) -> HeddleError {
1023    HeddleError::MissingObject {
1024        object_type: "blob".to_string(),
1025        id: hash.to_hex(),
1026    }
1027}
1028
1029fn blob_has_purge_evidence(store: &impl ObjectStore, hash: &ContentHash) -> Result<bool> {
1030    let Some(bytes) = store.get_redactions_bytes_for_blob(hash)? else {
1031        return Ok(false);
1032    };
1033    let redactions = RedactionsBlob::decode(&bytes).map_err(|error| {
1034        HeddleError::InvalidObject(format!(
1035            "invalid redaction sidecar for missing blob {}: {error}",
1036            hash.to_hex()
1037        ))
1038    })?;
1039    Ok(redactions
1040        .redactions
1041        .iter()
1042        .any(|redaction| redaction.redacted_blob == *hash && redaction.is_purged()))
1043}
1044
1045pub fn missing_blobs_in_tree(
1046    store: &impl ObjectStore,
1047    tree_hash: ContentHash,
1048) -> Result<Vec<ContentHash>> {
1049    let mut missing = Vec::new();
1050    collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
1051    Ok(missing)
1052}
1053
1054fn collect_missing_blobs_recursive(
1055    store: &impl ObjectStore,
1056    tree_hash: &ContentHash,
1057    missing: &mut Vec<ContentHash>,
1058) -> Result<()> {
1059    let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
1060        HeddleError::InvalidObject(format!(
1061            "load tree {} while collecting lazy hydration missing blobs: {err}",
1062            tree_hash.to_hex()
1063        ))
1064    })?
1065    else {
1066        return Ok(());
1067    };
1068
1069    for entry in tree.entries() {
1070        match entry.target() {
1071            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1072                if !store.has_blob(hash).map_err(|err| {
1073                    HeddleError::InvalidObject(format!(
1074                        "check blob {} while collecting lazy hydration missing blobs: {err}",
1075                        hash.to_hex()
1076                    ))
1077                })? {
1078                    missing.push(*hash);
1079                }
1080            }
1081            TreeEntryTarget::Tree { hash } => {
1082                collect_missing_blobs_recursive(store, hash, missing)?;
1083            }
1084            TreeEntryTarget::Gitlink { .. } => {}
1085            // Native child-spool edge: its target lives in a separate spool
1086            // object graph, not this store, so it is not walked here.
1087            TreeEntryTarget::Spoollink { .. } => {}
1088        }
1089    }
1090    Ok(())
1091}
1092
1093fn collect_excluded(
1094    store: &impl ObjectStore,
1095    roots: &[StateId],
1096) -> Result<(HashSet<StateId>, HashSet<ContentHash>)> {
1097    if roots.is_empty() {
1098        return Ok((HashSet::new(), HashSet::new()));
1099    }
1100
1101    let mut excluded_states: HashSet<StateId> = HashSet::new();
1102    let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
1103    let mut queue: VecDeque<StateId> = VecDeque::new();
1104
1105    for id in roots {
1106        queue.push_back(*id);
1107    }
1108
1109    while let Some(id) = queue.pop_front() {
1110        if !excluded_states.insert(id) {
1111            continue;
1112        }
1113
1114        let state = match store.get_state(&id)? {
1115            Some(state) => state,
1116            None => continue,
1117        };
1118
1119        for parent in &state.parents {
1120            queue.push_back(*parent);
1121        }
1122
1123        collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
1124        if let Some(provenance_root) = state.provenance {
1125            collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
1126        }
1127        for attachment in store.list_state_attachments(&id)? {
1128            match attachment.body {
1129                StateAttachmentBody::Context(root) => {
1130                    collect_tree_hashes(store, root, &mut excluded_hashes)?
1131                }
1132                StateAttachmentBody::RiskSignals(hash)
1133                | StateAttachmentBody::ReviewSignatures(hash)
1134                | StateAttachmentBody::Discussions(hash)
1135                | StateAttachmentBody::StructuredConflicts(hash) => {
1136                    excluded_hashes.insert(hash);
1137                }
1138                StateAttachmentBody::SemanticIndex(root) => {
1139                    collect_semantic_hashes(store, root, &mut excluded_hashes)?;
1140                }
1141                StateAttachmentBody::Signature(_) => {}
1142            }
1143        }
1144    }
1145
1146    Ok((excluded_states, excluded_hashes))
1147}
1148
1149fn collect_tree_hashes(
1150    store: &impl ObjectStore,
1151    tree_hash: ContentHash,
1152    excluded: &mut HashSet<ContentHash>,
1153) -> Result<()> {
1154    if !excluded.insert(tree_hash) {
1155        return Ok(());
1156    }
1157
1158    let tree = match store.get_tree(&tree_hash)? {
1159        Some(tree) => tree,
1160        None => return Ok(()),
1161    };
1162
1163    for entry in tree.entries() {
1164        match entry.target() {
1165            TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1166                excluded.insert(*hash);
1167            }
1168            TreeEntryTarget::Tree { hash } => {
1169                collect_tree_hashes(store, *hash, excluded)?;
1170            }
1171            TreeEntryTarget::Gitlink { .. } => {}
1172            // Native child-spool edge: its target lives in a separate spool
1173            // object graph, not this store, so it is not walked here.
1174            TreeEntryTarget::Spoollink { .. } => {}
1175        }
1176    }
1177
1178    Ok(())
1179}
1180
1181pub fn is_ancestor(
1182    store: &impl ObjectStore,
1183    ancestor: StateId,
1184    descendant: StateId,
1185) -> Result<bool> {
1186    walk_ancestor(ancestor, descendant, |id| ObjectStore::get_state(store, id))
1187}
1188
1189/// Walk ancestry against a read-only object source, including hosted stores.
1190pub fn is_ancestor_from_source(
1191    source: &(impl ObjectSource + ?Sized),
1192    ancestor: StateId,
1193    descendant: StateId,
1194) -> Result<bool> {
1195    walk_ancestor(ancestor, descendant, |id| source.get_state(id))
1196}
1197
1198/// Caller-owned limits for an async ancestry traversal. The source controls
1199/// its own I/O timeout; these checks run between completed reads.
1200#[cfg(feature = "async-source")]
1201#[derive(Clone, Debug)]
1202pub struct AncestryBudget {
1203    pub max_states: usize,
1204    pub cancelled: Arc<AtomicBool>,
1205    pub deadline: Option<Instant>,
1206}
1207
1208#[cfg(feature = "async-source")]
1209#[derive(Debug, thiserror::Error)]
1210pub enum AncestryError {
1211    #[error("ancestry traversal cancelled")]
1212    Cancelled,
1213    #[error("ancestry traversal deadline exceeded")]
1214    Deadline,
1215    #[error("ancestry work limit exhausted")]
1216    WorkLimit,
1217    #[error(transparent)]
1218    Source(#[from] HeddleError),
1219}
1220
1221/// Walk ancestry with caller-supplied work and interruption bounds. Missing
1222/// states (including parents) terminate only that branch; `Ok(false)` means
1223/// all reachable branches completed under the supplied budget.
1224#[cfg(feature = "async-source")]
1225pub async fn is_ancestor_async_bounded<S>(
1226    source: &S,
1227    ancestor: &StateId,
1228    descendant: &StateId,
1229    budget: &AncestryBudget,
1230) -> std::result::Result<bool, AncestryError>
1231where
1232    S: AsyncObjectSource + ?Sized,
1233{
1234    if ancestor == descendant {
1235        return Ok(true);
1236    }
1237    let mut seen = HashSet::new();
1238    let mut stack = vec![*descendant];
1239    while let Some(id) = stack.pop() {
1240        if budget.cancelled.load(Ordering::Acquire) {
1241            return Err(AncestryError::Cancelled);
1242        }
1243        if budget
1244            .deadline
1245            .is_some_and(|deadline| Instant::now() >= deadline)
1246        {
1247            return Err(AncestryError::Deadline);
1248        }
1249        if !seen.insert(id) {
1250            continue;
1251        }
1252        if seen.len() > budget.max_states {
1253            return Err(AncestryError::WorkLimit);
1254        }
1255        let Some(state) = source.get_state(&id).await? else {
1256            continue;
1257        };
1258        heddle_perf_contract::record_history_object_decode();
1259        if id == *ancestor {
1260            return Ok(true);
1261        }
1262        stack.extend(state.parents);
1263    }
1264    Ok(false)
1265}
1266
1267/// Unbounded compatibility entry point for local callers. Hosted callers
1268/// should use [`is_ancestor_async_bounded`].
1269#[cfg(feature = "async-source")]
1270pub async fn is_ancestor_async<S>(
1271    source: &S,
1272    ancestor: &StateId,
1273    descendant: &StateId,
1274) -> Result<bool>
1275where
1276    S: AsyncObjectSource + ?Sized,
1277{
1278    let budget = AncestryBudget {
1279        max_states: usize::MAX,
1280        cancelled: Arc::new(AtomicBool::new(false)),
1281        deadline: None,
1282    };
1283    is_ancestor_async_bounded(source, ancestor, descendant, &budget)
1284        .await
1285        .map_err(|error| match error {
1286            AncestryError::Source(error) => error,
1287            other => HeddleError::InvalidObject(other.to_string()),
1288        })
1289}
1290
1291fn walk_ancestor(
1292    ancestor: StateId,
1293    descendant: StateId,
1294    mut get_state: impl FnMut(&StateId) -> Result<Option<State>>,
1295) -> Result<bool> {
1296    if ancestor == descendant {
1297        return Ok(true);
1298    }
1299
1300    let mut seen: HashSet<StateId> = HashSet::new();
1301    let mut queue: VecDeque<StateId> = VecDeque::new();
1302    queue.push_back(descendant);
1303
1304    while let Some(id) = queue.pop_front() {
1305        if !seen.insert(id) {
1306            continue;
1307        }
1308        let state = match get_state(&id)? {
1309            Some(s) => s,
1310            None => return Ok(false),
1311        };
1312        for parent in state.parents {
1313            if parent == ancestor {
1314                return Ok(true);
1315            }
1316            queue.push_back(parent);
1317        }
1318    }
1319
1320    Ok(false)
1321}
1322
1323#[cfg(all(test, feature = "async-source"))]
1324mod async_ancestry_tests {
1325    use super::*;
1326    use crate::object::{Attribution, Blob, Principal, Tree};
1327    use std::{
1328        collections::HashMap,
1329        future::Future,
1330        sync::atomic::AtomicUsize,
1331        task::{Context, Poll, Waker},
1332    };
1333
1334    struct Source {
1335        states: HashMap<StateId, State>,
1336        reads: AtomicUsize,
1337        fail: Option<StateId>,
1338        cancel: Option<Arc<AtomicBool>>,
1339    }
1340
1341    impl AsyncObjectSource for Source {
1342        async fn get_tree(&self, _: &ContentHash) -> Result<Option<Tree>> {
1343            Ok(None)
1344        }
1345        async fn get_blob(&self, _: &ContentHash) -> Result<Option<Blob>> {
1346            Ok(None)
1347        }
1348        async fn get_state(&self, id: &StateId) -> Result<Option<State>> {
1349            self.reads.fetch_add(1, Ordering::Relaxed);
1350            if self.fail == Some(*id) {
1351                return Err(HeddleError::InvalidObject("source failed".into()));
1352            }
1353            if let Some(cancel) = &self.cancel {
1354                cancel.store(true, Ordering::Release);
1355            }
1356            Ok(self.states.get(id).cloned())
1357        }
1358    }
1359
1360    fn run<T>(future: impl Future<Output = T>) -> T {
1361        let mut future = Box::pin(future);
1362        let mut context = Context::from_waker(Waker::noop());
1363        loop {
1364            match future.as_mut().poll(&mut context) {
1365                Poll::Ready(value) => return value,
1366                Poll::Pending => std::thread::yield_now(),
1367            }
1368        }
1369    }
1370
1371    fn state(name: &str, parents: Vec<StateId>) -> State {
1372        State::new(
1373            Tree::new().hash(),
1374            parents,
1375            Attribution::human(Principal::new(name, "test@example.com")),
1376        )
1377    }
1378
1379    fn budget(limit: usize) -> AncestryBudget {
1380        AncestryBudget {
1381            max_states: limit,
1382            cancelled: Arc::new(AtomicBool::new(false)),
1383            deadline: None,
1384        }
1385    }
1386
1387    #[test]
1388    fn bounded_walk_distinguishes_complete_missing_error_and_interruption() {
1389        let root = state("root", vec![]);
1390        let left = state("left", vec![root.id()]);
1391        let right = state("right", vec![root.id()]);
1392        let merge = state("merge", vec![left.id(), right.id()]);
1393        let missing = state("missing", vec![]).id();
1394        let states = [root.clone(), left.clone(), right.clone(), merge.clone()]
1395            .into_iter()
1396            .map(|state| (state.id(), state))
1397            .collect();
1398        let mut source = Source {
1399            states,
1400            reads: AtomicUsize::new(0),
1401            fail: None,
1402            cancel: None,
1403        };
1404        assert!(
1405            run(is_ancestor_async_bounded(
1406                &source,
1407                &merge.id(),
1408                &merge.id(),
1409                &budget(0)
1410            ))
1411            .unwrap()
1412        );
1413        assert_eq!(source.reads.load(Ordering::Relaxed), 0);
1414
1415        assert!(
1416            run(is_ancestor_async_bounded(
1417                &source,
1418                &root.id(),
1419                &merge.id(),
1420                &budget(4)
1421            ))
1422            .unwrap()
1423        );
1424        source.states.remove(&root.id());
1425        assert!(
1426            !run(is_ancestor_async_bounded(
1427                &source,
1428                &root.id(),
1429                &merge.id(),
1430                &budget(4)
1431            ))
1432            .unwrap()
1433        );
1434        source.states.insert(root.id(), root.clone());
1435        assert!(
1436            !run(is_ancestor_async_bounded(
1437                &source,
1438                &merge.id(),
1439                &left.id(),
1440                &budget(4)
1441            ))
1442            .unwrap()
1443        );
1444        assert!(
1445            !run(is_ancestor_async_bounded(
1446                &source,
1447                &missing,
1448                &merge.id(),
1449                &budget(5)
1450            ))
1451            .unwrap()
1452        );
1453        assert!(matches!(
1454            run(is_ancestor_async_bounded(
1455                &source,
1456                &root.id(),
1457                &merge.id(),
1458                &budget(1)
1459            )),
1460            Err(AncestryError::WorkLimit)
1461        ));
1462
1463        source.fail = Some(merge.id());
1464        assert!(matches!(
1465            run(is_ancestor_async_bounded(
1466                &source,
1467                &root.id(),
1468                &merge.id(),
1469                &budget(4)
1470            )),
1471            Err(AncestryError::Source(_))
1472        ));
1473        source.fail = None;
1474
1475        let cancelled = budget(4);
1476        cancelled.cancelled.store(true, Ordering::Release);
1477        let before = source.reads.load(Ordering::Relaxed);
1478        assert!(matches!(
1479            run(is_ancestor_async_bounded(
1480                &source,
1481                &root.id(),
1482                &merge.id(),
1483                &cancelled
1484            )),
1485            Err(AncestryError::Cancelled)
1486        ));
1487        assert_eq!(source.reads.load(Ordering::Relaxed), before);
1488
1489        let mut expired = budget(4);
1490        expired.deadline = Some(Instant::now());
1491        assert!(matches!(
1492            run(is_ancestor_async_bounded(
1493                &source,
1494                &root.id(),
1495                &merge.id(),
1496                &expired
1497            )),
1498            Err(AncestryError::Deadline)
1499        ));
1500
1501        let mid_cancel = budget(4);
1502        source.cancel = Some(mid_cancel.cancelled.clone());
1503        assert!(matches!(
1504            run(is_ancestor_async_bounded(
1505                &source,
1506                &root.id(),
1507                &merge.id(),
1508                &mid_cancel
1509            )),
1510            Err(AncestryError::Cancelled)
1511        ));
1512    }
1513}