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    fs::{self, OpenOptions},
6    path::{Path, PathBuf},
7};
8
9use fs2::FileExt;
10use heddle_format::compression::{header_uncompressed_size, is_compressed};
11use tracing::{debug, instrument, trace};
12
13use super::{
14    FsStore,
15    fs_io::{list_hashes_from_dir, read_file_bytes, read_file_header},
16    fs_paths::{
17        action_path, actions_dir, blobs_dir, hash_path, redaction_path, redactions_dir,
18        state_attachment_index_lock_path, state_attachment_index_path, state_attachment_path,
19        state_attachments_dir, state_path, state_visibility_dir, state_visibility_path, states_dir,
20        trees_dir,
21    },
22};
23use crate::{
24    object::{
25        Action, ActionId, Blob, ContentHash, State, StateAttachment, StateAttachmentId, StateId,
26        Tree,
27    },
28    store::{
29        HeddleError, ObjectStore, Result, SnapshotCommitDescriptor, codec,
30        pack::{ObjectType, PackManager, PackObjectId},
31    },
32};
33
34/// Bytes we read off disk to recover a blob's uncompressed size.
35/// Must cover the 9-byte modern header **plus** the 4-byte ZSTD
36/// magic that `header_uncompressed_size` uses to disambiguate
37/// modern from legacy (5-byte) headers — without the magic in the
38/// peek buffer the lookup silently returns the on-disk byte length
39/// instead of the recorded uncompressed size, which left `stat`
40/// reporting the compressed size of every loose blob.
41const BLOB_HEADER_PEEK: usize = 13;
42
43fn validate_loaded_tree(tree: Tree) -> Result<Tree> {
44    tree.validate()?;
45    Ok(tree)
46}
47
48fn validate_blob_bytes(data: &[u8], hash: ContentHash) -> Result<()> {
49    let mut hasher = ContentHash::typed_hasher("blob", data.len() as u64);
50    hasher.update(data);
51    let found = ContentHash::from_bytes(hasher.finalize().into());
52    if found != hash {
53        return Err(HeddleError::Corruption {
54            expected: hash,
55            found,
56        });
57    }
58
59    Ok(())
60}
61
62fn validate_tree_serialized(data: &[u8], hash: ContentHash) -> Result<Tree> {
63    let tree = codec::decode_tree_serialized(data)?;
64    let tree = validate_loaded_tree(tree)?;
65    let found = tree.hash();
66    if found != hash {
67        return Err(HeddleError::Corruption {
68            expected: hash,
69            found,
70        });
71    }
72
73    Ok(tree)
74}
75
76fn validate_loaded_state(requested_id: &StateId, mut state: State) -> Result<State> {
77    let computed = state.id();
78    if computed != *requested_id {
79        return Err(HeddleError::InvalidObject(format!(
80            "state id mismatch: requested {requested_id}, computed {computed}"
81        )));
82    }
83    state.state_id = computed;
84    Ok(state)
85}
86
87fn validate_state_serialized(data: &[u8], id: StateId) -> Result<State> {
88    let state: State = rmp_serde::from_slice(data)?;
89    validate_loaded_state(&id, state)
90}
91
92fn validate_loaded_action(requested_id: &ActionId, action: Action) -> Result<Action> {
93    let found_id = action.compute_id();
94    if found_id != *requested_id {
95        return Err(HeddleError::InvalidObject(format!(
96            "action id mismatch: requested {}, found {}",
97            requested_id, found_id
98        )));
99    }
100
101    Ok(action)
102}
103
104fn validate_action_serialized(data: &[u8], id: ActionId) -> Result<Action> {
105    let action: Action = rmp_serde::from_slice(data)?;
106    validate_loaded_action(&id, action)
107}
108
109impl FsStore {
110    fn with_state_attachment_index_lock<T>(
111        &self,
112        state: &StateId,
113        operation: impl FnOnce() -> Result<T>,
114    ) -> Result<T> {
115        let path = state_attachment_index_lock_path(&self.root, state);
116        if let Some(parent) = path.parent() {
117            fs::create_dir_all(parent)?;
118        }
119        let file = OpenOptions::new()
120            .create(true)
121            .truncate(false)
122            .read(true)
123            .write(true)
124            .open(path)?;
125        file.lock_exclusive()?;
126        let result = operation();
127        file.unlock()?;
128        result
129    }
130
131    fn collect_state_attachment_ids(&self, state: &StateId) -> Result<Vec<StateAttachmentId>> {
132        let mut ids = Vec::new();
133        let dir = state_attachments_dir(&self.root, state);
134        if let Ok(entries) = fs::read_dir(dir) {
135            for entry in entries {
136                let attachment: StateAttachment = rmp_serde::from_slice(&fs::read(entry?.path())?)?;
137                if attachment.state_id != *state {
138                    return Err(HeddleError::InvalidObject(
139                        "state attachment stored under wrong state".to_string(),
140                    ));
141                }
142                ids.push(attachment.id());
143            }
144        }
145        if let Ok(manager) = self.pack_manager().read() {
146            for pack_id in manager.list_all_ids()? {
147                let PackObjectId::Hash(hash) = pack_id else {
148                    continue;
149                };
150                let Some((ObjectType::StateAttachment, bytes)) =
151                    manager.get_hashed_object(&hash)?
152                else {
153                    continue;
154                };
155                let attachment: StateAttachment = rmp_serde::from_slice(&bytes)?;
156                if attachment.state_id == *state {
157                    ids.push(attachment.id());
158                }
159            }
160        }
161        ids.sort();
162        ids.dedup();
163        Ok(ids)
164    }
165
166    fn rebuild_state_attachment_index(&self, state: &StateId) -> Result<Vec<StateAttachmentId>> {
167        #[cfg(test)]
168        fs::write(
169            state_attachment_index_path(&self.root, state).with_extension("rebuild-marker"),
170            b"rebuilt",
171        )?;
172        let ids = self.collect_state_attachment_ids(state)?;
173        let path = state_attachment_index_path(&self.root, state);
174        self.write_loose_object_atomic(&path, &rmp_serde::to_vec_named(&ids)?)?;
175        Ok(ids)
176    }
177
178    /// Publish the attachment index for objects already made durable in a
179    /// snapshot pack. This sidecar is only a materialized view: if a crash
180    /// loses it, [`rebuild_state_attachment_index`](Self::rebuild_state_attachment_index)
181    /// reconstructs it by scanning authoritative loose objects and packs.
182    pub(super) fn materialize_packed_attachment_index(
183        &self,
184        state: &StateId,
185        packed_ids: &[StateAttachmentId],
186        state_was_present: bool,
187    ) -> Result<()> {
188        if packed_ids.is_empty() {
189            return Ok(());
190        }
191        self.with_state_attachment_index_lock(state, || {
192            let path = state_attachment_index_path(&self.root, state);
193            let mut ids = if state_was_present {
194                match read_file_bytes(&path)? {
195                    Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
196                    None => self.collect_state_attachment_ids(state)?,
197                }
198            } else {
199                Vec::new()
200            };
201            ids.extend_from_slice(packed_ids);
202            ids.sort();
203            ids.dedup();
204            self.write_reconstructible_cache(&path, &rmp_serde::to_vec_named(&ids)?)?;
205            Ok(())
206        })
207    }
208}
209
210/// Validate every entry in a pack against its tagged id (checksum
211/// validation) and return the installed id list. This is the shared
212/// validated core for both install seams: the byte-buffer install
213/// (`install_pack`) and the memory-bounded temp-file install
214/// (`install_pack_streaming`) both run their pack through here, so
215/// both apply the same checksum validation and report the same
216/// installed ids regardless of how the bytes reach the store.
217fn validate_and_list_pack(reader: &crate::store::pack::PackReader) -> Result<Vec<PackObjectId>> {
218    let ids = reader.list_ids();
219    for id in &ids {
220        let Some((obj_type, data)) = reader.get_object_bytes(id)? else {
221            continue;
222        };
223        validate_pack_entry(id, obj_type, data.as_ref())?;
224    }
225    Ok(ids)
226}
227
228fn state_entries_from_pack(
229    reader: &crate::store::pack::PackReader,
230    ids: &[PackObjectId],
231) -> Result<Vec<(StateId, Vec<u8>)>> {
232    let mut states = Vec::new();
233    for id in ids {
234        let PackObjectId::StateId(change_id) = id else {
235            continue;
236        };
237        let Some((obj_type, data)) = reader.get_object(id)? else {
238            continue;
239        };
240        if obj_type != ObjectType::State {
241            return Err(HeddleError::InvalidObject(format!(
242                "pack id {} is indexed as {:?}, expected State",
243                change_id.to_string_full(),
244                obj_type
245            )));
246        }
247        validate_state_serialized(&data, *change_id)?;
248        states.push((*change_id, data));
249    }
250    Ok(states)
251}
252
253fn attachment_entries_from_pack(
254    reader: &crate::store::pack::PackReader,
255    ids: &[PackObjectId],
256) -> Result<Vec<StateAttachment>> {
257    let mut attachments = Vec::new();
258    for id in ids {
259        let Some((ObjectType::StateAttachment, data)) = reader.get_object(id)? else {
260            continue;
261        };
262        attachments.push(rmp_serde::from_slice(&data)?);
263    }
264    Ok(attachments)
265}
266
267fn validate_pack_entry(id: &PackObjectId, obj_type: ObjectType, data: &[u8]) -> Result<()> {
268    match (id, obj_type) {
269        (PackObjectId::Hash(hash), ObjectType::Blob) => validate_blob_bytes(data, *hash),
270        (PackObjectId::Hash(hash), ObjectType::Tree) => {
271            validate_tree_serialized(data, *hash).map(|_| ())
272        }
273        (PackObjectId::Hash(hash), ObjectType::Action) => {
274            validate_action_serialized(data, ActionId::from_hash(*hash)).map(|_| ())
275        }
276        (PackObjectId::StateId(change_id), ObjectType::State) => {
277            validate_state_serialized(data, *change_id).map(|_| ())
278        }
279        (PackObjectId::Hash(hash), ObjectType::StateAttachment) => {
280            let attachment: StateAttachment = rmp_serde::from_slice(data)?;
281            if attachment.id().as_hash() != hash {
282                return Err(HeddleError::InvalidObject(
283                    "state attachment pack id mismatch".to_string(),
284                ));
285            }
286            Ok(())
287        }
288        (PackObjectId::Hash(hash), ObjectType::SnapshotCommit) => {
289            let artifact: crate::store::SnapshotCommitArtifact = rmp_serde::from_slice(data)?;
290            artifact.validate()?;
291            if artifact.id() != *hash {
292                return Err(HeddleError::InvalidObject(
293                    "snapshot commit artifact pack id mismatch".to_string(),
294                ));
295            }
296            Ok(())
297        }
298        _ => Err(HeddleError::InvalidObject(format!(
299            "unsupported native pack object: {:?} {:?}",
300            id, obj_type
301        ))),
302    }
303}
304
305impl FsStore {
306    /// Insert into the recent-blob cache when the payload fits the size gate.
307    fn cache_recent_blob(&self, hash: ContentHash, blob: &Blob) {
308        if blob.content().len() > super::fs_store::RECENT_BLOB_CACHE_MAX_BYTES {
309            return;
310        }
311        if let Ok(mut cache) = self.recent_blobs.write() {
312            cache.insert(hash, blob.clone());
313        }
314    }
315
316    /// Single-pass blob lookup. The wrapper in `ObjectStore::get_blob`
317    /// retries this once after a stale-reload on miss.
318    fn try_get_blob_once(&self, hash: &ContentHash) -> Result<Option<Blob>> {
319        // Cache first — avoid `path.exists()` / pack probes on warm hits.
320        // Write lock: LRU promotion mutates the order list.
321        if let Ok(mut cache) = self.recent_blobs.write()
322            && let Some(blob) = cache.get(hash)
323        {
324            trace!("Found blob in recent object cache");
325            return Ok(Some(blob.clone()));
326        }
327
328        if let Ok(manager) = self.pack_manager().read()
329            && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
330            && obj_type == ObjectType::Blob
331        {
332            trace!("Found blob in packfile");
333            // Step 2: skip the BLAKE3 re-hash. The pack reader already
334            // located this entry by its content-addressed key in the
335            // pack index — anything served here either matches or
336            // means the pack itself is corrupted in ways a per-read
337            // hash check can't recover from cleanly. For multi-MB
338            // blobs the verify was the dominant tail of the cold
339            // read (~3GB/s × 10MB ≈ 3.3ms per call).
340            let blob = Blob::new(data);
341            self.cache_recent_blob(*hash, &blob);
342            return Ok(Some(blob));
343        }
344
345        let path = hash_path(&blobs_dir(&self.root), hash);
346        match read_file_bytes(&path)? {
347            Some(data) => {
348                trace!(size = data.as_slice().len(), "Blob data read");
349                let content = codec::decode_blob_content(data.as_slice())?;
350                let blob = Blob::new(content);
351                // Loose blobs are bare bytes on disk: a half-written
352                // file or bit-rot inside the payload would slip past
353                // the path-is-the-hash invariant. Keep the verify on
354                // this path. Pack-resident reads above skip it because
355                // pack entries are framed with offset + length records
356                // that fail to parse if the pack is corrupt.
357                if blob.hash() != *hash {
358                    return Err(HeddleError::Corruption {
359                        expected: *hash,
360                        found: blob.hash(),
361                    });
362                }
363                self.cache_recent_blob(*hash, &blob);
364                Ok(Some(blob))
365            }
366            None => Ok(None),
367        }
368    }
369
370    /// Shared body for `try_has_{blob,tree,state}_once`: object is
371    /// present iff the loose path exists or the pack manager
372    /// resolves it. Callers pass the loose path and the
373    /// pack-manager probe; the helper handles the lock.
374    fn loose_or_packed(
375        &self,
376        loose_path: &Path,
377        in_pack: impl FnOnce(&PackManager) -> bool,
378    ) -> Result<bool> {
379        if loose_path.exists() {
380            return Ok(true);
381        }
382        if let Ok(manager) = self.pack_manager().read() {
383            return Ok(in_pack(&manager));
384        }
385        Ok(false)
386    }
387
388    fn try_has_blob_once(&self, hash: &ContentHash) -> Result<bool> {
389        // Keep `has_blob` coherent with cache-first `get_blob`. A pure
390        // existence check needs no LRU promotion, so take the *read*
391        // lock and use `contains` — concurrent `has_blob` calls in
392        // heddled/mount don't serialize on the exclusive write lock.
393        if let Ok(cache) = self.recent_blobs.read()
394            && cache.contains(hash)
395        {
396            return Ok(true);
397        }
398        let path = hash_path(&blobs_dir(&self.root), hash);
399        self.loose_or_packed(&path, |m| m.has_object(hash))
400    }
401
402    /// Header-only size lookup for a single attempt. Tries:
403    /// 1. The recent-blob cache (we already have the bytes in
404    ///    memory — `len()` is free).
405    /// 2. The loose blob: peek the 9-byte compression header. For a
406    ///    compressed blob the recorded uncompressed size lives in the
407    ///    header. For an uncompressed blob (no recognised header) the
408    ///    on-disk file length IS the blob size.
409    /// 3. Any loaded pack: the pack format records the uncompressed
410    ///    size as a varint right after the tagged id, so we can decode
411    ///    it without touching the body.
412    ///
413    /// Cost: one short read (typically 9 bytes) for loose blobs, or a
414    /// pure in-memory varint decode for packed blobs. *No*
415    /// decompression.
416    fn try_get_blob_size_once(&self, hash: &ContentHash) -> Result<Option<u64>> {
417        if let Ok(mut cache) = self.recent_blobs.write()
418            && let Some(blob) = cache.get(hash)
419        {
420            return Ok(Some(blob.content().len() as u64));
421        }
422
423        let path = hash_path(&blobs_dir(&self.root), hash);
424        if let Some((header, file_len)) = read_file_header(&path, BLOB_HEADER_PEEK)? {
425            if let Some(size) = header_uncompressed_size(&header) {
426                return Ok(Some(size));
427            }
428            // No recognised compression header — the file is raw
429            // blob bytes. The on-disk length is the blob size.
430            return Ok(Some(file_len));
431        }
432
433        if let Ok(manager) = self.pack_manager().read()
434            && let Some(size) = manager.get_hashed_object_size(hash)?
435        {
436            return Ok(Some(size));
437        }
438        Ok(None)
439    }
440
441    fn try_get_tree_once(&self, hash: &ContentHash) -> Result<Option<Tree>> {
442        // Cache first. The recent-object cache only ever holds trees we
443        // wrote or read this process, so a hit is authoritative for a
444        // read. Write lock: LRU promotion mutates the order list.
445        if let Ok(mut cache) = self.recent_trees.write()
446            && let Some(tree) = cache.get(hash)
447        {
448            trace!("Found tree in recent object cache");
449            return Ok(Some(tree.clone()));
450        }
451
452        // Loose trees may be migration-promoted V2 shadows of an older packed
453        // V1 encoding at the same semantic tree hash. Prefer the loose copy
454        // when it exists, then fall through to pack lookup.
455        let path = hash_path(&trees_dir(&self.root), hash);
456        if path.exists()
457            && let Some(data) = read_file_bytes(&path)?
458        {
459            trace!(size = data.as_slice().len(), "Tree data read");
460            let tree = validate_loaded_tree(codec::decode_tree(data.as_slice())?)?;
461            if tree.hash() != *hash {
462                return Err(HeddleError::Corruption {
463                    expected: *hash,
464                    found: tree.hash(),
465                });
466            }
467            if let Ok(mut cache) = self.recent_trees.write() {
468                cache.insert(*hash, tree.clone());
469            }
470            return Ok(Some(tree));
471        }
472
473        if let Ok(manager) = self.pack_manager().read()
474            && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
475            && obj_type == ObjectType::Tree
476        {
477            trace!("Found tree in packfile");
478            let tree = validate_loaded_tree(codec::decode_tree_serialized(&data)?)?;
479            if tree.hash() != *hash {
480                return Err(HeddleError::Corruption {
481                    expected: *hash,
482                    found: tree.hash(),
483                });
484            }
485            if let Ok(mut cache) = self.recent_trees.write() {
486                cache.insert(*hash, tree.clone());
487            }
488            return Ok(Some(tree));
489        }
490        Ok(None)
491    }
492
493    fn try_get_tree_serialized_once(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
494        let path = hash_path(&trees_dir(&self.root), hash);
495        if path.exists()
496            && let Some(data) = read_file_bytes(&path)?
497        {
498            return Ok(Some(codec::decode_tree_body(data.as_slice())?));
499        }
500
501        if let Ok(manager) = self.pack_manager().read()
502            && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
503            && obj_type == ObjectType::Tree
504        {
505            return Ok(Some(data));
506        }
507
508        Ok(None)
509    }
510
511    fn try_has_tree_once(&self, hash: &ContentHash) -> Result<bool> {
512        // Read-lock `contains`: an existence check needs no LRU
513        // promotion, so it must not serialize on the write lock.
514        if let Ok(cache) = self.recent_trees.read()
515            && cache.contains(hash)
516        {
517            return Ok(true);
518        }
519        let path = hash_path(&trees_dir(&self.root), hash);
520        self.loose_or_packed(&path, |m| m.has_object(hash))
521    }
522
523    fn try_get_state_once(&self, id: &StateId) -> Result<Option<State>> {
524        // Cache first — avoid `path.exists()` / pack probes on warm hits.
525        // Write lock: LRU promotion mutates the order list. Put paths and
526        // successful reads below keep this coherent for the process.
527        if let Ok(mut cache) = self.recent_states.write()
528            && let Some(state) = cache.get(id)
529        {
530            trace!("Found state in recent object cache");
531            return Ok(Some(state.clone()));
532        }
533
534        let path = state_path(&self.root, id);
535        if let Some(data) = read_file_bytes(&path)? {
536            trace!(size = data.as_slice().len(), "State read from loose object");
537            let state = validate_loaded_state(id, codec::decode_state(data.as_slice())?)?;
538            if let Ok(mut cache) = self.recent_states.write() {
539                cache.insert(*id, state.clone());
540            }
541            return Ok(Some(state));
542        }
543
544        if let Ok(manager) = self.pack_manager().read()
545            && let Some((obj_type, data)) = manager.get_object(&PackObjectId::StateId(*id))?
546            && obj_type == ObjectType::State
547        {
548            trace!("Found state in packfile");
549            let state = validate_loaded_state(id, rmp_serde::from_slice(&data)?)?;
550            if let Ok(mut cache) = self.recent_states.write() {
551                cache.insert(*id, state.clone());
552            }
553            return Ok(Some(state));
554        }
555
556        Ok(None)
557    }
558
559    fn try_has_state_once(&self, id: &StateId) -> Result<bool> {
560        // Read-lock `contains`: an existence check needs no LRU
561        // promotion, so it must not serialize on the write lock.
562        if let Ok(cache) = self.recent_states.read()
563            && cache.contains(id)
564        {
565            return Ok(true);
566        }
567        let path = state_path(&self.root, id);
568        self.loose_or_packed(&path, |m| m.has_object_id(&PackObjectId::StateId(*id)))
569    }
570}
571
572impl FsStore {
573    pub(crate) fn snapshot_commit_descriptors_impl(&self) -> Result<Vec<SnapshotCommitDescriptor>> {
574        let manager = self
575            .pack_manager()
576            .read()
577            .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
578        manager.snapshot_commit_descriptors()
579    }
580
581    pub(crate) fn snapshot_commit_descriptor_for_state_impl(
582        &self,
583        state: &StateId,
584    ) -> Result<Option<SnapshotCommitDescriptor>> {
585        let manager = self
586            .pack_manager()
587            .read()
588            .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
589        manager.snapshot_commit_descriptor_for_state(state)
590    }
591}
592
593impl ObjectStore for FsStore {
594    fn clear_recent_caches(&self) {
595        self.clear_recent_object_caches();
596    }
597
598    /// Zero-copy pack fast path. When the blob lives in a packfile
599    /// and is non-delta + uncompressed, returns a `Bytes::slice`
600    /// view of the pack's mmap — no decompression, no allocation,
601    /// no memcpy. Compressed pack entries, delta entries, and
602    /// loose blobs fall back to `get_blob` and wrap the result in a
603    /// `Bytes` (the `Vec` → `Bytes` conversion is itself zero-copy).
604    fn get_blob_bytes(&self, hash: &ContentHash) -> Result<Option<bytes::Bytes>> {
605        if let Ok(manager) = self.pack_manager().read()
606            && let Some((obj_type, data)) = manager.get_hashed_object_bytes(hash)?
607            && obj_type == crate::store::pack::ObjectType::Blob
608        {
609            return Ok(Some(data));
610        }
611        Ok(self
612            .get_blob(hash)?
613            .map(|blob| bytes::Bytes::from(blob.into_content())))
614    }
615
616    #[instrument(skip(self), fields(hash = %hash.short()))]
617    fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>> {
618        if let Some(blob) = self.try_get_blob_once(hash)? {
619            return Ok(Some(blob));
620        }
621        // Miss path: a sibling FsStore (e.g. the worktree's repo
622        // backing the same `.heddle/`) may have installed a new pack
623        // since we loaded ours. Cheap disk-count check first; full
624        // reload only when the count grew.
625        if self.reload_packs_if_stale()?
626            && let Some(blob) = self.try_get_blob_once(hash)?
627        {
628            return Ok(Some(blob));
629        }
630        trace!("Blob not found");
631        match &self.external_source {
632            Some(source) => source.get_blob(hash),
633            None => Ok(None),
634        }
635    }
636
637    #[instrument(skip(self, blob), fields(size = blob.content().len()))]
638    fn put_blob(&self, blob: &Blob) -> Result<ContentHash> {
639        let hash = blob.hash();
640        let path = hash_path(&blobs_dir(&self.root), &hash);
641
642        if !path.exists() {
643            let data = codec::encode_blob_content(blob.content(), &self.compression)?;
644            trace!(compressed_size = data.len(), "Writing blob");
645            self.write_loose_object_atomic(&path, &data)?;
646        } else {
647            trace!("Blob already exists, skipping write");
648        }
649        self.cache_recent_blob(hash, blob);
650
651        Ok(hash)
652    }
653
654    #[instrument(skip(self, blob), fields(hash = %hash.short()))]
655    fn put_blob_with_hash(&self, blob: &Blob, hash: ContentHash) -> Result<ContentHash> {
656        if blob.hash() != hash {
657            return Err(HeddleError::Corruption {
658                expected: hash,
659                found: blob.hash(),
660            });
661        }
662
663        let path = hash_path(&blobs_dir(&self.root), &hash);
664
665        if !path.exists() {
666            let data = codec::encode_blob_content(blob.content(), &self.compression)?;
667            trace!(
668                compressed_size = data.len(),
669                "Writing blob with precomputed hash"
670            );
671            self.write_loose_object_atomic(&path, &data)?;
672        }
673        self.cache_recent_blob(hash, blob);
674
675        Ok(hash)
676    }
677
678    #[instrument(skip(self, data), fields(hash = %hash.short(), size = data.len()))]
679    fn put_blob_bytes_with_hash(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
680        validate_blob_bytes(data, hash)?;
681
682        let path = hash_path(&blobs_dir(&self.root), &hash);
683        if !path.exists() {
684            trace!(
685                size = data.len(),
686                "Writing raw blob bytes with precomputed hash"
687            );
688            self.write_loose_object_atomic(&path, data)?;
689        }
690        self.cache_recent_blob(hash, &Blob::from_slice(data));
691
692        Ok(hash)
693    }
694
695    #[instrument(skip(self), fields(hash = %hash.short()))]
696    fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
697        if ObjectStore::has_blob_locally(self, hash)? {
698            return Ok(true);
699        }
700        match &self.external_source {
701            Some(source) => Ok(source.get_blob(hash)?.is_some()),
702            None => Ok(false),
703        }
704    }
705
706    fn has_blob_locally(&self, hash: &ContentHash) -> Result<bool> {
707        if self.try_has_blob_once(hash)? {
708            return Ok(true);
709        }
710        Ok(self.reload_packs_if_stale()? && self.try_has_blob_once(hash)?)
711    }
712
713    /// Loose blob path safe for clonefile/copy materialization.
714    ///
715    /// Returns `Some(path)` only when the loose file exists, is
716    /// stored uncompressed, *and* its bytes hash to the expected
717    /// content hash. Compressed blobs and pack-only blobs fall
718    /// through to `None`; so do *torn* cache-mirror files (the
719    /// `AtomicWriteMode::NoSync` write side may leave one if the
720    /// host crashed during a previous promote). On the torn case
721    /// the caller re-promotes from the authoritative pack copy.
722    ///
723    /// Verification is amortised: a hash that passes the check once
724    /// in this process is recorded in `verified_loose_blobs` and
725    /// subsequent calls skip the read+hash. So the cost on the
726    /// materialize hot path is at most one BLAKE3 over each unique
727    /// blob per process lifetime — negligible for tiny blobs,
728    /// bounded by working-set size for huge ones.
729    fn loose_blob_path(&self, hash: &ContentHash) -> Option<PathBuf> {
730        let path = hash_path(&blobs_dir(&self.root), hash);
731        // Fast path: this process already verified (or wrote) this
732        // hash's loose mirror in `promote_to_loose_uncompressed`.
733        // Trust without re-hashing — `path.exists()` is the only
734        // I/O we need.
735        if let Ok(verified) = self.verified_loose_blobs.read()
736            && verified.contains(hash)
737            && path.exists()
738        {
739            return Some(path);
740        }
741
742        // First-time-this-process check: peek the header to filter
743        // out compressed-loose files cheaply, then verify the
744        // body's hash matches what the caller expects. A torn-write
745        // (post-crash) cache mirror fails this and the caller
746        // re-promotes from the pack.
747        //
748        // Header peek must cover the 9-byte modern header **plus**
749        // the 4-byte ZSTD magic that `is_compressed` checks —
750        // peeking only 9 bytes makes `is_compressed` falsely
751        // return `false` on a properly-compressed blob, and we'd
752        // hand the caller the compressed file path. Same off-by-4
753        // we fixed in `BLOB_HEADER_PEEK`.
754        let (header, _) = read_file_header(&path, BLOB_HEADER_PEEK).ok().flatten()?;
755        if is_compressed(&header) {
756            return None;
757        }
758        let bytes = read_file_bytes(&path).ok().flatten()?;
759        let actual = ContentHash::compute_typed("blob", bytes.as_slice());
760        if actual != *hash {
761            // Torn write or unrelated corruption. Leave the file on
762            // disk; the caller's `promote_to_loose_uncompressed`
763            // will overwrite it via the standard temp+rename path.
764            return None;
765        }
766        if let Ok(mut verified) = self.verified_loose_blobs.write() {
767            verified.insert(*hash, ());
768        }
769        Some(path)
770    }
771
772    /// Promote a blob to its uncompressed-loose canonical path so
773    /// `loose_blob_path` returns `Some(path)` and hardlink-first
774    /// materialization fires.
775    ///
776    /// Three cases:
777    /// 1. Already loose+uncompressed: peek the header, no-op.
778    /// 2. Loose but compressed: read+decompress, atomically rewrite
779    ///    the canonical path with raw bytes.
780    /// 3. Pack-only: read out of the pack via `get_blob`, atomically
781    ///    write to the canonical loose path. Pack copy is left in
782    ///    place — the next prune cycle will discard the loose mirror
783    ///    and a future materialize will re-promote.
784    #[instrument(skip(self), fields(hash = %hash.short()))]
785    fn promote_to_loose_uncompressed(&self, hash: &ContentHash) -> Result<bool> {
786        let path = hash_path(&blobs_dir(&self.root), hash);
787
788        // An external Git-overlay blob must stay external: materialization can
789        // read its bytes through `get_blob_bytes`, but promotion would turn a
790        // read-through into an accidental native copy and violate the source-
791        // authority boundary.
792        if !self.try_has_blob_once(hash)?
793            && let Some(source) = &self.external_source
794            && source.get_blob(hash)?.is_some()
795        {
796            return Ok(false);
797        }
798
799        // Idempotent fast path: already loose AND uncompressed.
800        if let Some((header, _)) = read_file_header(&path, 9)?
801            && !is_compressed(&header)
802        {
803            trace!("Blob already loose+uncompressed; skipping promotion");
804            return Ok(false);
805        }
806
807        // Either compressed-loose or pack-only. Reading via
808        // `get_blob` covers both: compressed-loose decompresses on
809        // the way out, pack-only reads from the loaded pack manager.
810        let blob = self.get_blob(hash)?.ok_or_else(|| {
811            HeddleError::NotFound(format!(
812                "blob {} not found in store; cannot promote to loose-uncompressed",
813                hash
814            ))
815        })?;
816
817        // Install the uncompressed bytes at the canonical loose path
818        // via the cache-mirror atomic-write variant: no fsync, just
819        // temp+rename. The fsync skip is what makes promotion fast
820        // (measured: ~5 ms/blob with `sync_data` vs ~0.2 ms without
821        // on macOS APFS); the safety comes from the read-side hash
822        // check in `loose_blob_path`. A torn write after a crash
823        // produces a file whose content hash doesn't match, so the
824        // next reader rejects it and re-promotes from the pack.
825        //
826        // Record the hash in this process's verified-blobs cache:
827        // we just wrote the bytes ourselves, so the subsequent read
828        // path can trust them without re-hashing.
829        debug!(
830            size = blob.content().len(),
831            "Promoting blob to loose-uncompressed canonical store"
832        );
833        self.write_loose_object_cache(&path, blob.content())?;
834        if let Ok(mut verified) = self.verified_loose_blobs.write() {
835            verified.insert(*hash, ());
836        }
837        Ok(true)
838    }
839
840    #[instrument(skip(self), fields(hash = %hash.short()))]
841    fn blob_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
842        if let Some(size) = self.try_get_blob_size_once(hash)? {
843            return Ok(Some(size));
844        }
845        // Sibling-store recovery, mirroring the read path: if a
846        // concurrent writer just installed a pack we don't know about,
847        // reload and retry once before reporting a miss.
848        if self.reload_packs_if_stale()?
849            && let Some(size) = self.try_get_blob_size_once(hash)?
850        {
851            return Ok(Some(size));
852        }
853        match &self.external_source {
854            Some(source) => Ok(source
855                .get_blob(hash)?
856                .map(|blob| blob.content().len() as u64)),
857            None => Ok(None),
858        }
859    }
860
861    #[instrument(skip(self), fields(hash = %hash.short()))]
862    fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>> {
863        if let Some(tree) = self.try_get_tree_once(hash)? {
864            return Ok(Some(tree));
865        }
866        if self.reload_packs_if_stale()?
867            && let Some(tree) = self.try_get_tree_once(hash)?
868        {
869            return Ok(Some(tree));
870        }
871        trace!("Tree not found");
872        match &self.external_source {
873            Some(source) => source.get_tree(hash),
874            None => Ok(None),
875        }
876    }
877
878    #[instrument(skip(self), fields(hash = %hash.short()))]
879    fn get_tree_serialized(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
880        if let Some(data) = self.try_get_tree_serialized_once(hash)? {
881            return Ok(Some(data));
882        }
883        if self.reload_packs_if_stale()?
884            && let Some(data) = self.try_get_tree_serialized_once(hash)?
885        {
886            return Ok(Some(data));
887        }
888        match &self.external_source {
889            Some(source) => source
890                .get_tree(hash)?
891                .map(|tree| rmp_serde::to_vec_named(&tree))
892                .transpose()
893                .map_err(|error| HeddleError::InvalidObject(error.to_string())),
894            None => Ok(None),
895        }
896    }
897
898    #[instrument(skip(self, tree), fields(entry_count = tree.entries().len()))]
899    fn put_tree(&self, tree: &Tree) -> Result<ContentHash> {
900        let hash = tree.hash();
901        let path = hash_path(&trees_dir(&self.root), &hash);
902
903        // `put_tree` is an ownership boundary: a native state that references
904        // this tree must survive loss or pruning of an overlay read-through
905        // source. Descriptor-only states do not call this method; they retain
906        // their explicit external-source semantics.
907        if !ObjectStore::has_tree_locally(self, &hash)? {
908            let (_, data) = codec::encode_tree(tree, &self.compression)?;
909            trace!(compressed_size = data.len(), "Writing tree");
910            self.write_loose_object_atomic(&path, &data)?;
911        } else {
912            trace!("Tree already exists, skipping write");
913        }
914        if let Ok(mut cache) = self.recent_trees.write() {
915            cache.insert(hash, tree.clone());
916        }
917
918        Ok(hash)
919    }
920
921    #[instrument(skip(self, data), fields(hash = %hash.short(), size = data.len()))]
922    fn put_tree_serialized(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
923        let tree = validate_tree_serialized(data, hash)?;
924
925        let path = hash_path(&trees_dir(&self.root), &hash);
926        let should_write = match read_file_bytes(&path)? {
927            Some(existing) => codec::decode_tree_body(existing.as_slice())? != data,
928            None => true,
929        };
930        if should_write {
931            trace!(size = data.len(), "Writing raw serialized tree");
932            self.write_loose_object_atomic(&path, data)?;
933        }
934        if let Ok(mut cache) = self.recent_trees.write() {
935            cache.insert(hash, tree);
936        }
937
938        Ok(hash)
939    }
940
941    #[instrument(skip(self), fields(hash = %hash.short()))]
942    fn has_tree(&self, hash: &ContentHash) -> Result<bool> {
943        if ObjectStore::has_tree_locally(self, hash)? {
944            return Ok(true);
945        }
946        match &self.external_source {
947            Some(source) => Ok(source.get_tree(hash)?.is_some()),
948            None => Ok(false),
949        }
950    }
951
952    fn has_tree_locally(&self, hash: &ContentHash) -> Result<bool> {
953        if self.try_has_tree_once(hash)? {
954            return Ok(true);
955        }
956        Ok(self.reload_packs_if_stale()? && self.try_has_tree_once(hash)?)
957    }
958
959    #[instrument(skip(self), fields(id = %id.short()))]
960    fn get_state(&self, id: &StateId) -> Result<Option<State>> {
961        if let Some(state) = self.try_get_state_once(id)? {
962            return Ok(Some(state));
963        }
964        if self.reload_packs_if_stale()?
965            && let Some(state) = self.try_get_state_once(id)?
966        {
967            return Ok(Some(state));
968        }
969        trace!("State not found");
970        match &self.external_source {
971            Some(source) => source.get_state(id),
972            None => Ok(None),
973        }
974    }
975
976    #[instrument(skip(self, state), fields(id = %state.id().short()))]
977    fn put_state(&self, state: &State) -> Result<()> {
978        let state_id = state.id();
979        let path = state_path(&self.root, &state_id);
980        let data = codec::encode_state(state, &self.compression)?;
981        trace!(compressed_size = data.len(), "Writing state");
982        self.write_loose_object_atomic(&path, &data)?;
983        if let Ok(mut cache) = self.recent_states.write() {
984            let mut cached = state.clone();
985            cached.state_id = state_id;
986            cache.insert(state_id, cached);
987        }
988        Ok(())
989    }
990
991    #[instrument(skip(self, data), fields(id = %id.short(), size = data.len()))]
992    fn put_state_serialized(&self, data: &[u8], id: StateId) -> Result<()> {
993        let state = validate_state_serialized(data, id)?;
994        let path = state_path(&self.root, &id);
995        trace!(size = data.len(), "Writing raw serialized state");
996        self.write_loose_object_atomic(&path, data)?;
997        if let Ok(mut cache) = self.recent_states.write() {
998            cache.insert(id, state);
999        }
1000        Ok(())
1001    }
1002
1003    #[instrument(skip(self), fields(id = %id.short()))]
1004    fn has_state(&self, id: &StateId) -> Result<bool> {
1005        if self.try_has_state_once(id)? {
1006            return Ok(true);
1007        }
1008        if self.reload_packs_if_stale()? && self.try_has_state_once(id)? {
1009            return Ok(true);
1010        }
1011        match &self.external_source {
1012            Some(source) => Ok(source.get_state(id)?.is_some()),
1013            None => Ok(false),
1014        }
1015    }
1016
1017    #[instrument(skip(self))]
1018    fn list_states(&self) -> Result<Vec<StateId>> {
1019        self.reload_packs_if_stale()?;
1020
1021        let dir = states_dir(&self.root);
1022        let mut states = Vec::new();
1023        if dir.exists() {
1024            for entry in fs::read_dir(&dir)? {
1025                let entry = entry?;
1026                let path = entry.path();
1027                if let Some(name) = path.file_stem()
1028                    && let Some(name_str) = name.to_str()
1029                    && let Ok(id) = StateId::parse(name_str)
1030                {
1031                    states.push(id);
1032                }
1033            }
1034        }
1035        if let Ok(manager) = self.pack_manager().read() {
1036            for id in manager.list_all_ids()? {
1037                if let PackObjectId::StateId(change_id) = id
1038                    && !states.contains(&change_id)
1039                {
1040                    states.push(change_id);
1041                }
1042            }
1043        }
1044        if let Some(source) = &self.external_source {
1045            for id in source.list_states()? {
1046                if !states.contains(&id) {
1047                    states.push(id);
1048                }
1049            }
1050        }
1051        debug!(count = states.len(), "Listed states");
1052        Ok(states)
1053    }
1054
1055    fn get_state_attachment(
1056        &self,
1057        state: &StateId,
1058        id: &StateAttachmentId,
1059    ) -> Result<Option<StateAttachment>> {
1060        let path = state_attachment_path(&self.root, state, id);
1061        let bytes = if let Some(bytes) = read_file_bytes(&path)? {
1062            bytes.as_slice().to_vec()
1063        } else if let Ok(manager) = self.pack_manager().read()
1064            && let Some((ObjectType::StateAttachment, bytes)) =
1065                manager.get_hashed_object(id.as_hash())?
1066        {
1067            bytes
1068        } else {
1069            return Ok(None);
1070        };
1071        let attachment: StateAttachment = rmp_serde::from_slice(&bytes)?;
1072        if attachment.state_id != *state || attachment.id() != *id {
1073            return Err(HeddleError::InvalidObject(
1074                "state attachment address does not match content".to_string(),
1075            ));
1076        }
1077        Ok(Some(attachment))
1078    }
1079
1080    fn put_state_attachment(&self, attachment: &StateAttachment) -> Result<StateAttachmentId> {
1081        let id = attachment.id();
1082        self.with_state_attachment_index_lock(&attachment.state_id, || {
1083            let index_path = state_attachment_index_path(&self.root, &attachment.state_id);
1084            let mut ids: Vec<StateAttachmentId> = match read_file_bytes(&index_path)? {
1085                Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
1086                None => self.rebuild_state_attachment_index(&attachment.state_id)?,
1087            };
1088            if !ids.contains(&id) {
1089                ids.push(id);
1090                ids.sort();
1091                self.write_loose_object_atomic(&index_path, &rmp_serde::to_vec_named(&ids)?)?;
1092            }
1093            let path = state_attachment_path(&self.root, &attachment.state_id, &id);
1094            self.write_loose_object_atomic(&path, &rmp_serde::to_vec_named(attachment)?)?;
1095            Ok(id)
1096        })
1097    }
1098
1099    fn list_state_attachments(&self, state: &StateId) -> Result<Vec<StateAttachment>> {
1100        self.with_state_attachment_index_lock(state, || {
1101            let index_path = state_attachment_index_path(&self.root, state);
1102            let mut ids: Vec<StateAttachmentId> = match read_file_bytes(&index_path)? {
1103                Some(bytes) => rmp_serde::from_slice(bytes.as_slice())?,
1104                None => self.rebuild_state_attachment_index(state)?,
1105            };
1106            let mut attachments = Vec::new();
1107            let mut stale = false;
1108            for id in &ids {
1109                match self.get_state_attachment(state, id)? {
1110                    Some(attachment) => attachments.push(attachment),
1111                    None => stale = true,
1112                }
1113            }
1114            if stale {
1115                ids = self.rebuild_state_attachment_index(state)?;
1116                attachments.clear();
1117                for id in ids {
1118                    let attachment = self.get_state_attachment(state, &id)?.ok_or_else(|| {
1119                        HeddleError::InvalidObject(format!(
1120                            "rebuilt state attachment index references missing {id}"
1121                        ))
1122                    })?;
1123                    attachments.push(attachment);
1124                }
1125            }
1126            Ok(attachments)
1127        })
1128    }
1129
1130    #[instrument(skip(self), fields(id = %id))]
1131    fn get_action(&self, id: &ActionId) -> Result<Option<Action>> {
1132        let path = action_path(&self.root, id);
1133        if !path.exists()
1134            && let Ok(manager) = self.pack_manager().read()
1135            && let Some((obj_type, data)) = manager.get_hashed_object(id.as_hash())?
1136            && obj_type == ObjectType::Action
1137        {
1138            trace!("Found action in packfile");
1139            let action = validate_loaded_action(id, rmp_serde::from_slice(&data)?)?;
1140            return Ok(Some(action));
1141        }
1142        match read_file_bytes(&path)? {
1143            Some(data) => {
1144                trace!(size = data.as_slice().len(), "Action data read");
1145                let action = validate_loaded_action(id, codec::decode_action(data.as_slice())?)?;
1146                Ok(Some(action))
1147            }
1148            None => {
1149                trace!("Action not found");
1150                Ok(None)
1151            }
1152        }
1153    }
1154
1155    #[instrument(skip(self, action))]
1156    fn put_action(&self, action: &mut Action) -> Result<ActionId> {
1157        let id = action.id();
1158        let path = action_path(&self.root, &id);
1159
1160        if !path.exists() {
1161            let (_, data) = codec::encode_action(action, &self.compression)?;
1162            trace!(id = %id, compressed_size = data.len(), "Writing action");
1163            self.write_loose_object_atomic(&path, &data)?;
1164        }
1165
1166        Ok(id)
1167    }
1168
1169    #[instrument(skip(self))]
1170    fn list_actions(&self) -> Result<Vec<ActionId>> {
1171        let dir = actions_dir(&self.root);
1172        let mut actions = Vec::new();
1173        if dir.exists() {
1174            for entry in fs::read_dir(&dir)? {
1175                let entry = entry?;
1176                let path = entry.path();
1177                if let Some(name) = path.file_stem()
1178                    && let Some(name_str) = name.to_str()
1179                    && let Ok(hash) = ContentHash::from_hex(name_str)
1180                {
1181                    actions.push(ActionId::from_hash(hash));
1182                }
1183            }
1184        }
1185        if let Ok(manager) = self.pack_manager().read() {
1186            for id in manager.list_all_ids()? {
1187                if let PackObjectId::Hash(hash) = id
1188                    && !actions.iter().any(|action_id| action_id.as_hash() == &hash)
1189                    && let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
1190                    && obj_type == ObjectType::Action
1191                {
1192                    actions.push(ActionId::from_hash(hash));
1193                }
1194            }
1195        }
1196        debug!(count = actions.len(), "Listed actions");
1197        Ok(actions)
1198    }
1199
1200    #[instrument(skip(self))]
1201    fn list_blobs(&self) -> Result<Vec<ContentHash>> {
1202        let dir = blobs_dir(&self.root);
1203        let mut blobs = list_hashes_from_dir(&dir)?;
1204        if let Ok(manager) = self.pack_manager().read() {
1205            for id in manager.list_all_ids()? {
1206                if let PackObjectId::Hash(hash) = id
1207                    && !blobs.contains(&hash)
1208                    && let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
1209                    && obj_type == ObjectType::Blob
1210                {
1211                    blobs.push(hash);
1212                }
1213            }
1214        }
1215        Ok(blobs)
1216    }
1217
1218    #[instrument(skip(self))]
1219    fn list_trees(&self) -> Result<Vec<ContentHash>> {
1220        let dir = trees_dir(&self.root);
1221        let mut trees = list_hashes_from_dir(&dir)?;
1222        if let Ok(manager) = self.pack_manager().read() {
1223            for id in manager.list_all_ids()? {
1224                if let PackObjectId::Hash(hash) = id
1225                    && !trees.contains(&hash)
1226                    && let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
1227                    && obj_type == ObjectType::Tree
1228                {
1229                    trees.push(hash);
1230                }
1231            }
1232        }
1233        Ok(trees)
1234    }
1235
1236    #[instrument(skip(self))]
1237    fn pack_objects(&self, aggressive: bool) -> Result<(u64, u64)> {
1238        self.pack_objects_impl(aggressive)
1239    }
1240
1241    #[instrument(skip(self), fields(id = ?id))]
1242    fn get_pack_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
1243        if let Ok(manager) = self.pack_manager().read()
1244            && let Some((obj_type, data)) = manager.get_object(id)?
1245        {
1246            return Ok(Some((obj_type, data)));
1247        }
1248
1249        match id {
1250            PackObjectId::Hash(hash) => {
1251                if let Some(blob) = self.get_blob(hash)? {
1252                    return Ok(Some((ObjectType::Blob, blob.content().to_vec())));
1253                }
1254                if let Some(tree) = self.get_tree(hash)? {
1255                    return Ok(Some((ObjectType::Tree, rmp_serde::to_vec_named(&tree)?)));
1256                }
1257                if let Some(action) = self.get_action(&ActionId::from_hash(*hash))? {
1258                    return Ok(Some((
1259                        ObjectType::Action,
1260                        rmp_serde::to_vec_named(&action)?,
1261                    )));
1262                }
1263                Ok(None)
1264            }
1265            PackObjectId::StateId(change_id) => {
1266                if let Some(state) = self.get_state(change_id)? {
1267                    Ok(Some((ObjectType::State, rmp_serde::to_vec_named(&state)?)))
1268                } else {
1269                    Ok(None)
1270                }
1271            }
1272        }
1273    }
1274
1275    #[instrument(skip(self, pack_data, index_data))]
1276    fn install_pack(&self, pack_data: &[u8], index_data: &[u8]) -> Result<Vec<PackObjectId>> {
1277        let reader = crate::store::pack::PackReader::from_slice(pack_data, index_data)?;
1278        let ids = validate_and_list_pack(&reader)?;
1279        let state_entries = state_entries_from_pack(&reader, &ids)?;
1280        self.install_pack_files(pack_data, index_data)?;
1281        for (id, data) in state_entries {
1282            self.put_state_serialized(&data, id)?;
1283        }
1284        for id in &ids {
1285            let Some((ObjectType::StateAttachment, data)) = reader.get_object(id)? else {
1286                continue;
1287            };
1288            let attachment: StateAttachment = rmp_serde::from_slice(&data)?;
1289            self.put_state_attachment(&attachment)?;
1290        }
1291        self.clear_recent_object_caches();
1292        Ok(ids)
1293    }
1294
1295    #[instrument(skip(self, blobs), fields(count = blobs.len()))]
1296    fn put_blobs_packed(&self, blobs: Vec<(crate::object::ContentHash, Vec<u8>)>) -> Result<()> {
1297        self.put_blobs_packed_impl(blobs)
1298    }
1299
1300    #[instrument(skip(self, blobs, tree, state), fields(blob_count = blobs.len()))]
1301    fn put_snapshot_objects_packed(
1302        &self,
1303        blobs: Vec<(ContentHash, Vec<u8>)>,
1304        tree: &Tree,
1305        state: &State,
1306    ) -> Result<()> {
1307        self.put_snapshot_objects_packed_impl(blobs, tree, state, Vec::new(), None)
1308            .map(|_| ())
1309    }
1310
1311    fn put_snapshot_objects_and_attachments_packed(
1312        &self,
1313        blobs: Vec<(ContentHash, Vec<u8>)>,
1314        tree: &Tree,
1315        state: &State,
1316        attachments: Vec<StateAttachment>,
1317    ) -> Result<()> {
1318        self.put_snapshot_objects_packed_impl(blobs, tree, state, attachments, None)
1319            .map(|_| ())
1320    }
1321
1322    #[instrument(skip(self))]
1323    fn install_pack_streaming(
1324        &self,
1325        pack_path: &std::path::Path,
1326        index_path: &std::path::Path,
1327    ) -> Result<Vec<PackObjectId>> {
1328        // Validate + list ids through the same core as the byte-buffer
1329        // seam, but via an mmap-backed reader so the pack is never
1330        // copied into the heap — the memory-bounded promise survives.
1331        // Drop the reader (releasing the mmap) before the rename so
1332        // the file move isn't racing an open mapping.
1333        let ids = {
1334            let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1335            validate_and_list_pack(&reader)?
1336        };
1337        let state_entries = {
1338            let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1339            state_entries_from_pack(&reader, &ids)?
1340        };
1341        let attachment_entries = {
1342            let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1343            attachment_entries_from_pack(&reader, &ids)?
1344        };
1345        self.install_pack_files_streaming(pack_path, index_path)?;
1346        for (id, data) in state_entries {
1347            self.put_state_serialized(&data, id)?;
1348        }
1349        for attachment in attachment_entries {
1350            self.put_state_attachment(&attachment)?;
1351        }
1352        Ok(ids)
1353    }
1354
1355    #[instrument(skip(self))]
1356    fn prune_loose_objects(&self) -> Result<(u64, u64)> {
1357        self.prune_loose_objects_impl()
1358    }
1359
1360    #[instrument(skip(self))]
1361    fn begin_snapshot_write_batch(&self) -> Result<()> {
1362        self.begin_snapshot_write_batch_impl()
1363    }
1364
1365    #[instrument(skip(self))]
1366    fn flush_snapshot_write_batch(&self) -> Result<()> {
1367        self.flush_snapshot_write_batch_impl()
1368    }
1369
1370    #[instrument(skip(self))]
1371    fn abort_snapshot_write_batch(&self) {
1372        self.abort_snapshot_write_batch_impl();
1373    }
1374
1375    fn has_redactions_for_blob(&self, blob: &ContentHash) -> Result<bool> {
1376        Ok(redaction_path(&self.root, blob).exists())
1377    }
1378
1379    fn get_redactions_bytes_for_blob(&self, blob: &ContentHash) -> Result<Option<Vec<u8>>> {
1380        let path = redaction_path(&self.root, blob);
1381        match fs::read(&path) {
1382            Ok(bytes) => Ok(Some(bytes)),
1383            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1384            Err(err) => Err(HeddleError::Io(err)),
1385        }
1386    }
1387
1388    fn put_redactions_bytes_for_blob(&self, blob: &ContentHash, bytes: &[u8]) -> Result<()> {
1389        let dir = redactions_dir(&self.root);
1390        if !dir.exists() {
1391            crate::fs_atomic::create_dir_all_durable(&dir)?;
1392        }
1393        let path = redaction_path(&self.root, blob);
1394        crate::fs_atomic::write_file_atomic(&path, bytes)?;
1395        Ok(())
1396    }
1397
1398    fn list_blobs_with_redactions(&self) -> Result<Vec<ContentHash>> {
1399        let dir = redactions_dir(&self.root);
1400        if !dir.exists() {
1401            return Ok(Vec::new());
1402        }
1403        let mut out = Vec::new();
1404        for entry in fs::read_dir(&dir)? {
1405            let entry = entry?;
1406            let path = entry.path();
1407            if path.extension().and_then(|e| e.to_str()) != Some("bin") {
1408                continue;
1409            }
1410            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1411                continue;
1412            };
1413            if let Ok(hash) = ContentHash::from_hex(stem) {
1414                out.push(hash);
1415            }
1416        }
1417        Ok(out)
1418    }
1419
1420    fn has_state_visibility_for_state(&self, state: &StateId) -> Result<bool> {
1421        Ok(state_visibility_path(&self.root, state).exists())
1422    }
1423
1424    fn get_state_visibility_bytes_for_state(&self, state: &StateId) -> Result<Option<Vec<u8>>> {
1425        let path = state_visibility_path(&self.root, state);
1426        match fs::read(&path) {
1427            Ok(bytes) => Ok(Some(bytes)),
1428            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1429            Err(err) => Err(HeddleError::Io(err)),
1430        }
1431    }
1432
1433    fn put_state_visibility_bytes_for_state(&self, state: &StateId, bytes: &[u8]) -> Result<()> {
1434        let dir = state_visibility_dir(&self.root);
1435        if !dir.exists() {
1436            crate::fs_atomic::create_dir_all_durable(&dir)?;
1437        }
1438        let path = state_visibility_path(&self.root, state);
1439        crate::fs_atomic::write_file_atomic(&path, bytes)?;
1440        Ok(())
1441    }
1442
1443    fn list_states_with_visibility(&self) -> Result<Vec<StateId>> {
1444        let dir = state_visibility_dir(&self.root);
1445        if !dir.exists() {
1446            return Ok(Vec::new());
1447        }
1448        let mut out = Vec::new();
1449        for entry in fs::read_dir(&dir)? {
1450            let entry = entry?;
1451            let path = entry.path();
1452            if path.extension().and_then(|e| e.to_str()) != Some("bin") {
1453                continue;
1454            }
1455            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1456                continue;
1457            };
1458            if let Ok(state) = StateId::parse(stem) {
1459                out.push(state);
1460            }
1461        }
1462        Ok(out)
1463    }
1464}
1465
1466#[cfg(test)]
1467mod state_attachment_tests {
1468    use std::sync::Arc;
1469
1470    use chrono::Utc;
1471
1472    use super::*;
1473    use crate::{
1474        object::{Attribution, Principal, StateAttachmentBody},
1475        store::{CompressionConfig, pack::PackBuilder},
1476    };
1477
1478    fn fixture(store: &FsStore) -> (State, StateAttachment) {
1479        let tree = store.put_tree(&Tree::new()).unwrap();
1480        let attribution = Attribution::human(Principal::new("Test", "test@example.com"));
1481        let state = State::new(tree, vec![], attribution.clone());
1482        store.put_state(&state).unwrap();
1483        let attachment = StateAttachment {
1484            state_id: state.id(),
1485            body: StateAttachmentBody::Context(ContentHash::compute(b"context")),
1486            attribution,
1487            created_at: Utc::now(),
1488            supersedes: None,
1489        };
1490        (state, attachment)
1491    }
1492
1493    #[test]
1494    fn concurrent_attachment_writes_keep_every_index_entry() {
1495        let temp = tempfile::TempDir::new().unwrap();
1496        let store = Arc::new(FsStore::new(temp.path()));
1497        let (state, base) = fixture(&store);
1498        let mut threads = Vec::new();
1499        for byte in 0..16u8 {
1500            let store = Arc::clone(&store);
1501            let mut attachment = base.clone();
1502            attachment.body = StateAttachmentBody::Context(ContentHash::compute(&[byte]));
1503            threads.push(std::thread::spawn(move || {
1504                store.put_state_attachment(&attachment).unwrap();
1505            }));
1506        }
1507        for thread in threads {
1508            thread.join().unwrap();
1509        }
1510        assert_eq!(store.list_state_attachments(&state.id()).unwrap().len(), 16);
1511    }
1512
1513    #[test]
1514    fn missing_index_rebuilds_from_loose_objects() {
1515        let temp = tempfile::TempDir::new().unwrap();
1516        let store = FsStore::new(temp.path());
1517        let (state, attachment) = fixture(&store);
1518        store.put_state_attachment(&attachment).unwrap();
1519        fs::remove_file(state_attachment_index_path(&store.root, &state.id())).unwrap();
1520        assert_eq!(
1521            store.list_state_attachments(&state.id()).unwrap(),
1522            vec![attachment]
1523        );
1524    }
1525
1526    #[test]
1527    fn packed_attachment_uses_state_index_for_lookup() {
1528        let temp = tempfile::TempDir::new().unwrap();
1529        let store = FsStore::new(temp.path());
1530        let (state, attachment) = fixture(&store);
1531        let mut builder = PackBuilder::new(CompressionConfig::default());
1532        builder.add(
1533            *attachment.id().as_hash(),
1534            ObjectType::StateAttachment,
1535            rmp_serde::to_vec_named(&attachment).unwrap(),
1536        );
1537        let (pack, index, _) = builder.build().unwrap();
1538        store.install_pack(&pack, &index).unwrap();
1539        fs::remove_file(state_attachment_path(
1540            &store.root,
1541            &state.id(),
1542            &attachment.id(),
1543        ))
1544        .unwrap();
1545        let rebuild_marker =
1546            state_attachment_index_path(&store.root, &state.id()).with_extension("rebuild-marker");
1547        let _ = fs::remove_file(&rebuild_marker);
1548        assert_eq!(
1549            store.list_state_attachments(&state.id()).unwrap(),
1550            vec![attachment.clone()]
1551        );
1552        assert_eq!(
1553            store.list_state_attachments(&state.id()).unwrap(),
1554            vec![attachment]
1555        );
1556        assert!(!rebuild_marker.exists());
1557    }
1558}