1use std::collections::{HashSet, VecDeque};
3
4use serde::{Deserialize, Serialize};
5
6use crate::{
7 error::{HeddleError, Result},
8 object::{
9 AnnotatedTag, BindingDelta, ContentHash, RedactionsBlob, ReverseDependencyIndex,
10 SemanticEntryKind, SemanticIndexRoot, SemanticTreeNode, State, StateAttachment,
11 StateAttachmentBody, StateAttachmentId, StateAttachmentKind, StateId, TreeEntryTarget,
12 decode_tree_delta_header, is_delta_tree,
13 },
14 store::{ObjectStore, pack::ObjectType as PackObjectType},
15};
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub enum ObjectId {
19 Hash(ContentHash),
20 StateId(StateId),
21 StateAttachment {
22 state: StateId,
23 id: StateAttachmentId,
24 kind: StateAttachmentKind,
30 },
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ObjectInfo {
35 pub id: ObjectId,
36 pub obj_type: ObjectType,
37 pub size: u64,
38 pub delta_base: Option<ContentHash>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub struct PlannedObject {
43 pub id: ObjectId,
44 pub obj_type: ObjectType,
45}
46
47#[derive(Debug, Clone)]
48pub struct StateClosureTransferObjects {
49 pub planned_objects: Vec<PlannedObject>,
50 pub full_objects: Option<Vec<ObjectInfo>>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum ObjectType {
55 Blob,
56 Tree,
57 State,
58 Action,
59 AnnotatedTag,
60 Redaction,
66 Purge,
70 StateVisibility,
77 StateAttachment,
78 KeyBinding,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub enum ObjectTypeBucket {
87 Blob,
88 Tree,
89 State,
90 Action,
91 AnnotatedTag,
92 Redaction,
93 Purge,
94 StateVisibility,
95 StateAttachment,
96 KeyBinding,
97}
98
99impl ObjectType {
100 pub fn wire_name(self) -> &'static str {
101 match self {
102 ObjectType::Blob => "blob",
103 ObjectType::Tree => "tree",
104 ObjectType::State => "state",
105 ObjectType::Action => "action",
106 ObjectType::AnnotatedTag => "annotated_tag",
107 ObjectType::Redaction => "redaction",
108 ObjectType::Purge => "purge",
109 ObjectType::StateVisibility => "state_visibility",
110 ObjectType::StateAttachment => "state_attachment",
111 ObjectType::KeyBinding => "key_binding",
112 }
113 }
114
115 pub fn from_wire(value: &str) -> Result<Self> {
116 match value {
117 "blob" => Ok(ObjectType::Blob),
118 "tree" => Ok(ObjectType::Tree),
119 "state" => Ok(ObjectType::State),
120 "action" => Ok(ObjectType::Action),
121 "annotated_tag" => Ok(ObjectType::AnnotatedTag),
122 "redaction" => Ok(ObjectType::Redaction),
123 "purge" => Ok(ObjectType::Purge),
124 "state_visibility" => Ok(ObjectType::StateVisibility),
125 "state_attachment" => Ok(ObjectType::StateAttachment),
126 "key_binding" => Ok(ObjectType::KeyBinding),
127 _ => Err(HeddleError::InvalidObject(format!(
128 "unknown object type: {value}"
129 ))),
130 }
131 }
132
133 pub fn packable(self) -> bool {
144 !matches!(
145 self,
146 ObjectType::Redaction
147 | ObjectType::Purge
148 | ObjectType::StateVisibility
149 | ObjectType::KeyBinding
150 )
151 }
152
153 pub fn packable_for_push(self) -> bool {
163 self.packable() && !matches!(self, ObjectType::StateAttachment)
164 }
165
166 pub fn packable_for_pull(self) -> bool {
173 self.packable()
174 }
175
176 pub fn pack_object_type(self) -> Result<PackObjectType> {
177 match self {
178 ObjectType::Blob => Ok(PackObjectType::Blob),
179 ObjectType::Tree => Ok(PackObjectType::Tree),
180 ObjectType::State => Ok(PackObjectType::State),
181 ObjectType::Action => Ok(PackObjectType::Action),
182 ObjectType::AnnotatedTag => Ok(PackObjectType::AnnotatedTag),
183 ObjectType::StateAttachment => Ok(PackObjectType::StateAttachment),
184 ObjectType::Redaction => Err(HeddleError::InvalidObject(
185 "Redaction sidecar records cannot be packed into the content-addressed object pack"
186 .to_string(),
187 )),
188 ObjectType::Purge => Err(HeddleError::InvalidObject(
189 "Purge sidecar records cannot be packed into the content-addressed object pack"
190 .to_string(),
191 )),
192 ObjectType::StateVisibility => Err(HeddleError::InvalidObject(
193 "StateVisibility sidecar records cannot be packed into the content-addressed object pack"
194 .to_string(),
195 )),
196 ObjectType::KeyBinding => Err(HeddleError::InvalidObject(
197 "KeyBinding registry objects cannot be packed into the content-addressed object pack"
198 .to_string(),
199 )),
200 }
201 }
202
203 pub fn bucket(self) -> ObjectTypeBucket {
204 match self {
205 ObjectType::Blob => ObjectTypeBucket::Blob,
206 ObjectType::Tree => ObjectTypeBucket::Tree,
207 ObjectType::State => ObjectTypeBucket::State,
208 ObjectType::Action => ObjectTypeBucket::Action,
209 ObjectType::AnnotatedTag => ObjectTypeBucket::AnnotatedTag,
210 ObjectType::Redaction => ObjectTypeBucket::Redaction,
211 ObjectType::Purge => ObjectTypeBucket::Purge,
212 ObjectType::StateVisibility => ObjectTypeBucket::StateVisibility,
213 ObjectType::StateAttachment => ObjectTypeBucket::StateAttachment,
214 ObjectType::KeyBinding => ObjectTypeBucket::KeyBinding,
215 }
216 }
217}
218
219#[derive(Debug, Clone, Default)]
220pub struct StateClosureOptions {
221 pub depth: Option<u32>,
222 pub exclude_states: Vec<StateId>,
223}
224
225pub fn enumerate_state_closure(
226 store: &impl ObjectStore,
227 state_id: StateId,
228) -> Result<Vec<ObjectInfo>> {
229 enumerate_state_closure_with_options(store, state_id, StateClosureOptions::default())
230}
231
232pub fn enumerate_state_closure_with_options(
233 store: &impl ObjectStore,
234 state_id: StateId,
235 options: StateClosureOptions,
236) -> Result<Vec<ObjectInfo>> {
237 let mut out = Vec::new();
238 walk_state_closure(store, state_id, options, |event| {
239 if let Some(info) = object_info_from_event(store, event)? {
240 out.push(info);
241 }
242 Ok(())
243 })?;
244 for (hash, tag) in annotated_tags_for_state(store, state_id)? {
245 out.push(annotated_tag_info(hash, &tag));
246 }
247
248 Ok(out)
249}
250
251pub fn enumerate_state_closure_plan(
252 store: &impl ObjectStore,
253 state_id: StateId,
254) -> Result<Vec<PlannedObject>> {
255 enumerate_state_closure_plan_with_options(store, state_id, StateClosureOptions::default())
256}
257
258pub fn enumerate_state_closure_plan_with_options(
259 store: &impl ObjectStore,
260 state_id: StateId,
261 options: StateClosureOptions,
262) -> Result<Vec<PlannedObject>> {
263 let mut out = Vec::new();
264 walk_state_closure(store, state_id, options, |event| {
265 if let Some(object) = planned_object_from_event(store, event)? {
266 out.push(object);
267 }
268 Ok(())
269 })?;
270 out.extend(
271 annotated_tags_for_state(store, state_id)?
272 .into_iter()
273 .map(|(hash, _)| PlannedObject {
274 id: ObjectId::Hash(hash),
275 obj_type: ObjectType::AnnotatedTag,
276 }),
277 );
278
279 Ok(out)
280}
281
282pub fn enumerate_state_closure_transfer_with_options(
283 store: &impl ObjectStore,
284 state_id: StateId,
285 options: StateClosureOptions,
286 full_descriptor_object_threshold: usize,
287) -> Result<StateClosureTransferObjects> {
288 let mut planned_objects = Vec::new();
289 let mut full_objects = Some(Vec::new());
290
291 walk_state_closure(store, state_id, options, |event| {
292 if let Some(object) = planned_object_from_event(store, event)? {
293 planned_objects.push(object);
294 }
295
296 if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
297 full_objects = None;
298 }
299 if let Some(objects) = full_objects.as_mut()
300 && let Some(info) = object_info_from_event(store, event)?
301 {
302 objects.push(info);
303 }
304
305 Ok(())
306 })?;
307 let tags = annotated_tags_for_state(store, state_id)?;
308 planned_objects.extend(tags.iter().map(|(hash, _)| PlannedObject {
309 id: ObjectId::Hash(*hash),
310 obj_type: ObjectType::AnnotatedTag,
311 }));
312 if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
313 full_objects = None;
314 }
315 if let Some(objects) = full_objects.as_mut() {
316 objects.extend(
317 tags.iter()
318 .map(|(hash, tag)| annotated_tag_info(*hash, tag)),
319 );
320 }
321
322 Ok(StateClosureTransferObjects {
323 planned_objects,
324 full_objects,
325 })
326}
327
328fn annotated_tags_for_state(
329 store: &impl ObjectStore,
330 state_id: StateId,
331) -> Result<Vec<(ContentHash, AnnotatedTag)>> {
332 let mut roots = Vec::new();
333 for hash in store.list_annotated_tags()? {
334 let Some(tag) = store.get_annotated_tag(&hash)? else {
335 continue;
336 };
337 if tag
338 .marker()
339 .is_some_and(|marker| marker.peeled_state == state_id)
340 {
341 roots.push((hash, tag));
342 }
343 }
344
345 let mut tags = Vec::new();
346 let mut seen = HashSet::new();
347 let mut stack = roots;
348 while let Some((hash, tag)) = stack.pop() {
349 if !seen.insert(hash) {
350 continue;
351 }
352 if let Some(inner_hash) = tag.target_tag() {
353 let inner = store.get_annotated_tag(&inner_hash)?.ok_or_else(|| {
354 HeddleError::NotFound(format!(
355 "annotated tag {hash} references missing inner tag {inner_hash}"
356 ))
357 })?;
358 stack.push((inner_hash, inner));
359 }
360 tags.push((hash, tag));
361 }
362 Ok(tags)
363}
364
365fn annotated_tag_info(hash: ContentHash, tag: &AnnotatedTag) -> ObjectInfo {
366 ObjectInfo {
367 id: ObjectId::Hash(hash),
368 obj_type: ObjectType::AnnotatedTag,
369 size: tag.encode_current_msgpack().len() as u64,
370 delta_base: None,
371 }
372}
373
374pub fn enumerate_state_closure_transfer_from_boundaries(
381 store: &impl ObjectStore,
382 state_id: StateId,
383 boundary_states: &[StateId],
384 full_descriptor_object_threshold: usize,
385) -> Result<StateClosureTransferObjects> {
386 let mut planned_objects = Vec::new();
387 let mut full_objects = Some(Vec::new());
388 let excluded_states = boundary_states.iter().copied().collect();
389
390 walk_state_closure_with_exclusions(
391 store,
392 state_id,
393 None,
394 excluded_states,
395 HashSet::new(),
396 |event| {
397 if let Some(object) = planned_object_from_event(store, event)? {
398 planned_objects.push(object);
399 }
400
401 if full_objects.is_some() && planned_objects.len() > full_descriptor_object_threshold {
402 full_objects = None;
403 }
404 if let Some(objects) = full_objects.as_mut()
405 && let Some(info) = object_info_from_event(store, event)?
406 {
407 objects.push(info);
408 }
409
410 Ok(())
411 },
412 )?;
413
414 Ok(StateClosureTransferObjects {
415 planned_objects,
416 full_objects,
417 })
418}
419
420#[derive(Debug, Clone, Copy)]
421enum StateClosureEvent<'a> {
422 State {
423 id: StateId,
424 state: &'a State,
425 },
426 Tree {
427 hash: ContentHash,
428 storage_size: u64,
429 delta_base: Option<ContentHash>,
430 },
431 Blob {
432 hash: ContentHash,
433 },
434 Redaction {
435 blob: ContentHash,
436 },
437 StateVisibility {
438 state: StateId,
439 },
440 StateAttachment {
441 state: StateId,
442 attachment: &'a StateAttachment,
443 },
444 ExcludedState {
445 id: StateId,
446 },
447 ExcludedHash {
448 hash: ContentHash,
449 },
450}
451
452fn walk_state_closure(
453 store: &impl ObjectStore,
454 state_id: StateId,
455 options: StateClosureOptions,
456 visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
457) -> Result<()> {
458 let (excluded_states, excluded_hashes) = collect_excluded(store, &options.exclude_states)?;
459
460 walk_state_closure_with_exclusions(
461 store,
462 state_id,
463 options.depth,
464 excluded_states,
465 excluded_hashes,
466 visit,
467 )
468}
469
470fn walk_state_closure_with_exclusions(
471 store: &impl ObjectStore,
472 state_id: StateId,
473 max_depth: Option<u32>,
474 excluded_states: HashSet<StateId>,
475 excluded_hashes: HashSet<ContentHash>,
476 mut visit: impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
477) -> Result<()> {
478 let mut seen_states: HashSet<StateId> = HashSet::new();
479 let mut seen_hashes: HashSet<ContentHash> = HashSet::new();
480 let mut queue: VecDeque<(StateId, u32)> = VecDeque::new();
481 queue.push_back((state_id, 0));
482
483 while let Some((id, depth)) = queue.pop_front() {
484 if excluded_states.contains(&id) {
485 visit(StateClosureEvent::ExcludedState { id })?;
486 continue;
487 }
488 if !seen_states.insert(id) {
489 continue;
490 }
491
492 let state = store
493 .get_state(&id)?
494 .ok_or_else(|| HeddleError::MissingObject {
495 object_type: "state".to_string(),
496 id: id.to_string(),
497 })?;
498
499 visit(StateClosureEvent::State { id, state: &state })?;
500 if store.has_state_visibility_for_state(&id)? {
501 visit(StateClosureEvent::StateVisibility { state: id })?;
502 }
503 for attachment in store.list_state_attachments(&id)? {
504 visit(StateClosureEvent::StateAttachment {
505 state: id,
506 attachment: &attachment,
507 })?;
508 match attachment.body {
509 StateAttachmentBody::Context(root) => walk_tree_closure_filtered(
510 store,
511 root,
512 &excluded_hashes,
513 &mut seen_hashes,
514 &mut visit,
515 )?,
516 StateAttachmentBody::RiskSignals(hash)
517 | StateAttachmentBody::ReviewSignatures(hash)
518 | StateAttachmentBody::Discussions(hash)
519 | StateAttachmentBody::StructuredConflicts(hash) => {
520 walk_blob_filtered(store, hash, &excluded_hashes, &mut seen_hashes, &mut visit)?
521 }
522 StateAttachmentBody::SemanticIndex(root) => walk_semantic_index_closure(
523 store,
524 root,
525 &excluded_hashes,
526 &mut seen_hashes,
527 &mut visit,
528 )?,
529 StateAttachmentBody::Signature(_) => {}
530 }
531 }
532
533 if max_depth.map(|max| depth < max).unwrap_or(true) {
534 for parent in &state.parents {
535 queue.push_back((*parent, depth + 1));
536 }
537 }
538
539 walk_tree_closure_filtered(
540 store,
541 state.tree,
542 &excluded_hashes,
543 &mut seen_hashes,
544 &mut visit,
545 )?;
546 if let Some(provenance_root) = state.provenance {
547 walk_tree_closure_filtered(
548 store,
549 provenance_root,
550 &excluded_hashes,
551 &mut seen_hashes,
552 &mut visit,
553 )?;
554 }
555 }
556
557 Ok(())
558}
559
560fn walk_tree_closure_filtered(
561 store: &impl ObjectStore,
562 tree_hash: ContentHash,
563 excluded: &HashSet<ContentHash>,
564 seen: &mut HashSet<ContentHash>,
565 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
566) -> Result<()> {
567 if excluded.contains(&tree_hash) {
568 visit(StateClosureEvent::ExcludedHash { hash: tree_hash })?;
569 return Ok(());
570 }
571 if !seen.insert(tree_hash) {
572 return Ok(());
573 }
574
575 let tree = store
576 .get_tree(&tree_hash)?
577 .ok_or_else(|| HeddleError::MissingObject {
578 object_type: "tree".to_string(),
579 id: tree_hash.to_hex(),
580 })?;
581
582 let (storage_size, delta_base) = tree_storage_metadata(store, tree_hash)?;
583 visit(StateClosureEvent::Tree {
584 hash: tree_hash,
585 storage_size,
586 delta_base,
587 })?;
588
589 if let Some(anchor) = delta_base {
590 walk_tree_storage_anchor(store, anchor, seen, visit)?;
591 }
592
593 for entry in tree.entries() {
594 match entry.target() {
595 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
596 walk_blob_filtered(store, *hash, excluded, seen, visit)?;
597 }
598 TreeEntryTarget::Tree { hash } => {
599 walk_tree_closure_filtered(store, *hash, excluded, seen, visit)?;
600 }
601 TreeEntryTarget::Gitlink { .. } => {}
602 TreeEntryTarget::Spoollink { .. } => {}
605 }
606 }
607
608 Ok(())
609}
610
611fn tree_storage_metadata(
612 store: &impl ObjectStore,
613 tree_hash: ContentHash,
614) -> Result<(u64, Option<ContentHash>)> {
615 let body =
616 store
617 .get_tree_serialized(&tree_hash)?
618 .ok_or_else(|| HeddleError::MissingObject {
619 object_type: "tree".to_string(),
620 id: tree_hash.to_hex(),
621 })?;
622 let delta_base = if is_delta_tree(&body) {
623 let header = decode_tree_delta_header(&body)?;
624 if header.anchor == tree_hash {
625 return Err(HeddleError::InvalidObject(
626 "HDC1 result id must differ from its anchor id".to_string(),
627 ));
628 }
629 Some(header.anchor)
630 } else {
631 None
632 };
633 Ok((body.len() as u64, delta_base))
634}
635
636fn walk_tree_storage_anchor(
637 store: &impl ObjectStore,
638 anchor_hash: ContentHash,
639 seen: &mut HashSet<ContentHash>,
640 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
641) -> Result<()> {
642 if !seen.insert(anchor_hash) {
643 return Ok(());
644 }
645 if store.get_tree(&anchor_hash)?.is_none() {
646 return Err(HeddleError::MissingObject {
647 object_type: "tree delta anchor".to_string(),
648 id: anchor_hash.to_hex(),
649 });
650 }
651 let (storage_size, delta_base) = tree_storage_metadata(store, anchor_hash)?;
652 if delta_base.is_some() {
653 return Err(HeddleError::InvalidObject(
654 "HDC1 anchor must be materialized; delta chains are forbidden".to_string(),
655 ));
656 }
657 visit(StateClosureEvent::Tree {
658 hash: anchor_hash,
659 storage_size,
660 delta_base: None,
661 })
662}
663
664fn walk_blob_filtered(
665 store: &impl ObjectStore,
666 blob_hash: ContentHash,
667 excluded: &HashSet<ContentHash>,
668 seen: &mut HashSet<ContentHash>,
669 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
670) -> Result<()> {
671 if excluded.contains(&blob_hash) {
672 visit(StateClosureEvent::ExcludedHash { hash: blob_hash })?;
673 return Ok(());
674 }
675 if !seen.insert(blob_hash) {
676 return Ok(());
677 }
678 visit(StateClosureEvent::Blob { hash: blob_hash })?;
679 if store.has_redactions_for_blob(&blob_hash)? {
680 visit(StateClosureEvent::Redaction { blob: blob_hash })?;
681 }
682 Ok(())
683}
684
685fn walk_semantic_index_closure(
698 store: &impl ObjectStore,
699 root_hash: ContentHash,
700 excluded: &HashSet<ContentHash>,
701 seen: &mut HashSet<ContentHash>,
702 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
703) -> Result<()> {
704 let mut stack: Vec<ContentHash> = vec![root_hash];
707 while let Some(node_hash) = stack.pop() {
708 if !emit_semantic_blob(store, node_hash, excluded, seen, visit)? {
709 continue; }
711 let blob = store
712 .get_blob(&node_hash)?
713 .ok_or_else(|| missing_blob(node_hash))?;
714 let node = decode_semantic_container(&blob, node_hash)?;
718 for child in node {
719 match child {
720 SemanticChild::Interior(hash) => stack.push(hash),
721 SemanticChild::Leaf(hash) => {
722 emit_semantic_blob(store, hash, excluded, seen, visit)?;
724 }
725 SemanticChild::BindingDelta(hash) => {
726 emit_binding_delta(store, hash, excluded, seen, visit)?;
732 }
733 SemanticChild::ImporterIndex(hash) => {
734 emit_importer_index(store, hash, excluded, seen, visit)?;
735 }
736 }
737 }
738 }
739 Ok(())
740}
741
742enum SemanticChild {
744 Interior(ContentHash),
745 Leaf(ContentHash),
746 BindingDelta(ContentHash),
747 ImporterIndex(ContentHash),
748}
749
750fn decode_semantic_container(
753 blob: &crate::object::Blob,
754 node_hash: ContentHash,
755) -> Result<Vec<SemanticChild>> {
756 if let Ok(root) = SemanticIndexRoot::decode(blob.content()) {
760 let mut children = vec![SemanticChild::Interior(root.tree)];
761 if let Some(binding_delta) = root.binding_delta {
762 children.push(SemanticChild::BindingDelta(binding_delta));
763 }
764 if let Some(importer_index) = root.importer_index {
765 children.push(SemanticChild::ImporterIndex(importer_index));
766 }
767 return Ok(children);
768 }
769 let node = SemanticTreeNode::decode(blob.content())
770 .map_err(|err| HeddleError::Serialization(format!("semantic node {node_hash}: {err}")))?;
771 Ok(node
772 .entries
773 .iter()
774 .filter_map(|entry| match entry.kind {
775 SemanticEntryKind::Dir => Some(SemanticChild::Interior(entry.node)),
776 SemanticEntryKind::File => Some(SemanticChild::Leaf(entry.node)),
777 SemanticEntryKind::Opaque => None,
779 })
780 .collect())
781}
782
783fn emit_binding_delta(
784 store: &impl ObjectStore,
785 hash: ContentHash,
786 excluded: &HashSet<ContentHash>,
787 seen: &mut HashSet<ContentHash>,
788 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
789) -> Result<()> {
790 if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
791 return Ok(());
792 }
793 let blob = store.get_blob(&hash)?.ok_or_else(|| missing_blob(hash))?;
794 BindingDelta::decode(blob.content()).map_err(|err| {
795 HeddleError::Serialization(format!("semantic binding delta {hash}: {err}"))
796 })?;
797 Ok(())
798}
799
800fn emit_importer_index(
801 store: &impl ObjectStore,
802 hash: ContentHash,
803 excluded: &HashSet<ContentHash>,
804 seen: &mut HashSet<ContentHash>,
805 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
806) -> Result<()> {
807 if !emit_semantic_blob(store, hash, excluded, seen, visit)? {
808 return Ok(());
809 }
810 let blob = store.get_blob(&hash)?.ok_or_else(|| missing_blob(hash))?;
811 ReverseDependencyIndex::decode(blob.content()).map_err(|err| {
812 HeddleError::Serialization(format!("semantic reverse-dependency index {hash}: {err}"))
813 })?;
814 Ok(())
815}
816
817fn emit_semantic_blob(
822 store: &impl ObjectStore,
823 hash: ContentHash,
824 excluded: &HashSet<ContentHash>,
825 seen: &mut HashSet<ContentHash>,
826 visit: &mut impl for<'event> FnMut(StateClosureEvent<'event>) -> Result<()>,
827) -> Result<bool> {
828 if excluded.contains(&hash) {
829 visit(StateClosureEvent::ExcludedHash { hash })?;
830 return Ok(false);
831 }
832 if !seen.insert(hash) {
833 return Ok(false);
834 }
835 if store.get_blob(&hash)?.is_none() {
836 return Err(missing_blob(hash));
837 }
838 visit(StateClosureEvent::Blob { hash })?;
839 Ok(true)
840}
841
842fn collect_semantic_hashes(
847 store: &impl ObjectStore,
848 root_hash: ContentHash,
849 excluded: &mut HashSet<ContentHash>,
850) -> Result<()> {
851 let mut stack: Vec<ContentHash> = vec![root_hash];
852 while let Some(node_hash) = stack.pop() {
853 if !excluded.insert(node_hash) {
854 continue;
855 }
856 let Some(blob) = store.get_blob(&node_hash)? else {
857 continue;
858 };
859 let children = match decode_semantic_container(&blob, node_hash) {
860 Ok(children) => children,
861 Err(_) => continue,
862 };
863 for child in children {
864 match child {
865 SemanticChild::Interior(hash) => stack.push(hash),
866 SemanticChild::Leaf(hash) => {
867 excluded.insert(hash);
868 }
869 SemanticChild::BindingDelta(hash) | SemanticChild::ImporterIndex(hash) => {
870 excluded.insert(hash);
871 }
872 }
873 }
874 }
875 Ok(())
876}
877
878fn object_info_from_event(
879 store: &impl ObjectStore,
880 event: StateClosureEvent<'_>,
881) -> Result<Option<ObjectInfo>> {
882 match event {
883 StateClosureEvent::State { id, state } => {
884 let state_bytes = rmp_serde::to_vec_named(state)?;
885 Ok(Some(ObjectInfo {
886 id: ObjectId::StateId(id),
887 obj_type: ObjectType::State,
888 size: state_bytes.len() as u64,
889 delta_base: None,
890 }))
891 }
892 StateClosureEvent::Tree {
893 hash,
894 storage_size,
895 delta_base,
896 } => Ok(Some(ObjectInfo {
897 id: ObjectId::Hash(hash),
898 obj_type: ObjectType::Tree,
899 size: storage_size,
900 delta_base,
901 })),
902 StateClosureEvent::Blob { hash, .. } => {
903 let Some(blob) = store.get_blob(&hash)? else {
904 if blob_has_purge_evidence(store, &hash)? {
905 return Ok(None);
906 }
907 return Err(missing_blob(hash));
908 };
909 Ok(Some(ObjectInfo {
910 id: ObjectId::Hash(hash),
911 obj_type: ObjectType::Blob,
912 size: blob.size() as u64,
913 delta_base: None,
914 }))
915 }
916 StateClosureEvent::Redaction { blob } => Ok(store
917 .get_redactions_bytes_for_blob(&blob)?
918 .map(|bytes| ObjectInfo {
919 id: ObjectId::Hash(blob),
920 obj_type: ObjectType::Redaction,
921 size: bytes.len() as u64,
922 delta_base: None,
923 })),
924 StateClosureEvent::StateVisibility { state } => Ok(store
925 .get_state_visibility_bytes_for_state(&state)?
926 .map(|bytes| ObjectInfo {
927 id: ObjectId::StateId(state),
928 obj_type: ObjectType::StateVisibility,
929 size: bytes.len() as u64,
930 delta_base: None,
931 })),
932 StateClosureEvent::StateAttachment { state, attachment } => {
933 let bytes = rmp_serde::to_vec_named(attachment)?;
934 Ok(Some(ObjectInfo {
935 id: ObjectId::StateAttachment {
936 state,
937 id: attachment.id(),
938 kind: attachment.body.kind(),
939 },
940 obj_type: ObjectType::StateAttachment,
941 size: bytes.len() as u64,
942 delta_base: None,
943 }))
944 }
945 StateClosureEvent::ExcludedState { id } => {
946 let _ = id;
947 Ok(None)
948 }
949 StateClosureEvent::ExcludedHash { hash } => {
950 let _ = hash;
951 Ok(None)
952 }
953 }
954}
955
956fn planned_object_from_event(
957 store: &impl ObjectStore,
958 event: StateClosureEvent<'_>,
959) -> Result<Option<PlannedObject>> {
960 match event {
961 StateClosureEvent::State { id, .. } => Ok(Some(PlannedObject {
962 id: ObjectId::StateId(id),
963 obj_type: ObjectType::State,
964 })),
965 StateClosureEvent::Tree { hash, .. } => Ok(Some(PlannedObject {
966 id: ObjectId::Hash(hash),
967 obj_type: ObjectType::Tree,
968 })),
969 StateClosureEvent::Blob { hash, .. } => {
970 if store.get_blob(&hash)?.is_none() {
971 if blob_has_purge_evidence(store, &hash)? {
972 return Ok(None);
973 }
974 return Err(missing_blob(hash));
975 }
976 Ok(Some(PlannedObject {
977 id: ObjectId::Hash(hash),
978 obj_type: ObjectType::Blob,
979 }))
980 }
981 StateClosureEvent::Redaction { blob } => Ok(Some(PlannedObject {
982 id: ObjectId::Hash(blob),
983 obj_type: ObjectType::Redaction,
984 })),
985 StateClosureEvent::StateVisibility { state } => Ok(Some(PlannedObject {
986 id: ObjectId::StateId(state),
987 obj_type: ObjectType::StateVisibility,
988 })),
989 StateClosureEvent::StateAttachment { state, attachment } => Ok(Some(PlannedObject {
990 id: ObjectId::StateAttachment {
991 state,
992 id: attachment.id(),
993 kind: attachment.body.kind(),
994 },
995 obj_type: ObjectType::StateAttachment,
996 })),
997 StateClosureEvent::ExcludedState { id } => {
998 let _ = id;
999 Ok(None)
1000 }
1001 StateClosureEvent::ExcludedHash { hash } => {
1002 let _ = hash;
1003 Ok(None)
1004 }
1005 }
1006}
1007
1008fn missing_blob(hash: ContentHash) -> HeddleError {
1012 HeddleError::MissingObject {
1013 object_type: "blob".to_string(),
1014 id: hash.to_hex(),
1015 }
1016}
1017
1018fn blob_has_purge_evidence(
1019 store: &impl ObjectStore,
1020 hash: &ContentHash,
1021) -> Result<bool> {
1022 let Some(bytes) = store.get_redactions_bytes_for_blob(hash)? else {
1023 return Ok(false);
1024 };
1025 let redactions = RedactionsBlob::decode(&bytes).map_err(|error| {
1026 HeddleError::InvalidObject(format!(
1027 "invalid redaction sidecar for missing blob {}: {error}",
1028 hash.to_hex()
1029 ))
1030 })?;
1031 Ok(redactions
1032 .redactions
1033 .iter()
1034 .any(|redaction| redaction.redacted_blob == *hash && redaction.is_purged()))
1035}
1036
1037pub fn missing_blobs_in_tree(
1038 store: &impl ObjectStore,
1039 tree_hash: ContentHash,
1040) -> Result<Vec<ContentHash>> {
1041 let mut missing = Vec::new();
1042 collect_missing_blobs_recursive(store, &tree_hash, &mut missing)?;
1043 Ok(missing)
1044}
1045
1046fn collect_missing_blobs_recursive(
1047 store: &impl ObjectStore,
1048 tree_hash: &ContentHash,
1049 missing: &mut Vec<ContentHash>,
1050) -> Result<()> {
1051 let Some(tree) = store.get_tree(tree_hash).map_err(|err| {
1052 HeddleError::InvalidObject(format!(
1053 "load tree {} while collecting lazy hydration missing blobs: {err}",
1054 tree_hash.to_hex()
1055 ))
1056 })?
1057 else {
1058 return Ok(());
1059 };
1060
1061 for entry in tree.entries() {
1062 match entry.target() {
1063 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1064 if !store.has_blob(hash).map_err(|err| {
1065 HeddleError::InvalidObject(format!(
1066 "check blob {} while collecting lazy hydration missing blobs: {err}",
1067 hash.to_hex()
1068 ))
1069 })? {
1070 missing.push(*hash);
1071 }
1072 }
1073 TreeEntryTarget::Tree { hash } => {
1074 collect_missing_blobs_recursive(store, hash, missing)?;
1075 }
1076 TreeEntryTarget::Gitlink { .. } => {}
1077 TreeEntryTarget::Spoollink { .. } => {}
1080 }
1081 }
1082 Ok(())
1083}
1084
1085fn collect_excluded(
1086 store: &impl ObjectStore,
1087 roots: &[StateId],
1088) -> Result<(HashSet<StateId>, HashSet<ContentHash>)> {
1089 if roots.is_empty() {
1090 return Ok((HashSet::new(), HashSet::new()));
1091 }
1092
1093 let mut excluded_states: HashSet<StateId> = HashSet::new();
1094 let mut excluded_hashes: HashSet<ContentHash> = HashSet::new();
1095 let mut queue: VecDeque<StateId> = VecDeque::new();
1096
1097 for id in roots {
1098 queue.push_back(*id);
1099 }
1100
1101 while let Some(id) = queue.pop_front() {
1102 if !excluded_states.insert(id) {
1103 continue;
1104 }
1105
1106 let state = match store.get_state(&id)? {
1107 Some(state) => state,
1108 None => continue,
1109 };
1110
1111 for parent in &state.parents {
1112 queue.push_back(*parent);
1113 }
1114
1115 collect_tree_hashes(store, state.tree, &mut excluded_hashes)?;
1116 if let Some(provenance_root) = state.provenance {
1117 collect_tree_hashes(store, provenance_root, &mut excluded_hashes)?;
1118 }
1119 for attachment in store.list_state_attachments(&id)? {
1120 match attachment.body {
1121 StateAttachmentBody::Context(root) => {
1122 collect_tree_hashes(store, root, &mut excluded_hashes)?
1123 }
1124 StateAttachmentBody::RiskSignals(hash)
1125 | StateAttachmentBody::ReviewSignatures(hash)
1126 | StateAttachmentBody::Discussions(hash)
1127 | StateAttachmentBody::StructuredConflicts(hash) => {
1128 excluded_hashes.insert(hash);
1129 }
1130 StateAttachmentBody::SemanticIndex(root) => {
1131 collect_semantic_hashes(store, root, &mut excluded_hashes)?;
1132 }
1133 StateAttachmentBody::Signature(_) => {}
1134 }
1135 }
1136 }
1137
1138 Ok((excluded_states, excluded_hashes))
1139}
1140
1141fn collect_tree_hashes(
1142 store: &impl ObjectStore,
1143 tree_hash: ContentHash,
1144 excluded: &mut HashSet<ContentHash>,
1145) -> Result<()> {
1146 if !excluded.insert(tree_hash) {
1147 return Ok(());
1148 }
1149
1150 let tree = match store.get_tree(&tree_hash)? {
1151 Some(tree) => tree,
1152 None => return Ok(()),
1153 };
1154
1155 for entry in tree.entries() {
1156 match entry.target() {
1157 TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
1158 excluded.insert(*hash);
1159 }
1160 TreeEntryTarget::Tree { hash } => {
1161 collect_tree_hashes(store, *hash, excluded)?;
1162 }
1163 TreeEntryTarget::Gitlink { .. } => {}
1164 TreeEntryTarget::Spoollink { .. } => {}
1167 }
1168 }
1169
1170 Ok(())
1171}
1172
1173pub fn is_ancestor(
1174 store: &impl ObjectStore,
1175 ancestor: StateId,
1176 descendant: StateId,
1177) -> Result<bool> {
1178 if ancestor == descendant {
1179 return Ok(true);
1180 }
1181
1182 let mut seen: HashSet<StateId> = HashSet::new();
1183 let mut queue: VecDeque<StateId> = VecDeque::new();
1184 queue.push_back(descendant);
1185
1186 while let Some(id) = queue.pop_front() {
1187 if !seen.insert(id) {
1188 continue;
1189 }
1190 let state = match store.get_state(&id)? {
1191 Some(s) => s,
1192 None => return Ok(false),
1193 };
1194 for parent in state.parents {
1195 if parent == ancestor {
1196 return Ok(true);
1197 }
1198 queue.push_back(parent);
1199 }
1200 }
1201
1202 Ok(false)
1203}