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