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