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