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