Skip to main content

objects/store/fs/
fs_impl.rs

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