1use std::{
5 collections::HashSet,
6 fs::{self, File, OpenOptions},
7 path::{Path, PathBuf},
8};
9
10use fs2::FileExt;
11use heddle_format::compression::{header_uncompressed_size, is_compressed};
12use tracing::{debug, instrument, trace};
13
14use super::{
15 FsStore,
16 fs_io::{list_hashes_from_dir, read_file_bytes, read_file_header},
17 fs_paths::{
18 action_path, actions_dir, annotated_tags_dir, blobs_dir, hash_path, redaction_path,
19 redactions_dir, state_attachment_index_lock_path, state_attachment_index_path,
20 state_attachment_path, state_attachments_dir, state_path, state_visibility_dir,
21 state_visibility_path, states_dir, tree_lineage_path, trees_dir,
22 },
23};
24use crate::{
25 object::{
26 Action, ActionId, AnnotatedTag, Blob, BytesTreeSource, ContentHash, FileTreeSource,
27 OpenedTreeBody, State, StateAttachment, StateAttachmentId, StateId, TREE_CANONICAL_MAGIC,
28 TREE_DELTA_HEADER_LEN, TREE_DELTA_MAGIC, TREE_LEAN_MAGIC, Tree, TreeByteSource,
29 TreeEntryReader, TreeResumeCursor, decode_tree_delta_header,
30 decode_tree_delta_header_prefix, is_delta_tree, is_streamable_tree,
31 },
32 store::{
33 HeddleError, ObjectStore, Result, SidecarStore, SnapshotCommitDescriptor, TreeWrite, codec,
34 codec::{EncodedTree, TreeDeltaBase, TreeEncodingKind, TreeLineage},
35 delta_source::DeltaTreeSource,
36 pack::{ObjectType, PackManager, PackObjectId},
37 },
38};
39
40const BLOB_HEADER_PEEK: usize = 13;
48
49fn validate_loaded_tree(tree: Tree) -> Result<Tree> {
50 tree.validate()?;
51 Ok(tree)
52}
53
54fn validate_blob_bytes(data: &[u8], hash: ContentHash) -> Result<()> {
55 let mut hasher = ContentHash::typed_hasher("blob", data.len() as u64);
56 hasher.update(data);
57 let found = ContentHash::from_bytes(hasher.finalize().into());
58 if found != hash {
59 return Err(HeddleError::Corruption {
60 expected: hash,
61 found,
62 });
63 }
64
65 Ok(())
66}
67
68fn validate_tree_serialized(data: &[u8], hash: ContentHash) -> Result<Tree> {
69 let tree = codec::decode_tree_serialized_with_key(data, hash, None)?;
70 let tree = validate_loaded_tree(tree)?;
71 let found = tree.hash();
72 if found != hash {
73 return Err(HeddleError::Corruption {
74 expected: hash,
75 found,
76 });
77 }
78
79 Ok(tree)
80}
81
82fn validate_annotated_tag(data: &[u8], hash: ContentHash) -> Result<AnnotatedTag> {
83 let tag = AnnotatedTag::decode_current_msgpack(data)
84 .map_err(|error| HeddleError::InvalidObject(error.to_string()))?;
85 if tag.hash() != hash {
86 return Err(HeddleError::Corruption {
87 expected: hash,
88 found: tag.hash(),
89 });
90 }
91 Ok(tag)
92}
93
94fn validate_loaded_state(requested_id: &StateId, mut state: State) -> Result<State> {
95 if !state.accepts_stored_id(requested_id) {
96 return Err(HeddleError::InvalidObject(format!(
97 "state id mismatch: requested {requested_id}, computed {}",
98 state.id()
99 )));
100 }
101 state.state_id = *requested_id;
102 Ok(state)
103}
104
105pub(super) fn validate_state_serialized(data: &[u8], id: StateId) -> Result<State> {
106 let state: State = rmp_serde::from_slice(data)?;
107 validate_loaded_state(&id, state)
108}
109
110fn validate_loaded_action(requested_id: &ActionId, action: Action) -> Result<Action> {
111 let found_id = action.compute_id();
112 if found_id != *requested_id {
113 return Err(HeddleError::InvalidObject(format!(
114 "action id mismatch: requested {}, found {}",
115 requested_id, found_id
116 )));
117 }
118
119 Ok(action)
120}
121
122fn validate_action_serialized(data: &[u8], id: ActionId) -> Result<Action> {
123 let action: Action = rmp_serde::from_slice(data)?;
124 validate_loaded_action(&id, action)
125}
126
127trait EnumerationCounter {
128 fn membership_check(&mut self);
129 fn header_read(&mut self);
130}
131
132struct NoopEnumerationCounter;
133
134impl EnumerationCounter for NoopEnumerationCounter {
135 fn membership_check(&mut self) {}
136 fn header_read(&mut self) {}
137}
138
139fn append_packed_hashes_with_counter(
140 hashes: &mut Vec<ContentHash>,
141 manager: &PackManager,
142 expected_type: ObjectType,
143 counter: &mut impl EnumerationCounter,
144) -> Result<()> {
145 let mut known: HashSet<_> = hashes.iter().copied().collect();
146 for id in manager.list_all_ids()? {
147 let hash = match id {
148 PackObjectId::Hash(hash) if expected_type != ObjectType::AnnotatedTag => hash,
149 PackObjectId::AnnotatedTag(hash) if expected_type == ObjectType::AnnotatedTag => hash,
150 PackObjectId::Hash(_) | PackObjectId::StateId(_) | PackObjectId::AnnotatedTag(_) => {
151 continue;
152 }
153 };
154 counter.membership_check();
155 if known.contains(&hash) {
156 continue;
157 }
158 counter.header_read();
159 let found_type = if expected_type == ObjectType::AnnotatedTag {
160 manager
161 .get_object(&PackObjectId::AnnotatedTag(hash))?
162 .map(|(object_type, _)| object_type)
163 } else {
164 manager.get_hashed_object_type(&hash)?
165 };
166 if found_type == Some(expected_type) {
167 known.insert(hash);
168 hashes.push(hash);
169 }
170 }
171 Ok(())
172}
173
174fn append_packed_hashes(
175 hashes: &mut Vec<ContentHash>,
176 manager: &PackManager,
177 expected_type: ObjectType,
178) -> Result<()> {
179 append_packed_hashes_with_counter(hashes, manager, expected_type, &mut NoopEnumerationCounter)
180}
181
182fn append_unique_states(
183 states: &mut Vec<StateId>,
184 known: &mut HashSet<StateId>,
185 incoming: impl IntoIterator<Item = StateId>,
186) {
187 for id in incoming {
188 if known.insert(id) {
189 states.push(id);
190 }
191 }
192}
193
194impl FsStore {
195 fn write_packed_state_mirrors_batch(&self, states: Vec<(StateId, Vec<u8>)>) -> Result<()> {
200 if states.is_empty() {
201 return Ok(());
202 }
203
204 self.begin_snapshot_write_batch_impl()?;
205 for (id, data) in states {
206 if let Err(error) = ObjectStore::put_state_serialized(self, &data, id) {
207 self.abort_snapshot_write_batch_impl();
208 return Err(error);
209 }
210 }
211 if let Err(error) = self.flush_snapshot_write_batch_impl() {
212 self.abort_snapshot_write_batch_impl();
213 return Err(error);
214 }
215 Ok(())
216 }
217
218 fn with_state_attachment_index_lock<T>(
219 &self,
220 state: &StateId,
221 operation: impl FnOnce() -> Result<T>,
222 ) -> Result<T> {
223 let path = state_attachment_index_lock_path(&self.root, state);
224 if let Some(parent) = path.parent() {
225 fs::create_dir_all(parent)?;
226 }
227 let file = OpenOptions::new()
228 .create(true)
229 .truncate(false)
230 .read(true)
231 .write(true)
232 .open(path)?;
233 file.lock_exclusive()?;
234 let result = operation();
235 file.unlock()?;
236 result
237 }
238
239 fn collect_state_attachment_ids(&self, state: &StateId) -> Result<Vec<StateAttachmentId>> {
240 let mut ids = Vec::new();
241 let dir = state_attachments_dir(&self.root, state);
242 if let Ok(entries) = fs::read_dir(dir) {
243 for entry in entries {
244 let attachment: StateAttachment = rmp_serde::from_slice(&fs::read(entry?.path())?)?;
245 if attachment.state_id != *state {
246 return Err(HeddleError::InvalidObject(
247 "state attachment stored under wrong state".to_string(),
248 ));
249 }
250 ids.push(attachment.id());
251 }
252 }
253 if let Ok(manager) = self.pack_manager().read() {
254 for pack_id in manager.list_all_ids()? {
255 let PackObjectId::Hash(hash) = pack_id else {
256 continue;
257 };
258 let Some((ObjectType::StateAttachment, bytes)) =
259 manager.get_hashed_object(&hash)?
260 else {
261 continue;
262 };
263 let attachment: StateAttachment = rmp_serde::from_slice(&bytes)?;
264 if attachment.state_id == *state {
265 ids.push(attachment.id());
266 }
267 }
268 }
269 ids.sort();
270 ids.dedup();
271 Ok(ids)
272 }
273
274 fn rebuild_state_attachment_index(&self, state: &StateId) -> Result<Vec<StateAttachmentId>> {
275 #[cfg(test)]
276 fs::write(
277 state_attachment_index_path(&self.root, state).with_extension("rebuild-marker"),
278 b"rebuilt",
279 )?;
280 let ids = self.collect_state_attachment_ids(state)?;
281 let path = state_attachment_index_path(&self.root, state);
282 self.write_loose_object_atomic(&path, &rmp_serde::to_vec_named(&ids)?)?;
283 Ok(ids)
284 }
285
286 pub(super) fn materialize_packed_attachment_index(
291 &self,
292 state: &StateId,
293 packed_ids: &[StateAttachmentId],
294 state_was_present: bool,
295 ) -> Result<()> {
296 if packed_ids.is_empty() {
297 return Ok(());
298 }
299 self.with_state_attachment_index_lock(state, || {
300 let path = state_attachment_index_path(&self.root, state);
301 let mut ids = if state_was_present {
302 match read_file_bytes(&path)? {
303 Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
304 None => self.collect_state_attachment_ids(state)?,
305 }
306 } else {
307 Vec::new()
308 };
309 ids.extend_from_slice(packed_ids);
310 ids.sort();
311 ids.dedup();
312 self.write_reconstructible_cache(&path, &rmp_serde::to_vec_named(&ids)?)?;
313 Ok(())
314 })
315 }
316}
317
318fn validate_and_list_pack(
326 store: &FsStore,
327 reader: &crate::store::pack::PackReader,
328) -> Result<Vec<PackObjectId>> {
329 let ids = reader.list_ids()?;
330 reader.visit_objects(|id, object_type, data| {
331 if let (PackObjectId::Hash(hash), ObjectType::Tree) = (id, object_type)
332 && is_delta_tree(data)
333 {
334 let header = decode_tree_delta_header(data)?;
335 if header.anchor == hash {
336 return Err(HeddleError::InvalidObject(
337 "HDC1 result id must differ from its anchor id".to_string(),
338 ));
339 }
340 let anchor_body = match reader.get_object(&PackObjectId::Hash(header.anchor))? {
341 Some((ObjectType::Tree, body)) => Some(body),
342 Some((kind, _)) => {
343 return Err(HeddleError::InvalidObject(format!(
344 "HDC1 anchor {} is indexed as {kind:?}, expected Tree",
345 header.anchor
346 )));
347 }
348 None => store.try_get_tree_serialized_once(&header.anchor)?,
349 }
350 .ok_or_else(|| HeddleError::NotFound(format!("tree delta anchor {}", header.anchor)))?;
351 if is_delta_tree(&anchor_body) {
352 return Err(HeddleError::InvalidObject(
353 "HDC1 anchor must be materialized; delta chains are forbidden".to_string(),
354 ));
355 }
356 let anchor = codec::decode_tree_serialized_with_key(&anchor_body, header.anchor, None)?;
357 codec::decode_tree_serialized_with_key(data, hash, Some(&anchor))?;
358 return Ok(());
359 }
360 validate_pack_entry(&id, object_type, data)
361 })?;
362 Ok(ids)
363}
364
365fn state_entries_from_pack(
366 reader: &crate::store::pack::PackReader,
367 ids: &[PackObjectId],
368) -> Result<Vec<(StateId, Vec<u8>)>> {
369 let mut states = Vec::new();
370 let expected = ids.iter().copied().collect::<HashSet<_>>();
371 reader.visit_objects(|id, object_type, data| {
372 if !expected.contains(&id) {
373 return Err(HeddleError::InvalidObject(
374 "pack visitor yielded an unindexed object".into(),
375 ));
376 }
377 if let PackObjectId::StateId(state_id) = id {
378 if object_type != ObjectType::State {
379 return Err(HeddleError::InvalidObject(format!(
380 "pack id {} is indexed as {object_type:?}, expected State",
381 state_id.to_string_full()
382 )));
383 }
384 validate_state_serialized(data, state_id)?;
385 states.push((state_id, data.to_vec()));
386 }
387 Ok(())
388 })?;
389 Ok(states)
390}
391
392fn attachment_entries_from_pack(
393 reader: &crate::store::pack::PackReader,
394 ids: &[PackObjectId],
395) -> Result<Vec<StateAttachment>> {
396 let mut attachments = Vec::new();
397 let expected = ids.iter().copied().collect::<HashSet<_>>();
398 reader.visit_objects(|id, object_type, data| {
399 if expected.contains(&id) && object_type == ObjectType::StateAttachment {
400 attachments.push(rmp_serde::from_slice(data)?);
401 }
402 Ok(())
403 })?;
404 Ok(attachments)
405}
406
407pub(super) fn validate_pack_entry(
408 id: &PackObjectId,
409 obj_type: ObjectType,
410 data: &[u8],
411) -> Result<()> {
412 match (id, obj_type) {
413 (PackObjectId::Hash(hash), ObjectType::Blob) => validate_blob_bytes(data, *hash),
414 (PackObjectId::AnnotatedTag(hash), ObjectType::AnnotatedTag) => {
415 validate_annotated_tag(data, *hash).map(|_| ())
416 }
417 (PackObjectId::Hash(hash), ObjectType::Tree) => {
418 validate_tree_serialized(data, *hash).map(|_| ())
419 }
420 (PackObjectId::Hash(hash), ObjectType::Action) => {
421 validate_action_serialized(data, ActionId::from_hash(*hash)).map(|_| ())
422 }
423 (PackObjectId::StateId(change_id), ObjectType::State) => {
424 validate_state_serialized(data, *change_id).map(|_| ())
425 }
426 (PackObjectId::Hash(hash), ObjectType::StateAttachment) => {
427 let attachment: StateAttachment = rmp_serde::from_slice(data)?;
428 if attachment.id().as_hash() != hash {
429 return Err(HeddleError::InvalidObject(
430 "state attachment pack id mismatch".to_string(),
431 ));
432 }
433 Ok(())
434 }
435 (PackObjectId::Hash(hash), ObjectType::SnapshotCommit) => {
436 let artifact: crate::store::SnapshotCommitArtifact = rmp_serde::from_slice(data)?;
437 artifact.validate()?;
438 if artifact.id() != *hash {
439 return Err(HeddleError::InvalidObject(
440 "snapshot commit artifact pack id mismatch".to_string(),
441 ));
442 }
443 Ok(())
444 }
445 (_, ObjectType::TimelineOperation) => Err(HeddleError::InvalidObject(
446 "timeline operations belong in the timeline pack store".to_string(),
447 )),
448 _ => Err(HeddleError::InvalidObject(format!(
449 "unsupported native pack object: {:?} {:?}",
450 id, obj_type
451 ))),
452 }
453}
454
455impl FsStore {
456 fn cache_recent_blob(&self, hash: ContentHash, blob: &Blob) {
458 if blob.content().len() > super::fs_store::RECENT_BLOB_CACHE_MAX_BYTES {
459 return;
460 }
461 if let Ok(mut cache) = self.recent_blobs.write() {
462 cache.insert(hash, blob.clone());
463 }
464 }
465
466 fn cache_recent_tree(&self, hash: ContentHash, tree: &Tree) {
467 if let Ok(mut cache) = self.recent_trees.write() {
468 cache.insert(hash, tree.clone());
469 }
470 }
471
472 fn cache_recent_state(&self, id: StateId, state: &State) {
473 if let Ok(mut cache) = self.recent_states.write() {
474 cache.insert(id, state.clone());
475 }
476 }
477
478 fn recent_blob(&self, hash: &ContentHash) -> Option<Blob> {
479 self.recent_blobs
480 .read()
481 .ok()
482 .and_then(|cache| cache.get(hash).cloned())
483 }
484
485 fn recent_tree(&self, hash: &ContentHash) -> Option<Tree> {
486 self.recent_trees
487 .read()
488 .ok()
489 .and_then(|cache| cache.get(hash).cloned())
490 }
491
492 fn recent_state(&self, id: &StateId) -> Option<State> {
493 self.recent_states
494 .read()
495 .ok()
496 .and_then(|cache| cache.get(id).cloned())
497 }
498
499 fn try_get_blob_once(&self, hash: &ContentHash) -> Result<Option<Blob>> {
502 if let Ok(cache) = self.recent_blobs.read()
505 && let Some(blob) = cache.get(hash)
506 {
507 trace!("Found blob in recent object cache");
508 return Ok(Some(blob.clone()));
509 }
510
511 if let Ok(manager) = self.pack_manager().read()
512 && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
513 && obj_type == ObjectType::Blob
514 {
515 trace!("Found blob in packfile");
516 validate_blob_bytes(&data, *hash)?;
517 let blob = Blob::new(data);
518 heddle_perf_contract::record_object_decode();
519 self.cache_recent_blob(*hash, &blob);
520 return Ok(Some(blob));
521 }
522
523 let path = hash_path(&blobs_dir(&self.root), hash);
524 match read_file_bytes(&path)? {
525 Some(data) => {
526 trace!(size = data.as_slice().len(), "Blob data read");
527 let content = codec::decode_blob_content(data.as_slice())?;
528 let blob = Blob::new(content);
529 heddle_perf_contract::record_object_decode();
530 if blob.hash() != *hash {
537 return Err(HeddleError::Corruption {
538 expected: *hash,
539 found: blob.hash(),
540 });
541 }
542 self.cache_recent_blob(*hash, &blob);
543 Ok(Some(blob))
544 }
545 None => Ok(None),
546 }
547 }
548
549 fn loose_or_packed(
554 &self,
555 loose_path: &Path,
556 in_pack: impl FnOnce(&PackManager) -> bool,
557 ) -> Result<bool> {
558 if loose_path.exists() {
559 return Ok(true);
560 }
561 if let Ok(manager) = self.pack_manager().read() {
562 return Ok(in_pack(&manager));
563 }
564 Ok(false)
565 }
566
567 fn try_has_blob_once(&self, hash: &ContentHash) -> Result<bool> {
568 let path = hash_path(&blobs_dir(&self.root), hash);
572 self.loose_or_packed(&path, |m| m.has_object(hash))
573 }
574
575 fn try_get_blob_size_once(&self, hash: &ContentHash) -> Result<Option<u64>> {
590 if let Ok(cache) = self.recent_blobs.read()
591 && let Some(blob) = cache.get(hash)
592 {
593 return Ok(Some(blob.content().len() as u64));
594 }
595
596 let path = hash_path(&blobs_dir(&self.root), hash);
597 if let Some((header, file_len)) = read_file_header(&path, BLOB_HEADER_PEEK)? {
598 if let Some(size) = header_uncompressed_size(&header) {
599 return Ok(Some(size));
600 }
601 return Ok(Some(file_len));
604 }
605
606 if let Ok(manager) = self.pack_manager().read()
607 && let Some(size) = manager.get_hashed_object_size(hash)?
608 {
609 return Ok(Some(size));
610 }
611 Ok(None)
612 }
613
614 fn try_open_tree_once(
615 &self,
616 tree_id: &ContentHash,
617 cursor: Option<&TreeResumeCursor>,
618 ) -> Result<Option<TreeEntryReader<OpenedTreeBody>>> {
619 let path = hash_path(&trees_dir(&self.root), tree_id);
620 if path.exists()
621 && let Some((header, len)) = read_file_header(&path, TREE_CANONICAL_MAGIC.len())?
622 {
623 if header.starts_with(TREE_CANONICAL_MAGIC) || header.starts_with(TREE_LEAN_MAGIC) {
624 let file = File::open(&path)?;
625 return Ok(Some(TreeEntryReader::open(
626 OpenedTreeBody::File(FileTreeSource::sequential_verify(file, len)),
627 *tree_id,
628 cursor,
629 )?));
630 }
631 if header.starts_with(TREE_DELTA_MAGIC) {
632 let file = File::open(&path)?;
633 return self.open_delta_tree_source(
634 *tree_id,
635 cursor,
636 OpenedTreeBody::File(FileTreeSource::sequential_verify(file, len)),
637 );
638 }
639 }
640 if path.exists()
641 && let Some(data) = read_file_bytes(&path)?
642 {
643 let body = codec::decode_tree_body(data.as_slice())?;
644 if is_streamable_tree(&body) {
645 return Ok(Some(TreeEntryReader::open(
646 OpenedTreeBody::Bytes(BytesTreeSource::sequential_verify(body)),
647 *tree_id,
648 cursor,
649 )?));
650 }
651 if is_delta_tree(&body) {
652 return self.open_delta_tree_source(
653 *tree_id,
654 cursor,
655 OpenedTreeBody::Bytes(BytesTreeSource::sequential_verify(body)),
656 );
657 }
658 }
659 let packed = if let Ok(manager) = self.pack_manager().read() {
660 manager.get_hashed_object(tree_id)?
661 } else {
662 None
663 };
664 if let Some((ObjectType::Tree, data)) = packed {
665 if is_streamable_tree(&data) {
666 return Ok(Some(TreeEntryReader::open(
667 OpenedTreeBody::Bytes(BytesTreeSource::sequential_verify(data)),
668 *tree_id,
669 cursor,
670 )?));
671 }
672 if is_delta_tree(&data) {
673 return self.open_delta_tree_source(
674 *tree_id,
675 cursor,
676 OpenedTreeBody::Bytes(BytesTreeSource::sequential_verify(data)),
677 );
678 }
679 }
680 Ok(None)
681 }
682
683 fn open_delta_tree_source(
684 &self,
685 tree_id: ContentHash,
686 cursor: Option<&TreeResumeCursor>,
687 mut delta: OpenedTreeBody,
688 ) -> Result<Option<TreeEntryReader<OpenedTreeBody>>> {
689 let object_len = usize::try_from(delta.len())
690 .map_err(|_| HeddleError::InvalidObject("HDC1 body exceeds usize".to_string()))?;
691 let mut header_bytes = [0u8; TREE_DELTA_HEADER_LEN];
692 delta.read_exact_at(0, &mut header_bytes)?;
693 let header = decode_tree_delta_header_prefix(&header_bytes, object_len)?;
694 let anchor = self
695 .try_open_materialized_tree_once(&header.anchor)?
696 .ok_or_else(|| HeddleError::NotFound(format!("tree delta anchor {}", header.anchor)))?;
697 let source = DeltaTreeSource::open(delta, anchor)?;
698 Ok(Some(TreeEntryReader::open(
699 OpenedTreeBody::Dynamic(Box::new(source)),
700 tree_id,
701 cursor,
702 )?))
703 }
704
705 fn try_open_materialized_tree_once(
706 &self,
707 tree_id: &ContentHash,
708 ) -> Result<Option<TreeEntryReader<OpenedTreeBody>>> {
709 let path = hash_path(&trees_dir(&self.root), tree_id);
710 if path.exists()
711 && let Some((header, len)) = read_file_header(&path, TREE_CANONICAL_MAGIC.len())?
712 {
713 if header.starts_with(TREE_DELTA_MAGIC) {
714 return Err(HeddleError::InvalidObject(
715 "HDC1 anchor must be materialized; delta chains are forbidden".to_string(),
716 ));
717 }
718 if header.starts_with(TREE_CANONICAL_MAGIC) || header.starts_with(TREE_LEAN_MAGIC) {
719 let file = File::open(&path)?;
720 return Ok(Some(TreeEntryReader::open(
721 OpenedTreeBody::File(FileTreeSource::sequential_verify(file, len)),
722 *tree_id,
723 None,
724 )?));
725 }
726 }
727 if path.exists()
728 && let Some(data) = read_file_bytes(&path)?
729 {
730 let body = codec::decode_tree_body(data.as_slice())?;
731 if is_delta_tree(&body) {
732 return Err(HeddleError::InvalidObject(
733 "HDC1 anchor must be materialized; delta chains are forbidden".to_string(),
734 ));
735 }
736 if is_streamable_tree(&body) {
737 return Ok(Some(TreeEntryReader::open(
738 OpenedTreeBody::Bytes(BytesTreeSource::sequential_verify(body)),
739 *tree_id,
740 None,
741 )?));
742 }
743 }
744 let packed = if let Ok(manager) = self.pack_manager().read() {
745 manager.get_hashed_object(tree_id)?
746 } else {
747 None
748 };
749 if let Some((ObjectType::Tree, data)) = packed {
750 if is_delta_tree(&data) {
751 return Err(HeddleError::InvalidObject(
752 "HDC1 anchor must be materialized; delta chains are forbidden".to_string(),
753 ));
754 }
755 if is_streamable_tree(&data) {
756 return Ok(Some(TreeEntryReader::open(
757 OpenedTreeBody::Bytes(BytesTreeSource::sequential_verify(data)),
758 *tree_id,
759 None,
760 )?));
761 }
762 }
763 if let Some(source) = &self.external_source
764 && let Some(tree) = source.get_tree(tree_id)?
765 {
766 return Ok(Some(TreeEntryReader::open(
767 OpenedTreeBody::Bytes(BytesTreeSource::sequential_verify(tree.encode_lean()?)),
768 *tree_id,
769 None,
770 )?));
771 }
772 Ok(None)
773 }
774
775 fn try_get_tree_once(&self, hash: &ContentHash) -> Result<Option<Tree>> {
776 if let Ok(cache) = self.recent_trees.read()
780 && let Some(tree) = cache.get(hash)
781 {
782 trace!("Found tree in recent object cache");
783 return Ok(Some(tree.clone()));
784 }
785
786 let path = hash_path(&trees_dir(&self.root), hash);
790 if path.exists()
791 && let Some(data) = read_file_bytes(&path)?
792 {
793 trace!(size = data.as_slice().len(), "Tree data read");
794 let body = codec::decode_tree_body(data.as_slice())?;
795 let tree = validate_loaded_tree(self.decode_tree_storage_body(*hash, &body)?)?;
796 heddle_perf_contract::record_object_decode();
797 if tree.hash() != *hash {
798 return Err(HeddleError::Corruption {
799 expected: *hash,
800 found: tree.hash(),
801 });
802 }
803 if let Ok(mut cache) = self.recent_trees.write() {
804 cache.insert(*hash, tree.clone());
805 }
806 return Ok(Some(tree));
807 }
808
809 if let Ok(manager) = self.pack_manager().read()
810 && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
811 && obj_type == ObjectType::Tree
812 {
813 trace!("Found tree in packfile");
814 let tree = validate_loaded_tree(self.decode_tree_storage_body(*hash, &data)?)?;
815 heddle_perf_contract::record_object_decode();
816 if tree.hash() != *hash {
817 return Err(HeddleError::Corruption {
818 expected: *hash,
819 found: tree.hash(),
820 });
821 }
822 if let Ok(mut cache) = self.recent_trees.write() {
823 cache.insert(*hash, tree.clone());
824 }
825 return Ok(Some(tree));
826 }
827 Ok(None)
828 }
829
830 pub(super) fn try_get_tree_serialized_once(
831 &self,
832 hash: &ContentHash,
833 ) -> Result<Option<Vec<u8>>> {
834 let path = hash_path(&trees_dir(&self.root), hash);
835 if path.exists()
836 && let Some(data) = read_file_bytes(&path)?
837 {
838 return Ok(Some(codec::decode_tree_body(data.as_slice())?));
839 }
840
841 if let Ok(manager) = self.pack_manager().read()
842 && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
843 && obj_type == ObjectType::Tree
844 {
845 return Ok(Some(data));
846 }
847
848 Ok(None)
849 }
850
851 fn decode_tree_storage_body(&self, hash: ContentHash, data: &[u8]) -> Result<Tree> {
852 let anchor = if is_delta_tree(data) {
853 let header = decode_tree_delta_header(data)?;
854 let anchor = if let Some(anchor_body) =
855 self.try_get_tree_serialized_once(&header.anchor)?
856 {
857 if is_delta_tree(&anchor_body) {
858 return Err(HeddleError::InvalidObject(
859 "HDC1 anchor must be materialized; delta chains are forbidden".to_string(),
860 ));
861 }
862 let tree =
863 codec::decode_tree_serialized_with_key(&anchor_body, header.anchor, None)?;
864 self.cache_recent_tree(header.anchor, &tree);
865 Some(tree)
866 } else if let Some(tree) = self.recent_tree(&header.anchor) {
867 Some(tree)
870 } else if let Some(source) = &self.external_source {
871 source.get_tree(&header.anchor)?
872 } else {
873 None
874 };
875 Some(anchor.ok_or_else(|| {
876 HeddleError::NotFound(format!("tree delta anchor {}", header.anchor))
877 })?)
878 } else {
879 None
880 };
881 codec::decode_tree_serialized_with_key(data, hash, anchor.as_ref())
882 }
883
884 pub(super) fn encode_tree_write(&self, write: &TreeWrite) -> Result<EncodedTree> {
885 let Some(parent) = write.parent else {
886 return codec::encode_tree_hot(&write.tree, None);
887 };
888 let Some(parent_body) = self.try_get_tree_serialized_once(&parent)? else {
889 return codec::encode_tree_hot(&write.tree, None);
890 };
891 let base = if is_delta_tree(&parent_body) {
892 let header = decode_tree_delta_header(&parent_body)?;
893 let Some(lineage) = self.read_tree_lineage(&parent)? else {
894 return codec::encode_tree_hot(&write.tree, None);
895 };
896 if lineage.anchor != header.anchor || lineage.depth == 0 {
897 return codec::encode_tree_hot(&write.tree, None);
898 }
899 let Some(anchor_body) = self.try_get_tree_serialized_once(&lineage.anchor)? else {
900 return codec::encode_tree_hot(&write.tree, None);
901 };
902 if is_delta_tree(&anchor_body) {
903 return Err(HeddleError::InvalidObject(
904 "HDC1 lineage points to another delta".to_string(),
905 ));
906 }
907 let anchor =
908 codec::decode_tree_serialized_with_key(&anchor_body, lineage.anchor, None)?;
909 Some((lineage.anchor, anchor, lineage.depth))
910 } else {
911 let anchor = codec::decode_tree_serialized_with_key(&parent_body, parent, None)?;
912 Some((parent, anchor, 0))
913 };
914 let Some((anchor_id, anchor, parent_depth)) = base else {
915 return codec::encode_tree_hot(&write.tree, None);
916 };
917 codec::encode_tree_hot(
918 &write.tree,
919 Some(TreeDeltaBase {
920 anchor_id,
921 anchor: &anchor,
922 parent_depth,
923 }),
924 )
925 }
926
927 fn read_tree_lineage(&self, hash: &ContentHash) -> Result<Option<TreeLineage>> {
928 let Some(bytes) = read_file_bytes(&tree_lineage_path(&self.root, hash))? else {
929 return Ok(None);
930 };
931 let data = bytes.as_slice();
932 if data.len() != 33 {
933 return Ok(None);
934 }
935 let anchor = match data[..32].try_into() {
936 Ok(bytes) => ContentHash::from_bytes(bytes),
937 Err(_) => return Ok(None),
938 };
939 let depth = data[32];
940 if depth == 0 || depth >= crate::object::TREE_DELTA_ANCHOR_INTERVAL {
941 return Ok(None);
942 }
943 Ok(Some(TreeLineage { anchor, depth }))
944 }
945
946 pub(super) fn remember_tree_encoding(
947 &self,
948 hash: ContentHash,
949 kind: TreeEncodingKind,
950 ) -> Result<()> {
951 let TreeEncodingKind::Delta { anchor, depth, .. } = kind else {
952 return Ok(());
953 };
954 let mut bytes = Vec::with_capacity(33);
955 bytes.extend_from_slice(anchor.as_bytes());
956 bytes.push(depth);
957 self.write_reconstructible_cache(&tree_lineage_path(&self.root, &hash), &bytes)
958 }
959
960 fn try_has_tree_once(&self, hash: &ContentHash) -> Result<bool> {
961 let path = hash_path(&trees_dir(&self.root), hash);
965 self.loose_or_packed(&path, |m| m.has_object(hash))
966 }
967
968 fn try_get_state_once(&self, id: &StateId) -> Result<Option<State>> {
969 if let Ok(cache) = self.recent_states.read()
974 && let Some(state) = cache.get(id)
975 {
976 trace!("Found state in recent object cache");
977 return Ok(Some(state.clone()));
978 }
979
980 let path = state_path(&self.root, id);
981 if let Some(data) = read_file_bytes(&path)? {
982 trace!(size = data.as_slice().len(), "State read from loose object");
983 let state = validate_loaded_state(id, codec::decode_state(data.as_slice())?)?;
984 heddle_perf_contract::record_object_decode();
985 if let Ok(mut cache) = self.recent_states.write() {
986 cache.insert(*id, state.clone());
987 }
988 return Ok(Some(state));
989 }
990
991 if let Ok(manager) = self.pack_manager().read()
992 && let Some((obj_type, data)) = manager.get_object(&PackObjectId::StateId(*id))?
993 && obj_type == ObjectType::State
994 {
995 trace!("Found state in packfile");
996 let state = validate_loaded_state(id, rmp_serde::from_slice(&data)?)?;
997 heddle_perf_contract::record_object_decode();
998 if let Ok(mut cache) = self.recent_states.write() {
999 cache.insert(*id, state.clone());
1000 }
1001 return Ok(Some(state));
1002 }
1003
1004 Ok(None)
1005 }
1006
1007 fn try_has_state_once(&self, id: &StateId) -> Result<bool> {
1008 if let Ok(cache) = self.recent_states.read()
1011 && cache.contains(id)
1012 {
1013 return Ok(true);
1014 }
1015 let path = state_path(&self.root, id);
1016 self.loose_or_packed(&path, |m| m.has_object_id(&PackObjectId::StateId(*id)))
1017 }
1018
1019 fn try_get_action_once(&self, id: &ActionId) -> Result<Option<Action>> {
1020 let path = action_path(&self.root, id);
1021 if let Some(data) = read_file_bytes(&path)? {
1022 trace!(size = data.as_slice().len(), "Action data read");
1023 return Ok(Some(validate_loaded_action(
1024 id,
1025 codec::decode_action(data.as_slice())?,
1026 )?));
1027 }
1028 if let Ok(manager) = self.pack_manager().read()
1029 && let Some((ObjectType::Action, data)) = manager.get_hashed_object(id.as_hash())?
1030 {
1031 trace!("Found action in packfile");
1032 return Ok(Some(validate_loaded_action(
1033 id,
1034 rmp_serde::from_slice(&data)?,
1035 )?));
1036 }
1037 Ok(None)
1038 }
1039
1040 fn try_get_state_attachment_once(
1041 &self,
1042 state: &StateId,
1043 id: &StateAttachmentId,
1044 ) -> Result<Option<StateAttachment>> {
1045 let path = state_attachment_path(&self.root, state, id);
1046 let file_bytes = read_file_bytes(&path)?;
1047 if let Some(bytes) = file_bytes.as_ref() {
1048 let attachment: StateAttachment = rmp_serde::from_slice(bytes.as_slice())?;
1049 return Self::validate_state_attachment(attachment, state, id).map(Some);
1050 }
1051 if let Ok(manager) = self.pack_manager().read()
1052 && let Some((ObjectType::StateAttachment, pack_bytes)) =
1053 manager.get_hashed_object(id.as_hash())?
1054 {
1055 let attachment: StateAttachment = rmp_serde::from_slice(&pack_bytes)?;
1056 return Self::validate_state_attachment(attachment, state, id).map(Some);
1057 }
1058 Ok(None)
1059 }
1060
1061 fn validate_state_attachment(
1062 attachment: StateAttachment,
1063 state: &StateId,
1064 id: &StateAttachmentId,
1065 ) -> Result<StateAttachment> {
1066 if attachment.state_id != *state || attachment.id() != *id {
1067 return Err(HeddleError::InvalidObject(
1068 "state attachment address does not match content".to_string(),
1069 ));
1070 }
1071 Ok(attachment)
1072 }
1073}
1074
1075impl FsStore {
1076 #[doc(hidden)]
1078 pub fn snapshot_commit_recovery_descriptors(&self) -> Result<Vec<SnapshotCommitDescriptor>> {
1079 self.reload_packs_if_stale()?;
1080 let manager = self
1081 .pack_manager()
1082 .read()
1083 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
1084 manager.snapshot_commit_recovery_descriptors()
1085 }
1086
1087 #[doc(hidden)]
1091 pub fn snapshot_commit_descriptors(&self) -> Result<Vec<SnapshotCommitDescriptor>> {
1092 self.reload_packs_if_stale()?;
1093 let manager = self
1094 .pack_manager()
1095 .read()
1096 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
1097 manager.snapshot_commit_descriptors()
1098 }
1099
1100 #[doc(hidden)]
1103 pub fn snapshot_commit_descriptor_for_state(
1104 &self,
1105 state: &StateId,
1106 ) -> Result<Option<SnapshotCommitDescriptor>> {
1107 self.reload_packs_if_stale()?;
1108 let manager = self
1109 .pack_manager()
1110 .read()
1111 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
1112 manager.snapshot_commit_descriptor_for_state(state)
1113 }
1114}
1115
1116impl ObjectStore for FsStore {
1117 fn get_annotated_tag(&self, hash: &ContentHash) -> Result<Option<AnnotatedTag>> {
1118 let path = hash_path(&annotated_tags_dir(&self.root), hash);
1119 if let Some(data) = read_file_bytes(&path)? {
1120 return validate_annotated_tag(data.as_slice(), *hash).map(Some);
1121 }
1122 self.reload_packs_if_stale()?;
1123 if let Ok(manager) = self.pack_manager().read()
1124 && let Some((ObjectType::AnnotatedTag, data)) =
1125 manager.get_object(&PackObjectId::AnnotatedTag(*hash))?
1126 {
1127 return validate_annotated_tag(&data, *hash).map(Some);
1128 }
1129 Ok(None)
1130 }
1131
1132 fn put_annotated_tag(&self, tag: &AnnotatedTag) -> Result<ContentHash> {
1133 let hash = tag.hash();
1134 let path = hash_path(&annotated_tags_dir(&self.root), &hash);
1135 if !path.exists() {
1136 self.write_loose_object_atomic(&path, &tag.encode_current_msgpack())?;
1137 }
1138 Ok(hash)
1139 }
1140
1141 fn list_annotated_tags(&self) -> Result<Vec<ContentHash>> {
1142 self.reload_packs_if_stale()?;
1143 let mut hashes = list_hashes_from_dir(&annotated_tags_dir(&self.root))?;
1144 if let Ok(manager) = self.pack_manager().read() {
1145 append_packed_hashes(&mut hashes, &manager, ObjectType::AnnotatedTag)?;
1146 }
1147 Ok(hashes)
1148 }
1149
1150 fn clear_recent_caches(&self) {
1151 self.clear_recent_object_caches();
1152 }
1153
1154 fn get_blob_bytes(&self, hash: &ContentHash) -> Result<Option<bytes::Bytes>> {
1161 if let Ok(manager) = self.pack_manager().read()
1162 && let Some((obj_type, data)) = manager.get_hashed_object_bytes(hash)?
1163 && obj_type == crate::store::pack::ObjectType::Blob
1164 {
1165 validate_blob_bytes(data.as_ref(), *hash)?;
1166 return Ok(Some(data));
1167 }
1168 Ok(self
1169 .get_blob(hash)?
1170 .map(|blob| bytes::Bytes::from(blob.into_content())))
1171 }
1172
1173 #[instrument(skip(self), fields(hash = %hash.short()))]
1174 fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>> {
1175 if let Some(blob) = self.recent_blob(hash) {
1176 return Ok(Some(blob));
1177 }
1178 if let Some(blob) = self.try_get_blob_once(hash)? {
1179 return Ok(Some(blob));
1180 }
1181 if self.reload_packs_if_stale()?
1186 && let Some(blob) = self.try_get_blob_once(hash)?
1187 {
1188 return Ok(Some(blob));
1189 }
1190 if let Some(source) = &self.external_source
1191 && let Some(blob) = source.get_blob(hash)?
1192 {
1193 self.cache_recent_blob(*hash, &blob);
1194 return Ok(Some(blob));
1195 }
1196 trace!("Blob not found");
1197 Ok(None)
1198 }
1199
1200 #[instrument(skip(self, blob), fields(size = blob.content().len()))]
1201 fn put_blob(&self, blob: &Blob) -> Result<ContentHash> {
1202 let hash = blob.hash();
1203 let path = hash_path(&blobs_dir(&self.root), &hash);
1204
1205 if !path.exists() {
1206 let data = codec::encode_blob_content(blob.content(), &self.compression)?;
1207 trace!(compressed_size = data.len(), "Writing blob");
1208 self.write_loose_object_atomic(&path, &data)?;
1209 } else {
1210 trace!("Blob already exists, skipping write");
1211 }
1212 self.cache_recent_blob(hash, blob);
1213
1214 Ok(hash)
1215 }
1216
1217 #[instrument(skip(self, blob), fields(hash = %hash.short()))]
1218 fn put_blob_with_hash(&self, blob: &Blob, hash: ContentHash) -> Result<ContentHash> {
1219 if blob.hash() != hash {
1220 return Err(HeddleError::Corruption {
1221 expected: hash,
1222 found: blob.hash(),
1223 });
1224 }
1225
1226 let path = hash_path(&blobs_dir(&self.root), &hash);
1227
1228 if !path.exists() {
1229 let data = codec::encode_blob_content(blob.content(), &self.compression)?;
1230 trace!(
1231 compressed_size = data.len(),
1232 "Writing blob with precomputed hash"
1233 );
1234 self.write_loose_object_atomic(&path, &data)?;
1235 }
1236 self.cache_recent_blob(hash, blob);
1237
1238 Ok(hash)
1239 }
1240
1241 #[instrument(skip(self, data), fields(hash = %hash.short(), size = data.len()))]
1242 fn put_blob_bytes_with_hash(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
1243 validate_blob_bytes(data, hash)?;
1244
1245 let path = hash_path(&blobs_dir(&self.root), &hash);
1246 if !path.exists() {
1247 trace!(
1248 size = data.len(),
1249 "Writing raw blob bytes with precomputed hash"
1250 );
1251 self.write_loose_object_atomic(&path, data)?;
1252 }
1253 self.cache_recent_blob(hash, &Blob::from_slice(data));
1254
1255 Ok(hash)
1256 }
1257
1258 #[instrument(skip(self), fields(hash = %hash.short()))]
1259 fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
1260 if ObjectStore::has_blob_locally(self, hash)? {
1261 return Ok(true);
1262 }
1263 if let Some(source) = &self.external_source {
1264 if self.recent_blob(hash).is_some() {
1265 return Ok(true);
1266 }
1267 if let Some(blob) = source.get_blob(hash)? {
1268 self.cache_recent_blob(*hash, &blob);
1269 return Ok(true);
1270 }
1271 }
1272 Ok(false)
1273 }
1274
1275 fn has_blob_locally(&self, hash: &ContentHash) -> Result<bool> {
1276 if self.try_has_blob_once(hash)? {
1277 return Ok(true);
1278 }
1279 Ok(self.reload_packs_if_stale()? && self.try_has_blob_once(hash)?)
1280 }
1281
1282 fn loose_blob_path(&self, hash: &ContentHash) -> Option<PathBuf> {
1299 let path = hash_path(&blobs_dir(&self.root), hash);
1300 if let Ok(verified) = self.verified_loose_blobs.read()
1305 && verified.contains(hash)
1306 && path.exists()
1307 {
1308 return Some(path);
1309 }
1310
1311 let (header, _) = read_file_header(&path, BLOB_HEADER_PEEK).ok().flatten()?;
1324 if is_compressed(&header) {
1325 return None;
1326 }
1327 let bytes = read_file_bytes(&path).ok().flatten()?;
1328 let actual = ContentHash::compute_typed("blob", bytes.as_slice());
1329 if actual != *hash {
1330 return None;
1334 }
1335 if let Ok(mut verified) = self.verified_loose_blobs.write() {
1336 verified.insert(*hash, ());
1337 }
1338 Some(path)
1339 }
1340
1341 #[instrument(skip(self), fields(hash = %hash.short()))]
1354 fn promote_to_loose_uncompressed(&self, hash: &ContentHash) -> Result<bool> {
1355 let path = hash_path(&blobs_dir(&self.root), hash);
1356
1357 if !ObjectStore::has_blob_locally(self, hash)?
1361 && let Some(source) = &self.external_source
1362 && (self.recent_blob(hash).is_some() || source.get_blob(hash)?.is_some())
1363 {
1364 return Ok(false);
1365 }
1366
1367 if let Some((header, _)) = read_file_header(&path, 9)?
1369 && !is_compressed(&header)
1370 {
1371 trace!("Blob already loose+uncompressed; skipping promotion");
1372 return Ok(false);
1373 }
1374
1375 let blob = self.get_blob(hash)?.ok_or_else(|| {
1379 HeddleError::NotFound(format!(
1380 "blob {} not found in store; cannot promote to loose-uncompressed",
1381 hash
1382 ))
1383 })?;
1384
1385 debug!(
1398 size = blob.content().len(),
1399 "Promoting blob to loose-uncompressed canonical store"
1400 );
1401 self.write_loose_object_cache(&path, blob.content())?;
1402 if let Ok(mut verified) = self.verified_loose_blobs.write() {
1403 verified.insert(*hash, ());
1404 }
1405 Ok(true)
1406 }
1407
1408 #[instrument(skip(self), fields(hash = %hash.short()))]
1409 fn blob_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
1410 if let Some(size) = self.try_get_blob_size_once(hash)? {
1411 return Ok(Some(size));
1412 }
1413 if self.reload_packs_if_stale()?
1417 && let Some(size) = self.try_get_blob_size_once(hash)?
1418 {
1419 return Ok(Some(size));
1420 }
1421 if let Some(source) = &self.external_source {
1422 if let Some(blob) = self.recent_blob(hash) {
1423 return Ok(Some(blob.content().len() as u64));
1424 }
1425 if let Some(blob) = source.get_blob(hash)? {
1426 let size = blob.content().len() as u64;
1427 self.cache_recent_blob(*hash, &blob);
1428 return Ok(Some(size));
1429 }
1430 }
1431 Ok(None)
1432 }
1433
1434 #[instrument(skip(self), fields(hash = %hash.short()))]
1435 fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>> {
1436 if let Some(tree) = self.recent_tree(hash) {
1437 return Ok(Some(tree));
1438 }
1439 if let Some(tree) = self.try_get_tree_once(hash)? {
1440 return Ok(Some(tree));
1441 }
1442 if self.reload_packs_if_stale()?
1443 && let Some(tree) = self.try_get_tree_once(hash)?
1444 {
1445 return Ok(Some(tree));
1446 }
1447 if let Some(source) = &self.external_source
1448 && let Some(tree) = source.get_tree(hash)?
1449 {
1450 self.cache_recent_tree(*hash, &tree);
1451 return Ok(Some(tree));
1452 }
1453 trace!("Tree not found");
1454 Ok(None)
1455 }
1456
1457 #[instrument(skip(self), fields(hash = %hash.short()))]
1458 fn get_tree_serialized(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
1459 if let Some(data) = self.try_get_tree_serialized_once(hash)? {
1460 return Ok(Some(data));
1461 }
1462 if self.reload_packs_if_stale()?
1463 && let Some(data) = self.try_get_tree_serialized_once(hash)?
1464 {
1465 return Ok(Some(data));
1466 }
1467 let external_tree = if let Some(tree) = self.recent_tree(hash) {
1468 Some(tree)
1469 } else if let Some(source) = &self.external_source {
1470 let tree = source.get_tree(hash)?;
1471 if let Some(tree) = &tree {
1472 self.cache_recent_tree(*hash, tree);
1473 }
1474 tree
1475 } else {
1476 None
1477 };
1478 if let Some(tree) = external_tree {
1479 return tree.encode_canonical().map(Some).map_err(HeddleError::from);
1480 }
1481 Ok(None)
1482 }
1483
1484 fn open_tree(
1485 &self,
1486 tree_id: &ContentHash,
1487 cursor: Option<&TreeResumeCursor>,
1488 ) -> Result<Option<TreeEntryReader<OpenedTreeBody>>> {
1489 if let Some(reader) = self.try_open_tree_once(tree_id, cursor)? {
1490 return Ok(Some(reader));
1491 }
1492 if self.reload_packs_if_stale()?
1493 && let Some(reader) = self.try_open_tree_once(tree_id, cursor)?
1494 {
1495 return Ok(Some(reader));
1496 }
1497 if let Some(data) = ObjectStore::get_tree_serialized(self, tree_id)? {
1498 let body = if is_streamable_tree(&data) {
1499 data
1500 } else if data.starts_with(TREE_DELTA_MAGIC) {
1501 self.get_tree(tree_id)?
1502 .ok_or_else(|| HeddleError::NotFound(format!("tree {tree_id}")))?
1503 .encode_lean()?
1504 } else {
1505 return Ok(None);
1506 };
1507 return Ok(Some(TreeEntryReader::open(
1508 OpenedTreeBody::Bytes(BytesTreeSource::sequential_verify(body)),
1509 *tree_id,
1510 cursor,
1511 )?));
1512 }
1513 Ok(None)
1514 }
1515
1516 #[instrument(skip(self, tree), fields(entry_count = tree.entries().len()))]
1517 fn put_tree(&self, tree: &Tree) -> Result<ContentHash> {
1518 let hash = tree.hash();
1519 let path = hash_path(&trees_dir(&self.root), &hash);
1520
1521 if !ObjectStore::has_tree_locally(self, &hash)? {
1526 let (_, data) = codec::encode_tree(tree, &self.compression)?;
1527 trace!(compressed_size = data.len(), "Writing tree");
1528 self.write_loose_object_atomic(&path, &data)?;
1529 } else {
1530 trace!("Tree already exists, skipping write");
1531 }
1532 if let Ok(mut cache) = self.recent_trees.write() {
1533 cache.insert(hash, tree.clone());
1534 }
1535
1536 Ok(hash)
1537 }
1538
1539 #[instrument(skip(self, data), fields(hash = %hash.short(), size = data.len()))]
1540 fn put_tree_serialized(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
1541 let tree = validate_loaded_tree(self.decode_tree_storage_body(hash, data)?)?;
1542
1543 let path = hash_path(&trees_dir(&self.root), &hash);
1544 let should_write = match read_file_bytes(&path)? {
1545 Some(existing) => codec::decode_tree_body(existing.as_slice())? != data,
1546 None => true,
1547 };
1548 if should_write {
1549 trace!(size = data.len(), "Writing raw serialized tree");
1550 self.write_loose_object_atomic(&path, data)?;
1551 }
1552 if let Ok(mut cache) = self.recent_trees.write() {
1553 cache.insert(hash, tree);
1554 }
1555
1556 Ok(hash)
1557 }
1558
1559 #[instrument(skip(self), fields(hash = %hash.short()))]
1560 fn has_tree(&self, hash: &ContentHash) -> Result<bool> {
1561 if ObjectStore::has_tree_locally(self, hash)? {
1562 return Ok(true);
1563 }
1564 if let Some(source) = &self.external_source {
1565 if self.recent_tree(hash).is_some() {
1566 return Ok(true);
1567 }
1568 if let Some(tree) = source.get_tree(hash)? {
1569 self.cache_recent_tree(*hash, &tree);
1570 return Ok(true);
1571 }
1572 }
1573 Ok(false)
1574 }
1575
1576 fn has_tree_locally(&self, hash: &ContentHash) -> Result<bool> {
1577 if self.try_has_tree_once(hash)? {
1578 return Ok(true);
1579 }
1580 Ok(self.reload_packs_if_stale()? && self.try_has_tree_once(hash)?)
1581 }
1582
1583 #[instrument(skip(self), fields(id = %id.short()))]
1584 fn get_state(&self, id: &StateId) -> Result<Option<State>> {
1585 if let Some(state) = self.recent_state(id) {
1586 return Ok(Some(state));
1587 }
1588 if let Some(state) = self.try_get_state_once(id)? {
1589 return Ok(Some(state));
1590 }
1591 if self.reload_packs_if_stale()?
1592 && let Some(state) = self.try_get_state_once(id)?
1593 {
1594 return Ok(Some(state));
1595 }
1596 if let Some(source) = &self.external_source
1597 && let Some(state) = source.get_state(id)?
1598 {
1599 self.cache_recent_state(*id, &state);
1600 return Ok(Some(state));
1601 }
1602 trace!("State not found");
1603 Ok(None)
1604 }
1605
1606 #[instrument(skip(self, state), fields(id = %state.id().short()))]
1607 fn put_state(&self, state: &State) -> Result<()> {
1608 let state_id = state.id();
1609 let path = state_path(&self.root, &state_id);
1610 let data = codec::encode_state(state, &self.compression)?;
1611 trace!(compressed_size = data.len(), "Writing state");
1612 self.write_loose_object_atomic(&path, &data)?;
1613 if let Ok(mut cache) = self.recent_states.write() {
1614 let mut cached = state.clone();
1615 cached.state_id = state_id;
1616 cache.insert(state_id, cached);
1617 }
1618 Ok(())
1619 }
1620
1621 #[instrument(skip(self, data), fields(id = %id.short(), size = data.len()))]
1622 fn put_state_serialized(&self, data: &[u8], id: StateId) -> Result<()> {
1623 let state = validate_state_serialized(data, id)?;
1624 let path = state_path(&self.root, &id);
1625 trace!(size = data.len(), "Writing raw serialized state");
1626 self.write_loose_object_atomic(&path, data)?;
1627 if let Ok(mut cache) = self.recent_states.write() {
1628 cache.insert(id, state);
1629 }
1630 Ok(())
1631 }
1632
1633 #[instrument(skip(self), fields(id = %id.short()))]
1634 fn has_state(&self, id: &StateId) -> Result<bool> {
1635 if self.try_has_state_once(id)? {
1636 return Ok(true);
1637 }
1638 if self.reload_packs_if_stale()? && self.try_has_state_once(id)? {
1639 return Ok(true);
1640 }
1641 if let Some(source) = &self.external_source {
1642 if self.recent_state(id).is_some() {
1643 return Ok(true);
1644 }
1645 if let Some(state) = source.get_state(id)? {
1646 self.cache_recent_state(*id, &state);
1647 return Ok(true);
1648 }
1649 }
1650 Ok(false)
1651 }
1652
1653 #[instrument(skip(self))]
1654 fn list_states(&self) -> Result<Vec<StateId>> {
1655 self.reload_packs_if_stale()?;
1656
1657 let mut states = Vec::new();
1658 let mut known = HashSet::new();
1659 let dir = states_dir(&self.root);
1660 if dir.exists() {
1661 for entry in fs::read_dir(&dir)? {
1662 let entry = entry?;
1663 let path = entry.path();
1664 if let Some(name) = path.file_stem()
1665 && let Some(name_str) = name.to_str()
1666 && let Ok(id) = StateId::parse(name_str)
1667 && known.insert(id)
1668 {
1669 states.push(id);
1670 }
1671 }
1672 }
1673 if let Ok(manager) = self.pack_manager().read() {
1674 append_unique_states(
1675 &mut states,
1676 &mut known,
1677 manager
1678 .list_all_ids()?
1679 .into_iter()
1680 .filter_map(|id| match id {
1681 PackObjectId::StateId(state) => Some(state),
1682 PackObjectId::Hash(_) | PackObjectId::AnnotatedTag(_) => None,
1683 }),
1684 );
1685 }
1686 if let Some(source) = &self.external_source {
1687 append_unique_states(&mut states, &mut known, source.list_states()?);
1688 }
1689 debug!(count = states.len(), "Listed states");
1690 Ok(states)
1691 }
1692
1693 fn get_state_attachment(
1694 &self,
1695 state: &StateId,
1696 id: &StateAttachmentId,
1697 ) -> Result<Option<StateAttachment>> {
1698 if let Some(attachment) = self.try_get_state_attachment_once(state, id)? {
1699 return Ok(Some(attachment));
1700 }
1701 if self.reload_packs_if_stale()? {
1702 return self.try_get_state_attachment_once(state, id);
1703 }
1704 Ok(None)
1705 }
1706
1707 fn put_state_attachment(&self, attachment: &StateAttachment) -> Result<StateAttachmentId> {
1708 let id = attachment.id();
1709 self.with_state_attachment_index_lock(&attachment.state_id, || {
1710 let index_path = state_attachment_index_path(&self.root, &attachment.state_id);
1711 let mut ids: Vec<StateAttachmentId> = match read_file_bytes(&index_path)? {
1712 Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
1713 None => self.rebuild_state_attachment_index(&attachment.state_id)?,
1714 };
1715 if !ids.contains(&id) {
1716 ids.push(id);
1717 ids.sort();
1718 self.write_loose_object_atomic(&index_path, &rmp_serde::to_vec_named(&ids)?)?;
1719 }
1720 let path = state_attachment_path(&self.root, &attachment.state_id, &id);
1721 self.write_loose_object_atomic(&path, &rmp_serde::to_vec_named(attachment)?)?;
1722 Ok(id)
1723 })
1724 }
1725
1726 fn list_state_attachments(&self, state: &StateId) -> Result<Vec<StateAttachment>> {
1727 self.with_state_attachment_index_lock(state, || {
1728 let index_path = state_attachment_index_path(&self.root, state);
1729 let mut ids: Vec<StateAttachmentId> = match read_file_bytes(&index_path)? {
1730 Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
1731 None => self.rebuild_state_attachment_index(state)?,
1732 };
1733 let mut attachments = Vec::new();
1734 let mut stale = false;
1735 for id in &ids {
1736 match self.get_state_attachment(state, id)? {
1737 Some(attachment) => attachments.push(attachment),
1738 None => stale = true,
1739 }
1740 }
1741 if stale {
1742 ids = self.rebuild_state_attachment_index(state)?;
1743 attachments.clear();
1744 for id in ids {
1745 let attachment = self.get_state_attachment(state, &id)?.ok_or_else(|| {
1746 HeddleError::InvalidObject(format!(
1747 "rebuilt state attachment index references missing {id}"
1748 ))
1749 })?;
1750 attachments.push(attachment);
1751 }
1752 }
1753 Ok(attachments)
1754 })
1755 }
1756
1757 #[instrument(skip(self), fields(id = %id))]
1758 fn get_action(&self, id: &ActionId) -> Result<Option<Action>> {
1759 if let Some(action) = self.try_get_action_once(id)? {
1760 return Ok(Some(action));
1761 }
1762 if self.reload_packs_if_stale()? {
1763 return self.try_get_action_once(id);
1764 }
1765 trace!("Action not found");
1766 Ok(None)
1767 }
1768
1769 #[instrument(skip(self, action))]
1770 fn put_action(&self, action: &mut Action) -> Result<ActionId> {
1771 let id = action.id();
1772 let path = action_path(&self.root, &id);
1773
1774 if !path.exists() {
1775 let (_, data) = codec::encode_action(action, &self.compression)?;
1776 trace!(id = %id, compressed_size = data.len(), "Writing action");
1777 self.write_loose_object_atomic(&path, &data)?;
1778 }
1779
1780 Ok(id)
1781 }
1782
1783 #[instrument(skip(self))]
1784 fn list_actions(&self) -> Result<Vec<ActionId>> {
1785 self.reload_packs_if_stale()?;
1786 let dir = actions_dir(&self.root);
1787 let mut action_hashes = Vec::new();
1788 if dir.exists() {
1789 for entry in fs::read_dir(&dir)? {
1790 let entry = entry?;
1791 let path = entry.path();
1792 if let Some(name) = path.file_stem()
1793 && let Some(name_str) = name.to_str()
1794 && let Ok(hash) = ContentHash::from_hex(name_str)
1795 {
1796 action_hashes.push(hash);
1797 }
1798 }
1799 }
1800 if let Ok(manager) = self.pack_manager().read() {
1801 append_packed_hashes(&mut action_hashes, &manager, ObjectType::Action)?;
1802 }
1803 let actions = action_hashes
1804 .into_iter()
1805 .map(ActionId::from_hash)
1806 .collect::<Vec<_>>();
1807 debug!(count = actions.len(), "Listed actions");
1808 Ok(actions)
1809 }
1810
1811 #[instrument(skip(self))]
1812 fn list_blobs(&self) -> Result<Vec<ContentHash>> {
1813 self.reload_packs_if_stale()?;
1814 let dir = blobs_dir(&self.root);
1815 let mut blobs = list_hashes_from_dir(&dir)?;
1816 if let Ok(manager) = self.pack_manager().read() {
1817 append_packed_hashes(&mut blobs, &manager, ObjectType::Blob)?;
1818 }
1819 Ok(blobs)
1820 }
1821
1822 #[instrument(skip(self))]
1823 fn list_trees(&self) -> Result<Vec<ContentHash>> {
1824 self.reload_packs_if_stale()?;
1825 let dir = trees_dir(&self.root);
1826 let mut trees = list_hashes_from_dir(&dir)?;
1827 if let Ok(manager) = self.pack_manager().read() {
1828 append_packed_hashes(&mut trees, &manager, ObjectType::Tree)?;
1829 }
1830 Ok(trees)
1831 }
1832
1833 #[instrument(skip(self))]
1834 fn pack_objects(&self, delta_search: bool) -> Result<(u64, u64)> {
1835 self.pack_objects_impl(delta_search)
1836 }
1837
1838 #[instrument(skip(self), fields(id = ?id))]
1839 fn get_pack_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
1840 if let Ok(manager) = self.pack_manager().read()
1841 && let Some((obj_type, data)) = manager.get_object(id)?
1842 {
1843 return Ok(Some((obj_type, data)));
1844 }
1845
1846 match id {
1847 PackObjectId::AnnotatedTag(hash) => Ok(self
1848 .get_annotated_tag(hash)?
1849 .map(|tag| (ObjectType::AnnotatedTag, tag.encode_current_msgpack()))),
1850 PackObjectId::Hash(hash) => {
1851 if let Some(blob) = self.get_blob(hash)? {
1852 return Ok(Some((ObjectType::Blob, blob.into_content())));
1853 }
1854 if let Some(tree_data) = self.get_tree_serialized(hash)? {
1858 return Ok(Some((ObjectType::Tree, tree_data)));
1859 }
1860 if let Some(action) = self.get_action(&ActionId::from_hash(*hash))? {
1861 return Ok(Some((
1862 ObjectType::Action,
1863 rmp_serde::to_vec_named(&action)?,
1864 )));
1865 }
1866 Ok(None)
1867 }
1868 PackObjectId::StateId(change_id) => {
1869 if let Some(state) = self.get_state(change_id)? {
1870 Ok(Some((ObjectType::State, rmp_serde::to_vec_named(&state)?)))
1871 } else {
1872 Ok(None)
1873 }
1874 }
1875 }
1876 }
1877
1878 #[instrument(skip(self, pack_data, index_data))]
1879 fn install_pack(&self, pack_data: &[u8], index_data: &[u8]) -> Result<Vec<PackObjectId>> {
1880 let reader = crate::store::pack::PackReader::from_slice(pack_data, index_data)?;
1881 let ids = validate_and_list_pack(self, &reader)?;
1882 let state_entries = state_entries_from_pack(&reader, &ids)?;
1883 let attachment_entries = attachment_entries_from_pack(&reader, &ids)?;
1884 self.install_pack_files(pack_data, index_data)?;
1885 self.write_packed_state_mirrors_batch(state_entries)?;
1886 for attachment in attachment_entries {
1887 self.put_state_attachment(&attachment)?;
1888 }
1889 self.clear_recent_object_caches();
1890 Ok(ids)
1891 }
1892
1893 #[instrument(skip(self, blobs), fields(count = blobs.len()))]
1894 fn put_blobs_packed(&self, blobs: Vec<(crate::object::ContentHash, Vec<u8>)>) -> Result<()> {
1895 self.put_blobs_packed_impl(blobs)
1896 }
1897
1898 #[instrument(skip(self, blobs, tree, state), fields(blob_count = blobs.len()))]
1899 fn put_snapshot_objects_packed(
1900 &self,
1901 blobs: Vec<(ContentHash, Vec<u8>)>,
1902 tree: &Tree,
1903 state: &State,
1904 ) -> Result<()> {
1905 self.put_snapshot_objects_packed_impl(
1906 blobs,
1907 Vec::new(),
1908 &TreeWrite::anchor(tree.clone()),
1909 state,
1910 Vec::new(),
1911 None,
1912 )
1913 .map(|_| ())
1914 }
1915
1916 fn put_snapshot_objects_and_attachments_packed(
1917 &self,
1918 blobs: Vec<(ContentHash, Vec<u8>)>,
1919 tree: &Tree,
1920 state: &State,
1921 attachments: Vec<StateAttachment>,
1922 ) -> Result<()> {
1923 self.put_snapshot_objects_packed_impl(
1924 blobs,
1925 Vec::new(),
1926 &TreeWrite::anchor(tree.clone()),
1927 state,
1928 attachments,
1929 None,
1930 )
1931 .map(|_| ())
1932 }
1933
1934 #[instrument(skip(self))]
1935 fn install_pack_streaming(
1936 &self,
1937 pack_path: &std::path::Path,
1938 index_path: &std::path::Path,
1939 ) -> Result<Vec<PackObjectId>> {
1940 let ids = {
1946 let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1947 validate_and_list_pack(self, &reader)?
1948 };
1949 let state_entries = {
1950 let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1951 state_entries_from_pack(&reader, &ids)?
1952 };
1953 let attachment_entries = {
1954 let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1955 attachment_entries_from_pack(&reader, &ids)?
1956 };
1957 self.install_pack_files_streaming(pack_path, index_path)?;
1958 self.write_packed_state_mirrors_batch(state_entries)?;
1959 for attachment in attachment_entries {
1960 self.put_state_attachment(&attachment)?;
1961 }
1962 Ok(ids)
1963 }
1964
1965 #[instrument(skip(self))]
1966 fn prune_loose_objects(&self) -> Result<(u64, u64)> {
1967 self.prune_loose_objects_impl()
1968 }
1969
1970 fn discard_corrupt_clone_packs(&self) -> Result<usize> {
1971 let packs = super::fs_paths::packs_dir(&self.root);
1972 let mut removed = 0;
1973 for entry in match fs::read_dir(&packs) {
1974 Ok(entries) => entries,
1975 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
1976 Err(error) => return Err(error.into()),
1977 } {
1978 let path = entry?.path();
1979 if path.extension().and_then(|value| value.to_str()) != Some("pack") {
1980 continue;
1981 }
1982 let index = path.with_extension("idx");
1983 let valid = crate::store::pack::PackReader::open(&path, &index)
1984 .and_then(|reader| validate_and_list_pack(self, &reader).map(|_| ()))
1985 .is_ok();
1986 if !valid {
1987 let _ = fs::remove_file(&path);
1988 let _ = fs::remove_file(&index);
1989 removed += 1;
1990 }
1991 }
1992 if removed > 0 {
1993 self.reload_packs()?;
1994 self.clear_recent_object_caches();
1995 }
1996 Ok(removed)
1997 }
1998
1999 #[instrument(skip(self))]
2000 fn begin_snapshot_write_batch(&self) -> Result<()> {
2001 self.begin_snapshot_write_batch_impl()
2002 }
2003
2004 #[instrument(skip(self))]
2005 fn flush_snapshot_write_batch(&self) -> Result<()> {
2006 self.flush_snapshot_write_batch_impl()
2007 }
2008
2009 #[instrument(skip(self))]
2010 fn abort_snapshot_write_batch(&self) {
2011 self.abort_snapshot_write_batch_impl();
2012 }
2013}
2014
2015impl SidecarStore for FsStore {
2016 fn has_redactions_for_blob(&self, blob: &ContentHash) -> Result<bool> {
2017 Ok(redaction_path(&self.root, blob).exists())
2018 }
2019
2020 fn get_redactions_bytes_for_blob(&self, blob: &ContentHash) -> Result<Option<Vec<u8>>> {
2021 let path = redaction_path(&self.root, blob);
2022 match fs::read(&path) {
2023 Ok(bytes) => Ok(Some(bytes)),
2024 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
2025 Err(err) => Err(HeddleError::Io(err)),
2026 }
2027 }
2028
2029 fn put_redactions_bytes_for_blob(&self, blob: &ContentHash, bytes: &[u8]) -> Result<()> {
2030 let dir = redactions_dir(&self.root);
2031 if !dir.exists() {
2032 crate::fs_atomic::create_dir_all_durable(&dir)?;
2033 }
2034 let path = redaction_path(&self.root, blob);
2035 crate::fs_atomic::write_file_atomic(&path, bytes)?;
2036 Ok(())
2037 }
2038
2039 fn list_blobs_with_redactions(&self) -> Result<Vec<ContentHash>> {
2040 let dir = redactions_dir(&self.root);
2041 if !dir.exists() {
2042 return Ok(Vec::new());
2043 }
2044 let mut out = Vec::new();
2045 for entry in fs::read_dir(&dir)? {
2046 let entry = entry?;
2047 let path = entry.path();
2048 if path.extension().and_then(|e| e.to_str()) != Some("bin") {
2049 continue;
2050 }
2051 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
2052 continue;
2053 };
2054 if let Ok(hash) = ContentHash::from_hex(stem) {
2055 out.push(hash);
2056 }
2057 }
2058 Ok(out)
2059 }
2060
2061 fn has_state_visibility_for_state(&self, state: &StateId) -> Result<bool> {
2062 Ok(state_visibility_path(&self.root, state).exists())
2063 }
2064
2065 fn get_state_visibility_bytes_for_state(&self, state: &StateId) -> Result<Option<Vec<u8>>> {
2066 let path = state_visibility_path(&self.root, state);
2067 match fs::read(&path) {
2068 Ok(bytes) => Ok(Some(bytes)),
2069 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
2070 Err(err) => Err(HeddleError::Io(err)),
2071 }
2072 }
2073
2074 fn put_state_visibility_bytes_for_state(&self, state: &StateId, bytes: &[u8]) -> Result<()> {
2075 let dir = state_visibility_dir(&self.root);
2076 if !dir.exists() {
2077 crate::fs_atomic::create_dir_all_durable(&dir)?;
2078 }
2079 let path = state_visibility_path(&self.root, state);
2080 crate::fs_atomic::write_file_atomic(&path, bytes)?;
2081 Ok(())
2082 }
2083
2084 fn list_states_with_visibility(&self) -> Result<Vec<StateId>> {
2085 let dir = state_visibility_dir(&self.root);
2086 if !dir.exists() {
2087 return Ok(Vec::new());
2088 }
2089 let mut out = Vec::new();
2090 for entry in fs::read_dir(&dir)? {
2091 let entry = entry?;
2092 let path = entry.path();
2093 if path.extension().and_then(|e| e.to_str()) != Some("bin") {
2094 continue;
2095 }
2096 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
2097 continue;
2098 };
2099 if let Ok(state) = StateId::parse(stem) {
2100 out.push(state);
2101 }
2102 }
2103 Ok(out)
2104 }
2105}
2106
2107#[cfg(test)]
2108mod state_attachment_tests {
2109 use std::sync::Arc;
2110
2111 use chrono::Utc;
2112
2113 use super::*;
2114 use crate::{
2115 object::{Attribution, Principal, StateAttachmentBody},
2116 store::{CompressionConfig, pack::PackBuilder},
2117 };
2118
2119 fn fixture(store: &FsStore) -> (State, StateAttachment) {
2120 let tree = store.put_tree(&Tree::new()).unwrap();
2121 let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
2122 let state = State::new(tree, vec![], attribution.clone());
2123 store.put_state(&state).unwrap();
2124 let attachment = StateAttachment {
2125 state_id: state.id(),
2126 body: StateAttachmentBody::Context(ContentHash::compute(b"context")),
2127 attribution,
2128 created_at: Utc::now(),
2129 supersedes: None,
2130 };
2131 (state, attachment)
2132 }
2133
2134 #[test]
2135 fn concurrent_attachment_writes_keep_every_index_entry() {
2136 let temp = tempfile::TempDir::new().unwrap();
2137 let store = Arc::new(FsStore::new(temp.path()));
2138 let (state, base) = fixture(&store);
2139 let mut threads = Vec::new();
2140 for byte in 0..16u8 {
2141 let store = Arc::clone(&store);
2142 let mut attachment = base.clone();
2143 attachment.body = StateAttachmentBody::Context(ContentHash::compute(&[byte]));
2144 threads.push(std::thread::spawn(move || {
2145 store.put_state_attachment(&attachment).unwrap();
2146 }));
2147 }
2148 for thread in threads {
2149 thread.join().unwrap();
2150 }
2151 assert_eq!(store.list_state_attachments(&state.id()).unwrap().len(), 16);
2152 }
2153
2154 #[test]
2155 fn missing_index_rebuilds_from_loose_objects() {
2156 let temp = tempfile::TempDir::new().unwrap();
2157 let store = FsStore::new(temp.path());
2158 let (state, attachment) = fixture(&store);
2159 store.put_state_attachment(&attachment).unwrap();
2160 fs::remove_file(state_attachment_index_path(&store.root, &state.id())).unwrap();
2161 assert_eq!(
2162 store.list_state_attachments(&state.id()).unwrap(),
2163 vec![attachment]
2164 );
2165 }
2166
2167 #[test]
2168 fn packed_attachment_uses_state_index_for_lookup() {
2169 let temp = tempfile::TempDir::new().unwrap();
2170 let store = FsStore::new(temp.path());
2171 let (state, attachment) = fixture(&store);
2172 let mut builder = PackBuilder::new(CompressionConfig::default());
2173 builder.add(
2174 *attachment.id().as_hash(),
2175 ObjectType::StateAttachment,
2176 rmp_serde::to_vec_named(&attachment).unwrap(),
2177 );
2178 let (pack, index, _) = builder.build().unwrap();
2179 store.install_pack(&pack, &index).unwrap();
2180 fs::remove_file(state_attachment_path(
2181 &store.root,
2182 &state.id(),
2183 &attachment.id(),
2184 ))
2185 .unwrap();
2186 let rebuild_marker =
2187 state_attachment_index_path(&store.root, &state.id()).with_extension("rebuild-marker");
2188 let _ = fs::remove_file(&rebuild_marker);
2189 assert_eq!(
2190 store.list_state_attachments(&state.id()).unwrap(),
2191 vec![attachment.clone()]
2192 );
2193 assert_eq!(
2194 store.list_state_attachments(&state.id()).unwrap(),
2195 vec![attachment]
2196 );
2197 assert!(!rebuild_marker.exists());
2198 }
2199}
2200
2201#[cfg(test)]
2202mod enumeration_tests {
2203 use heddle_format::{compression::CompressionConfig, delta::DeltaEncoder};
2204 use tempfile::TempDir;
2205
2206 use super::*;
2207 use crate::store::pack::{
2208 PackBuilder, PackContainerSpec, PackIndex, append_container_checksum,
2209 encode_tagged_entry_parts, write_container_header,
2210 };
2211
2212 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
2213 struct TestEnumerationMetrics {
2214 membership_checks: u64,
2215 header_reads: u64,
2216 full_object_decodes: u64,
2217 }
2218
2219 impl EnumerationCounter for TestEnumerationMetrics {
2220 fn membership_check(&mut self) {
2221 self.membership_checks += 1;
2222 }
2223
2224 fn header_read(&mut self) {
2225 self.header_reads += 1;
2226 }
2227 }
2228
2229 fn install_pack_files(
2230 dir: &TempDir,
2231 name: &str,
2232 pack_data: &[u8],
2233 index_data: &[u8],
2234 ) -> PackManager {
2235 fs::write(dir.path().join(format!("{name}.pack")), pack_data).unwrap();
2236 fs::write(dir.path().join(format!("{name}.idx")), index_data).unwrap();
2237 PackManager::new(dir.path().to_path_buf())
2238 }
2239
2240 fn raw_mixed_manager() -> (TempDir, PackManager, Vec<(ContentHash, ObjectType)>) {
2241 let dir = TempDir::new().unwrap();
2242 let objects = [
2243 (ObjectType::Blob, b"packed blob".as_slice()),
2244 (ObjectType::Tree, b"packed tree".as_slice()),
2245 (ObjectType::Action, b"packed action".as_slice()),
2246 ];
2247 let mut builder = PackBuilder::new(CompressionConfig::disabled());
2248 let mut classified = Vec::new();
2249 for (obj_type, data) in objects {
2250 let hash = ContentHash::compute_typed("enumeration-test", data);
2251 builder.add(hash, obj_type, data.to_vec());
2252 classified.push((hash, obj_type));
2253 }
2254 let (pack, index, _) = builder.build().unwrap();
2255 let manager = install_pack_files(&dir, "mixed", &pack, &index);
2256 (dir, manager, classified)
2257 }
2258
2259 fn delta_chain_manager() -> (TempDir, PackManager, Vec<ContentHash>) {
2260 const SPEC: PackContainerSpec = PackContainerSpec {
2261 magic: b"LMPK",
2262 version: 4,
2263 };
2264 let dir = TempDir::new().unwrap();
2265 let base = b"delta-chain base payload ".repeat(64);
2266 let mut middle = base.clone();
2267 middle[200..208].copy_from_slice(b"middle!!");
2268 let mut tip = middle.clone();
2269 tip[900..908].copy_from_slice(b"tip!!!!!");
2270 let bodies = [&base, &middle, &tip];
2271 let hashes = bodies
2272 .iter()
2273 .map(|body| ContentHash::compute_typed("blob", body))
2274 .collect::<Vec<_>>();
2275 let middle_delta = DeltaEncoder::encode(&base, &middle);
2276 let tip_delta = DeltaEncoder::encode(&middle, &tip);
2277
2278 let mut pack = Vec::new();
2279 let mut index = PackIndex::new();
2280 write_container_header(&mut pack, SPEC, 3);
2281 for (position, payload) in [
2282 base.as_slice(),
2283 middle_delta.as_slice(),
2284 tip_delta.as_slice(),
2285 ]
2286 .into_iter()
2287 .enumerate()
2288 {
2289 index.add(PackObjectId::Hash(hashes[position]), pack.len() as u64);
2290 let (stored_type, base_id) = if position == 0 {
2291 (ObjectType::Blob, None)
2292 } else {
2293 (
2294 ObjectType::Delta,
2295 Some(PackObjectId::Hash(hashes[position - 1])),
2296 )
2297 };
2298 encode_tagged_entry_parts(
2299 &mut pack,
2300 PackObjectId::Hash(hashes[position]),
2301 stored_type,
2302 bodies[position].len(),
2303 base_id,
2304 payload,
2305 )
2306 .unwrap();
2307 }
2308 index.sort();
2309 append_container_checksum(&mut pack);
2310 let manager = install_pack_files(&dir, "delta-chain", &pack, &index.to_bytes());
2311 (dir, manager, hashes)
2312 }
2313
2314 fn legacy_append_packed_hashes(
2315 hashes: &mut Vec<ContentHash>,
2316 manager: &PackManager,
2317 expected_type: ObjectType,
2318 ) -> Result<TestEnumerationMetrics> {
2319 let mut metrics = TestEnumerationMetrics::default();
2320 for id in manager.list_all_ids()? {
2321 let PackObjectId::Hash(hash) = id else {
2322 continue;
2323 };
2324 let mut already_listed = false;
2325 for listed in hashes.iter() {
2326 metrics.membership_checks += 1;
2327 if listed == &hash {
2328 already_listed = true;
2329 break;
2330 }
2331 }
2332 if already_listed {
2333 continue;
2334 }
2335 metrics.full_object_decodes += 1;
2336 if let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
2337 && obj_type == expected_type
2338 {
2339 hashes.push(hash);
2340 }
2341 }
2342 Ok(metrics)
2343 }
2344
2345 fn assert_new_matches_legacy(
2346 label: &str,
2347 manager: &PackManager,
2348 loose: Vec<ContentHash>,
2349 expected_type: ObjectType,
2350 ) {
2351 let mut new = loose.clone();
2352 let mut new_metrics = TestEnumerationMetrics::default();
2353 append_packed_hashes_with_counter(&mut new, manager, expected_type, &mut new_metrics)
2354 .unwrap();
2355 let mut legacy = loose;
2356 legacy_append_packed_hashes(&mut legacy, manager, expected_type).unwrap();
2357 assert_eq!(new, legacy, "fixture {label} changed output or ordering");
2358 assert_eq!(new_metrics.full_object_decodes, 0, "fixture {label}");
2359 }
2360
2361 #[test]
2362 fn type_only_enumeration_matches_full_decode_across_fixture_set() {
2363 let empty_dir = TempDir::new().unwrap();
2364 let empty = PackManager::new(empty_dir.path().to_path_buf());
2365 let loose_hash = ContentHash::compute(b"loose only");
2366 assert_new_matches_legacy("loose-only", &empty, vec![loose_hash], ObjectType::Blob);
2367
2368 let (_raw_dir, raw, classified) = raw_mixed_manager();
2369 for expected_type in [ObjectType::Blob, ObjectType::Tree, ObjectType::Action] {
2370 assert_new_matches_legacy("packed-only", &raw, Vec::new(), expected_type);
2371 assert_new_matches_legacy(
2372 "mixed",
2373 &raw,
2374 vec![ContentHash::compute_typed("loose", &[expected_type as u8])],
2375 expected_type,
2376 );
2377 let duplicate = classified
2378 .iter()
2379 .find_map(|(hash, obj_type)| (*obj_type == expected_type).then_some(*hash))
2380 .unwrap();
2381 assert_new_matches_legacy(
2382 "duplicate-loose-packed",
2383 &raw,
2384 vec![duplicate],
2385 expected_type,
2386 );
2387 }
2388 for (hash, expected_type) in classified {
2389 assert_eq!(
2390 raw.get_hashed_object_type(&hash).unwrap(),
2391 raw.get_hashed_object(&hash)
2392 .unwrap()
2393 .map(|(obj_type, _)| obj_type)
2394 );
2395 assert_eq!(
2396 raw.get_hashed_object_type(&hash).unwrap(),
2397 Some(expected_type)
2398 );
2399 }
2400
2401 let (_delta_dir, delta, delta_hashes) = delta_chain_manager();
2402 assert_new_matches_legacy("two-link-delta-chain", &delta, Vec::new(), ObjectType::Blob);
2403 for hash in delta_hashes {
2404 assert_eq!(
2405 delta.get_hashed_object_type(&hash).unwrap(),
2406 Some(ObjectType::Blob)
2407 );
2408 assert_eq!(
2409 delta.get_hashed_object_type(&hash).unwrap(),
2410 delta
2411 .get_hashed_object(&hash)
2412 .unwrap()
2413 .map(|(obj_type, _)| obj_type)
2414 );
2415 }
2416 }
2417
2418 #[test]
2419 fn structural_counter_rejects_vec_scan_and_full_decode_negative_control() {
2420 let (_dir, manager, _) = raw_mixed_manager();
2421 let loose = (0..64u8)
2422 .map(|byte| ContentHash::compute_typed("loose", &[byte]))
2423 .collect::<Vec<_>>();
2424 let packed_hashes = manager
2425 .list_all_ids()
2426 .unwrap()
2427 .into_iter()
2428 .filter(|id| matches!(id, PackObjectId::Hash(_)))
2429 .count() as u64;
2430
2431 for expected_type in [ObjectType::Blob, ObjectType::Tree, ObjectType::Action] {
2432 let mut optimized = loose.clone();
2433 let mut optimized_metrics = TestEnumerationMetrics::default();
2434 append_packed_hashes_with_counter(
2435 &mut optimized,
2436 &manager,
2437 expected_type,
2438 &mut optimized_metrics,
2439 )
2440 .unwrap();
2441 assert_eq!(optimized_metrics.membership_checks, packed_hashes);
2442 assert_eq!(optimized_metrics.header_reads, packed_hashes);
2443 assert_eq!(optimized_metrics.full_object_decodes, 0);
2444
2445 let mut legacy = loose.clone();
2446 let legacy_metrics =
2447 legacy_append_packed_hashes(&mut legacy, &manager, expected_type).unwrap();
2448 assert!(legacy_metrics.membership_checks >= loose.len() as u64 * packed_hashes);
2449 assert_eq!(legacy_metrics.full_object_decodes, packed_hashes);
2450 assert!(
2451 !(legacy_metrics.membership_checks <= packed_hashes
2452 && legacy_metrics.full_object_decodes == 0),
2453 "negative control unexpectedly passed the structural contract: {legacy_metrics:?}"
2454 );
2455 }
2456 }
2457
2458 #[test]
2459 fn state_union_preserves_first_seen_order_at_scale() {
2460 let first = (0..20_000u32)
2461 .map(|value| {
2462 StateId::from_bytes(*ContentHash::compute(&value.to_le_bytes()).as_bytes())
2463 })
2464 .collect::<Vec<_>>();
2465 let second = first[10_000..]
2466 .iter()
2467 .copied()
2468 .chain((20_000..30_000u32).map(|value| {
2469 StateId::from_bytes(*ContentHash::compute(&value.to_le_bytes()).as_bytes())
2470 }))
2471 .collect::<Vec<_>>();
2472 let mut states = Vec::new();
2473 let mut known = HashSet::new();
2474
2475 append_unique_states(&mut states, &mut known, first.iter().copied());
2476 append_unique_states(&mut states, &mut known, second);
2477
2478 assert_eq!(states.len(), 30_000);
2479 assert_eq!(&states[..20_000], first.as_slice());
2480 }
2481}