1use std::{
5 fs::{self, OpenOptions},
6 path::{Path, PathBuf},
7};
8
9use fs2::FileExt;
10use heddle_format::compression::{header_uncompressed_size, is_compressed};
11use tracing::{debug, instrument, trace};
12
13use super::{
14 FsStore,
15 fs_io::{list_hashes_from_dir, read_file_bytes, read_file_header},
16 fs_paths::{
17 action_path, actions_dir, blobs_dir, hash_path, redaction_path, redactions_dir,
18 state_attachment_index_lock_path, state_attachment_index_path, state_attachment_path,
19 state_attachments_dir, state_path, state_visibility_dir, state_visibility_path, states_dir,
20 trees_dir,
21 },
22};
23use crate::{
24 object::{
25 Action, ActionId, Blob, ContentHash, State, StateAttachment, StateAttachmentId, StateId,
26 Tree,
27 },
28 store::{
29 HeddleError, ObjectStore, Result, SnapshotCommitDescriptor, codec,
30 pack::{ObjectType, PackManager, PackObjectId},
31 },
32};
33
34const BLOB_HEADER_PEEK: usize = 13;
42
43fn validate_loaded_tree(tree: Tree) -> Result<Tree> {
44 tree.validate()?;
45 Ok(tree)
46}
47
48fn validate_blob_bytes(data: &[u8], hash: ContentHash) -> Result<()> {
49 let mut hasher = ContentHash::typed_hasher("blob", data.len() as u64);
50 hasher.update(data);
51 let found = ContentHash::from_bytes(hasher.finalize().into());
52 if found != hash {
53 return Err(HeddleError::Corruption {
54 expected: hash,
55 found,
56 });
57 }
58
59 Ok(())
60}
61
62fn validate_tree_serialized(data: &[u8], hash: ContentHash) -> Result<Tree> {
63 let tree = codec::decode_tree_serialized(data)?;
64 let tree = validate_loaded_tree(tree)?;
65 let found = tree.hash();
66 if found != hash {
67 return Err(HeddleError::Corruption {
68 expected: hash,
69 found,
70 });
71 }
72
73 Ok(tree)
74}
75
76fn validate_loaded_state(requested_id: &StateId, mut state: State) -> Result<State> {
77 let computed = state.id();
78 if computed != *requested_id {
79 return Err(HeddleError::InvalidObject(format!(
80 "state id mismatch: requested {requested_id}, computed {computed}"
81 )));
82 }
83 state.state_id = computed;
84 Ok(state)
85}
86
87fn validate_state_serialized(data: &[u8], id: StateId) -> Result<State> {
88 let state: State = rmp_serde::from_slice(data)?;
89 validate_loaded_state(&id, state)
90}
91
92fn validate_loaded_action(requested_id: &ActionId, action: Action) -> Result<Action> {
93 let found_id = action.compute_id();
94 if found_id != *requested_id {
95 return Err(HeddleError::InvalidObject(format!(
96 "action id mismatch: requested {}, found {}",
97 requested_id, found_id
98 )));
99 }
100
101 Ok(action)
102}
103
104fn validate_action_serialized(data: &[u8], id: ActionId) -> Result<Action> {
105 let action: Action = rmp_serde::from_slice(data)?;
106 validate_loaded_action(&id, action)
107}
108
109impl FsStore {
110 fn with_state_attachment_index_lock<T>(
111 &self,
112 state: &StateId,
113 operation: impl FnOnce() -> Result<T>,
114 ) -> Result<T> {
115 let path = state_attachment_index_lock_path(&self.root, state);
116 if let Some(parent) = path.parent() {
117 fs::create_dir_all(parent)?;
118 }
119 let file = OpenOptions::new()
120 .create(true)
121 .truncate(false)
122 .read(true)
123 .write(true)
124 .open(path)?;
125 file.lock_exclusive()?;
126 let result = operation();
127 file.unlock()?;
128 result
129 }
130
131 fn collect_state_attachment_ids(&self, state: &StateId) -> Result<Vec<StateAttachmentId>> {
132 let mut ids = Vec::new();
133 let dir = state_attachments_dir(&self.root, state);
134 if let Ok(entries) = fs::read_dir(dir) {
135 for entry in entries {
136 let attachment: StateAttachment = rmp_serde::from_slice(&fs::read(entry?.path())?)?;
137 if attachment.state_id != *state {
138 return Err(HeddleError::InvalidObject(
139 "state attachment stored under wrong state".to_string(),
140 ));
141 }
142 ids.push(attachment.id());
143 }
144 }
145 if let Ok(manager) = self.pack_manager().read() {
146 for pack_id in manager.list_all_ids()? {
147 let PackObjectId::Hash(hash) = pack_id else {
148 continue;
149 };
150 let Some((ObjectType::StateAttachment, bytes)) =
151 manager.get_hashed_object(&hash)?
152 else {
153 continue;
154 };
155 let attachment: StateAttachment = rmp_serde::from_slice(&bytes)?;
156 if attachment.state_id == *state {
157 ids.push(attachment.id());
158 }
159 }
160 }
161 ids.sort();
162 ids.dedup();
163 Ok(ids)
164 }
165
166 fn rebuild_state_attachment_index(&self, state: &StateId) -> Result<Vec<StateAttachmentId>> {
167 #[cfg(test)]
168 fs::write(
169 state_attachment_index_path(&self.root, state).with_extension("rebuild-marker"),
170 b"rebuilt",
171 )?;
172 let ids = self.collect_state_attachment_ids(state)?;
173 let path = state_attachment_index_path(&self.root, state);
174 self.write_loose_object_atomic(&path, &rmp_serde::to_vec_named(&ids)?)?;
175 Ok(ids)
176 }
177
178 pub(super) fn materialize_packed_attachment_index(
183 &self,
184 state: &StateId,
185 packed_ids: &[StateAttachmentId],
186 state_was_present: bool,
187 ) -> Result<()> {
188 if packed_ids.is_empty() {
189 return Ok(());
190 }
191 self.with_state_attachment_index_lock(state, || {
192 let path = state_attachment_index_path(&self.root, state);
193 let mut ids = if state_was_present {
194 match read_file_bytes(&path)? {
195 Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
196 None => self.collect_state_attachment_ids(state)?,
197 }
198 } else {
199 Vec::new()
200 };
201 ids.extend_from_slice(packed_ids);
202 ids.sort();
203 ids.dedup();
204 self.write_reconstructible_cache(&path, &rmp_serde::to_vec_named(&ids)?)?;
205 Ok(())
206 })
207 }
208}
209
210fn validate_and_list_pack(reader: &crate::store::pack::PackReader) -> Result<Vec<PackObjectId>> {
218 let ids = reader.list_ids();
219 for id in &ids {
220 let Some((obj_type, data)) = reader.get_object_bytes(id)? else {
221 continue;
222 };
223 validate_pack_entry(id, obj_type, data.as_ref())?;
224 }
225 Ok(ids)
226}
227
228fn state_entries_from_pack(
229 reader: &crate::store::pack::PackReader,
230 ids: &[PackObjectId],
231) -> Result<Vec<(StateId, Vec<u8>)>> {
232 let mut states = Vec::new();
233 for id in ids {
234 let PackObjectId::StateId(change_id) = id else {
235 continue;
236 };
237 let Some((obj_type, data)) = reader.get_object(id)? else {
238 continue;
239 };
240 if obj_type != ObjectType::State {
241 return Err(HeddleError::InvalidObject(format!(
242 "pack id {} is indexed as {:?}, expected State",
243 change_id.to_string_full(),
244 obj_type
245 )));
246 }
247 validate_state_serialized(&data, *change_id)?;
248 states.push((*change_id, data));
249 }
250 Ok(states)
251}
252
253fn attachment_entries_from_pack(
254 reader: &crate::store::pack::PackReader,
255 ids: &[PackObjectId],
256) -> Result<Vec<StateAttachment>> {
257 let mut attachments = Vec::new();
258 for id in ids {
259 let Some((ObjectType::StateAttachment, data)) = reader.get_object(id)? else {
260 continue;
261 };
262 attachments.push(rmp_serde::from_slice(&data)?);
263 }
264 Ok(attachments)
265}
266
267fn validate_pack_entry(id: &PackObjectId, obj_type: ObjectType, data: &[u8]) -> Result<()> {
268 match (id, obj_type) {
269 (PackObjectId::Hash(hash), ObjectType::Blob) => validate_blob_bytes(data, *hash),
270 (PackObjectId::Hash(hash), ObjectType::Tree) => {
271 validate_tree_serialized(data, *hash).map(|_| ())
272 }
273 (PackObjectId::Hash(hash), ObjectType::Action) => {
274 validate_action_serialized(data, ActionId::from_hash(*hash)).map(|_| ())
275 }
276 (PackObjectId::StateId(change_id), ObjectType::State) => {
277 validate_state_serialized(data, *change_id).map(|_| ())
278 }
279 (PackObjectId::Hash(hash), ObjectType::StateAttachment) => {
280 let attachment: StateAttachment = rmp_serde::from_slice(data)?;
281 if attachment.id().as_hash() != hash {
282 return Err(HeddleError::InvalidObject(
283 "state attachment pack id mismatch".to_string(),
284 ));
285 }
286 Ok(())
287 }
288 (PackObjectId::Hash(hash), ObjectType::SnapshotCommit) => {
289 let artifact: crate::store::SnapshotCommitArtifact = rmp_serde::from_slice(data)?;
290 artifact.validate()?;
291 if artifact.id() != *hash {
292 return Err(HeddleError::InvalidObject(
293 "snapshot commit artifact pack id mismatch".to_string(),
294 ));
295 }
296 Ok(())
297 }
298 _ => Err(HeddleError::InvalidObject(format!(
299 "unsupported native pack object: {:?} {:?}",
300 id, obj_type
301 ))),
302 }
303}
304
305impl FsStore {
306 fn cache_recent_blob(&self, hash: ContentHash, blob: &Blob) {
308 if blob.content().len() > super::fs_store::RECENT_BLOB_CACHE_MAX_BYTES {
309 return;
310 }
311 if let Ok(mut cache) = self.recent_blobs.write() {
312 cache.insert(hash, blob.clone());
313 }
314 }
315
316 fn try_get_blob_once(&self, hash: &ContentHash) -> Result<Option<Blob>> {
319 if let Ok(mut cache) = self.recent_blobs.write()
322 && let Some(blob) = cache.get(hash)
323 {
324 trace!("Found blob in recent object cache");
325 return Ok(Some(blob.clone()));
326 }
327
328 if let Ok(manager) = self.pack_manager().read()
329 && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
330 && obj_type == ObjectType::Blob
331 {
332 trace!("Found blob in packfile");
333 let blob = Blob::new(data);
341 self.cache_recent_blob(*hash, &blob);
342 return Ok(Some(blob));
343 }
344
345 let path = hash_path(&blobs_dir(&self.root), hash);
346 match read_file_bytes(&path)? {
347 Some(data) => {
348 trace!(size = data.as_slice().len(), "Blob data read");
349 let content = codec::decode_blob_content(data.as_slice())?;
350 let blob = Blob::new(content);
351 if blob.hash() != *hash {
358 return Err(HeddleError::Corruption {
359 expected: *hash,
360 found: blob.hash(),
361 });
362 }
363 self.cache_recent_blob(*hash, &blob);
364 Ok(Some(blob))
365 }
366 None => Ok(None),
367 }
368 }
369
370 fn loose_or_packed(
375 &self,
376 loose_path: &Path,
377 in_pack: impl FnOnce(&PackManager) -> bool,
378 ) -> Result<bool> {
379 if loose_path.exists() {
380 return Ok(true);
381 }
382 if let Ok(manager) = self.pack_manager().read() {
383 return Ok(in_pack(&manager));
384 }
385 Ok(false)
386 }
387
388 fn try_has_blob_once(&self, hash: &ContentHash) -> Result<bool> {
389 if let Ok(cache) = self.recent_blobs.read()
394 && cache.contains(hash)
395 {
396 return Ok(true);
397 }
398 let path = hash_path(&blobs_dir(&self.root), hash);
399 self.loose_or_packed(&path, |m| m.has_object(hash))
400 }
401
402 fn try_get_blob_size_once(&self, hash: &ContentHash) -> Result<Option<u64>> {
417 if let Ok(mut cache) = self.recent_blobs.write()
418 && let Some(blob) = cache.get(hash)
419 {
420 return Ok(Some(blob.content().len() as u64));
421 }
422
423 let path = hash_path(&blobs_dir(&self.root), hash);
424 if let Some((header, file_len)) = read_file_header(&path, BLOB_HEADER_PEEK)? {
425 if let Some(size) = header_uncompressed_size(&header) {
426 return Ok(Some(size));
427 }
428 return Ok(Some(file_len));
431 }
432
433 if let Ok(manager) = self.pack_manager().read()
434 && let Some(size) = manager.get_hashed_object_size(hash)?
435 {
436 return Ok(Some(size));
437 }
438 Ok(None)
439 }
440
441 fn try_get_tree_once(&self, hash: &ContentHash) -> Result<Option<Tree>> {
442 if let Ok(mut cache) = self.recent_trees.write()
446 && let Some(tree) = cache.get(hash)
447 {
448 trace!("Found tree in recent object cache");
449 return Ok(Some(tree.clone()));
450 }
451
452 let path = hash_path(&trees_dir(&self.root), hash);
456 if path.exists()
457 && let Some(data) = read_file_bytes(&path)?
458 {
459 trace!(size = data.as_slice().len(), "Tree data read");
460 let tree = validate_loaded_tree(codec::decode_tree(data.as_slice())?)?;
461 if tree.hash() != *hash {
462 return Err(HeddleError::Corruption {
463 expected: *hash,
464 found: tree.hash(),
465 });
466 }
467 if let Ok(mut cache) = self.recent_trees.write() {
468 cache.insert(*hash, tree.clone());
469 }
470 return Ok(Some(tree));
471 }
472
473 if let Ok(manager) = self.pack_manager().read()
474 && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
475 && obj_type == ObjectType::Tree
476 {
477 trace!("Found tree in packfile");
478 let tree = validate_loaded_tree(codec::decode_tree_serialized(&data)?)?;
479 if tree.hash() != *hash {
480 return Err(HeddleError::Corruption {
481 expected: *hash,
482 found: tree.hash(),
483 });
484 }
485 if let Ok(mut cache) = self.recent_trees.write() {
486 cache.insert(*hash, tree.clone());
487 }
488 return Ok(Some(tree));
489 }
490 Ok(None)
491 }
492
493 fn try_get_tree_serialized_once(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
494 let path = hash_path(&trees_dir(&self.root), hash);
495 if path.exists()
496 && let Some(data) = read_file_bytes(&path)?
497 {
498 return Ok(Some(codec::decode_tree_body(data.as_slice())?));
499 }
500
501 if let Ok(manager) = self.pack_manager().read()
502 && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
503 && obj_type == ObjectType::Tree
504 {
505 return Ok(Some(data));
506 }
507
508 Ok(None)
509 }
510
511 fn try_has_tree_once(&self, hash: &ContentHash) -> Result<bool> {
512 if let Ok(cache) = self.recent_trees.read()
515 && cache.contains(hash)
516 {
517 return Ok(true);
518 }
519 let path = hash_path(&trees_dir(&self.root), hash);
520 self.loose_or_packed(&path, |m| m.has_object(hash))
521 }
522
523 fn try_get_state_once(&self, id: &StateId) -> Result<Option<State>> {
524 if let Ok(mut cache) = self.recent_states.write()
528 && let Some(state) = cache.get(id)
529 {
530 trace!("Found state in recent object cache");
531 return Ok(Some(state.clone()));
532 }
533
534 let path = state_path(&self.root, id);
535 if let Some(data) = read_file_bytes(&path)? {
536 trace!(size = data.as_slice().len(), "State read from loose object");
537 let state = validate_loaded_state(id, codec::decode_state(data.as_slice())?)?;
538 if let Ok(mut cache) = self.recent_states.write() {
539 cache.insert(*id, state.clone());
540 }
541 return Ok(Some(state));
542 }
543
544 if let Ok(manager) = self.pack_manager().read()
545 && let Some((obj_type, data)) = manager.get_object(&PackObjectId::StateId(*id))?
546 && obj_type == ObjectType::State
547 {
548 trace!("Found state in packfile");
549 let state = validate_loaded_state(id, rmp_serde::from_slice(&data)?)?;
550 if let Ok(mut cache) = self.recent_states.write() {
551 cache.insert(*id, state.clone());
552 }
553 return Ok(Some(state));
554 }
555
556 Ok(None)
557 }
558
559 fn try_has_state_once(&self, id: &StateId) -> Result<bool> {
560 if let Ok(cache) = self.recent_states.read()
563 && cache.contains(id)
564 {
565 return Ok(true);
566 }
567 let path = state_path(&self.root, id);
568 self.loose_or_packed(&path, |m| m.has_object_id(&PackObjectId::StateId(*id)))
569 }
570}
571
572impl FsStore {
573 pub(crate) fn snapshot_commit_descriptors_impl(&self) -> Result<Vec<SnapshotCommitDescriptor>> {
574 let manager = self
575 .pack_manager()
576 .read()
577 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
578 manager.snapshot_commit_descriptors()
579 }
580
581 pub(crate) fn snapshot_commit_descriptor_for_state_impl(
582 &self,
583 state: &StateId,
584 ) -> Result<Option<SnapshotCommitDescriptor>> {
585 let manager = self
586 .pack_manager()
587 .read()
588 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
589 manager.snapshot_commit_descriptor_for_state(state)
590 }
591}
592
593impl ObjectStore for FsStore {
594 fn clear_recent_caches(&self) {
595 self.clear_recent_object_caches();
596 }
597
598 fn get_blob_bytes(&self, hash: &ContentHash) -> Result<Option<bytes::Bytes>> {
605 if let Ok(manager) = self.pack_manager().read()
606 && let Some((obj_type, data)) = manager.get_hashed_object_bytes(hash)?
607 && obj_type == crate::store::pack::ObjectType::Blob
608 {
609 return Ok(Some(data));
610 }
611 Ok(self
612 .get_blob(hash)?
613 .map(|blob| bytes::Bytes::from(blob.into_content())))
614 }
615
616 #[instrument(skip(self), fields(hash = %hash.short()))]
617 fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>> {
618 if let Some(blob) = self.try_get_blob_once(hash)? {
619 return Ok(Some(blob));
620 }
621 if self.reload_packs_if_stale()?
626 && let Some(blob) = self.try_get_blob_once(hash)?
627 {
628 return Ok(Some(blob));
629 }
630 trace!("Blob not found");
631 match &self.external_source {
632 Some(source) => source.get_blob(hash),
633 None => Ok(None),
634 }
635 }
636
637 #[instrument(skip(self, blob), fields(size = blob.content().len()))]
638 fn put_blob(&self, blob: &Blob) -> Result<ContentHash> {
639 let hash = blob.hash();
640 let path = hash_path(&blobs_dir(&self.root), &hash);
641
642 if !path.exists() {
643 let data = codec::encode_blob_content(blob.content(), &self.compression)?;
644 trace!(compressed_size = data.len(), "Writing blob");
645 self.write_loose_object_atomic(&path, &data)?;
646 } else {
647 trace!("Blob already exists, skipping write");
648 }
649 self.cache_recent_blob(hash, blob);
650
651 Ok(hash)
652 }
653
654 #[instrument(skip(self, blob), fields(hash = %hash.short()))]
655 fn put_blob_with_hash(&self, blob: &Blob, hash: ContentHash) -> Result<ContentHash> {
656 if blob.hash() != hash {
657 return Err(HeddleError::Corruption {
658 expected: hash,
659 found: blob.hash(),
660 });
661 }
662
663 let path = hash_path(&blobs_dir(&self.root), &hash);
664
665 if !path.exists() {
666 let data = codec::encode_blob_content(blob.content(), &self.compression)?;
667 trace!(
668 compressed_size = data.len(),
669 "Writing blob with precomputed hash"
670 );
671 self.write_loose_object_atomic(&path, &data)?;
672 }
673 self.cache_recent_blob(hash, blob);
674
675 Ok(hash)
676 }
677
678 #[instrument(skip(self, data), fields(hash = %hash.short(), size = data.len()))]
679 fn put_blob_bytes_with_hash(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
680 validate_blob_bytes(data, hash)?;
681
682 let path = hash_path(&blobs_dir(&self.root), &hash);
683 if !path.exists() {
684 trace!(
685 size = data.len(),
686 "Writing raw blob bytes with precomputed hash"
687 );
688 self.write_loose_object_atomic(&path, data)?;
689 }
690 self.cache_recent_blob(hash, &Blob::from_slice(data));
691
692 Ok(hash)
693 }
694
695 #[instrument(skip(self), fields(hash = %hash.short()))]
696 fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
697 if ObjectStore::has_blob_locally(self, hash)? {
698 return Ok(true);
699 }
700 match &self.external_source {
701 Some(source) => Ok(source.get_blob(hash)?.is_some()),
702 None => Ok(false),
703 }
704 }
705
706 fn has_blob_locally(&self, hash: &ContentHash) -> Result<bool> {
707 if self.try_has_blob_once(hash)? {
708 return Ok(true);
709 }
710 Ok(self.reload_packs_if_stale()? && self.try_has_blob_once(hash)?)
711 }
712
713 fn loose_blob_path(&self, hash: &ContentHash) -> Option<PathBuf> {
730 let path = hash_path(&blobs_dir(&self.root), hash);
731 if let Ok(verified) = self.verified_loose_blobs.read()
736 && verified.contains(hash)
737 && path.exists()
738 {
739 return Some(path);
740 }
741
742 let (header, _) = read_file_header(&path, BLOB_HEADER_PEEK).ok().flatten()?;
755 if is_compressed(&header) {
756 return None;
757 }
758 let bytes = read_file_bytes(&path).ok().flatten()?;
759 let actual = ContentHash::compute_typed("blob", bytes.as_slice());
760 if actual != *hash {
761 return None;
765 }
766 if let Ok(mut verified) = self.verified_loose_blobs.write() {
767 verified.insert(*hash, ());
768 }
769 Some(path)
770 }
771
772 #[instrument(skip(self), fields(hash = %hash.short()))]
785 fn promote_to_loose_uncompressed(&self, hash: &ContentHash) -> Result<bool> {
786 let path = hash_path(&blobs_dir(&self.root), hash);
787
788 if !self.try_has_blob_once(hash)?
793 && let Some(source) = &self.external_source
794 && source.get_blob(hash)?.is_some()
795 {
796 return Ok(false);
797 }
798
799 if let Some((header, _)) = read_file_header(&path, 9)?
801 && !is_compressed(&header)
802 {
803 trace!("Blob already loose+uncompressed; skipping promotion");
804 return Ok(false);
805 }
806
807 let blob = self.get_blob(hash)?.ok_or_else(|| {
811 HeddleError::NotFound(format!(
812 "blob {} not found in store; cannot promote to loose-uncompressed",
813 hash
814 ))
815 })?;
816
817 debug!(
830 size = blob.content().len(),
831 "Promoting blob to loose-uncompressed canonical store"
832 );
833 self.write_loose_object_cache(&path, blob.content())?;
834 if let Ok(mut verified) = self.verified_loose_blobs.write() {
835 verified.insert(*hash, ());
836 }
837 Ok(true)
838 }
839
840 #[instrument(skip(self), fields(hash = %hash.short()))]
841 fn blob_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
842 if let Some(size) = self.try_get_blob_size_once(hash)? {
843 return Ok(Some(size));
844 }
845 if self.reload_packs_if_stale()?
849 && let Some(size) = self.try_get_blob_size_once(hash)?
850 {
851 return Ok(Some(size));
852 }
853 match &self.external_source {
854 Some(source) => Ok(source
855 .get_blob(hash)?
856 .map(|blob| blob.content().len() as u64)),
857 None => Ok(None),
858 }
859 }
860
861 #[instrument(skip(self), fields(hash = %hash.short()))]
862 fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>> {
863 if let Some(tree) = self.try_get_tree_once(hash)? {
864 return Ok(Some(tree));
865 }
866 if self.reload_packs_if_stale()?
867 && let Some(tree) = self.try_get_tree_once(hash)?
868 {
869 return Ok(Some(tree));
870 }
871 trace!("Tree not found");
872 match &self.external_source {
873 Some(source) => source.get_tree(hash),
874 None => Ok(None),
875 }
876 }
877
878 #[instrument(skip(self), fields(hash = %hash.short()))]
879 fn get_tree_serialized(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
880 if let Some(data) = self.try_get_tree_serialized_once(hash)? {
881 return Ok(Some(data));
882 }
883 if self.reload_packs_if_stale()?
884 && let Some(data) = self.try_get_tree_serialized_once(hash)?
885 {
886 return Ok(Some(data));
887 }
888 match &self.external_source {
889 Some(source) => source
890 .get_tree(hash)?
891 .map(|tree| rmp_serde::to_vec_named(&tree))
892 .transpose()
893 .map_err(|error| HeddleError::InvalidObject(error.to_string())),
894 None => Ok(None),
895 }
896 }
897
898 #[instrument(skip(self, tree), fields(entry_count = tree.entries().len()))]
899 fn put_tree(&self, tree: &Tree) -> Result<ContentHash> {
900 let hash = tree.hash();
901 let path = hash_path(&trees_dir(&self.root), &hash);
902
903 if !ObjectStore::has_tree_locally(self, &hash)? {
908 let (_, data) = codec::encode_tree(tree, &self.compression)?;
909 trace!(compressed_size = data.len(), "Writing tree");
910 self.write_loose_object_atomic(&path, &data)?;
911 } else {
912 trace!("Tree already exists, skipping write");
913 }
914 if let Ok(mut cache) = self.recent_trees.write() {
915 cache.insert(hash, tree.clone());
916 }
917
918 Ok(hash)
919 }
920
921 #[instrument(skip(self, data), fields(hash = %hash.short(), size = data.len()))]
922 fn put_tree_serialized(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
923 let tree = validate_tree_serialized(data, hash)?;
924
925 let path = hash_path(&trees_dir(&self.root), &hash);
926 let should_write = match read_file_bytes(&path)? {
927 Some(existing) => codec::decode_tree_body(existing.as_slice())? != data,
928 None => true,
929 };
930 if should_write {
931 trace!(size = data.len(), "Writing raw serialized tree");
932 self.write_loose_object_atomic(&path, data)?;
933 }
934 if let Ok(mut cache) = self.recent_trees.write() {
935 cache.insert(hash, tree);
936 }
937
938 Ok(hash)
939 }
940
941 #[instrument(skip(self), fields(hash = %hash.short()))]
942 fn has_tree(&self, hash: &ContentHash) -> Result<bool> {
943 if ObjectStore::has_tree_locally(self, hash)? {
944 return Ok(true);
945 }
946 match &self.external_source {
947 Some(source) => Ok(source.get_tree(hash)?.is_some()),
948 None => Ok(false),
949 }
950 }
951
952 fn has_tree_locally(&self, hash: &ContentHash) -> Result<bool> {
953 if self.try_has_tree_once(hash)? {
954 return Ok(true);
955 }
956 Ok(self.reload_packs_if_stale()? && self.try_has_tree_once(hash)?)
957 }
958
959 #[instrument(skip(self), fields(id = %id.short()))]
960 fn get_state(&self, id: &StateId) -> Result<Option<State>> {
961 if let Some(state) = self.try_get_state_once(id)? {
962 return Ok(Some(state));
963 }
964 if self.reload_packs_if_stale()?
965 && let Some(state) = self.try_get_state_once(id)?
966 {
967 return Ok(Some(state));
968 }
969 trace!("State not found");
970 match &self.external_source {
971 Some(source) => source.get_state(id),
972 None => Ok(None),
973 }
974 }
975
976 #[instrument(skip(self, state), fields(id = %state.id().short()))]
977 fn put_state(&self, state: &State) -> Result<()> {
978 let state_id = state.id();
979 let path = state_path(&self.root, &state_id);
980 let data = codec::encode_state(state, &self.compression)?;
981 trace!(compressed_size = data.len(), "Writing state");
982 self.write_loose_object_atomic(&path, &data)?;
983 if let Ok(mut cache) = self.recent_states.write() {
984 let mut cached = state.clone();
985 cached.state_id = state_id;
986 cache.insert(state_id, cached);
987 }
988 Ok(())
989 }
990
991 #[instrument(skip(self, data), fields(id = %id.short(), size = data.len()))]
992 fn put_state_serialized(&self, data: &[u8], id: StateId) -> Result<()> {
993 let state = validate_state_serialized(data, id)?;
994 let path = state_path(&self.root, &id);
995 trace!(size = data.len(), "Writing raw serialized state");
996 self.write_loose_object_atomic(&path, data)?;
997 if let Ok(mut cache) = self.recent_states.write() {
998 cache.insert(id, state);
999 }
1000 Ok(())
1001 }
1002
1003 #[instrument(skip(self), fields(id = %id.short()))]
1004 fn has_state(&self, id: &StateId) -> Result<bool> {
1005 if self.try_has_state_once(id)? {
1006 return Ok(true);
1007 }
1008 if self.reload_packs_if_stale()? && self.try_has_state_once(id)? {
1009 return Ok(true);
1010 }
1011 match &self.external_source {
1012 Some(source) => Ok(source.get_state(id)?.is_some()),
1013 None => Ok(false),
1014 }
1015 }
1016
1017 #[instrument(skip(self))]
1018 fn list_states(&self) -> Result<Vec<StateId>> {
1019 self.reload_packs_if_stale()?;
1020
1021 let dir = states_dir(&self.root);
1022 let mut states = Vec::new();
1023 if dir.exists() {
1024 for entry in fs::read_dir(&dir)? {
1025 let entry = entry?;
1026 let path = entry.path();
1027 if let Some(name) = path.file_stem()
1028 && let Some(name_str) = name.to_str()
1029 && let Ok(id) = StateId::parse(name_str)
1030 {
1031 states.push(id);
1032 }
1033 }
1034 }
1035 if let Ok(manager) = self.pack_manager().read() {
1036 for id in manager.list_all_ids()? {
1037 if let PackObjectId::StateId(change_id) = id
1038 && !states.contains(&change_id)
1039 {
1040 states.push(change_id);
1041 }
1042 }
1043 }
1044 if let Some(source) = &self.external_source {
1045 for id in source.list_states()? {
1046 if !states.contains(&id) {
1047 states.push(id);
1048 }
1049 }
1050 }
1051 debug!(count = states.len(), "Listed states");
1052 Ok(states)
1053 }
1054
1055 fn get_state_attachment(
1056 &self,
1057 state: &StateId,
1058 id: &StateAttachmentId,
1059 ) -> Result<Option<StateAttachment>> {
1060 let path = state_attachment_path(&self.root, state, id);
1061 let bytes = if let Some(bytes) = read_file_bytes(&path)? {
1062 bytes.as_slice().to_vec()
1063 } else if let Ok(manager) = self.pack_manager().read()
1064 && let Some((ObjectType::StateAttachment, bytes)) =
1065 manager.get_hashed_object(id.as_hash())?
1066 {
1067 bytes
1068 } else {
1069 return Ok(None);
1070 };
1071 let attachment: StateAttachment = rmp_serde::from_slice(&bytes)?;
1072 if attachment.state_id != *state || attachment.id() != *id {
1073 return Err(HeddleError::InvalidObject(
1074 "state attachment address does not match content".to_string(),
1075 ));
1076 }
1077 Ok(Some(attachment))
1078 }
1079
1080 fn put_state_attachment(&self, attachment: &StateAttachment) -> Result<StateAttachmentId> {
1081 let id = attachment.id();
1082 self.with_state_attachment_index_lock(&attachment.state_id, || {
1083 let index_path = state_attachment_index_path(&self.root, &attachment.state_id);
1084 let mut ids: Vec<StateAttachmentId> = match read_file_bytes(&index_path)? {
1085 Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
1086 None => self.rebuild_state_attachment_index(&attachment.state_id)?,
1087 };
1088 if !ids.contains(&id) {
1089 ids.push(id);
1090 ids.sort();
1091 self.write_loose_object_atomic(&index_path, &rmp_serde::to_vec_named(&ids)?)?;
1092 }
1093 let path = state_attachment_path(&self.root, &attachment.state_id, &id);
1094 self.write_loose_object_atomic(&path, &rmp_serde::to_vec_named(attachment)?)?;
1095 Ok(id)
1096 })
1097 }
1098
1099 fn list_state_attachments(&self, state: &StateId) -> Result<Vec<StateAttachment>> {
1100 self.with_state_attachment_index_lock(state, || {
1101 let index_path = state_attachment_index_path(&self.root, state);
1102 let mut ids: Vec<StateAttachmentId> = match read_file_bytes(&index_path)? {
1103 Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
1104 None => self.rebuild_state_attachment_index(state)?,
1105 };
1106 let mut attachments = Vec::new();
1107 let mut stale = false;
1108 for id in &ids {
1109 match self.get_state_attachment(state, id)? {
1110 Some(attachment) => attachments.push(attachment),
1111 None => stale = true,
1112 }
1113 }
1114 if stale {
1115 ids = self.rebuild_state_attachment_index(state)?;
1116 attachments.clear();
1117 for id in ids {
1118 let attachment = self.get_state_attachment(state, &id)?.ok_or_else(|| {
1119 HeddleError::InvalidObject(format!(
1120 "rebuilt state attachment index references missing {id}"
1121 ))
1122 })?;
1123 attachments.push(attachment);
1124 }
1125 }
1126 Ok(attachments)
1127 })
1128 }
1129
1130 #[instrument(skip(self), fields(id = %id))]
1131 fn get_action(&self, id: &ActionId) -> Result<Option<Action>> {
1132 let path = action_path(&self.root, id);
1133 if !path.exists()
1134 && let Ok(manager) = self.pack_manager().read()
1135 && let Some((obj_type, data)) = manager.get_hashed_object(id.as_hash())?
1136 && obj_type == ObjectType::Action
1137 {
1138 trace!("Found action in packfile");
1139 let action = validate_loaded_action(id, rmp_serde::from_slice(&data)?)?;
1140 return Ok(Some(action));
1141 }
1142 match read_file_bytes(&path)? {
1143 Some(data) => {
1144 trace!(size = data.as_slice().len(), "Action data read");
1145 let action = validate_loaded_action(id, codec::decode_action(data.as_slice())?)?;
1146 Ok(Some(action))
1147 }
1148 None => {
1149 trace!("Action not found");
1150 Ok(None)
1151 }
1152 }
1153 }
1154
1155 #[instrument(skip(self, action))]
1156 fn put_action(&self, action: &mut Action) -> Result<ActionId> {
1157 let id = action.id();
1158 let path = action_path(&self.root, &id);
1159
1160 if !path.exists() {
1161 let (_, data) = codec::encode_action(action, &self.compression)?;
1162 trace!(id = %id, compressed_size = data.len(), "Writing action");
1163 self.write_loose_object_atomic(&path, &data)?;
1164 }
1165
1166 Ok(id)
1167 }
1168
1169 #[instrument(skip(self))]
1170 fn list_actions(&self) -> Result<Vec<ActionId>> {
1171 let dir = actions_dir(&self.root);
1172 let mut actions = Vec::new();
1173 if dir.exists() {
1174 for entry in fs::read_dir(&dir)? {
1175 let entry = entry?;
1176 let path = entry.path();
1177 if let Some(name) = path.file_stem()
1178 && let Some(name_str) = name.to_str()
1179 && let Ok(hash) = ContentHash::from_hex(name_str)
1180 {
1181 actions.push(ActionId::from_hash(hash));
1182 }
1183 }
1184 }
1185 if let Ok(manager) = self.pack_manager().read() {
1186 for id in manager.list_all_ids()? {
1187 if let PackObjectId::Hash(hash) = id
1188 && !actions.iter().any(|action_id| action_id.as_hash() == &hash)
1189 && let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
1190 && obj_type == ObjectType::Action
1191 {
1192 actions.push(ActionId::from_hash(hash));
1193 }
1194 }
1195 }
1196 debug!(count = actions.len(), "Listed actions");
1197 Ok(actions)
1198 }
1199
1200 #[instrument(skip(self))]
1201 fn list_blobs(&self) -> Result<Vec<ContentHash>> {
1202 let dir = blobs_dir(&self.root);
1203 let mut blobs = list_hashes_from_dir(&dir)?;
1204 if let Ok(manager) = self.pack_manager().read() {
1205 for id in manager.list_all_ids()? {
1206 if let PackObjectId::Hash(hash) = id
1207 && !blobs.contains(&hash)
1208 && let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
1209 && obj_type == ObjectType::Blob
1210 {
1211 blobs.push(hash);
1212 }
1213 }
1214 }
1215 Ok(blobs)
1216 }
1217
1218 #[instrument(skip(self))]
1219 fn list_trees(&self) -> Result<Vec<ContentHash>> {
1220 let dir = trees_dir(&self.root);
1221 let mut trees = list_hashes_from_dir(&dir)?;
1222 if let Ok(manager) = self.pack_manager().read() {
1223 for id in manager.list_all_ids()? {
1224 if let PackObjectId::Hash(hash) = id
1225 && !trees.contains(&hash)
1226 && let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
1227 && obj_type == ObjectType::Tree
1228 {
1229 trees.push(hash);
1230 }
1231 }
1232 }
1233 Ok(trees)
1234 }
1235
1236 #[instrument(skip(self))]
1237 fn pack_objects(&self, aggressive: bool) -> Result<(u64, u64)> {
1238 self.pack_objects_impl(aggressive)
1239 }
1240
1241 #[instrument(skip(self), fields(id = ?id))]
1242 fn get_pack_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
1243 if let Ok(manager) = self.pack_manager().read()
1244 && let Some((obj_type, data)) = manager.get_object(id)?
1245 {
1246 return Ok(Some((obj_type, data)));
1247 }
1248
1249 match id {
1250 PackObjectId::Hash(hash) => {
1251 if let Some(blob) = self.get_blob(hash)? {
1252 return Ok(Some((ObjectType::Blob, blob.content().to_vec())));
1253 }
1254 if let Some(tree) = self.get_tree(hash)? {
1255 return Ok(Some((ObjectType::Tree, rmp_serde::to_vec_named(&tree)?)));
1256 }
1257 if let Some(action) = self.get_action(&ActionId::from_hash(*hash))? {
1258 return Ok(Some((
1259 ObjectType::Action,
1260 rmp_serde::to_vec_named(&action)?,
1261 )));
1262 }
1263 Ok(None)
1264 }
1265 PackObjectId::StateId(change_id) => {
1266 if let Some(state) = self.get_state(change_id)? {
1267 Ok(Some((ObjectType::State, rmp_serde::to_vec_named(&state)?)))
1268 } else {
1269 Ok(None)
1270 }
1271 }
1272 }
1273 }
1274
1275 #[instrument(skip(self, pack_data, index_data))]
1276 fn install_pack(&self, pack_data: &[u8], index_data: &[u8]) -> Result<Vec<PackObjectId>> {
1277 let reader = crate::store::pack::PackReader::from_slice(pack_data, index_data)?;
1278 let ids = validate_and_list_pack(&reader)?;
1279 let state_entries = state_entries_from_pack(&reader, &ids)?;
1280 self.install_pack_files(pack_data, index_data)?;
1281 for (id, data) in state_entries {
1282 self.put_state_serialized(&data, id)?;
1283 }
1284 for id in &ids {
1285 let Some((ObjectType::StateAttachment, data)) = reader.get_object(id)? else {
1286 continue;
1287 };
1288 let attachment: StateAttachment = rmp_serde::from_slice(&data)?;
1289 self.put_state_attachment(&attachment)?;
1290 }
1291 self.clear_recent_object_caches();
1292 Ok(ids)
1293 }
1294
1295 #[instrument(skip(self, blobs), fields(count = blobs.len()))]
1296 fn put_blobs_packed(&self, blobs: Vec<(crate::object::ContentHash, Vec<u8>)>) -> Result<()> {
1297 self.put_blobs_packed_impl(blobs)
1298 }
1299
1300 #[instrument(skip(self, blobs, tree, state), fields(blob_count = blobs.len()))]
1301 fn put_snapshot_objects_packed(
1302 &self,
1303 blobs: Vec<(ContentHash, Vec<u8>)>,
1304 tree: &Tree,
1305 state: &State,
1306 ) -> Result<()> {
1307 self.put_snapshot_objects_packed_impl(blobs, tree, state, Vec::new(), None)
1308 .map(|_| ())
1309 }
1310
1311 fn put_snapshot_objects_and_attachments_packed(
1312 &self,
1313 blobs: Vec<(ContentHash, Vec<u8>)>,
1314 tree: &Tree,
1315 state: &State,
1316 attachments: Vec<StateAttachment>,
1317 ) -> Result<()> {
1318 self.put_snapshot_objects_packed_impl(blobs, tree, state, attachments, None)
1319 .map(|_| ())
1320 }
1321
1322 #[instrument(skip(self))]
1323 fn install_pack_streaming(
1324 &self,
1325 pack_path: &std::path::Path,
1326 index_path: &std::path::Path,
1327 ) -> Result<Vec<PackObjectId>> {
1328 let ids = {
1334 let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1335 validate_and_list_pack(&reader)?
1336 };
1337 let state_entries = {
1338 let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1339 state_entries_from_pack(&reader, &ids)?
1340 };
1341 let attachment_entries = {
1342 let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1343 attachment_entries_from_pack(&reader, &ids)?
1344 };
1345 self.install_pack_files_streaming(pack_path, index_path)?;
1346 for (id, data) in state_entries {
1347 self.put_state_serialized(&data, id)?;
1348 }
1349 for attachment in attachment_entries {
1350 self.put_state_attachment(&attachment)?;
1351 }
1352 Ok(ids)
1353 }
1354
1355 #[instrument(skip(self))]
1356 fn prune_loose_objects(&self) -> Result<(u64, u64)> {
1357 self.prune_loose_objects_impl()
1358 }
1359
1360 #[instrument(skip(self))]
1361 fn begin_snapshot_write_batch(&self) -> Result<()> {
1362 self.begin_snapshot_write_batch_impl()
1363 }
1364
1365 #[instrument(skip(self))]
1366 fn flush_snapshot_write_batch(&self) -> Result<()> {
1367 self.flush_snapshot_write_batch_impl()
1368 }
1369
1370 #[instrument(skip(self))]
1371 fn abort_snapshot_write_batch(&self) {
1372 self.abort_snapshot_write_batch_impl();
1373 }
1374
1375 fn has_redactions_for_blob(&self, blob: &ContentHash) -> Result<bool> {
1376 Ok(redaction_path(&self.root, blob).exists())
1377 }
1378
1379 fn get_redactions_bytes_for_blob(&self, blob: &ContentHash) -> Result<Option<Vec<u8>>> {
1380 let path = redaction_path(&self.root, blob);
1381 match fs::read(&path) {
1382 Ok(bytes) => Ok(Some(bytes)),
1383 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1384 Err(err) => Err(HeddleError::Io(err)),
1385 }
1386 }
1387
1388 fn put_redactions_bytes_for_blob(&self, blob: &ContentHash, bytes: &[u8]) -> Result<()> {
1389 let dir = redactions_dir(&self.root);
1390 if !dir.exists() {
1391 crate::fs_atomic::create_dir_all_durable(&dir)?;
1392 }
1393 let path = redaction_path(&self.root, blob);
1394 crate::fs_atomic::write_file_atomic(&path, bytes)?;
1395 Ok(())
1396 }
1397
1398 fn list_blobs_with_redactions(&self) -> Result<Vec<ContentHash>> {
1399 let dir = redactions_dir(&self.root);
1400 if !dir.exists() {
1401 return Ok(Vec::new());
1402 }
1403 let mut out = Vec::new();
1404 for entry in fs::read_dir(&dir)? {
1405 let entry = entry?;
1406 let path = entry.path();
1407 if path.extension().and_then(|e| e.to_str()) != Some("bin") {
1408 continue;
1409 }
1410 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1411 continue;
1412 };
1413 if let Ok(hash) = ContentHash::from_hex(stem) {
1414 out.push(hash);
1415 }
1416 }
1417 Ok(out)
1418 }
1419
1420 fn has_state_visibility_for_state(&self, state: &StateId) -> Result<bool> {
1421 Ok(state_visibility_path(&self.root, state).exists())
1422 }
1423
1424 fn get_state_visibility_bytes_for_state(&self, state: &StateId) -> Result<Option<Vec<u8>>> {
1425 let path = state_visibility_path(&self.root, state);
1426 match fs::read(&path) {
1427 Ok(bytes) => Ok(Some(bytes)),
1428 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1429 Err(err) => Err(HeddleError::Io(err)),
1430 }
1431 }
1432
1433 fn put_state_visibility_bytes_for_state(&self, state: &StateId, bytes: &[u8]) -> Result<()> {
1434 let dir = state_visibility_dir(&self.root);
1435 if !dir.exists() {
1436 crate::fs_atomic::create_dir_all_durable(&dir)?;
1437 }
1438 let path = state_visibility_path(&self.root, state);
1439 crate::fs_atomic::write_file_atomic(&path, bytes)?;
1440 Ok(())
1441 }
1442
1443 fn list_states_with_visibility(&self) -> Result<Vec<StateId>> {
1444 let dir = state_visibility_dir(&self.root);
1445 if !dir.exists() {
1446 return Ok(Vec::new());
1447 }
1448 let mut out = Vec::new();
1449 for entry in fs::read_dir(&dir)? {
1450 let entry = entry?;
1451 let path = entry.path();
1452 if path.extension().and_then(|e| e.to_str()) != Some("bin") {
1453 continue;
1454 }
1455 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1456 continue;
1457 };
1458 if let Ok(state) = StateId::parse(stem) {
1459 out.push(state);
1460 }
1461 }
1462 Ok(out)
1463 }
1464}
1465
1466#[cfg(test)]
1467mod state_attachment_tests {
1468 use std::sync::Arc;
1469
1470 use chrono::Utc;
1471
1472 use super::*;
1473 use crate::{
1474 object::{Attribution, Principal, StateAttachmentBody},
1475 store::{CompressionConfig, pack::PackBuilder},
1476 };
1477
1478 fn fixture(store: &FsStore) -> (State, StateAttachment) {
1479 let tree = store.put_tree(&Tree::new()).unwrap();
1480 let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
1481 let state = State::new(tree, vec![], attribution.clone());
1482 store.put_state(&state).unwrap();
1483 let attachment = StateAttachment {
1484 state_id: state.id(),
1485 body: StateAttachmentBody::Context(ContentHash::compute(b"context")),
1486 attribution,
1487 created_at: Utc::now(),
1488 supersedes: None,
1489 };
1490 (state, attachment)
1491 }
1492
1493 #[test]
1494 fn concurrent_attachment_writes_keep_every_index_entry() {
1495 let temp = tempfile::TempDir::new().unwrap();
1496 let store = Arc::new(FsStore::new(temp.path()));
1497 let (state, base) = fixture(&store);
1498 let mut threads = Vec::new();
1499 for byte in 0..16u8 {
1500 let store = Arc::clone(&store);
1501 let mut attachment = base.clone();
1502 attachment.body = StateAttachmentBody::Context(ContentHash::compute(&[byte]));
1503 threads.push(std::thread::spawn(move || {
1504 store.put_state_attachment(&attachment).unwrap();
1505 }));
1506 }
1507 for thread in threads {
1508 thread.join().unwrap();
1509 }
1510 assert_eq!(store.list_state_attachments(&state.id()).unwrap().len(), 16);
1511 }
1512
1513 #[test]
1514 fn missing_index_rebuilds_from_loose_objects() {
1515 let temp = tempfile::TempDir::new().unwrap();
1516 let store = FsStore::new(temp.path());
1517 let (state, attachment) = fixture(&store);
1518 store.put_state_attachment(&attachment).unwrap();
1519 fs::remove_file(state_attachment_index_path(&store.root, &state.id())).unwrap();
1520 assert_eq!(
1521 store.list_state_attachments(&state.id()).unwrap(),
1522 vec![attachment]
1523 );
1524 }
1525
1526 #[test]
1527 fn packed_attachment_uses_state_index_for_lookup() {
1528 let temp = tempfile::TempDir::new().unwrap();
1529 let store = FsStore::new(temp.path());
1530 let (state, attachment) = fixture(&store);
1531 let mut builder = PackBuilder::new(CompressionConfig::default());
1532 builder.add(
1533 *attachment.id().as_hash(),
1534 ObjectType::StateAttachment,
1535 rmp_serde::to_vec_named(&attachment).unwrap(),
1536 );
1537 let (pack, index, _) = builder.build().unwrap();
1538 store.install_pack(&pack, &index).unwrap();
1539 fs::remove_file(state_attachment_path(
1540 &store.root,
1541 &state.id(),
1542 &attachment.id(),
1543 ))
1544 .unwrap();
1545 let rebuild_marker =
1546 state_attachment_index_path(&store.root, &state.id()).with_extension("rebuild-marker");
1547 let _ = fs::remove_file(&rebuild_marker);
1548 assert_eq!(
1549 store.list_state_attachments(&state.id()).unwrap(),
1550 vec![attachment.clone()]
1551 );
1552 assert_eq!(
1553 store.list_state_attachments(&state.id()).unwrap(),
1554 vec![attachment]
1555 );
1556 assert!(!rebuild_marker.exists());
1557 }
1558}