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