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