Skip to main content

objects/store/fs/
fs_impl.rs

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