1use std::{
5 collections::HashSet,
6 fs::{self, 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, trees_dir,
22 },
23};
24use crate::{
25 object::{
26 Action, ActionId, AnnotatedTag, Blob, ContentHash, State, StateAttachment,
27 StateAttachmentId, StateId, Tree,
28 },
29 store::{
30 HeddleError, ObjectStore, Result, SnapshotCommitDescriptor, codec,
31 pack::{ObjectType, PackManager, PackObjectId},
32 },
33};
34
35const BLOB_HEADER_PEEK: usize = 13;
43
44fn validate_loaded_tree(tree: Tree) -> Result<Tree> {
45 tree.validate()?;
46 Ok(tree)
47}
48
49fn validate_blob_bytes(data: &[u8], hash: ContentHash) -> Result<()> {
50 let mut hasher = ContentHash::typed_hasher("blob", data.len() as u64);
51 hasher.update(data);
52 let found = ContentHash::from_bytes(hasher.finalize().into());
53 if found != hash {
54 return Err(HeddleError::Corruption {
55 expected: hash,
56 found,
57 });
58 }
59
60 Ok(())
61}
62
63fn validate_tree_serialized(data: &[u8], hash: ContentHash) -> Result<Tree> {
64 let tree = codec::decode_tree_serialized(data)?;
65 let tree = validate_loaded_tree(tree)?;
66 let found = tree.hash();
67 if found != hash {
68 return Err(HeddleError::Corruption {
69 expected: hash,
70 found,
71 });
72 }
73
74 Ok(tree)
75}
76
77fn validate_annotated_tag(data: &[u8], hash: ContentHash) -> Result<AnnotatedTag> {
78 let tag = AnnotatedTag::decode_current_msgpack(data)
79 .map_err(|error| HeddleError::InvalidObject(error.to_string()))?;
80 if tag.hash() != hash {
81 return Err(HeddleError::Corruption {
82 expected: hash,
83 found: tag.hash(),
84 });
85 }
86 Ok(tag)
87}
88
89fn validate_loaded_state(requested_id: &StateId, mut state: State) -> Result<State> {
90 let computed = state.id();
91 if computed != *requested_id {
92 return Err(HeddleError::InvalidObject(format!(
93 "state id mismatch: requested {requested_id}, computed {computed}"
94 )));
95 }
96 state.state_id = computed;
97 Ok(state)
98}
99
100pub(super) fn validate_state_serialized(data: &[u8], id: StateId) -> Result<State> {
101 let state: State = rmp_serde::from_slice(data)?;
102 validate_loaded_state(&id, state)
103}
104
105fn validate_loaded_action(requested_id: &ActionId, action: Action) -> Result<Action> {
106 let found_id = action.compute_id();
107 if found_id != *requested_id {
108 return Err(HeddleError::InvalidObject(format!(
109 "action id mismatch: requested {}, found {}",
110 requested_id, found_id
111 )));
112 }
113
114 Ok(action)
115}
116
117fn validate_action_serialized(data: &[u8], id: ActionId) -> Result<Action> {
118 let action: Action = rmp_serde::from_slice(data)?;
119 validate_loaded_action(&id, action)
120}
121
122trait EnumerationCounter {
123 fn membership_check(&mut self);
124 fn header_read(&mut self);
125}
126
127struct NoopEnumerationCounter;
128
129impl EnumerationCounter for NoopEnumerationCounter {
130 fn membership_check(&mut self) {}
131 fn header_read(&mut self) {}
132}
133
134fn append_packed_hashes_with_counter(
135 hashes: &mut Vec<ContentHash>,
136 manager: &PackManager,
137 expected_type: ObjectType,
138 counter: &mut impl EnumerationCounter,
139) -> Result<()> {
140 let mut known: HashSet<_> = hashes.iter().copied().collect();
141 for id in manager.list_all_ids()? {
142 let hash = match id {
143 PackObjectId::Hash(hash) if expected_type != ObjectType::AnnotatedTag => hash,
144 PackObjectId::AnnotatedTag(hash) if expected_type == ObjectType::AnnotatedTag => hash,
145 PackObjectId::Hash(_) | PackObjectId::StateId(_) | PackObjectId::AnnotatedTag(_) => {
146 continue;
147 }
148 };
149 counter.membership_check();
150 if known.contains(&hash) {
151 continue;
152 }
153 counter.header_read();
154 let found_type = if expected_type == ObjectType::AnnotatedTag {
155 manager
156 .get_object(&PackObjectId::AnnotatedTag(hash))?
157 .map(|(object_type, _)| object_type)
158 } else {
159 manager.get_hashed_object_type(&hash)?
160 };
161 if found_type == Some(expected_type) {
162 known.insert(hash);
163 hashes.push(hash);
164 }
165 }
166 Ok(())
167}
168
169fn append_packed_hashes(
170 hashes: &mut Vec<ContentHash>,
171 manager: &PackManager,
172 expected_type: ObjectType,
173) -> Result<()> {
174 append_packed_hashes_with_counter(hashes, manager, expected_type, &mut NoopEnumerationCounter)
175}
176
177fn append_unique_states(
178 states: &mut Vec<StateId>,
179 known: &mut HashSet<StateId>,
180 incoming: impl IntoIterator<Item = StateId>,
181) {
182 for id in incoming {
183 if known.insert(id) {
184 states.push(id);
185 }
186 }
187}
188
189impl FsStore {
190 fn write_packed_state_mirrors_batch(&self, states: Vec<(StateId, Vec<u8>)>) -> Result<()> {
195 if states.is_empty() {
196 return Ok(());
197 }
198
199 self.begin_snapshot_write_batch_impl()?;
200 for (id, data) in states {
201 if let Err(error) = ObjectStore::put_state_serialized(self, &data, id) {
202 self.abort_snapshot_write_batch_impl();
203 return Err(error);
204 }
205 }
206 if let Err(error) = self.flush_snapshot_write_batch_impl() {
207 self.abort_snapshot_write_batch_impl();
208 return Err(error);
209 }
210 Ok(())
211 }
212
213 fn with_state_attachment_index_lock<T>(
214 &self,
215 state: &StateId,
216 operation: impl FnOnce() -> Result<T>,
217 ) -> Result<T> {
218 let path = state_attachment_index_lock_path(&self.root, state);
219 if let Some(parent) = path.parent() {
220 fs::create_dir_all(parent)?;
221 }
222 let file = OpenOptions::new()
223 .create(true)
224 .truncate(false)
225 .read(true)
226 .write(true)
227 .open(path)?;
228 file.lock_exclusive()?;
229 let result = operation();
230 file.unlock()?;
231 result
232 }
233
234 fn collect_state_attachment_ids(&self, state: &StateId) -> Result<Vec<StateAttachmentId>> {
235 let mut ids = Vec::new();
236 let dir = state_attachments_dir(&self.root, state);
237 if let Ok(entries) = fs::read_dir(dir) {
238 for entry in entries {
239 let attachment: StateAttachment = rmp_serde::from_slice(&fs::read(entry?.path())?)?;
240 if attachment.state_id != *state {
241 return Err(HeddleError::InvalidObject(
242 "state attachment stored under wrong state".to_string(),
243 ));
244 }
245 ids.push(attachment.id());
246 }
247 }
248 if let Ok(manager) = self.pack_manager().read() {
249 for pack_id in manager.list_all_ids()? {
250 let PackObjectId::Hash(hash) = pack_id else {
251 continue;
252 };
253 let Some((ObjectType::StateAttachment, bytes)) =
254 manager.get_hashed_object(&hash)?
255 else {
256 continue;
257 };
258 let attachment: StateAttachment = rmp_serde::from_slice(&bytes)?;
259 if attachment.state_id == *state {
260 ids.push(attachment.id());
261 }
262 }
263 }
264 ids.sort();
265 ids.dedup();
266 Ok(ids)
267 }
268
269 fn rebuild_state_attachment_index(&self, state: &StateId) -> Result<Vec<StateAttachmentId>> {
270 #[cfg(test)]
271 fs::write(
272 state_attachment_index_path(&self.root, state).with_extension("rebuild-marker"),
273 b"rebuilt",
274 )?;
275 let ids = self.collect_state_attachment_ids(state)?;
276 let path = state_attachment_index_path(&self.root, state);
277 self.write_loose_object_atomic(&path, &rmp_serde::to_vec_named(&ids)?)?;
278 Ok(ids)
279 }
280
281 pub(super) fn materialize_packed_attachment_index(
286 &self,
287 state: &StateId,
288 packed_ids: &[StateAttachmentId],
289 state_was_present: bool,
290 ) -> Result<()> {
291 if packed_ids.is_empty() {
292 return Ok(());
293 }
294 self.with_state_attachment_index_lock(state, || {
295 let path = state_attachment_index_path(&self.root, state);
296 let mut ids = if state_was_present {
297 match read_file_bytes(&path)? {
298 Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
299 None => self.collect_state_attachment_ids(state)?,
300 }
301 } else {
302 Vec::new()
303 };
304 ids.extend_from_slice(packed_ids);
305 ids.sort();
306 ids.dedup();
307 self.write_reconstructible_cache(&path, &rmp_serde::to_vec_named(&ids)?)?;
308 Ok(())
309 })
310 }
311}
312
313fn validate_and_list_pack(reader: &crate::store::pack::PackReader) -> Result<Vec<PackObjectId>> {
321 let ids = reader.list_ids()?;
322 reader.visit_objects(|id, object_type, data| validate_pack_entry(&id, object_type, data))?;
323 Ok(ids)
324}
325
326fn state_entries_from_pack(
327 reader: &crate::store::pack::PackReader,
328 ids: &[PackObjectId],
329) -> Result<Vec<(StateId, Vec<u8>)>> {
330 let mut states = Vec::new();
331 let expected = ids.iter().copied().collect::<HashSet<_>>();
332 reader.visit_objects(|id, object_type, data| {
333 if !expected.contains(&id) {
334 return Err(HeddleError::InvalidObject(
335 "pack visitor yielded an unindexed object".into(),
336 ));
337 }
338 if let PackObjectId::StateId(state_id) = id {
339 if object_type != ObjectType::State {
340 return Err(HeddleError::InvalidObject(format!(
341 "pack id {} is indexed as {object_type:?}, expected State",
342 state_id.to_string_full()
343 )));
344 }
345 validate_state_serialized(data, state_id)?;
346 states.push((state_id, data.to_vec()));
347 }
348 Ok(())
349 })?;
350 Ok(states)
351}
352
353fn attachment_entries_from_pack(
354 reader: &crate::store::pack::PackReader,
355 ids: &[PackObjectId],
356) -> Result<Vec<StateAttachment>> {
357 let mut attachments = Vec::new();
358 let expected = ids.iter().copied().collect::<HashSet<_>>();
359 reader.visit_objects(|id, object_type, data| {
360 if expected.contains(&id) && object_type == ObjectType::StateAttachment {
361 attachments.push(rmp_serde::from_slice(data)?);
362 }
363 Ok(())
364 })?;
365 Ok(attachments)
366}
367
368pub(super) fn validate_pack_entry(
369 id: &PackObjectId,
370 obj_type: ObjectType,
371 data: &[u8],
372) -> Result<()> {
373 match (id, obj_type) {
374 (PackObjectId::Hash(hash), ObjectType::Blob) => validate_blob_bytes(data, *hash),
375 (PackObjectId::AnnotatedTag(hash), ObjectType::AnnotatedTag) => {
376 validate_annotated_tag(data, *hash).map(|_| ())
377 }
378 (PackObjectId::Hash(hash), ObjectType::Tree) => {
379 validate_tree_serialized(data, *hash).map(|_| ())
380 }
381 (PackObjectId::Hash(hash), ObjectType::Action) => {
382 validate_action_serialized(data, ActionId::from_hash(*hash)).map(|_| ())
383 }
384 (PackObjectId::StateId(change_id), ObjectType::State) => {
385 validate_state_serialized(data, *change_id).map(|_| ())
386 }
387 (PackObjectId::Hash(hash), ObjectType::StateAttachment) => {
388 let attachment: StateAttachment = rmp_serde::from_slice(data)?;
389 if attachment.id().as_hash() != hash {
390 return Err(HeddleError::InvalidObject(
391 "state attachment pack id mismatch".to_string(),
392 ));
393 }
394 Ok(())
395 }
396 (PackObjectId::Hash(hash), ObjectType::SnapshotCommit) => {
397 let artifact: crate::store::SnapshotCommitArtifact = rmp_serde::from_slice(data)?;
398 artifact.validate()?;
399 if artifact.id() != *hash {
400 return Err(HeddleError::InvalidObject(
401 "snapshot commit artifact pack id mismatch".to_string(),
402 ));
403 }
404 Ok(())
405 }
406 (_, ObjectType::TimelineOperation) => Err(HeddleError::InvalidObject(
407 "timeline operations belong in the timeline pack store".to_string(),
408 )),
409 _ => Err(HeddleError::InvalidObject(format!(
410 "unsupported native pack object: {:?} {:?}",
411 id, obj_type
412 ))),
413 }
414}
415
416impl FsStore {
417 fn cache_recent_blob(&self, hash: ContentHash, blob: &Blob) {
419 if blob.content().len() > super::fs_store::RECENT_BLOB_CACHE_MAX_BYTES {
420 return;
421 }
422 if let Ok(mut cache) = self.recent_blobs.write() {
423 cache.insert(hash, blob.clone());
424 }
425 }
426
427 fn cache_recent_tree(&self, hash: ContentHash, tree: &Tree) {
428 if let Ok(mut cache) = self.recent_trees.write() {
429 cache.insert(hash, tree.clone());
430 }
431 }
432
433 fn cache_recent_state(&self, id: StateId, state: &State) {
434 if let Ok(mut cache) = self.recent_states.write() {
435 cache.insert(id, state.clone());
436 }
437 }
438
439 fn recent_blob(&self, hash: &ContentHash) -> Option<Blob> {
440 self.recent_blobs
441 .read()
442 .ok()
443 .and_then(|cache| cache.get(hash).cloned())
444 }
445
446 fn recent_tree(&self, hash: &ContentHash) -> Option<Tree> {
447 self.recent_trees
448 .read()
449 .ok()
450 .and_then(|cache| cache.get(hash).cloned())
451 }
452
453 fn recent_state(&self, id: &StateId) -> Option<State> {
454 self.recent_states
455 .read()
456 .ok()
457 .and_then(|cache| cache.get(id).cloned())
458 }
459
460 fn try_get_blob_once(&self, hash: &ContentHash) -> Result<Option<Blob>> {
463 if let Ok(cache) = self.recent_blobs.read()
466 && let Some(blob) = cache.get(hash)
467 {
468 trace!("Found blob in recent object cache");
469 return Ok(Some(blob.clone()));
470 }
471
472 if let Ok(manager) = self.pack_manager().read()
473 && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
474 && obj_type == ObjectType::Blob
475 {
476 trace!("Found blob in packfile");
477 validate_blob_bytes(&data, *hash)?;
478 let blob = Blob::new(data);
479 heddle_perf_contract::record_object_decode();
480 self.cache_recent_blob(*hash, &blob);
481 return Ok(Some(blob));
482 }
483
484 let path = hash_path(&blobs_dir(&self.root), hash);
485 match read_file_bytes(&path)? {
486 Some(data) => {
487 trace!(size = data.as_slice().len(), "Blob data read");
488 let content = codec::decode_blob_content(data.as_slice())?;
489 let blob = Blob::new(content);
490 heddle_perf_contract::record_object_decode();
491 if blob.hash() != *hash {
498 return Err(HeddleError::Corruption {
499 expected: *hash,
500 found: blob.hash(),
501 });
502 }
503 self.cache_recent_blob(*hash, &blob);
504 Ok(Some(blob))
505 }
506 None => Ok(None),
507 }
508 }
509
510 fn loose_or_packed(
515 &self,
516 loose_path: &Path,
517 in_pack: impl FnOnce(&PackManager) -> bool,
518 ) -> Result<bool> {
519 if loose_path.exists() {
520 return Ok(true);
521 }
522 if let Ok(manager) = self.pack_manager().read() {
523 return Ok(in_pack(&manager));
524 }
525 Ok(false)
526 }
527
528 fn try_has_blob_once(&self, hash: &ContentHash) -> Result<bool> {
529 let path = hash_path(&blobs_dir(&self.root), hash);
533 self.loose_or_packed(&path, |m| m.has_object(hash))
534 }
535
536 fn try_get_blob_size_once(&self, hash: &ContentHash) -> Result<Option<u64>> {
551 if let Ok(cache) = self.recent_blobs.read()
552 && let Some(blob) = cache.get(hash)
553 {
554 return Ok(Some(blob.content().len() as u64));
555 }
556
557 let path = hash_path(&blobs_dir(&self.root), hash);
558 if let Some((header, file_len)) = read_file_header(&path, BLOB_HEADER_PEEK)? {
559 if let Some(size) = header_uncompressed_size(&header) {
560 return Ok(Some(size));
561 }
562 return Ok(Some(file_len));
565 }
566
567 if let Ok(manager) = self.pack_manager().read()
568 && let Some(size) = manager.get_hashed_object_size(hash)?
569 {
570 return Ok(Some(size));
571 }
572 Ok(None)
573 }
574
575 fn try_get_tree_once(&self, hash: &ContentHash) -> Result<Option<Tree>> {
576 if let Ok(cache) = self.recent_trees.read()
580 && let Some(tree) = cache.get(hash)
581 {
582 trace!("Found tree in recent object cache");
583 return Ok(Some(tree.clone()));
584 }
585
586 let path = hash_path(&trees_dir(&self.root), hash);
590 if path.exists()
591 && let Some(data) = read_file_bytes(&path)?
592 {
593 trace!(size = data.as_slice().len(), "Tree data read");
594 let tree = validate_loaded_tree(codec::decode_tree(data.as_slice())?)?;
595 heddle_perf_contract::record_object_decode();
596 if tree.hash() != *hash {
597 return Err(HeddleError::Corruption {
598 expected: *hash,
599 found: tree.hash(),
600 });
601 }
602 if let Ok(mut cache) = self.recent_trees.write() {
603 cache.insert(*hash, tree.clone());
604 }
605 return Ok(Some(tree));
606 }
607
608 if let Ok(manager) = self.pack_manager().read()
609 && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
610 && obj_type == ObjectType::Tree
611 {
612 trace!("Found tree in packfile");
613 let tree = validate_loaded_tree(codec::decode_tree_serialized(&data)?)?;
614 heddle_perf_contract::record_object_decode();
615 if tree.hash() != *hash {
616 return Err(HeddleError::Corruption {
617 expected: *hash,
618 found: tree.hash(),
619 });
620 }
621 if let Ok(mut cache) = self.recent_trees.write() {
622 cache.insert(*hash, tree.clone());
623 }
624 return Ok(Some(tree));
625 }
626 Ok(None)
627 }
628
629 fn try_get_tree_serialized_once(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
630 let path = hash_path(&trees_dir(&self.root), hash);
631 if path.exists()
632 && let Some(data) = read_file_bytes(&path)?
633 {
634 return Ok(Some(codec::decode_tree_body(data.as_slice())?));
635 }
636
637 if let Ok(manager) = self.pack_manager().read()
638 && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
639 && obj_type == ObjectType::Tree
640 {
641 validate_tree_serialized(&data, *hash)?;
642 return Ok(Some(data));
643 }
644
645 Ok(None)
646 }
647
648 fn try_has_tree_once(&self, hash: &ContentHash) -> Result<bool> {
649 let path = hash_path(&trees_dir(&self.root), hash);
653 self.loose_or_packed(&path, |m| m.has_object(hash))
654 }
655
656 fn try_get_state_once(&self, id: &StateId) -> Result<Option<State>> {
657 if let Ok(cache) = self.recent_states.read()
662 && let Some(state) = cache.get(id)
663 {
664 trace!("Found state in recent object cache");
665 return Ok(Some(state.clone()));
666 }
667
668 let path = state_path(&self.root, id);
669 if let Some(data) = read_file_bytes(&path)? {
670 trace!(size = data.as_slice().len(), "State read from loose object");
671 let state = validate_loaded_state(id, codec::decode_state(data.as_slice())?)?;
672 heddle_perf_contract::record_object_decode();
673 if let Ok(mut cache) = self.recent_states.write() {
674 cache.insert(*id, state.clone());
675 }
676 return Ok(Some(state));
677 }
678
679 if let Ok(manager) = self.pack_manager().read()
680 && let Some((obj_type, data)) = manager.get_object(&PackObjectId::StateId(*id))?
681 && obj_type == ObjectType::State
682 {
683 trace!("Found state in packfile");
684 let state = validate_loaded_state(id, rmp_serde::from_slice(&data)?)?;
685 heddle_perf_contract::record_object_decode();
686 if let Ok(mut cache) = self.recent_states.write() {
687 cache.insert(*id, state.clone());
688 }
689 return Ok(Some(state));
690 }
691
692 Ok(None)
693 }
694
695 fn try_has_state_once(&self, id: &StateId) -> Result<bool> {
696 if let Ok(cache) = self.recent_states.read()
699 && cache.contains(id)
700 {
701 return Ok(true);
702 }
703 let path = state_path(&self.root, id);
704 self.loose_or_packed(&path, |m| m.has_object_id(&PackObjectId::StateId(*id)))
705 }
706
707 fn try_get_action_once(&self, id: &ActionId) -> Result<Option<Action>> {
708 let path = action_path(&self.root, id);
709 if let Some(data) = read_file_bytes(&path)? {
710 trace!(size = data.as_slice().len(), "Action data read");
711 return Ok(Some(validate_loaded_action(
712 id,
713 codec::decode_action(data.as_slice())?,
714 )?));
715 }
716 if let Ok(manager) = self.pack_manager().read()
717 && let Some((ObjectType::Action, data)) = manager.get_hashed_object(id.as_hash())?
718 {
719 trace!("Found action in packfile");
720 return Ok(Some(validate_loaded_action(
721 id,
722 rmp_serde::from_slice(&data)?,
723 )?));
724 }
725 Ok(None)
726 }
727
728 fn try_get_state_attachment_once(
729 &self,
730 state: &StateId,
731 id: &StateAttachmentId,
732 ) -> Result<Option<StateAttachment>> {
733 let path = state_attachment_path(&self.root, state, id);
734 let bytes = if let Some(bytes) = read_file_bytes(&path)? {
735 bytes.as_slice().to_vec()
736 } else if let Ok(manager) = self.pack_manager().read()
737 && let Some((ObjectType::StateAttachment, bytes)) =
738 manager.get_hashed_object(id.as_hash())?
739 {
740 bytes
741 } else {
742 return Ok(None);
743 };
744 let attachment: StateAttachment = rmp_serde::from_slice(&bytes)?;
745 if attachment.state_id != *state || attachment.id() != *id {
746 return Err(HeddleError::InvalidObject(
747 "state attachment address does not match content".to_string(),
748 ));
749 }
750 Ok(Some(attachment))
751 }
752}
753
754impl FsStore {
755 pub(crate) fn snapshot_commit_recovery_descriptors_impl(
756 &self,
757 ) -> Result<Vec<SnapshotCommitDescriptor>> {
758 self.reload_packs_if_stale()?;
759 let manager = self
760 .pack_manager()
761 .read()
762 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
763 manager.snapshot_commit_recovery_descriptors()
764 }
765
766 pub(crate) fn snapshot_commit_descriptors_impl(&self) -> Result<Vec<SnapshotCommitDescriptor>> {
767 self.reload_packs_if_stale()?;
768 let manager = self
769 .pack_manager()
770 .read()
771 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
772 manager.snapshot_commit_descriptors()
773 }
774
775 pub(crate) fn snapshot_commit_descriptor_for_state_impl(
776 &self,
777 state: &StateId,
778 ) -> Result<Option<SnapshotCommitDescriptor>> {
779 self.reload_packs_if_stale()?;
780 let manager = self
781 .pack_manager()
782 .read()
783 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
784 manager.snapshot_commit_descriptor_for_state(state)
785 }
786}
787
788impl ObjectStore for FsStore {
789 fn get_annotated_tag(&self, hash: &ContentHash) -> Result<Option<AnnotatedTag>> {
790 let path = hash_path(&annotated_tags_dir(&self.root), hash);
791 if let Some(data) = read_file_bytes(&path)? {
792 return validate_annotated_tag(data.as_slice(), *hash).map(Some);
793 }
794 self.reload_packs_if_stale()?;
795 if let Ok(manager) = self.pack_manager().read()
796 && let Some((ObjectType::AnnotatedTag, data)) =
797 manager.get_object(&PackObjectId::AnnotatedTag(*hash))?
798 {
799 return validate_annotated_tag(&data, *hash).map(Some);
800 }
801 Ok(None)
802 }
803
804 fn put_annotated_tag(&self, tag: &AnnotatedTag) -> Result<ContentHash> {
805 let hash = tag.hash();
806 let path = hash_path(&annotated_tags_dir(&self.root), &hash);
807 if !path.exists() {
808 self.write_loose_object_atomic(&path, &tag.encode_current_msgpack())?;
809 }
810 Ok(hash)
811 }
812
813 fn list_annotated_tags(&self) -> Result<Vec<ContentHash>> {
814 self.reload_packs_if_stale()?;
815 let mut hashes = list_hashes_from_dir(&annotated_tags_dir(&self.root))?;
816 if let Ok(manager) = self.pack_manager().read() {
817 append_packed_hashes(&mut hashes, &manager, ObjectType::AnnotatedTag)?;
818 }
819 Ok(hashes)
820 }
821
822 fn clear_recent_caches(&self) {
823 self.clear_recent_object_caches();
824 }
825
826 fn get_blob_bytes(&self, hash: &ContentHash) -> Result<Option<bytes::Bytes>> {
833 if let Ok(manager) = self.pack_manager().read()
834 && let Some((obj_type, data)) = manager.get_hashed_object_bytes(hash)?
835 && obj_type == crate::store::pack::ObjectType::Blob
836 {
837 validate_blob_bytes(data.as_ref(), *hash)?;
838 return Ok(Some(data));
839 }
840 Ok(self
841 .get_blob(hash)?
842 .map(|blob| bytes::Bytes::from(blob.into_content())))
843 }
844
845 #[instrument(skip(self), fields(hash = %hash.short()))]
846 fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>> {
847 if let Some(blob) = self.recent_blob(hash) {
848 return Ok(Some(blob));
849 }
850 if let Some(blob) = self.try_get_blob_once(hash)? {
851 return Ok(Some(blob));
852 }
853 if self.reload_packs_if_stale()?
858 && let Some(blob) = self.try_get_blob_once(hash)?
859 {
860 return Ok(Some(blob));
861 }
862 if let Some(source) = &self.external_source
863 && let Some(blob) = source.get_blob(hash)?
864 {
865 self.cache_recent_blob(*hash, &blob);
866 return Ok(Some(blob));
867 }
868 trace!("Blob not found");
869 Ok(None)
870 }
871
872 #[instrument(skip(self, blob), fields(size = blob.content().len()))]
873 fn put_blob(&self, blob: &Blob) -> Result<ContentHash> {
874 let hash = blob.hash();
875 let path = hash_path(&blobs_dir(&self.root), &hash);
876
877 if !path.exists() {
878 let data = codec::encode_blob_content(blob.content(), &self.compression)?;
879 trace!(compressed_size = data.len(), "Writing blob");
880 self.write_loose_object_atomic(&path, &data)?;
881 } else {
882 trace!("Blob already exists, skipping write");
883 }
884 self.cache_recent_blob(hash, blob);
885
886 Ok(hash)
887 }
888
889 #[instrument(skip(self, blob), fields(hash = %hash.short()))]
890 fn put_blob_with_hash(&self, blob: &Blob, hash: ContentHash) -> Result<ContentHash> {
891 if blob.hash() != hash {
892 return Err(HeddleError::Corruption {
893 expected: hash,
894 found: blob.hash(),
895 });
896 }
897
898 let path = hash_path(&blobs_dir(&self.root), &hash);
899
900 if !path.exists() {
901 let data = codec::encode_blob_content(blob.content(), &self.compression)?;
902 trace!(
903 compressed_size = data.len(),
904 "Writing blob with precomputed hash"
905 );
906 self.write_loose_object_atomic(&path, &data)?;
907 }
908 self.cache_recent_blob(hash, blob);
909
910 Ok(hash)
911 }
912
913 #[instrument(skip(self, data), fields(hash = %hash.short(), size = data.len()))]
914 fn put_blob_bytes_with_hash(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
915 validate_blob_bytes(data, hash)?;
916
917 let path = hash_path(&blobs_dir(&self.root), &hash);
918 if !path.exists() {
919 trace!(
920 size = data.len(),
921 "Writing raw blob bytes with precomputed hash"
922 );
923 self.write_loose_object_atomic(&path, data)?;
924 }
925 self.cache_recent_blob(hash, &Blob::from_slice(data));
926
927 Ok(hash)
928 }
929
930 #[instrument(skip(self), fields(hash = %hash.short()))]
931 fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
932 if ObjectStore::has_blob_locally(self, hash)? {
933 return Ok(true);
934 }
935 if let Some(source) = &self.external_source {
936 if self.recent_blob(hash).is_some() {
937 return Ok(true);
938 }
939 if let Some(blob) = source.get_blob(hash)? {
940 self.cache_recent_blob(*hash, &blob);
941 return Ok(true);
942 }
943 }
944 Ok(false)
945 }
946
947 fn has_blob_locally(&self, hash: &ContentHash) -> Result<bool> {
948 if self.try_has_blob_once(hash)? {
949 return Ok(true);
950 }
951 Ok(self.reload_packs_if_stale()? && self.try_has_blob_once(hash)?)
952 }
953
954 fn loose_blob_path(&self, hash: &ContentHash) -> Option<PathBuf> {
971 let path = hash_path(&blobs_dir(&self.root), hash);
972 if let Ok(verified) = self.verified_loose_blobs.read()
977 && verified.contains(hash)
978 && path.exists()
979 {
980 return Some(path);
981 }
982
983 let (header, _) = read_file_header(&path, BLOB_HEADER_PEEK).ok().flatten()?;
996 if is_compressed(&header) {
997 return None;
998 }
999 let bytes = read_file_bytes(&path).ok().flatten()?;
1000 let actual = ContentHash::compute_typed("blob", bytes.as_slice());
1001 if actual != *hash {
1002 return None;
1006 }
1007 if let Ok(mut verified) = self.verified_loose_blobs.write() {
1008 verified.insert(*hash, ());
1009 }
1010 Some(path)
1011 }
1012
1013 #[instrument(skip(self), fields(hash = %hash.short()))]
1026 fn promote_to_loose_uncompressed(&self, hash: &ContentHash) -> Result<bool> {
1027 let path = hash_path(&blobs_dir(&self.root), hash);
1028
1029 if !ObjectStore::has_blob_locally(self, hash)?
1033 && let Some(source) = &self.external_source
1034 && (self.recent_blob(hash).is_some() || source.get_blob(hash)?.is_some())
1035 {
1036 return Ok(false);
1037 }
1038
1039 if let Some((header, _)) = read_file_header(&path, 9)?
1041 && !is_compressed(&header)
1042 {
1043 trace!("Blob already loose+uncompressed; skipping promotion");
1044 return Ok(false);
1045 }
1046
1047 let blob = self.get_blob(hash)?.ok_or_else(|| {
1051 HeddleError::NotFound(format!(
1052 "blob {} not found in store; cannot promote to loose-uncompressed",
1053 hash
1054 ))
1055 })?;
1056
1057 debug!(
1070 size = blob.content().len(),
1071 "Promoting blob to loose-uncompressed canonical store"
1072 );
1073 self.write_loose_object_cache(&path, blob.content())?;
1074 if let Ok(mut verified) = self.verified_loose_blobs.write() {
1075 verified.insert(*hash, ());
1076 }
1077 Ok(true)
1078 }
1079
1080 #[instrument(skip(self), fields(hash = %hash.short()))]
1081 fn blob_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
1082 if let Some(size) = self.try_get_blob_size_once(hash)? {
1083 return Ok(Some(size));
1084 }
1085 if self.reload_packs_if_stale()?
1089 && let Some(size) = self.try_get_blob_size_once(hash)?
1090 {
1091 return Ok(Some(size));
1092 }
1093 if let Some(source) = &self.external_source {
1094 if let Some(blob) = self.recent_blob(hash) {
1095 return Ok(Some(blob.content().len() as u64));
1096 }
1097 if let Some(blob) = source.get_blob(hash)? {
1098 let size = blob.content().len() as u64;
1099 self.cache_recent_blob(*hash, &blob);
1100 return Ok(Some(size));
1101 }
1102 }
1103 Ok(None)
1104 }
1105
1106 #[instrument(skip(self), fields(hash = %hash.short()))]
1107 fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>> {
1108 if let Some(tree) = self.recent_tree(hash) {
1109 return Ok(Some(tree));
1110 }
1111 if let Some(tree) = self.try_get_tree_once(hash)? {
1112 return Ok(Some(tree));
1113 }
1114 if self.reload_packs_if_stale()?
1115 && let Some(tree) = self.try_get_tree_once(hash)?
1116 {
1117 return Ok(Some(tree));
1118 }
1119 if let Some(source) = &self.external_source
1120 && let Some(tree) = source.get_tree(hash)?
1121 {
1122 self.cache_recent_tree(*hash, &tree);
1123 return Ok(Some(tree));
1124 }
1125 trace!("Tree not found");
1126 Ok(None)
1127 }
1128
1129 #[instrument(skip(self), fields(hash = %hash.short()))]
1130 fn get_tree_serialized(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
1131 if let Some(data) = self.try_get_tree_serialized_once(hash)? {
1132 return Ok(Some(data));
1133 }
1134 if self.reload_packs_if_stale()?
1135 && let Some(data) = self.try_get_tree_serialized_once(hash)?
1136 {
1137 return Ok(Some(data));
1138 }
1139 let external_tree = if let Some(tree) = self.recent_tree(hash) {
1140 Some(tree)
1141 } else if let Some(source) = &self.external_source {
1142 let tree = source.get_tree(hash)?;
1143 if let Some(tree) = &tree {
1144 self.cache_recent_tree(*hash, tree);
1145 }
1146 tree
1147 } else {
1148 None
1149 };
1150 if let Some(tree) = external_tree {
1151 return rmp_serde::to_vec_named(&tree)
1152 .map(Some)
1153 .map_err(|error| HeddleError::InvalidObject(error.to_string()));
1154 }
1155 Ok(None)
1156 }
1157
1158 #[instrument(skip(self, tree), fields(entry_count = tree.entries().len()))]
1159 fn put_tree(&self, tree: &Tree) -> Result<ContentHash> {
1160 let hash = tree.hash();
1161 let path = hash_path(&trees_dir(&self.root), &hash);
1162
1163 if !ObjectStore::has_tree_locally(self, &hash)? {
1168 let (_, data) = codec::encode_tree(tree, &self.compression)?;
1169 trace!(compressed_size = data.len(), "Writing tree");
1170 self.write_loose_object_atomic(&path, &data)?;
1171 } else {
1172 trace!("Tree already exists, skipping write");
1173 }
1174 if let Ok(mut cache) = self.recent_trees.write() {
1175 cache.insert(hash, tree.clone());
1176 }
1177
1178 Ok(hash)
1179 }
1180
1181 #[instrument(skip(self, data), fields(hash = %hash.short(), size = data.len()))]
1182 fn put_tree_serialized(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
1183 let tree = validate_tree_serialized(data, hash)?;
1184
1185 let path = hash_path(&trees_dir(&self.root), &hash);
1186 let should_write = match read_file_bytes(&path)? {
1187 Some(existing) => codec::decode_tree_body(existing.as_slice())? != data,
1188 None => true,
1189 };
1190 if should_write {
1191 trace!(size = data.len(), "Writing raw serialized tree");
1192 self.write_loose_object_atomic(&path, data)?;
1193 }
1194 if let Ok(mut cache) = self.recent_trees.write() {
1195 cache.insert(hash, tree);
1196 }
1197
1198 Ok(hash)
1199 }
1200
1201 #[instrument(skip(self), fields(hash = %hash.short()))]
1202 fn has_tree(&self, hash: &ContentHash) -> Result<bool> {
1203 if ObjectStore::has_tree_locally(self, hash)? {
1204 return Ok(true);
1205 }
1206 if let Some(source) = &self.external_source {
1207 if self.recent_tree(hash).is_some() {
1208 return Ok(true);
1209 }
1210 if let Some(tree) = source.get_tree(hash)? {
1211 self.cache_recent_tree(*hash, &tree);
1212 return Ok(true);
1213 }
1214 }
1215 Ok(false)
1216 }
1217
1218 fn has_tree_locally(&self, hash: &ContentHash) -> Result<bool> {
1219 if self.try_has_tree_once(hash)? {
1220 return Ok(true);
1221 }
1222 Ok(self.reload_packs_if_stale()? && self.try_has_tree_once(hash)?)
1223 }
1224
1225 #[instrument(skip(self), fields(id = %id.short()))]
1226 fn get_state(&self, id: &StateId) -> Result<Option<State>> {
1227 if let Some(state) = self.recent_state(id) {
1228 return Ok(Some(state));
1229 }
1230 if let Some(state) = self.try_get_state_once(id)? {
1231 return Ok(Some(state));
1232 }
1233 if self.reload_packs_if_stale()?
1234 && let Some(state) = self.try_get_state_once(id)?
1235 {
1236 return Ok(Some(state));
1237 }
1238 if let Some(source) = &self.external_source
1239 && let Some(state) = source.get_state(id)?
1240 {
1241 self.cache_recent_state(*id, &state);
1242 return Ok(Some(state));
1243 }
1244 trace!("State not found");
1245 Ok(None)
1246 }
1247
1248 #[instrument(skip(self, state), fields(id = %state.id().short()))]
1249 fn put_state(&self, state: &State) -> Result<()> {
1250 let state_id = state.id();
1251 let path = state_path(&self.root, &state_id);
1252 let data = codec::encode_state(state, &self.compression)?;
1253 trace!(compressed_size = data.len(), "Writing state");
1254 self.write_loose_object_atomic(&path, &data)?;
1255 if let Ok(mut cache) = self.recent_states.write() {
1256 let mut cached = state.clone();
1257 cached.state_id = state_id;
1258 cache.insert(state_id, cached);
1259 }
1260 Ok(())
1261 }
1262
1263 #[instrument(skip(self, data), fields(id = %id.short(), size = data.len()))]
1264 fn put_state_serialized(&self, data: &[u8], id: StateId) -> Result<()> {
1265 let state = validate_state_serialized(data, id)?;
1266 let path = state_path(&self.root, &id);
1267 trace!(size = data.len(), "Writing raw serialized state");
1268 self.write_loose_object_atomic(&path, data)?;
1269 if let Ok(mut cache) = self.recent_states.write() {
1270 cache.insert(id, state);
1271 }
1272 Ok(())
1273 }
1274
1275 #[instrument(skip(self), fields(id = %id.short()))]
1276 fn has_state(&self, id: &StateId) -> Result<bool> {
1277 if self.try_has_state_once(id)? {
1278 return Ok(true);
1279 }
1280 if self.reload_packs_if_stale()? && self.try_has_state_once(id)? {
1281 return Ok(true);
1282 }
1283 if let Some(source) = &self.external_source {
1284 if self.recent_state(id).is_some() {
1285 return Ok(true);
1286 }
1287 if let Some(state) = source.get_state(id)? {
1288 self.cache_recent_state(*id, &state);
1289 return Ok(true);
1290 }
1291 }
1292 Ok(false)
1293 }
1294
1295 #[instrument(skip(self))]
1296 fn list_states(&self) -> Result<Vec<StateId>> {
1297 self.reload_packs_if_stale()?;
1298
1299 let mut states = Vec::new();
1300 let mut known = HashSet::new();
1301 let dir = states_dir(&self.root);
1302 if dir.exists() {
1303 for entry in fs::read_dir(&dir)? {
1304 let entry = entry?;
1305 let path = entry.path();
1306 if let Some(name) = path.file_stem()
1307 && let Some(name_str) = name.to_str()
1308 && let Ok(id) = StateId::parse(name_str)
1309 && known.insert(id)
1310 {
1311 states.push(id);
1312 }
1313 }
1314 }
1315 if let Ok(manager) = self.pack_manager().read() {
1316 append_unique_states(
1317 &mut states,
1318 &mut known,
1319 manager
1320 .list_all_ids()?
1321 .into_iter()
1322 .filter_map(|id| match id {
1323 PackObjectId::StateId(state) => Some(state),
1324 PackObjectId::Hash(_) | PackObjectId::AnnotatedTag(_) => None,
1325 }),
1326 );
1327 }
1328 if let Some(source) = &self.external_source {
1329 append_unique_states(&mut states, &mut known, source.list_states()?);
1330 }
1331 debug!(count = states.len(), "Listed states");
1332 Ok(states)
1333 }
1334
1335 fn get_state_attachment(
1336 &self,
1337 state: &StateId,
1338 id: &StateAttachmentId,
1339 ) -> Result<Option<StateAttachment>> {
1340 if let Some(attachment) = self.try_get_state_attachment_once(state, id)? {
1341 return Ok(Some(attachment));
1342 }
1343 if self.reload_packs_if_stale()? {
1344 return self.try_get_state_attachment_once(state, id);
1345 }
1346 Ok(None)
1347 }
1348
1349 fn put_state_attachment(&self, attachment: &StateAttachment) -> Result<StateAttachmentId> {
1350 let id = attachment.id();
1351 self.with_state_attachment_index_lock(&attachment.state_id, || {
1352 let index_path = state_attachment_index_path(&self.root, &attachment.state_id);
1353 let mut ids: Vec<StateAttachmentId> = match read_file_bytes(&index_path)? {
1354 Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
1355 None => self.rebuild_state_attachment_index(&attachment.state_id)?,
1356 };
1357 if !ids.contains(&id) {
1358 ids.push(id);
1359 ids.sort();
1360 self.write_loose_object_atomic(&index_path, &rmp_serde::to_vec_named(&ids)?)?;
1361 }
1362 let path = state_attachment_path(&self.root, &attachment.state_id, &id);
1363 self.write_loose_object_atomic(&path, &rmp_serde::to_vec_named(attachment)?)?;
1364 Ok(id)
1365 })
1366 }
1367
1368 fn list_state_attachments(&self, state: &StateId) -> Result<Vec<StateAttachment>> {
1369 self.with_state_attachment_index_lock(state, || {
1370 let index_path = state_attachment_index_path(&self.root, state);
1371 let mut ids: Vec<StateAttachmentId> = match read_file_bytes(&index_path)? {
1372 Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
1373 None => self.rebuild_state_attachment_index(state)?,
1374 };
1375 let mut attachments = Vec::new();
1376 let mut stale = false;
1377 for id in &ids {
1378 match self.get_state_attachment(state, id)? {
1379 Some(attachment) => attachments.push(attachment),
1380 None => stale = true,
1381 }
1382 }
1383 if stale {
1384 ids = self.rebuild_state_attachment_index(state)?;
1385 attachments.clear();
1386 for id in ids {
1387 let attachment = self.get_state_attachment(state, &id)?.ok_or_else(|| {
1388 HeddleError::InvalidObject(format!(
1389 "rebuilt state attachment index references missing {id}"
1390 ))
1391 })?;
1392 attachments.push(attachment);
1393 }
1394 }
1395 Ok(attachments)
1396 })
1397 }
1398
1399 #[instrument(skip(self), fields(id = %id))]
1400 fn get_action(&self, id: &ActionId) -> Result<Option<Action>> {
1401 if let Some(action) = self.try_get_action_once(id)? {
1402 return Ok(Some(action));
1403 }
1404 if self.reload_packs_if_stale()? {
1405 return self.try_get_action_once(id);
1406 }
1407 trace!("Action not found");
1408 Ok(None)
1409 }
1410
1411 #[instrument(skip(self, action))]
1412 fn put_action(&self, action: &mut Action) -> Result<ActionId> {
1413 let id = action.id();
1414 let path = action_path(&self.root, &id);
1415
1416 if !path.exists() {
1417 let (_, data) = codec::encode_action(action, &self.compression)?;
1418 trace!(id = %id, compressed_size = data.len(), "Writing action");
1419 self.write_loose_object_atomic(&path, &data)?;
1420 }
1421
1422 Ok(id)
1423 }
1424
1425 #[instrument(skip(self))]
1426 fn list_actions(&self) -> Result<Vec<ActionId>> {
1427 self.reload_packs_if_stale()?;
1428 let dir = actions_dir(&self.root);
1429 let mut action_hashes = Vec::new();
1430 if dir.exists() {
1431 for entry in fs::read_dir(&dir)? {
1432 let entry = entry?;
1433 let path = entry.path();
1434 if let Some(name) = path.file_stem()
1435 && let Some(name_str) = name.to_str()
1436 && let Ok(hash) = ContentHash::from_hex(name_str)
1437 {
1438 action_hashes.push(hash);
1439 }
1440 }
1441 }
1442 if let Ok(manager) = self.pack_manager().read() {
1443 append_packed_hashes(&mut action_hashes, &manager, ObjectType::Action)?;
1444 }
1445 let actions = action_hashes
1446 .into_iter()
1447 .map(ActionId::from_hash)
1448 .collect::<Vec<_>>();
1449 debug!(count = actions.len(), "Listed actions");
1450 Ok(actions)
1451 }
1452
1453 #[instrument(skip(self))]
1454 fn list_blobs(&self) -> Result<Vec<ContentHash>> {
1455 self.reload_packs_if_stale()?;
1456 let dir = blobs_dir(&self.root);
1457 let mut blobs = list_hashes_from_dir(&dir)?;
1458 if let Ok(manager) = self.pack_manager().read() {
1459 append_packed_hashes(&mut blobs, &manager, ObjectType::Blob)?;
1460 }
1461 Ok(blobs)
1462 }
1463
1464 #[instrument(skip(self))]
1465 fn list_trees(&self) -> Result<Vec<ContentHash>> {
1466 self.reload_packs_if_stale()?;
1467 let dir = trees_dir(&self.root);
1468 let mut trees = list_hashes_from_dir(&dir)?;
1469 if let Ok(manager) = self.pack_manager().read() {
1470 append_packed_hashes(&mut trees, &manager, ObjectType::Tree)?;
1471 }
1472 Ok(trees)
1473 }
1474
1475 #[instrument(skip(self))]
1476 fn pack_objects(&self, delta_search: bool) -> Result<(u64, u64)> {
1477 self.pack_objects_impl(delta_search)
1478 }
1479
1480 #[instrument(skip(self), fields(id = ?id))]
1481 fn get_pack_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
1482 if let Ok(manager) = self.pack_manager().read()
1483 && let Some((obj_type, data)) = manager.get_object(id)?
1484 {
1485 return Ok(Some((obj_type, data)));
1486 }
1487
1488 match id {
1489 PackObjectId::AnnotatedTag(hash) => Ok(self
1490 .get_annotated_tag(hash)?
1491 .map(|tag| (ObjectType::AnnotatedTag, tag.encode_current_msgpack()))),
1492 PackObjectId::Hash(hash) => {
1493 if let Some(blob) = self.get_blob(hash)? {
1494 return Ok(Some((ObjectType::Blob, blob.content().to_vec())));
1495 }
1496 if let Some(tree) = self.get_tree(hash)? {
1497 return Ok(Some((ObjectType::Tree, rmp_serde::to_vec_named(&tree)?)));
1498 }
1499 if let Some(action) = self.get_action(&ActionId::from_hash(*hash))? {
1500 return Ok(Some((
1501 ObjectType::Action,
1502 rmp_serde::to_vec_named(&action)?,
1503 )));
1504 }
1505 Ok(None)
1506 }
1507 PackObjectId::StateId(change_id) => {
1508 if let Some(state) = self.get_state(change_id)? {
1509 Ok(Some((ObjectType::State, rmp_serde::to_vec_named(&state)?)))
1510 } else {
1511 Ok(None)
1512 }
1513 }
1514 }
1515 }
1516
1517 #[instrument(skip(self, pack_data, index_data))]
1518 fn install_pack(&self, pack_data: &[u8], index_data: &[u8]) -> Result<Vec<PackObjectId>> {
1519 let reader = crate::store::pack::PackReader::from_slice(pack_data, index_data)?;
1520 let ids = validate_and_list_pack(&reader)?;
1521 let state_entries = state_entries_from_pack(&reader, &ids)?;
1522 let attachment_entries = attachment_entries_from_pack(&reader, &ids)?;
1523 self.install_pack_files(pack_data, index_data)?;
1524 self.write_packed_state_mirrors_batch(state_entries)?;
1525 for attachment in attachment_entries {
1526 self.put_state_attachment(&attachment)?;
1527 }
1528 self.clear_recent_object_caches();
1529 Ok(ids)
1530 }
1531
1532 #[instrument(skip(self, blobs), fields(count = blobs.len()))]
1533 fn put_blobs_packed(&self, blobs: Vec<(crate::object::ContentHash, Vec<u8>)>) -> Result<()> {
1534 self.put_blobs_packed_impl(blobs)
1535 }
1536
1537 #[instrument(skip(self, blobs, tree, state), fields(blob_count = blobs.len()))]
1538 fn put_snapshot_objects_packed(
1539 &self,
1540 blobs: Vec<(ContentHash, Vec<u8>)>,
1541 tree: &Tree,
1542 state: &State,
1543 ) -> Result<()> {
1544 self.put_snapshot_objects_packed_impl(blobs, Vec::new(), tree, state, Vec::new(), None)
1545 .map(|_| ())
1546 }
1547
1548 fn put_snapshot_objects_and_attachments_packed(
1549 &self,
1550 blobs: Vec<(ContentHash, Vec<u8>)>,
1551 tree: &Tree,
1552 state: &State,
1553 attachments: Vec<StateAttachment>,
1554 ) -> Result<()> {
1555 self.put_snapshot_objects_packed_impl(blobs, Vec::new(), tree, state, attachments, None)
1556 .map(|_| ())
1557 }
1558
1559 #[instrument(skip(self))]
1560 fn install_pack_streaming(
1561 &self,
1562 pack_path: &std::path::Path,
1563 index_path: &std::path::Path,
1564 ) -> Result<Vec<PackObjectId>> {
1565 let ids = {
1571 let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1572 validate_and_list_pack(&reader)?
1573 };
1574 let state_entries = {
1575 let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1576 state_entries_from_pack(&reader, &ids)?
1577 };
1578 let attachment_entries = {
1579 let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1580 attachment_entries_from_pack(&reader, &ids)?
1581 };
1582 self.install_pack_files_streaming(pack_path, index_path)?;
1583 self.write_packed_state_mirrors_batch(state_entries)?;
1584 for attachment in attachment_entries {
1585 self.put_state_attachment(&attachment)?;
1586 }
1587 Ok(ids)
1588 }
1589
1590 #[instrument(skip(self))]
1591 fn prune_loose_objects(&self) -> Result<(u64, u64)> {
1592 self.prune_loose_objects_impl()
1593 }
1594
1595 fn discard_corrupt_clone_packs(&self) -> Result<usize> {
1596 let packs = super::fs_paths::packs_dir(&self.root);
1597 let mut removed = 0;
1598 for entry in match fs::read_dir(&packs) {
1599 Ok(entries) => entries,
1600 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
1601 Err(error) => return Err(error.into()),
1602 } {
1603 let path = entry?.path();
1604 if path.extension().and_then(|value| value.to_str()) != Some("pack") {
1605 continue;
1606 }
1607 let index = path.with_extension("idx");
1608 let valid = crate::store::pack::PackReader::open(&path, &index)
1609 .and_then(|reader| {
1610 reader.visit_objects(|id, kind, bytes| validate_pack_entry(&id, kind, bytes))
1611 })
1612 .is_ok();
1613 if !valid {
1614 let _ = fs::remove_file(&path);
1615 let _ = fs::remove_file(&index);
1616 removed += 1;
1617 }
1618 }
1619 if removed > 0 {
1620 self.reload_packs()?;
1621 self.clear_recent_object_caches();
1622 }
1623 Ok(removed)
1624 }
1625
1626 #[instrument(skip(self))]
1627 fn begin_snapshot_write_batch(&self) -> Result<()> {
1628 self.begin_snapshot_write_batch_impl()
1629 }
1630
1631 #[instrument(skip(self))]
1632 fn flush_snapshot_write_batch(&self) -> Result<()> {
1633 self.flush_snapshot_write_batch_impl()
1634 }
1635
1636 #[instrument(skip(self))]
1637 fn abort_snapshot_write_batch(&self) {
1638 self.abort_snapshot_write_batch_impl();
1639 }
1640
1641 fn has_redactions_for_blob(&self, blob: &ContentHash) -> Result<bool> {
1642 Ok(redaction_path(&self.root, blob).exists())
1643 }
1644
1645 fn get_redactions_bytes_for_blob(&self, blob: &ContentHash) -> Result<Option<Vec<u8>>> {
1646 let path = redaction_path(&self.root, blob);
1647 match fs::read(&path) {
1648 Ok(bytes) => Ok(Some(bytes)),
1649 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1650 Err(err) => Err(HeddleError::Io(err)),
1651 }
1652 }
1653
1654 fn put_redactions_bytes_for_blob(&self, blob: &ContentHash, bytes: &[u8]) -> Result<()> {
1655 let dir = redactions_dir(&self.root);
1656 if !dir.exists() {
1657 crate::fs_atomic::create_dir_all_durable(&dir)?;
1658 }
1659 let path = redaction_path(&self.root, blob);
1660 crate::fs_atomic::write_file_atomic(&path, bytes)?;
1661 Ok(())
1662 }
1663
1664 fn list_blobs_with_redactions(&self) -> Result<Vec<ContentHash>> {
1665 let dir = redactions_dir(&self.root);
1666 if !dir.exists() {
1667 return Ok(Vec::new());
1668 }
1669 let mut out = Vec::new();
1670 for entry in fs::read_dir(&dir)? {
1671 let entry = entry?;
1672 let path = entry.path();
1673 if path.extension().and_then(|e| e.to_str()) != Some("bin") {
1674 continue;
1675 }
1676 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1677 continue;
1678 };
1679 if let Ok(hash) = ContentHash::from_hex(stem) {
1680 out.push(hash);
1681 }
1682 }
1683 Ok(out)
1684 }
1685
1686 fn has_state_visibility_for_state(&self, state: &StateId) -> Result<bool> {
1687 Ok(state_visibility_path(&self.root, state).exists())
1688 }
1689
1690 fn get_state_visibility_bytes_for_state(&self, state: &StateId) -> Result<Option<Vec<u8>>> {
1691 let path = state_visibility_path(&self.root, state);
1692 match fs::read(&path) {
1693 Ok(bytes) => Ok(Some(bytes)),
1694 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1695 Err(err) => Err(HeddleError::Io(err)),
1696 }
1697 }
1698
1699 fn put_state_visibility_bytes_for_state(&self, state: &StateId, bytes: &[u8]) -> Result<()> {
1700 let dir = state_visibility_dir(&self.root);
1701 if !dir.exists() {
1702 crate::fs_atomic::create_dir_all_durable(&dir)?;
1703 }
1704 let path = state_visibility_path(&self.root, state);
1705 crate::fs_atomic::write_file_atomic(&path, bytes)?;
1706 Ok(())
1707 }
1708
1709 fn list_states_with_visibility(&self) -> Result<Vec<StateId>> {
1710 let dir = state_visibility_dir(&self.root);
1711 if !dir.exists() {
1712 return Ok(Vec::new());
1713 }
1714 let mut out = Vec::new();
1715 for entry in fs::read_dir(&dir)? {
1716 let entry = entry?;
1717 let path = entry.path();
1718 if path.extension().and_then(|e| e.to_str()) != Some("bin") {
1719 continue;
1720 }
1721 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1722 continue;
1723 };
1724 if let Ok(state) = StateId::parse(stem) {
1725 out.push(state);
1726 }
1727 }
1728 Ok(out)
1729 }
1730}
1731
1732#[cfg(test)]
1733mod state_attachment_tests {
1734 use std::sync::Arc;
1735
1736 use chrono::Utc;
1737
1738 use super::*;
1739 use crate::{
1740 object::{Attribution, Principal, StateAttachmentBody},
1741 store::{CompressionConfig, pack::PackBuilder},
1742 };
1743
1744 fn fixture(store: &FsStore) -> (State, StateAttachment) {
1745 let tree = store.put_tree(&Tree::new()).unwrap();
1746 let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
1747 let state = State::new(tree, vec![], attribution.clone());
1748 store.put_state(&state).unwrap();
1749 let attachment = StateAttachment {
1750 state_id: state.id(),
1751 body: StateAttachmentBody::Context(ContentHash::compute(b"context")),
1752 attribution,
1753 created_at: Utc::now(),
1754 supersedes: None,
1755 };
1756 (state, attachment)
1757 }
1758
1759 #[test]
1760 fn concurrent_attachment_writes_keep_every_index_entry() {
1761 let temp = tempfile::TempDir::new().unwrap();
1762 let store = Arc::new(FsStore::new(temp.path()));
1763 let (state, base) = fixture(&store);
1764 let mut threads = Vec::new();
1765 for byte in 0..16u8 {
1766 let store = Arc::clone(&store);
1767 let mut attachment = base.clone();
1768 attachment.body = StateAttachmentBody::Context(ContentHash::compute(&[byte]));
1769 threads.push(std::thread::spawn(move || {
1770 store.put_state_attachment(&attachment).unwrap();
1771 }));
1772 }
1773 for thread in threads {
1774 thread.join().unwrap();
1775 }
1776 assert_eq!(store.list_state_attachments(&state.id()).unwrap().len(), 16);
1777 }
1778
1779 #[test]
1780 fn missing_index_rebuilds_from_loose_objects() {
1781 let temp = tempfile::TempDir::new().unwrap();
1782 let store = FsStore::new(temp.path());
1783 let (state, attachment) = fixture(&store);
1784 store.put_state_attachment(&attachment).unwrap();
1785 fs::remove_file(state_attachment_index_path(&store.root, &state.id())).unwrap();
1786 assert_eq!(
1787 store.list_state_attachments(&state.id()).unwrap(),
1788 vec![attachment]
1789 );
1790 }
1791
1792 #[test]
1793 fn packed_attachment_uses_state_index_for_lookup() {
1794 let temp = tempfile::TempDir::new().unwrap();
1795 let store = FsStore::new(temp.path());
1796 let (state, attachment) = fixture(&store);
1797 let mut builder = PackBuilder::new(CompressionConfig::default());
1798 builder.add(
1799 *attachment.id().as_hash(),
1800 ObjectType::StateAttachment,
1801 rmp_serde::to_vec_named(&attachment).unwrap(),
1802 );
1803 let (pack, index, _) = builder.build().unwrap();
1804 store.install_pack(&pack, &index).unwrap();
1805 fs::remove_file(state_attachment_path(
1806 &store.root,
1807 &state.id(),
1808 &attachment.id(),
1809 ))
1810 .unwrap();
1811 let rebuild_marker =
1812 state_attachment_index_path(&store.root, &state.id()).with_extension("rebuild-marker");
1813 let _ = fs::remove_file(&rebuild_marker);
1814 assert_eq!(
1815 store.list_state_attachments(&state.id()).unwrap(),
1816 vec![attachment.clone()]
1817 );
1818 assert_eq!(
1819 store.list_state_attachments(&state.id()).unwrap(),
1820 vec![attachment]
1821 );
1822 assert!(!rebuild_marker.exists());
1823 }
1824}
1825
1826#[cfg(test)]
1827mod enumeration_tests {
1828 use heddle_format::{compression::CompressionConfig, delta::DeltaEncoder};
1829 use tempfile::TempDir;
1830
1831 use super::*;
1832 use crate::store::pack::{
1833 PackBuilder, PackContainerSpec, PackIndex, append_container_checksum,
1834 encode_tagged_entry_parts, write_container_header,
1835 };
1836
1837 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1838 struct TestEnumerationMetrics {
1839 membership_checks: u64,
1840 header_reads: u64,
1841 full_object_decodes: u64,
1842 }
1843
1844 impl EnumerationCounter for TestEnumerationMetrics {
1845 fn membership_check(&mut self) {
1846 self.membership_checks += 1;
1847 }
1848
1849 fn header_read(&mut self) {
1850 self.header_reads += 1;
1851 }
1852 }
1853
1854 fn install_pack_files(
1855 dir: &TempDir,
1856 name: &str,
1857 pack_data: &[u8],
1858 index_data: &[u8],
1859 ) -> PackManager {
1860 fs::write(dir.path().join(format!("{name}.pack")), pack_data).unwrap();
1861 fs::write(dir.path().join(format!("{name}.idx")), index_data).unwrap();
1862 PackManager::new(dir.path().to_path_buf())
1863 }
1864
1865 fn raw_mixed_manager() -> (TempDir, PackManager, Vec<(ContentHash, ObjectType)>) {
1866 let dir = TempDir::new().unwrap();
1867 let objects = [
1868 (ObjectType::Blob, b"packed blob".as_slice()),
1869 (ObjectType::Tree, b"packed tree".as_slice()),
1870 (ObjectType::Action, b"packed action".as_slice()),
1871 ];
1872 let mut builder = PackBuilder::new(CompressionConfig::disabled());
1873 let mut classified = Vec::new();
1874 for (obj_type, data) in objects {
1875 let hash = ContentHash::compute_typed("enumeration-test", data);
1876 builder.add(hash, obj_type, data.to_vec());
1877 classified.push((hash, obj_type));
1878 }
1879 let (pack, index, _) = builder.build().unwrap();
1880 let manager = install_pack_files(&dir, "mixed", &pack, &index);
1881 (dir, manager, classified)
1882 }
1883
1884 fn delta_chain_manager() -> (TempDir, PackManager, Vec<ContentHash>) {
1885 const SPEC: PackContainerSpec = PackContainerSpec {
1886 magic: b"LMPK",
1887 version: 4,
1888 };
1889 let dir = TempDir::new().unwrap();
1890 let base = b"delta-chain base payload ".repeat(64);
1891 let mut middle = base.clone();
1892 middle[200..208].copy_from_slice(b"middle!!");
1893 let mut tip = middle.clone();
1894 tip[900..908].copy_from_slice(b"tip!!!!!");
1895 let bodies = [&base, &middle, &tip];
1896 let hashes = bodies
1897 .iter()
1898 .map(|body| ContentHash::compute_typed("blob", body))
1899 .collect::<Vec<_>>();
1900 let middle_delta = DeltaEncoder::encode(&base, &middle);
1901 let tip_delta = DeltaEncoder::encode(&middle, &tip);
1902
1903 let mut pack = Vec::new();
1904 let mut index = PackIndex::new();
1905 write_container_header(&mut pack, SPEC, 3);
1906 for (position, payload) in [
1907 base.as_slice(),
1908 middle_delta.as_slice(),
1909 tip_delta.as_slice(),
1910 ]
1911 .into_iter()
1912 .enumerate()
1913 {
1914 index.add(PackObjectId::Hash(hashes[position]), pack.len() as u64);
1915 let (stored_type, base_id) = if position == 0 {
1916 (ObjectType::Blob, None)
1917 } else {
1918 (
1919 ObjectType::Delta,
1920 Some(PackObjectId::Hash(hashes[position - 1])),
1921 )
1922 };
1923 encode_tagged_entry_parts(
1924 &mut pack,
1925 PackObjectId::Hash(hashes[position]),
1926 stored_type,
1927 bodies[position].len(),
1928 base_id,
1929 payload,
1930 )
1931 .unwrap();
1932 }
1933 index.sort();
1934 append_container_checksum(&mut pack);
1935 let manager = install_pack_files(&dir, "delta-chain", &pack, &index.to_bytes());
1936 (dir, manager, hashes)
1937 }
1938
1939 fn legacy_append_packed_hashes(
1940 hashes: &mut Vec<ContentHash>,
1941 manager: &PackManager,
1942 expected_type: ObjectType,
1943 ) -> Result<TestEnumerationMetrics> {
1944 let mut metrics = TestEnumerationMetrics::default();
1945 for id in manager.list_all_ids()? {
1946 let PackObjectId::Hash(hash) = id else {
1947 continue;
1948 };
1949 let mut already_listed = false;
1950 for listed in hashes.iter() {
1951 metrics.membership_checks += 1;
1952 if listed == &hash {
1953 already_listed = true;
1954 break;
1955 }
1956 }
1957 if already_listed {
1958 continue;
1959 }
1960 metrics.full_object_decodes += 1;
1961 if let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
1962 && obj_type == expected_type
1963 {
1964 hashes.push(hash);
1965 }
1966 }
1967 Ok(metrics)
1968 }
1969
1970 fn assert_new_matches_legacy(
1971 label: &str,
1972 manager: &PackManager,
1973 loose: Vec<ContentHash>,
1974 expected_type: ObjectType,
1975 ) {
1976 let mut new = loose.clone();
1977 let mut new_metrics = TestEnumerationMetrics::default();
1978 append_packed_hashes_with_counter(&mut new, manager, expected_type, &mut new_metrics)
1979 .unwrap();
1980 let mut legacy = loose;
1981 legacy_append_packed_hashes(&mut legacy, manager, expected_type).unwrap();
1982 assert_eq!(new, legacy, "fixture {label} changed output or ordering");
1983 assert_eq!(new_metrics.full_object_decodes, 0, "fixture {label}");
1984 }
1985
1986 #[test]
1987 fn type_only_enumeration_matches_full_decode_across_fixture_set() {
1988 let empty_dir = TempDir::new().unwrap();
1989 let empty = PackManager::new(empty_dir.path().to_path_buf());
1990 let loose_hash = ContentHash::compute(b"loose only");
1991 assert_new_matches_legacy("loose-only", &empty, vec![loose_hash], ObjectType::Blob);
1992
1993 let (_raw_dir, raw, classified) = raw_mixed_manager();
1994 for expected_type in [ObjectType::Blob, ObjectType::Tree, ObjectType::Action] {
1995 assert_new_matches_legacy("packed-only", &raw, Vec::new(), expected_type);
1996 assert_new_matches_legacy(
1997 "mixed",
1998 &raw,
1999 vec![ContentHash::compute_typed("loose", &[expected_type as u8])],
2000 expected_type,
2001 );
2002 let duplicate = classified
2003 .iter()
2004 .find_map(|(hash, obj_type)| (*obj_type == expected_type).then_some(*hash))
2005 .unwrap();
2006 assert_new_matches_legacy(
2007 "duplicate-loose-packed",
2008 &raw,
2009 vec![duplicate],
2010 expected_type,
2011 );
2012 }
2013 for (hash, expected_type) in classified {
2014 assert_eq!(
2015 raw.get_hashed_object_type(&hash).unwrap(),
2016 raw.get_hashed_object(&hash)
2017 .unwrap()
2018 .map(|(obj_type, _)| obj_type)
2019 );
2020 assert_eq!(
2021 raw.get_hashed_object_type(&hash).unwrap(),
2022 Some(expected_type)
2023 );
2024 }
2025
2026 let (_delta_dir, delta, delta_hashes) = delta_chain_manager();
2027 assert_new_matches_legacy("two-link-delta-chain", &delta, Vec::new(), ObjectType::Blob);
2028 for hash in delta_hashes {
2029 assert_eq!(
2030 delta.get_hashed_object_type(&hash).unwrap(),
2031 Some(ObjectType::Blob)
2032 );
2033 assert_eq!(
2034 delta.get_hashed_object_type(&hash).unwrap(),
2035 delta
2036 .get_hashed_object(&hash)
2037 .unwrap()
2038 .map(|(obj_type, _)| obj_type)
2039 );
2040 }
2041 }
2042
2043 #[test]
2044 fn structural_counter_rejects_vec_scan_and_full_decode_negative_control() {
2045 let (_dir, manager, _) = raw_mixed_manager();
2046 let loose = (0..64u8)
2047 .map(|byte| ContentHash::compute_typed("loose", &[byte]))
2048 .collect::<Vec<_>>();
2049 let packed_hashes = manager
2050 .list_all_ids()
2051 .unwrap()
2052 .into_iter()
2053 .filter(|id| matches!(id, PackObjectId::Hash(_)))
2054 .count() as u64;
2055
2056 for expected_type in [ObjectType::Blob, ObjectType::Tree, ObjectType::Action] {
2057 let mut optimized = loose.clone();
2058 let mut optimized_metrics = TestEnumerationMetrics::default();
2059 append_packed_hashes_with_counter(
2060 &mut optimized,
2061 &manager,
2062 expected_type,
2063 &mut optimized_metrics,
2064 )
2065 .unwrap();
2066 assert_eq!(optimized_metrics.membership_checks, packed_hashes);
2067 assert_eq!(optimized_metrics.header_reads, packed_hashes);
2068 assert_eq!(optimized_metrics.full_object_decodes, 0);
2069
2070 let mut legacy = loose.clone();
2071 let legacy_metrics =
2072 legacy_append_packed_hashes(&mut legacy, &manager, expected_type).unwrap();
2073 assert!(legacy_metrics.membership_checks >= loose.len() as u64 * packed_hashes);
2074 assert_eq!(legacy_metrics.full_object_decodes, packed_hashes);
2075 assert!(
2076 !(legacy_metrics.membership_checks <= packed_hashes
2077 && legacy_metrics.full_object_decodes == 0),
2078 "negative control unexpectedly passed the structural contract: {legacy_metrics:?}"
2079 );
2080 }
2081 }
2082
2083 #[test]
2084 fn state_union_preserves_first_seen_order_at_scale() {
2085 let first = (0..20_000u32)
2086 .map(|value| {
2087 StateId::from_bytes(*ContentHash::compute(&value.to_le_bytes()).as_bytes())
2088 })
2089 .collect::<Vec<_>>();
2090 let second = first[10_000..]
2091 .iter()
2092 .copied()
2093 .chain((20_000..30_000u32).map(|value| {
2094 StateId::from_bytes(*ContentHash::compute(&value.to_le_bytes()).as_bytes())
2095 }))
2096 .collect::<Vec<_>>();
2097 let mut states = Vec::new();
2098 let mut known = HashSet::new();
2099
2100 append_unique_states(&mut states, &mut known, first.iter().copied());
2101 append_unique_states(&mut states, &mut known, second);
2102
2103 assert_eq!(states.len(), 30_000);
2104 assert_eq!(&states[..20_000], first.as_slice());
2105 }
2106}