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