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