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