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,
6    path::{Path, PathBuf},
7};
8
9use heddle_format::compression::{header_uncompressed_size, is_compressed};
10use tracing::{debug, instrument, trace};
11
12use super::{
13    FsStore,
14    fs_io::{list_hashes_from_dir, read_file_bytes, read_file_header},
15    fs_paths::{
16        action_path, actions_dir, blobs_dir, hash_path, redaction_path, redactions_dir, state_path,
17        state_visibility_dir, state_visibility_path, states_dir, trees_dir,
18    },
19};
20use crate::{
21    object::{Action, ActionId, Blob, ChangeId, ContentHash, State, Tree},
22    store::{
23        HeddleError, ObjectStore, Result, codec,
24        pack::{ObjectType, PackManager, PackObjectId},
25    },
26};
27
28/// Bytes we read off disk to recover a blob's uncompressed size.
29/// Must cover the 9-byte modern header **plus** the 4-byte ZSTD
30/// magic that `header_uncompressed_size` uses to disambiguate
31/// modern from legacy (5-byte) headers — without the magic in the
32/// peek buffer the lookup silently returns the on-disk byte length
33/// instead of the recorded uncompressed size, which left `stat`
34/// reporting the compressed size of every loose blob.
35const BLOB_HEADER_PEEK: usize = 13;
36
37fn validate_loaded_tree(tree: Tree) -> Result<Tree> {
38    tree.validate()?;
39    Ok(tree)
40}
41
42fn validate_blob_bytes(data: &[u8], hash: ContentHash) -> Result<()> {
43    let mut hasher = ContentHash::typed_hasher("blob", data.len() as u64);
44    hasher.update(data);
45    let found = ContentHash::from_bytes(hasher.finalize().into());
46    if found != hash {
47        return Err(HeddleError::Corruption {
48            expected: hash,
49            found,
50        });
51    }
52
53    Ok(())
54}
55
56fn validate_tree_serialized(data: &[u8], hash: ContentHash) -> Result<Tree> {
57    let tree = codec::decode_tree_serialized(data)?;
58    let tree = validate_loaded_tree(tree)?;
59    let found = tree.hash();
60    if found != hash {
61        return Err(HeddleError::Corruption {
62            expected: hash,
63            found,
64        });
65    }
66
67    Ok(tree)
68}
69
70fn validate_loaded_state(requested_id: &ChangeId, state: State) -> Result<State> {
71    if state.change_id != *requested_id {
72        return Err(HeddleError::InvalidObject(format!(
73            "state change_id mismatch: requested {}, found {}",
74            requested_id, state.change_id
75        )));
76    }
77
78    Ok(state)
79}
80
81fn validate_state_serialized(data: &[u8], id: ChangeId) -> Result<State> {
82    let state: State = rmp_serde::from_slice(data)?;
83    validate_loaded_state(&id, state)
84}
85
86fn validate_loaded_action(requested_id: &ActionId, action: Action) -> Result<Action> {
87    let found_id = action.compute_id();
88    if found_id != *requested_id {
89        return Err(HeddleError::InvalidObject(format!(
90            "action id mismatch: requested {}, found {}",
91            requested_id, found_id
92        )));
93    }
94
95    Ok(action)
96}
97
98fn validate_action_serialized(data: &[u8], id: ActionId) -> Result<Action> {
99    let action: Action = rmp_serde::from_slice(data)?;
100    validate_loaded_action(&id, action)
101}
102
103/// Validate every entry in a pack against its tagged id (checksum
104/// validation) and return the installed id list. This is the shared
105/// validated core for both install seams: the byte-buffer install
106/// (`install_pack`) and the memory-bounded temp-file install
107/// (`install_pack_streaming`) both run their pack through here, so
108/// both apply the same checksum validation and report the same
109/// installed ids regardless of how the bytes reach the store.
110fn validate_and_list_pack(reader: &crate::store::pack::PackReader) -> Result<Vec<PackObjectId>> {
111    let ids = reader.list_ids();
112    for id in &ids {
113        let Some((obj_type, data)) = reader.get_object_bytes(id)? else {
114            continue;
115        };
116        validate_pack_entry(id, obj_type, data.as_ref())?;
117    }
118    Ok(ids)
119}
120
121fn state_entries_from_pack(
122    reader: &crate::store::pack::PackReader,
123    ids: &[PackObjectId],
124) -> Result<Vec<(ChangeId, Vec<u8>)>> {
125    let mut states = Vec::new();
126    for id in ids {
127        let PackObjectId::ChangeId(change_id) = id else {
128            continue;
129        };
130        let Some((obj_type, data)) = reader.get_object(id)? else {
131            continue;
132        };
133        if obj_type != ObjectType::State {
134            return Err(HeddleError::InvalidObject(format!(
135                "pack id {} is indexed as {:?}, expected State",
136                change_id.to_string_full(),
137                obj_type
138            )));
139        }
140        validate_state_serialized(&data, *change_id)?;
141        states.push((*change_id, data));
142    }
143    Ok(states)
144}
145
146fn validate_pack_entry(id: &PackObjectId, obj_type: ObjectType, data: &[u8]) -> Result<()> {
147    match (id, obj_type) {
148        (PackObjectId::Hash(hash), ObjectType::Blob) => validate_blob_bytes(data, *hash),
149        (PackObjectId::Hash(hash), ObjectType::Tree) => {
150            validate_tree_serialized(data, *hash).map(|_| ())
151        }
152        (PackObjectId::Hash(hash), ObjectType::Action) => {
153            validate_action_serialized(data, ActionId::from_hash(*hash)).map(|_| ())
154        }
155        (PackObjectId::ChangeId(change_id), ObjectType::State) => {
156            validate_state_serialized(data, *change_id).map(|_| ())
157        }
158        _ => Err(HeddleError::InvalidObject(format!(
159            "unsupported native pack object: {:?} {:?}",
160            id, obj_type
161        ))),
162    }
163}
164
165impl FsStore {
166    /// Insert into the recent-blob cache when the payload fits the size gate.
167    fn cache_recent_blob(&self, hash: ContentHash, blob: &Blob) {
168        if blob.content().len() > super::fs_store::RECENT_BLOB_CACHE_MAX_BYTES {
169            return;
170        }
171        if let Ok(mut cache) = self.recent_blobs.write() {
172            cache.insert(hash, blob.clone());
173        }
174    }
175
176    /// Single-pass blob lookup. The wrapper in `ObjectStore::get_blob`
177    /// retries this once after a stale-reload on miss.
178    fn try_get_blob_once(&self, hash: &ContentHash) -> Result<Option<Blob>> {
179        // Cache first — avoid `path.exists()` / pack probes on warm hits.
180        // Write lock: LRU promotion mutates the order list.
181        if let Ok(mut cache) = self.recent_blobs.write()
182            && let Some(blob) = cache.get(hash)
183        {
184            trace!("Found blob in recent object cache");
185            return Ok(Some(blob.clone()));
186        }
187
188        if let Ok(manager) = self.pack_manager().read()
189            && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
190            && obj_type == ObjectType::Blob
191        {
192            trace!("Found blob in packfile");
193            // Step 2: skip the BLAKE3 re-hash. The pack reader already
194            // located this entry by its content-addressed key in the
195            // pack index — anything served here either matches or
196            // means the pack itself is corrupted in ways a per-read
197            // hash check can't recover from cleanly. For multi-MB
198            // blobs the verify was the dominant tail of the cold
199            // read (~3GB/s × 10MB ≈ 3.3ms per call).
200            let blob = Blob::new(data);
201            self.cache_recent_blob(*hash, &blob);
202            return Ok(Some(blob));
203        }
204
205        let path = hash_path(&blobs_dir(&self.root), hash);
206        match read_file_bytes(&path)? {
207            Some(data) => {
208                trace!(size = data.as_slice().len(), "Blob data read");
209                let content = codec::decode_blob_content(data.as_slice())?;
210                let blob = Blob::new(content);
211                // Loose blobs are bare bytes on disk: a half-written
212                // file or bit-rot inside the payload would slip past
213                // the path-is-the-hash invariant. Keep the verify on
214                // this path. Pack-resident reads above skip it because
215                // pack entries are framed with offset + length records
216                // that fail to parse if the pack is corrupt.
217                if blob.hash() != *hash {
218                    return Err(HeddleError::Corruption {
219                        expected: *hash,
220                        found: blob.hash(),
221                    });
222                }
223                self.cache_recent_blob(*hash, &blob);
224                Ok(Some(blob))
225            }
226            None => Ok(None),
227        }
228    }
229
230    /// Shared body for `try_has_{blob,tree,state}_once`: object is
231    /// present iff the loose path exists or the pack manager
232    /// resolves it. Callers pass the loose path and the
233    /// pack-manager probe; the helper handles the lock.
234    fn loose_or_packed(
235        &self,
236        loose_path: &Path,
237        in_pack: impl FnOnce(&PackManager) -> bool,
238    ) -> Result<bool> {
239        if loose_path.exists() {
240            return Ok(true);
241        }
242        if let Ok(manager) = self.pack_manager().read() {
243            return Ok(in_pack(&manager));
244        }
245        Ok(false)
246    }
247
248    fn try_has_blob_once(&self, hash: &ContentHash) -> Result<bool> {
249        // Keep `has_blob` coherent with cache-first `get_blob`. A pure
250        // existence check needs no LRU promotion, so take the *read*
251        // lock and use `contains` — concurrent `has_blob` calls in
252        // heddled/mount don't serialize on the exclusive write lock.
253        if let Ok(cache) = self.recent_blobs.read()
254            && cache.contains(hash)
255        {
256            return Ok(true);
257        }
258        let path = hash_path(&blobs_dir(&self.root), hash);
259        self.loose_or_packed(&path, |m| m.has_object(hash))
260    }
261
262    /// Header-only size lookup for a single attempt. Tries:
263    /// 1. The recent-blob cache (we already have the bytes in
264    ///    memory — `len()` is free).
265    /// 2. The loose blob: peek the 9-byte compression header. For a
266    ///    compressed blob the recorded uncompressed size lives in the
267    ///    header. For an uncompressed blob (no recognised header) the
268    ///    on-disk file length IS the blob size.
269    /// 3. Any loaded pack: the pack format records the uncompressed
270    ///    size as a varint right after the tagged id, so we can decode
271    ///    it without touching the body.
272    ///
273    /// Cost: one short read (typically 9 bytes) for loose blobs, or a
274    /// pure in-memory varint decode for packed blobs. *No*
275    /// decompression.
276    fn try_get_blob_size_once(&self, hash: &ContentHash) -> Result<Option<u64>> {
277        if let Ok(mut cache) = self.recent_blobs.write()
278            && let Some(blob) = cache.get(hash)
279        {
280            return Ok(Some(blob.content().len() as u64));
281        }
282
283        let path = hash_path(&blobs_dir(&self.root), hash);
284        if let Some((header, file_len)) = read_file_header(&path, BLOB_HEADER_PEEK)? {
285            if let Some(size) = header_uncompressed_size(&header) {
286                return Ok(Some(size));
287            }
288            // No recognised compression header — the file is raw
289            // blob bytes. The on-disk length is the blob size.
290            return Ok(Some(file_len));
291        }
292
293        if let Ok(manager) = self.pack_manager().read()
294            && let Some(size) = manager.get_hashed_object_size(hash)?
295        {
296            return Ok(Some(size));
297        }
298        Ok(None)
299    }
300
301    fn try_get_tree_once(&self, hash: &ContentHash) -> Result<Option<Tree>> {
302        // Cache first. The recent-object cache only ever holds trees we
303        // wrote or read this process, so a hit is authoritative for a
304        // read. Write lock: LRU promotion mutates the order list.
305        if let Ok(mut cache) = self.recent_trees.write()
306            && let Some(tree) = cache.get(hash)
307        {
308            trace!("Found tree in recent object cache");
309            return Ok(Some(tree.clone()));
310        }
311
312        // Loose trees may be migration-promoted V2 shadows of an older packed
313        // V1 encoding at the same semantic tree hash. Prefer the loose copy
314        // when it exists, then fall through to pack lookup.
315        let path = hash_path(&trees_dir(&self.root), hash);
316        if path.exists()
317            && let Some(data) = read_file_bytes(&path)?
318        {
319            trace!(size = data.as_slice().len(), "Tree data read");
320            let tree = validate_loaded_tree(codec::decode_tree(data.as_slice())?)?;
321            if tree.hash() != *hash {
322                return Err(HeddleError::Corruption {
323                    expected: *hash,
324                    found: tree.hash(),
325                });
326            }
327            if let Ok(mut cache) = self.recent_trees.write() {
328                cache.insert(*hash, tree.clone());
329            }
330            return Ok(Some(tree));
331        }
332
333        if let Ok(manager) = self.pack_manager().read()
334            && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
335            && obj_type == ObjectType::Tree
336        {
337            trace!("Found tree in packfile");
338            let tree = validate_loaded_tree(codec::decode_tree_serialized(&data)?)?;
339            if tree.hash() != *hash {
340                return Err(HeddleError::Corruption {
341                    expected: *hash,
342                    found: tree.hash(),
343                });
344            }
345            if let Ok(mut cache) = self.recent_trees.write() {
346                cache.insert(*hash, tree.clone());
347            }
348            return Ok(Some(tree));
349        }
350        Ok(None)
351    }
352
353    fn try_get_tree_serialized_once(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
354        let path = hash_path(&trees_dir(&self.root), hash);
355        if path.exists()
356            && let Some(data) = read_file_bytes(&path)?
357        {
358            return Ok(Some(codec::decode_tree_body(data.as_slice())?));
359        }
360
361        if let Ok(manager) = self.pack_manager().read()
362            && let Some((obj_type, data)) = manager.get_hashed_object(hash)?
363            && obj_type == ObjectType::Tree
364        {
365            return Ok(Some(data));
366        }
367
368        Ok(None)
369    }
370
371    fn try_has_tree_once(&self, hash: &ContentHash) -> Result<bool> {
372        // Read-lock `contains`: an existence check needs no LRU
373        // promotion, so it must not serialize on the write lock.
374        if let Ok(cache) = self.recent_trees.read()
375            && cache.contains(hash)
376        {
377            return Ok(true);
378        }
379        let path = hash_path(&trees_dir(&self.root), hash);
380        self.loose_or_packed(&path, |m| m.has_object(hash))
381    }
382
383    fn try_get_state_once(&self, id: &ChangeId) -> Result<Option<State>> {
384        // Cache first — avoid `path.exists()` / pack probes on warm hits.
385        // Write lock: LRU promotion mutates the order list. Put paths and
386        // successful reads below keep this coherent for the process.
387        if let Ok(mut cache) = self.recent_states.write()
388            && let Some(state) = cache.get(id)
389        {
390            trace!("Found state in recent object cache");
391            return Ok(Some(state.clone()));
392        }
393
394        // States are addressed by `change_id`, NOT by content hash, so the same
395        // id can resolve to two different bodies: a copy packed at adopt/import
396        // time and a newer LOOSE copy written by an authorized rewrite (the #570
397        // fidelity backfill re-hashes + rewrites adopted states loose). The
398        // loose object MUST shadow the packed one — read it FIRST and only
399        // consult the pack when no loose object exists, or a cold read (cache
400        // miss) returns the stale packed body. (Trees/blobs are
401        // content-addressed and can't go stale this way, so their read paths
402        // deliberately keep pack-first ordering.)
403        let path = state_path(&self.root, id);
404        if let Some(data) = read_file_bytes(&path)? {
405            trace!(
406                size = data.as_slice().len(),
407                "State read from loose object (shadows any packed copy)"
408            );
409            let state = validate_loaded_state(id, codec::decode_state(data.as_slice())?)?;
410            if let Ok(mut cache) = self.recent_states.write() {
411                cache.insert(*id, state.clone());
412            }
413            return Ok(Some(state));
414        }
415
416        if let Ok(manager) = self.pack_manager().read()
417            && let Some((obj_type, data)) = manager.get_object(&PackObjectId::ChangeId(*id))?
418            && obj_type == ObjectType::State
419        {
420            trace!("Found state in packfile");
421            let state = validate_loaded_state(id, rmp_serde::from_slice(&data)?)?;
422            if let Ok(mut cache) = self.recent_states.write() {
423                cache.insert(*id, state.clone());
424            }
425            return Ok(Some(state));
426        }
427
428        Ok(None)
429    }
430
431    fn try_has_state_once(&self, id: &ChangeId) -> Result<bool> {
432        // Read-lock `contains`: an existence check needs no LRU
433        // promotion, so it must not serialize on the write lock.
434        if let Ok(cache) = self.recent_states.read()
435            && cache.contains(id)
436        {
437            return Ok(true);
438        }
439        let path = state_path(&self.root, id);
440        self.loose_or_packed(&path, |m| m.has_object_id(&PackObjectId::ChangeId(*id)))
441    }
442}
443
444impl ObjectStore for FsStore {
445    fn clear_recent_caches(&self) {
446        self.clear_recent_object_caches();
447    }
448
449    /// Zero-copy pack fast path. When the blob lives in a packfile
450    /// and is non-delta + uncompressed, returns a `Bytes::slice`
451    /// view of the pack's mmap — no decompression, no allocation,
452    /// no memcpy. Compressed pack entries, delta entries, and
453    /// loose blobs fall back to `get_blob` and wrap the result in a
454    /// `Bytes` (the `Vec` → `Bytes` conversion is itself zero-copy).
455    fn get_blob_bytes(&self, hash: &ContentHash) -> Result<Option<bytes::Bytes>> {
456        if let Ok(manager) = self.pack_manager().read()
457            && let Some((obj_type, data)) = manager.get_hashed_object_bytes(hash)?
458            && obj_type == crate::store::pack::ObjectType::Blob
459        {
460            return Ok(Some(data));
461        }
462        Ok(self
463            .get_blob(hash)?
464            .map(|blob| bytes::Bytes::from(blob.into_content())))
465    }
466
467    #[instrument(skip(self), fields(hash = %hash.short()))]
468    fn get_blob(&self, hash: &ContentHash) -> Result<Option<Blob>> {
469        if let Some(blob) = self.try_get_blob_once(hash)? {
470            return Ok(Some(blob));
471        }
472        // Miss path: a sibling FsStore (e.g. the worktree's repo
473        // backing the same `.heddle/`) may have installed a new pack
474        // since we loaded ours. Cheap disk-count check first; full
475        // reload only when the count grew.
476        if self.reload_packs_if_stale()?
477            && let Some(blob) = self.try_get_blob_once(hash)?
478        {
479            return Ok(Some(blob));
480        }
481        trace!("Blob not found");
482        Ok(None)
483    }
484
485    #[instrument(skip(self, blob), fields(size = blob.content().len()))]
486    fn put_blob(&self, blob: &Blob) -> Result<ContentHash> {
487        let hash = blob.hash();
488        let path = hash_path(&blobs_dir(&self.root), &hash);
489
490        if !path.exists() {
491            let data = codec::encode_blob_content(blob.content(), &self.compression)?;
492            trace!(compressed_size = data.len(), "Writing blob");
493            self.write_loose_object_atomic(&path, &data)?;
494        } else {
495            trace!("Blob already exists, skipping write");
496        }
497        self.cache_recent_blob(hash, blob);
498
499        Ok(hash)
500    }
501
502    #[instrument(skip(self, blob), fields(hash = %hash.short()))]
503    fn put_blob_with_hash(&self, blob: &Blob, hash: ContentHash) -> Result<ContentHash> {
504        if blob.hash() != hash {
505            return Err(HeddleError::Corruption {
506                expected: hash,
507                found: blob.hash(),
508            });
509        }
510
511        let path = hash_path(&blobs_dir(&self.root), &hash);
512
513        if !path.exists() {
514            let data = codec::encode_blob_content(blob.content(), &self.compression)?;
515            trace!(
516                compressed_size = data.len(),
517                "Writing blob with precomputed hash"
518            );
519            self.write_loose_object_atomic(&path, &data)?;
520        }
521        self.cache_recent_blob(hash, blob);
522
523        Ok(hash)
524    }
525
526    #[instrument(skip(self, data), fields(hash = %hash.short(), size = data.len()))]
527    fn put_blob_bytes_with_hash(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
528        validate_blob_bytes(data, hash)?;
529
530        let path = hash_path(&blobs_dir(&self.root), &hash);
531        if !path.exists() {
532            trace!(
533                size = data.len(),
534                "Writing raw blob bytes with precomputed hash"
535            );
536            self.write_loose_object_atomic(&path, data)?;
537        }
538        self.cache_recent_blob(hash, &Blob::from_slice(data));
539
540        Ok(hash)
541    }
542
543    #[instrument(skip(self), fields(hash = %hash.short()))]
544    fn has_blob(&self, hash: &ContentHash) -> Result<bool> {
545        if self.try_has_blob_once(hash)? {
546            return Ok(true);
547        }
548        if self.reload_packs_if_stale()? {
549            return self.try_has_blob_once(hash);
550        }
551        Ok(false)
552    }
553
554    /// Loose blob path safe for clonefile/copy materialization.
555    ///
556    /// Returns `Some(path)` only when the loose file exists, is
557    /// stored uncompressed, *and* its bytes hash to the expected
558    /// content hash. Compressed blobs and pack-only blobs fall
559    /// through to `None`; so do *torn* cache-mirror files (the
560    /// `AtomicWriteMode::NoSync` write side may leave one if the
561    /// host crashed during a previous promote). On the torn case
562    /// the caller re-promotes from the authoritative pack copy.
563    ///
564    /// Verification is amortised: a hash that passes the check once
565    /// in this process is recorded in `verified_loose_blobs` and
566    /// subsequent calls skip the read+hash. So the cost on the
567    /// materialize hot path is at most one BLAKE3 over each unique
568    /// blob per process lifetime — negligible for tiny blobs,
569    /// bounded by working-set size for huge ones.
570    fn loose_blob_path(&self, hash: &ContentHash) -> Option<PathBuf> {
571        let path = hash_path(&blobs_dir(&self.root), hash);
572        // Fast path: this process already verified (or wrote) this
573        // hash's loose mirror in `promote_to_loose_uncompressed`.
574        // Trust without re-hashing — `path.exists()` is the only
575        // I/O we need.
576        if let Ok(verified) = self.verified_loose_blobs.read()
577            && verified.contains(hash)
578            && path.exists()
579        {
580            return Some(path);
581        }
582
583        // First-time-this-process check: peek the header to filter
584        // out compressed-loose files cheaply, then verify the
585        // body's hash matches what the caller expects. A torn-write
586        // (post-crash) cache mirror fails this and the caller
587        // re-promotes from the pack.
588        //
589        // Header peek must cover the 9-byte modern header **plus**
590        // the 4-byte ZSTD magic that `is_compressed` checks —
591        // peeking only 9 bytes makes `is_compressed` falsely
592        // return `false` on a properly-compressed blob, and we'd
593        // hand the caller the compressed file path. Same off-by-4
594        // we fixed in `BLOB_HEADER_PEEK`.
595        let (header, _) = read_file_header(&path, BLOB_HEADER_PEEK).ok().flatten()?;
596        if is_compressed(&header) {
597            return None;
598        }
599        let bytes = read_file_bytes(&path).ok().flatten()?;
600        let actual = ContentHash::compute_typed("blob", bytes.as_slice());
601        if actual != *hash {
602            // Torn write or unrelated corruption. Leave the file on
603            // disk; the caller's `promote_to_loose_uncompressed`
604            // will overwrite it via the standard temp+rename path.
605            return None;
606        }
607        if let Ok(mut verified) = self.verified_loose_blobs.write() {
608            verified.insert(*hash, ());
609        }
610        Some(path)
611    }
612
613    /// Promote a blob to its uncompressed-loose canonical path so
614    /// `loose_blob_path` returns `Some(path)` and hardlink-first
615    /// materialization fires.
616    ///
617    /// Three cases:
618    /// 1. Already loose+uncompressed: peek the header, no-op.
619    /// 2. Loose but compressed: read+decompress, atomically rewrite
620    ///    the canonical path with raw bytes.
621    /// 3. Pack-only: read out of the pack via `get_blob`, atomically
622    ///    write to the canonical loose path. Pack copy is left in
623    ///    place — the next prune cycle will discard the loose mirror
624    ///    and a future materialize will re-promote.
625    #[instrument(skip(self), fields(hash = %hash.short()))]
626    fn promote_to_loose_uncompressed(&self, hash: &ContentHash) -> Result<bool> {
627        let path = hash_path(&blobs_dir(&self.root), hash);
628
629        // Idempotent fast path: already loose AND uncompressed.
630        if let Some((header, _)) = read_file_header(&path, 9)?
631            && !is_compressed(&header)
632        {
633            trace!("Blob already loose+uncompressed; skipping promotion");
634            return Ok(false);
635        }
636
637        // Either compressed-loose or pack-only. Reading via
638        // `get_blob` covers both: compressed-loose decompresses on
639        // the way out, pack-only reads from the loaded pack manager.
640        let blob = self.get_blob(hash)?.ok_or_else(|| {
641            HeddleError::NotFound(format!(
642                "blob {} not found in store; cannot promote to loose-uncompressed",
643                hash
644            ))
645        })?;
646
647        // Install the uncompressed bytes at the canonical loose path
648        // via the cache-mirror atomic-write variant: no fsync, just
649        // temp+rename. The fsync skip is what makes promotion fast
650        // (measured: ~5 ms/blob with `sync_data` vs ~0.2 ms without
651        // on macOS APFS); the safety comes from the read-side hash
652        // check in `loose_blob_path`. A torn write after a crash
653        // produces a file whose content hash doesn't match, so the
654        // next reader rejects it and re-promotes from the pack.
655        //
656        // Record the hash in this process's verified-blobs cache:
657        // we just wrote the bytes ourselves, so the subsequent read
658        // path can trust them without re-hashing.
659        debug!(
660            size = blob.content().len(),
661            "Promoting blob to loose-uncompressed canonical store"
662        );
663        self.write_loose_object_cache(&path, blob.content())?;
664        if let Ok(mut verified) = self.verified_loose_blobs.write() {
665            verified.insert(*hash, ());
666        }
667        Ok(true)
668    }
669
670    #[instrument(skip(self), fields(hash = %hash.short()))]
671    fn blob_size(&self, hash: &ContentHash) -> Result<Option<u64>> {
672        if let Some(size) = self.try_get_blob_size_once(hash)? {
673            return Ok(Some(size));
674        }
675        // Sibling-store recovery, mirroring the read path: if a
676        // concurrent writer just installed a pack we don't know about,
677        // reload and retry once before reporting a miss.
678        if self.reload_packs_if_stale()?
679            && let Some(size) = self.try_get_blob_size_once(hash)?
680        {
681            return Ok(Some(size));
682        }
683        Ok(None)
684    }
685
686    #[instrument(skip(self), fields(hash = %hash.short()))]
687    fn get_tree(&self, hash: &ContentHash) -> Result<Option<Tree>> {
688        if let Some(tree) = self.try_get_tree_once(hash)? {
689            return Ok(Some(tree));
690        }
691        if self.reload_packs_if_stale()?
692            && let Some(tree) = self.try_get_tree_once(hash)?
693        {
694            return Ok(Some(tree));
695        }
696        trace!("Tree not found");
697        Ok(None)
698    }
699
700    #[instrument(skip(self), fields(hash = %hash.short()))]
701    fn get_tree_serialized(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
702        if let Some(data) = self.try_get_tree_serialized_once(hash)? {
703            return Ok(Some(data));
704        }
705        if self.reload_packs_if_stale()?
706            && let Some(data) = self.try_get_tree_serialized_once(hash)?
707        {
708            return Ok(Some(data));
709        }
710        Ok(None)
711    }
712
713    #[instrument(skip(self, tree), fields(entry_count = tree.entries().len()))]
714    fn put_tree(&self, tree: &Tree) -> Result<ContentHash> {
715        let hash = tree.hash();
716        let path = hash_path(&trees_dir(&self.root), &hash);
717
718        if !path.exists() {
719            let (_, data) = codec::encode_tree(tree, &self.compression)?;
720            trace!(compressed_size = data.len(), "Writing tree");
721            self.write_loose_object_atomic(&path, &data)?;
722        } else {
723            trace!("Tree already exists, skipping write");
724        }
725        if let Ok(mut cache) = self.recent_trees.write() {
726            cache.insert(hash, tree.clone());
727        }
728
729        Ok(hash)
730    }
731
732    #[instrument(skip(self, data), fields(hash = %hash.short(), size = data.len()))]
733    fn put_tree_serialized(&self, data: &[u8], hash: ContentHash) -> Result<ContentHash> {
734        let tree = validate_tree_serialized(data, hash)?;
735
736        let path = hash_path(&trees_dir(&self.root), &hash);
737        let should_write = match read_file_bytes(&path)? {
738            Some(existing) => codec::decode_tree_body(existing.as_slice())? != data,
739            None => true,
740        };
741        if should_write {
742            trace!(size = data.len(), "Writing raw serialized tree");
743            self.write_loose_object_atomic(&path, data)?;
744        }
745        if let Ok(mut cache) = self.recent_trees.write() {
746            cache.insert(hash, tree);
747        }
748
749        Ok(hash)
750    }
751
752    #[instrument(skip(self), fields(hash = %hash.short()))]
753    fn has_tree(&self, hash: &ContentHash) -> Result<bool> {
754        if self.try_has_tree_once(hash)? {
755            return Ok(true);
756        }
757        if self.reload_packs_if_stale()? {
758            return self.try_has_tree_once(hash);
759        }
760        Ok(false)
761    }
762
763    #[instrument(skip(self), fields(id = %id.short()))]
764    fn get_state(&self, id: &ChangeId) -> Result<Option<State>> {
765        if let Some(state) = self.try_get_state_once(id)? {
766            return Ok(Some(state));
767        }
768        if self.reload_packs_if_stale()?
769            && let Some(state) = self.try_get_state_once(id)?
770        {
771            return Ok(Some(state));
772        }
773        trace!("State not found");
774        Ok(None)
775    }
776
777    #[instrument(skip(self, state), fields(id = %state.change_id.short()))]
778    fn put_state(&self, state: &State) -> Result<()> {
779        let path = state_path(&self.root, &state.change_id);
780        let data = codec::encode_state(state, &self.compression)?;
781        trace!(compressed_size = data.len(), "Writing state");
782        self.write_loose_object_atomic(&path, &data)?;
783        if let Ok(mut cache) = self.recent_states.write() {
784            cache.insert(state.change_id, state.clone());
785        }
786        Ok(())
787    }
788
789    #[instrument(skip(self, data), fields(id = %id.short(), size = data.len()))]
790    fn put_state_serialized(&self, data: &[u8], id: ChangeId) -> Result<()> {
791        let state = validate_state_serialized(data, id)?;
792        let path = state_path(&self.root, &id);
793        trace!(size = data.len(), "Writing raw serialized state");
794        self.write_loose_object_atomic(&path, data)?;
795        if let Ok(mut cache) = self.recent_states.write() {
796            cache.insert(id, state);
797        }
798        Ok(())
799    }
800
801    #[instrument(skip(self), fields(id = %id.short()))]
802    fn has_state(&self, id: &ChangeId) -> Result<bool> {
803        if self.try_has_state_once(id)? {
804            return Ok(true);
805        }
806        if self.reload_packs_if_stale()? {
807            return self.try_has_state_once(id);
808        }
809        Ok(false)
810    }
811
812    #[instrument(skip(self))]
813    fn list_states(&self) -> Result<Vec<ChangeId>> {
814        self.reload_packs_if_stale()?;
815
816        let dir = states_dir(&self.root);
817        if !dir.exists() {
818            return Ok(Vec::new());
819        }
820
821        let mut states = Vec::new();
822        for entry in fs::read_dir(&dir)? {
823            let entry = entry?;
824            let path = entry.path();
825            if let Some(name) = path.file_stem()
826                && let Some(name_str) = name.to_str()
827                && let Ok(id) = ChangeId::parse(name_str)
828            {
829                states.push(id);
830            }
831        }
832        if let Ok(manager) = self.pack_manager().read() {
833            for id in manager.list_all_ids()? {
834                if let PackObjectId::ChangeId(change_id) = id
835                    && !states.contains(&change_id)
836                {
837                    states.push(change_id);
838                }
839            }
840        }
841        debug!(count = states.len(), "Listed states");
842        Ok(states)
843    }
844
845    #[instrument(skip(self), fields(id = %id))]
846    fn get_action(&self, id: &ActionId) -> Result<Option<Action>> {
847        let path = action_path(&self.root, id);
848        if !path.exists()
849            && let Ok(manager) = self.pack_manager().read()
850            && let Some((obj_type, data)) = manager.get_hashed_object(id.as_hash())?
851            && obj_type == ObjectType::Action
852        {
853            trace!("Found action in packfile");
854            let action = validate_loaded_action(id, rmp_serde::from_slice(&data)?)?;
855            return Ok(Some(action));
856        }
857        match read_file_bytes(&path)? {
858            Some(data) => {
859                trace!(size = data.as_slice().len(), "Action data read");
860                let action = validate_loaded_action(id, codec::decode_action(data.as_slice())?)?;
861                Ok(Some(action))
862            }
863            None => {
864                trace!("Action not found");
865                Ok(None)
866            }
867        }
868    }
869
870    #[instrument(skip(self, action))]
871    fn put_action(&self, action: &mut Action) -> Result<ActionId> {
872        let id = action.id();
873        let path = action_path(&self.root, &id);
874
875        if !path.exists() {
876            let (_, data) = codec::encode_action(action, &self.compression)?;
877            trace!(id = %id, compressed_size = data.len(), "Writing action");
878            self.write_loose_object_atomic(&path, &data)?;
879        }
880
881        Ok(id)
882    }
883
884    #[instrument(skip(self))]
885    fn list_actions(&self) -> Result<Vec<ActionId>> {
886        let dir = actions_dir(&self.root);
887        let mut actions = Vec::new();
888        if dir.exists() {
889            for entry in fs::read_dir(&dir)? {
890                let entry = entry?;
891                let path = entry.path();
892                if let Some(name) = path.file_stem()
893                    && let Some(name_str) = name.to_str()
894                    && let Ok(hash) = ContentHash::from_hex(name_str)
895                {
896                    actions.push(ActionId::from_hash(hash));
897                }
898            }
899        }
900        if let Ok(manager) = self.pack_manager().read() {
901            for id in manager.list_all_ids()? {
902                if let PackObjectId::Hash(hash) = id
903                    && !actions.iter().any(|action_id| action_id.as_hash() == &hash)
904                    && let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
905                    && obj_type == ObjectType::Action
906                {
907                    actions.push(ActionId::from_hash(hash));
908                }
909            }
910        }
911        debug!(count = actions.len(), "Listed actions");
912        Ok(actions)
913    }
914
915    #[instrument(skip(self))]
916    fn list_blobs(&self) -> Result<Vec<ContentHash>> {
917        let dir = blobs_dir(&self.root);
918        let mut blobs = list_hashes_from_dir(&dir)?;
919        if let Ok(manager) = self.pack_manager().read() {
920            for id in manager.list_all_ids()? {
921                if let PackObjectId::Hash(hash) = id
922                    && !blobs.contains(&hash)
923                    && let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
924                    && obj_type == ObjectType::Blob
925                {
926                    blobs.push(hash);
927                }
928            }
929        }
930        Ok(blobs)
931    }
932
933    #[instrument(skip(self))]
934    fn list_trees(&self) -> Result<Vec<ContentHash>> {
935        let dir = trees_dir(&self.root);
936        let mut trees = list_hashes_from_dir(&dir)?;
937        if let Ok(manager) = self.pack_manager().read() {
938            for id in manager.list_all_ids()? {
939                if let PackObjectId::Hash(hash) = id
940                    && !trees.contains(&hash)
941                    && let Some((obj_type, _)) = manager.get_hashed_object(&hash)?
942                    && obj_type == ObjectType::Tree
943                {
944                    trees.push(hash);
945                }
946            }
947        }
948        Ok(trees)
949    }
950
951    #[instrument(skip(self))]
952    fn pack_objects(&self, aggressive: bool) -> Result<(u64, u64)> {
953        self.pack_objects_impl(aggressive)
954    }
955
956    #[instrument(skip(self), fields(id = ?id))]
957    fn get_pack_object(&self, id: &PackObjectId) -> Result<Option<(ObjectType, Vec<u8>)>> {
958        if let Ok(manager) = self.pack_manager().read()
959            && let Some((obj_type, data)) = manager.get_object(id)?
960        {
961            return Ok(Some((obj_type, data)));
962        }
963
964        match id {
965            PackObjectId::Hash(hash) => {
966                if let Some(blob) = self.get_blob(hash)? {
967                    return Ok(Some((ObjectType::Blob, blob.content().to_vec())));
968                }
969                if let Some(tree) = self.get_tree(hash)? {
970                    return Ok(Some((ObjectType::Tree, rmp_serde::to_vec_named(&tree)?)));
971                }
972                if let Some(action) = self.get_action(&ActionId::from_hash(*hash))? {
973                    return Ok(Some((
974                        ObjectType::Action,
975                        rmp_serde::to_vec_named(&action)?,
976                    )));
977                }
978                Ok(None)
979            }
980            PackObjectId::ChangeId(change_id) => {
981                if let Some(state) = self.get_state(change_id)? {
982                    Ok(Some((ObjectType::State, rmp_serde::to_vec_named(&state)?)))
983                } else {
984                    Ok(None)
985                }
986            }
987        }
988    }
989
990    #[instrument(skip(self, pack_data, index_data))]
991    fn install_pack(&self, pack_data: &[u8], index_data: &[u8]) -> Result<Vec<PackObjectId>> {
992        let reader = crate::store::pack::PackReader::from_slice(pack_data, index_data)?;
993        let ids = validate_and_list_pack(&reader)?;
994        let state_entries = state_entries_from_pack(&reader, &ids)?;
995        self.install_pack_files(pack_data, index_data)?;
996        for (id, data) in state_entries {
997            self.put_state_serialized(&data, id)?;
998        }
999        self.clear_recent_object_caches();
1000        Ok(ids)
1001    }
1002
1003    #[instrument(skip(self, blobs), fields(count = blobs.len()))]
1004    fn put_blobs_packed(&self, blobs: Vec<(crate::object::ContentHash, Vec<u8>)>) -> Result<()> {
1005        self.put_blobs_packed_impl(blobs)
1006    }
1007
1008    #[instrument(skip(self))]
1009    fn install_pack_streaming(
1010        &self,
1011        pack_path: &std::path::Path,
1012        index_path: &std::path::Path,
1013    ) -> Result<Vec<PackObjectId>> {
1014        // Validate + list ids through the same core as the byte-buffer
1015        // seam, but via an mmap-backed reader so the pack is never
1016        // copied into the heap — the memory-bounded promise survives.
1017        // Drop the reader (releasing the mmap) before the rename so
1018        // the file move isn't racing an open mapping.
1019        let ids = {
1020            let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1021            validate_and_list_pack(&reader)?
1022        };
1023        let state_entries = {
1024            let reader = crate::store::pack::PackReader::open(pack_path, index_path)?;
1025            state_entries_from_pack(&reader, &ids)?
1026        };
1027        self.install_pack_files_streaming(pack_path, index_path)?;
1028        for (id, data) in state_entries {
1029            self.put_state_serialized(&data, id)?;
1030        }
1031        Ok(ids)
1032    }
1033
1034    #[instrument(skip(self))]
1035    fn prune_loose_objects(&self) -> Result<(u64, u64)> {
1036        self.prune_loose_objects_impl()
1037    }
1038
1039    #[instrument(skip(self))]
1040    fn begin_snapshot_write_batch(&self) -> Result<()> {
1041        self.begin_snapshot_write_batch_impl()
1042    }
1043
1044    #[instrument(skip(self))]
1045    fn flush_snapshot_write_batch(&self) -> Result<()> {
1046        self.flush_snapshot_write_batch_impl()
1047    }
1048
1049    #[instrument(skip(self))]
1050    fn abort_snapshot_write_batch(&self) {
1051        self.abort_snapshot_write_batch_impl();
1052    }
1053
1054    fn has_redactions_for_blob(&self, blob: &ContentHash) -> Result<bool> {
1055        Ok(redaction_path(&self.root, blob).exists())
1056    }
1057
1058    fn get_redactions_bytes_for_blob(&self, blob: &ContentHash) -> Result<Option<Vec<u8>>> {
1059        let path = redaction_path(&self.root, blob);
1060        match fs::read(&path) {
1061            Ok(bytes) => Ok(Some(bytes)),
1062            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1063            Err(err) => Err(HeddleError::Io(err)),
1064        }
1065    }
1066
1067    fn put_redactions_bytes_for_blob(&self, blob: &ContentHash, bytes: &[u8]) -> Result<()> {
1068        let dir = redactions_dir(&self.root);
1069        if !dir.exists() {
1070            crate::fs_atomic::create_dir_all_durable(&dir)?;
1071        }
1072        let path = redaction_path(&self.root, blob);
1073        crate::fs_atomic::write_file_atomic(&path, bytes)?;
1074        Ok(())
1075    }
1076
1077    fn list_blobs_with_redactions(&self) -> Result<Vec<ContentHash>> {
1078        let dir = redactions_dir(&self.root);
1079        if !dir.exists() {
1080            return Ok(Vec::new());
1081        }
1082        let mut out = Vec::new();
1083        for entry in fs::read_dir(&dir)? {
1084            let entry = entry?;
1085            let path = entry.path();
1086            if path.extension().and_then(|e| e.to_str()) != Some("bin") {
1087                continue;
1088            }
1089            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1090                continue;
1091            };
1092            if let Ok(hash) = ContentHash::from_hex(stem) {
1093                out.push(hash);
1094            }
1095        }
1096        Ok(out)
1097    }
1098
1099    fn has_state_visibility_for_state(&self, state: &ChangeId) -> Result<bool> {
1100        Ok(state_visibility_path(&self.root, state).exists())
1101    }
1102
1103    fn get_state_visibility_bytes_for_state(&self, state: &ChangeId) -> Result<Option<Vec<u8>>> {
1104        let path = state_visibility_path(&self.root, state);
1105        match fs::read(&path) {
1106            Ok(bytes) => Ok(Some(bytes)),
1107            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1108            Err(err) => Err(HeddleError::Io(err)),
1109        }
1110    }
1111
1112    fn put_state_visibility_bytes_for_state(&self, state: &ChangeId, bytes: &[u8]) -> Result<()> {
1113        let dir = state_visibility_dir(&self.root);
1114        if !dir.exists() {
1115            crate::fs_atomic::create_dir_all_durable(&dir)?;
1116        }
1117        let path = state_visibility_path(&self.root, state);
1118        crate::fs_atomic::write_file_atomic(&path, bytes)?;
1119        Ok(())
1120    }
1121
1122    fn list_states_with_visibility(&self) -> Result<Vec<ChangeId>> {
1123        let dir = state_visibility_dir(&self.root);
1124        if !dir.exists() {
1125            return Ok(Vec::new());
1126        }
1127        let mut out = Vec::new();
1128        for entry in fs::read_dir(&dir)? {
1129            let entry = entry?;
1130            let path = entry.path();
1131            if path.extension().and_then(|e| e.to_str()) != Some("bin") {
1132                continue;
1133            }
1134            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
1135                continue;
1136            };
1137            if let Ok(state) = ChangeId::parse(stem) {
1138                out.push(state);
1139            }
1140        }
1141        Ok(out)
1142    }
1143}