Skip to main content

objects/transfer/
graph.rs

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