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