Skip to main content

objects/store/fs/
fs_impl.rs

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