Skip to main content

sley_odb/
loose.rs

1use flate2::Compression;
2use flate2::read::ZlibDecoder;
3use flate2::write::ZlibEncoder;
4use flate2::{Decompress, FlushDecompress};
5use parking_lot::RwLock;
6use std::sync::Mutex;
7use sley_core::{GitError, MissingObjectContext, ObjectFormat, ObjectId, Result};
8use sley_formats::{Bundle, BundleReference};
9use sley_object::{
10    Commit, EncodedObject, ObjectType, Tag, TreeEntries, parse_framed_object,
11    tree_entry_object_type,
12};
13use sley_pack::{
14    MultiPackIndex, MultiPackIndexOidLookup, PackBitmapIndex, PackBitmapWriter, PackFile,
15    PackIndex, PackIndexByteSource, PackIndexEntry, PackIndexViewData, PackInput,
16    PackReverseIndex, PackStreamIndexBuild, PackWrite, PackWriteOptions, PackWriteSummary,
17};
18use std::collections::{HashMap, HashSet};
19use std::io::{Read, Write};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::{Arc, OnceLock};
23use std::{env, fs};
24
25use crate::{
26    grafted_parents, implied_empty_tree_object, unique_temp_path, with_missing_object_context,
27    ObjectReader, ObjectWriter,
28};
29
30use crate::registry::repository_objects_dir;
31use crate::reachability::{loose_object_id_set, loose_object_ids};
32use crate::pack::verify_reads_enabled;
33
34pub(crate) fn collect_loose_object_ids(
35    objects_dir: &Path,
36    format: ObjectFormat,
37    oids: &mut HashSet<ObjectId>,
38) -> Result<()> {
39    if !objects_dir.exists() {
40        return Ok(());
41    }
42    let hex_len = format.hex_len();
43    for entry in fs::read_dir(objects_dir)? {
44        let entry = entry?;
45        if !entry.file_type()?.is_dir() {
46            continue;
47        }
48        let name = entry.file_name();
49        let Some(fanout) = name.to_str() else {
50            continue;
51        };
52        if fanout.len() != 2 || !fanout.bytes().all(|byte| byte.is_ascii_hexdigit()) {
53            continue;
54        }
55        for object_entry in fs::read_dir(entry.path())? {
56            let object_entry = object_entry?;
57            if !object_entry.file_type()?.is_file() {
58                continue;
59            }
60            let name = object_entry.file_name();
61            let Some(suffix) = name.to_str() else {
62                continue;
63            };
64            if suffix.len() != hex_len - 2 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
65                continue;
66            }
67            oids.insert(ObjectId::from_hex(format, &format!("{fanout}{suffix}"))?);
68        }
69    }
70    Ok(())
71}
72
73pub(crate) fn collect_loose_fanout_object_ids(
74    objects_dir: &Path,
75    format: ObjectFormat,
76    fanout: u8,
77    oids: &mut HashSet<ObjectId>,
78) -> Result<()> {
79    let fanout_hex = format!("{fanout:02x}");
80    let fanout_dir = objects_dir.join(&fanout_hex);
81    let entries = match fs::read_dir(&fanout_dir) {
82        Ok(entries) => entries,
83        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
84        Err(err) => return Err(GitError::Io(err.to_string())),
85    };
86    let hex_len = format.hex_len();
87    for object_entry in entries {
88        let object_entry = object_entry?;
89        let name = object_entry.file_name();
90        let Some(suffix) = name.to_str() else {
91            continue;
92        };
93        if suffix.len() != hex_len - 2 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) {
94            continue;
95        }
96        oids.insert(ObjectId::from_hex(
97            format,
98            &format!("{fanout_hex}{suffix}"),
99        )?);
100    }
101    Ok(())
102}
103
104/// The set of `objects/XX/` fanout directories that actually exist on disk,
105/// learned from a single `read_dir(objects/)`. A freshly cloned or repacked
106/// repository has zero loose-object fanout dirs (everything is packed), so this
107/// lets a loose-presence probe skip the per-fanout `opendir(objects/XX)` that
108/// would otherwise miss with ENOENT on every distinct id prefix — the
109/// constant-factor loose-probe floor on packed-repo reads. Returns the present
110/// fanout bytes (`0x00..=0xff`); a missing `objects/` dir yields the empty set.
111pub(crate) fn present_loose_fanouts(objects_dir: &Path) -> Result<HashSet<u8>> {
112    let mut present = HashSet::new();
113    let entries = match fs::read_dir(objects_dir) {
114        Ok(entries) => entries,
115        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(present),
116        Err(err) => return Err(GitError::Io(err.to_string())),
117    };
118    for entry in entries {
119        let entry = entry?;
120        let name = entry.file_name();
121        let Some(name) = name.to_str() else {
122            continue;
123        };
124        if name.len() != 2 {
125            continue;
126        }
127        let mut bytes = name.bytes();
128        let (Some(hi), Some(lo)) = (bytes.next(), bytes.next()) else {
129            continue;
130        };
131        let (Some(hi), Some(lo)) = ((hi as char).to_digit(16), (lo as char).to_digit(16)) else {
132            continue;
133        };
134        // Only count it as a fanout dir if it really is a directory; `git` keeps
135        // non-fanout entries (`pack`, `info`) under `objects/` that happen to be
136        // dirs too, but those never collide with a two-hex-char name.
137        if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
138            present.insert(((hi << 4) | lo) as u8);
139        }
140    }
141    Ok(present)
142}
143
144#[derive(Debug, Default)]
145pub(crate) struct LoosePresenceCache {
146    /// Fanout bytes whose `objects/XX/` listing has been folded into `objects`.
147    loaded_fanouts: HashSet<u8>,
148    objects: HashSet<ObjectId>,
149    /// Which of the 256 `objects/XX/` fanout dirs exist on disk, learned from a
150    /// single `read_dir(objects/)`. `None` until first queried. A fanout absent
151    /// from this set cannot hold a loose object, so its per-fanout `read_dir`
152    /// (which would miss with ENOENT) is skipped entirely.
153    pub(crate) present_fanouts: Option<HashSet<u8>>,
154}
155
156/// Every object id resolvable through a pack (any `.idx` or the
157/// multi-pack-index) under `objects_dir/pack`. Used by `--unpacked`
158/// filtering: an object is "unpacked" when absent from this set, regardless
159/// of a loose copy also existing.
160/// Maximum number of bytes git will inflate when reading a loose object's
161/// `"<type> <size>\0"` header (git's `MAX_HEADER_LEN` in object-file.c). The NUL
162/// terminator must land within this window, so a header of 32 or more non-NUL
163/// bytes is rejected as too long.
164const MAX_LOOSE_HEADER_LEN: usize = 32;
165
166/// git's exact `error:`-level diagnostic for a loose object whose header overflows
167/// `MAX_LOOSE_HEADER_LEN` (object-file.c: `error(_("header for %s too long, exceeds
168/// %d bytes"), ...)`). Shared by the header-only and full-read paths so both surface
169/// byte-identical text.
170fn loose_header_too_long(oid: &ObjectId) -> GitError {
171    GitError::InvalidObject(format!(
172        "header for {oid} too long, exceeds {MAX_LOOSE_HEADER_LEN} bytes"
173    ))
174}
175
176/// git's `error:`-level diagnostic when the loose framing header cannot be inflated at
177/// all (object-file.c `loose_object_info`, the `ULHR_BAD` arm: `error(_("unable to
178/// unpack %s header"), ...)`).
179fn loose_unpack_header_failed(oid: &ObjectId) -> GitError {
180    GitError::InvalidObject(format!("unable to unpack {oid} header"))
181}
182
183/// git-zlib.c's `error("inflate: %s (%s)", ...)` text for an inflate failure whose
184/// cause is identifiable from the zlib stream header. The checks mirror zlib's own
185/// `inflate()` HEAD-state validation, in order: the FCHECK checksum over CMF+FLG,
186/// the compression method, the window size, and the FDICT preset-dictionary bit
187/// (zlib reports `Z_NEED_DICT` with a NULL `msg`, which git renders as
188/// "(no message)"). Failures past the stream header return `None`: flate2 does not
189/// surface zlib's per-case `msg` strings, so no diagnostic is fabricated for them.
190fn inflate_header_diagnostic(input: &[u8]) -> Option<&'static str> {
191    let [cmf, flg, ..] = *input else { return None };
192    if ((u16::from(cmf) << 8) | u16::from(flg)) % 31 != 0 {
193        return Some("inflate: data stream error (incorrect header check)");
194    }
195    if cmf & 0x0f != 8 {
196        return Some("inflate: data stream error (unknown compression method)");
197    }
198    if cmf >> 4 > 7 {
199        return Some("inflate: data stream error (invalid window size)");
200    }
201    if flg & 0x20 != 0 {
202        return Some("inflate: needs dictionary (no message)");
203    }
204    None
205}
206
207/// Print the `error: inflate: ...` line git's zlib wrapper emits the moment
208/// `inflate()` fails, when the failure is classifiable from the stream header.
209fn emit_inflate_diagnostic(input: &[u8]) {
210    if let Some(diagnostic) = inflate_header_diagnostic(input) {
211        eprintln!("error: {diagnostic}");
212    }
213}
214
215/// Integrity verdict for a single loose object file, as classified by
216/// [`LooseObjectStore::verify_object`].
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum LooseObjectIntegrity {
219    /// Inflated, parsed, and re-hashed to its path-derived oid.
220    Ok,
221    /// Readable and well-formed, but its content hashes to a different oid
222    /// (a loose file stored under the wrong path).
223    HashMismatch { actual: ObjectId },
224    /// Unreadable: corrupt zlib stream, truncated content, or unparseable header.
225    /// The `error:`-level diagnostics were already printed to stderr.
226    Corrupt,
227}
228
229#[derive(Debug, Clone)]
230pub struct LooseObjectStore {
231    pub(crate) objects_dir: PathBuf,
232    format: ObjectFormat,
233    /// Lazily-populated set of loose object ids present on disk, mirroring git's
234    /// `loose_objects_cache` (object-file.c). A lookup scans the queried
235    /// `objects/XX/` fanout once; afterward misses in that fanout are in-memory
236    /// checks instead of failed exact-path opens. Shared across
237    /// `FileObjectDatabase` clones via `Arc` so a write through one handle is
238    /// visible to reads through another; cleared by `refresh_read_cache` so
239    /// objects installed out-of-band (fetch, repack) become visible. Writes
240    /// extend the set in place rather than invalidating it.
241    pub(crate) loose_cache: Arc<Mutex<LoosePresenceCache>>,
242}
243
244impl LooseObjectStore {
245    pub fn new(objects_dir: impl Into<PathBuf>, format: ObjectFormat) -> Self {
246        Self {
247            objects_dir: objects_dir.into(),
248            format,
249            loose_cache: Arc::new(Mutex::new(LoosePresenceCache::default())),
250        }
251    }
252
253    /// Whether `oid` is present according to the loose-object cache, populating
254    /// the cache on first use. Returns `None` when the lock cannot be trusted or
255    /// the scan fails; callers should fall back to an exact filesystem probe in
256    /// that case so a cache-building problem cannot change read semantics.
257    fn cached_loose_presence(&self, oid: &ObjectId) -> Option<bool> {
258        let mut guard = self.loose_cache.lock().ok()?;
259        let fanout = oid.as_bytes()[0];
260        if !guard.loaded_fanouts.contains(&fanout) {
261            // Learn (once) which `objects/XX/` dirs exist via a single
262            // `read_dir(objects/)`. If this id's fanout dir is absent, no loose
263            // object can live there — skip the per-fanout `read_dir` that would
264            // otherwise miss with ENOENT. For an all-packed repo (every fanout
265            // absent) this collapses the whole loose-probe cost to one
266            // `read_dir(objects/)`.
267            if guard.present_fanouts.is_none() {
268                guard.present_fanouts = Some(present_loose_fanouts(&self.objects_dir).ok()?);
269            }
270            let fanout_present = guard
271                .present_fanouts
272                .as_ref()
273                .is_some_and(|present| present.contains(&fanout));
274            if fanout_present {
275                collect_loose_fanout_object_ids(
276                    &self.objects_dir,
277                    self.format,
278                    fanout,
279                    &mut guard.objects,
280                )
281                .ok()?;
282            }
283            // Mark the fanout loaded regardless: an absent fanout contributes no
284            // ids, and the `present_fanouts` set already proved it empty, so we
285            // never need to rescan it (a later loose write into a previously
286            // absent fanout goes through `note_loose_write`, which records the
287            // id directly, or `invalidate_cache`, which clears `present_fanouts`
288            // so the next probe re-learns the dir set).
289            guard.loaded_fanouts.insert(fanout);
290        }
291        Some(guard.objects.contains(oid))
292    }
293
294    /// Populate the loose-object cache and return the sorted ids. This mirrors
295    /// git's `odb_loose_cache` lazy fill and is reserved for operations that
296    /// really need loose-object enumeration.
297    fn loose_object_ids_cached(&self) -> Result<Vec<ObjectId>> {
298        if let Ok(mut guard) = self.loose_cache.lock() {
299            guard.objects = loose_object_id_set(&self.objects_dir, self.format)?;
300            guard.loaded_fanouts = (0..=u8::MAX).collect();
301            let mut ids = guard.objects.iter().copied().collect::<Vec<_>>();
302            ids.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
303            return Ok(ids);
304        }
305        loose_object_ids(&self.objects_dir, self.format)
306    }
307
308    /// Record `oid` as present in loose storage so subsequent reads find it
309    /// without a rescan. A no-op when the cache has not been populated yet (the
310    /// eventual lazy scan will pick the object up) or the lock is poisoned.
311    fn note_loose_write(&self, oid: ObjectId) {
312        if let Ok(mut guard) = self.loose_cache.lock() {
313            // Keep the present-fanout set coherent: writing this object created
314            // (or kept) its `objects/XX/` dir, so a sibling id in the same fanout
315            // must be scannable on its next probe rather than short-circuited as
316            // an absent fanout.
317            let fanout = oid.as_bytes()[0];
318            if let Some(present) = guard.present_fanouts.as_mut() {
319                present.insert(fanout);
320            }
321            guard.objects.insert(oid);
322        }
323    }
324
325    /// Drop the in-memory loose set so the next access rescans the fanout. Called
326    /// by `FileObjectDatabase::refresh_read_cache` after out-of-band installs.
327    pub(crate) fn invalidate_cache(&self) {
328        if let Ok(mut guard) = self.loose_cache.lock() {
329            *guard = LoosePresenceCache::default();
330        }
331    }
332
333    pub fn from_git_dir(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Self {
334        Self::new(repository_objects_dir(git_dir), format)
335    }
336
337    fn validate_oid_format(&self, oid: &ObjectId) -> Result<()> {
338        if oid.format() != self.format {
339            return Err(GitError::InvalidObjectId(format!(
340                "object {oid} uses {}, store uses {}",
341                oid.format().name(),
342                self.format.name()
343            )));
344        }
345        Ok(())
346    }
347
348    pub fn object_path(&self, oid: &ObjectId) -> Result<PathBuf> {
349        self.validate_oid_format(oid)?;
350        let hex = oid.to_hex();
351        Ok(self.objects_dir.join(&hex[..2]).join(&hex[2..]))
352    }
353
354    pub fn exists(&self, oid: &ObjectId) -> Result<bool> {
355        self.validate_oid_format(oid)?;
356        if self.cached_loose_presence(oid) == Some(false) {
357            return Ok(false);
358        }
359        let path = self.object_path(oid)?;
360        Ok(path.exists())
361    }
362
363    pub fn disk_size(&self, oid: &ObjectId) -> Result<Option<u64>> {
364        self.validate_oid_format(oid)?;
365        if self.cached_loose_presence(oid) == Some(false) {
366            return Ok(None);
367        }
368        let path = self.object_path(oid)?;
369        match fs::metadata(path) {
370            Ok(metadata) => Ok(Some(metadata.len())),
371            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
372            Err(err) => Err(GitError::Io(err.to_string())),
373        }
374    }
375
376    /// The object type and content size of `oid` from loose storage, inflating only
377    /// the framing header (`"<type> <size>\0"`) and not the body. Output-limited
378    /// reads keep miniz from inflating past the header even for large objects.
379    /// Returns `Ok(None)` when the loose object is absent.
380    pub fn read_header(&self, oid: &ObjectId) -> Result<Option<(ObjectType, u64)>> {
381        self.validate_oid_format(oid)?;
382        if self.cached_loose_presence(oid) == Some(false) {
383            return Ok(None);
384        }
385        let path = self.object_path(oid)?;
386        let compressed = match fs::read(&path) {
387            Ok(compressed) => compressed,
388            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
389            Err(err) => return Err(GitError::Io(err.to_string())),
390        };
391        match inflate_loose_header(&compressed)? {
392            LooseHeader::Ok(header) => {
393                let header = std::str::from_utf8(&header)
394                    .map_err(|err| GitError::InvalidObject(err.to_string()))?;
395                let (kind, size) = header
396                    .split_once(' ')
397                    .ok_or_else(|| GitError::InvalidObject("missing object size".into()))?;
398                let object_type = kind.parse::<ObjectType>()?;
399                let size = size
400                    .parse::<u64>()
401                    .map_err(|_| GitError::InvalidObject("invalid object size".into()))?;
402                Ok(Some((object_type, size)))
403            }
404            LooseHeader::Bad => {
405                // git's ULHR_BAD: the zlib wrapper's `error: inflate: ...` line, then
406                // "unable to unpack <oid> header".
407                emit_inflate_diagnostic(compressed.get(..2).unwrap_or(&compressed));
408                Err(loose_unpack_header_failed(oid))
409            }
410            LooseHeader::TooLong => {
411                // git inflates only the first `MAX_LOOSE_HEADER_LEN` bytes
412                // (object-file.c `unpack_loose_header`) and reports ULHR_TOO_LONG when
413                // no NUL terminator lands within them — whether the stream simply ends
414                // early or overflows the window. Both collapse to the same diagnostic.
415                Err(loose_header_too_long(oid))
416            }
417        }
418    }
419
420    /// Loose object ids in this store, sorted by hex.
421    pub fn object_ids(&self) -> Result<Vec<ObjectId>> {
422        self.loose_object_ids_cached()
423    }
424
425    /// fsck's loose-object integrity probe, mirroring C git's `read_loose_object`
426    /// (object-file.c) as called from `fsck_loose` (builtin/fsck.c): inflate and
427    /// parse the file at `oid`'s loose path, then re-hash its content against the
428    /// path-derived oid. `display_path` appears verbatim in the `error:`-level
429    /// diagnostics — the path-form messages of `read_loose_object` ("unable to
430    /// unpack header of <path>"), unlike the oid-form messages of the normal read
431    /// path. Returns `Ok(None)` when no loose file exists for `oid`.
432    pub fn verify_object(
433        &self,
434        oid: &ObjectId,
435        display_path: &str,
436    ) -> Result<Option<LooseObjectIntegrity>> {
437        let path = self.object_path(oid)?;
438        let compressed = match fs::read(&path) {
439            Ok(compressed) => compressed,
440            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
441            Err(err) => return Err(GitError::Io(err.to_string())),
442        };
443        let mut decoder = ZlibDecoder::new(compressed.as_slice());
444        let mut framed = Vec::new();
445        if decoder.read_to_end(&mut framed).is_err() {
446            emit_inflate_diagnostic(&compressed);
447            // git inflates the header first (`unpack_loose_header`), then the body
448            // (`unpack_loose_rest`). If the header inflated (its NUL is visible in
449            // the partial output) but the body broke, that is a *content*
450            // corruption: git's `unpack_loose_rest` prints `corrupt loose object
451            // '<oid>'` (status != Z_STREAM_END), then `read_loose_object` adds
452            // `unable to unpack contents of <path>`. If inflation died before the
453            // header materialized, only the header message fires.
454            if framed_loose_header_terminated(&framed) {
455                eprintln!("error: corrupt loose object '{oid}'");
456                eprintln!("error: unable to unpack contents of {display_path}");
457            } else {
458                eprintln!("error: unable to unpack header of {display_path}");
459            }
460            return Ok(Some(LooseObjectIntegrity::Corrupt));
461        }
462        if !framed_loose_header_terminated(&framed) {
463            // ULHR_TOO_LONG collapses into the same path-form message here: C's
464            // `read_loose_object` treats every non-OK `unpack_loose_header` alike.
465            eprintln!("error: unable to unpack header of {display_path}");
466            return Ok(Some(LooseObjectIntegrity::Corrupt));
467        }
468        // git's `unpack_loose_rest`/`check_stream_oid` reject trailing bytes after
469        // the zlib stream: a fully-inflated object whose compressed input was not
470        // entirely consumed is `garbage at end of loose object '<oid>'`, then
471        // `object corrupt or missing: <path>` from `fsck_loose`. (read_to_end
472        // stops at Z_STREAM_END and silently ignores the trailing bytes, so we
473        // compare consumed input against the file size ourselves.)
474        if (decoder.total_in() as usize) < compressed.len() {
475            // git's `unpack_loose_rest` prints `garbage at end of loose object`
476            // then returns NULL, so `read_loose_object` also prints `unable to
477            // unpack contents of <path>`.
478            eprintln!("error: garbage at end of loose object '{oid}'");
479            eprintln!("error: unable to unpack contents of {display_path}");
480            return Ok(Some(LooseObjectIntegrity::Corrupt));
481        }
482        // A truncated object can inflate to a clean stream end yet yield fewer
483        // body bytes than the header's declared size. git's `unpack_loose_rest`
484        // inflates exactly `size` bytes and, finding the stream ends short,
485        // prints `corrupt loose object '<oid>'`; `read_loose_object` then adds
486        // `unable to unpack contents of <path>`. Detect the short body here so it
487        // is not misreported as a header-parse failure.
488        if let Some(declared) = loose_header_declared_size(&framed) {
489            let nul = framed.iter().position(|&b| b == 0).unwrap_or(framed.len());
490            let body_len = framed.len() - (nul + 1).min(framed.len());
491            if body_len < declared {
492                eprintln!("error: corrupt loose object '{oid}'");
493                eprintln!("error: unable to unpack contents of {display_path}");
494                return Ok(Some(LooseObjectIntegrity::Corrupt));
495            }
496        }
497        let Ok(object) = parse_framed_object(&framed) else {
498            // Distinguish git's two header-parse failures: a structurally valid
499            // `"<word> <size>\0"` header whose *type word* is not a known object
500            // type yields `unable to parse type from header '<header>'`, while a
501            // genuinely malformed header yields `unable to parse header`.
502            if let Some(header) = loose_header_with_unknown_type(&framed) {
503                eprintln!("error: unable to parse type from header '{header}' of {display_path}");
504            } else {
505                eprintln!("error: unable to parse header of {display_path}");
506            }
507            return Ok(Some(LooseObjectIntegrity::Corrupt));
508        };
509        let actual = object.object_id(self.format)?;
510        if &actual != oid {
511            return Ok(Some(LooseObjectIntegrity::HashMismatch { actual }));
512        }
513        Ok(Some(LooseObjectIntegrity::Ok))
514    }
515}
516
517/// Whether the inflated framing bytes contain the header's NUL terminator within
518/// git's `MAX_HEADER_LEN` window (object-file.c `unpack_loose_header`'s success
519/// condition).
520fn framed_loose_header_terminated(framed: &[u8]) -> bool {
521    framed
522        .iter()
523        .take(MAX_LOOSE_HEADER_LEN)
524        .any(|byte| *byte == 0)
525}
526
527/// If the framing has a structurally valid `"<word> <size>\0"` header whose body
528/// length matches `<size>` but whose `<word>` is not a known object type, return
529/// the header string (the bytes before the NUL). Mirrors git's
530/// `parse_loose_header` reporting `unable to parse type from header '<header>'`.
531fn loose_header_with_unknown_type(framed: &[u8]) -> Option<String> {
532    let nul = framed.iter().position(|&b| b == 0)?;
533    let header = std::str::from_utf8(&framed[..nul]).ok()?;
534    let (kind, size) = header.split_once(' ')?;
535    let size: usize = size.parse().ok()?;
536    // Body length must match the declared size (otherwise it is a different
537    // corruption, handled by the generic path).
538    if framed.len() - (nul + 1) != size {
539        return None;
540    }
541    // A known type word would have parsed successfully upstream; only return
542    // when the word is genuinely unknown.
543    if kind.parse::<ObjectType>().is_ok() {
544        return None;
545    }
546    Some(header.to_string())
547}
548
549/// The size declared in a loose object's `"<type> <size>\0"` header, if the
550/// header is structurally a `<word> <decimal-size>` pair. Used to detect a body
551/// inflated short of its declared length (a truncated object).
552fn loose_header_declared_size(framed: &[u8]) -> Option<usize> {
553    let nul = framed.iter().position(|&b| b == 0)?;
554    let header = std::str::from_utf8(&framed[..nul]).ok()?;
555    let (_kind, size) = header.split_once(' ')?;
556    size.parse::<usize>().ok()
557}
558
559/// Read up to `prefix.len()` bytes from the start of `file`, returning how many
560/// were available (short only when the file itself is shorter).
561/// Outcome of inflating a loose object's header, mirroring git's
562/// `unpack_loose_header` result codes (object-file.c `enum
563/// unpack_loose_header_result`).
564enum LooseHeader {
565    /// ULHR_OK: a NUL-terminated header was found within the window. Carries the
566    /// header bytes up to (not including) the NUL.
567    Ok(Vec<u8>),
568    /// ULHR_BAD: the zlib stream would not inflate (status != Z_OK/Z_STREAM_END).
569    Bad,
570    /// ULHR_TOO_LONG: the inflated output filled the header window with no NUL.
571    TooLong,
572}
573
574/// Inflate a loose object's *header* exactly as git's `unpack_loose_header` does
575/// (object-file.c): a single bounded inflate into a `MAX_LOOSE_HEADER_LEN`-byte
576/// output buffer, then look for the header-terminating NUL in what came out.
577///
578/// The byte budget is load-bearing for corruption parity: git inflates only up to
579/// `MAX_HEADER_LEN` (32) bytes of *output* before stopping, so a `cat-file -s`/`-t`
580/// header read detects a zlib data error only when it lands within those first 32
581/// inflated bytes (the header plus the start of the body for a small object) — and
582/// silently returns the header for corruption buried deeper in the body, which the
583/// full-object read path catches instead. A byte-by-byte loop that stopped at the
584/// NUL would never inflate into the corrupt region and miss the bit-error case
585/// (t1060 "getting type of a corrupt blob fails"); feeding too much output budget
586/// would over-detect relative to git. So this matches git's exact window.
587fn inflate_loose_header(compressed: &[u8]) -> Result<LooseHeader> {
588    let mut out = [0u8; MAX_LOOSE_HEADER_LEN];
589    let mut decompress = Decompress::new(true);
590    // git feeds the whole mapped file as `avail_in` and inflates once into a
591    // 32-byte `avail_out`; zlib stops at the output limit (Z_OK with avail_out==0)
592    // or at the stream's end, propagating Z_DATA_ERROR for a corrupt stream.
593    let status = decompress.decompress(compressed, &mut out, FlushDecompress::None);
594    let produced = decompress.total_out() as usize;
595    match status {
596        Ok(_) => {
597            let window = &out[..produced.min(MAX_LOOSE_HEADER_LEN)];
598            match window.iter().position(|&byte| byte == 0) {
599                Some(nul) => Ok(LooseHeader::Ok(window[..nul].to_vec())),
600                // No NUL within the window: either the stream ended early or the
601                // header overflows `MAX_LOOSE_HEADER_LEN`. git collapses both into
602                // ULHR_TOO_LONG (object-file.c `unpack_loose_header`).
603                None => Ok(LooseHeader::TooLong),
604            }
605        }
606        // Any zlib error before a NUL materializes is git's ULHR_BAD.
607        Err(_) => Ok(LooseHeader::Bad),
608    }
609}
610
611impl ObjectReader for LooseObjectStore {
612    fn read_object(&self, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
613        self.validate_oid_format(oid)?;
614        // Skip the `open()` (and its ENOENT) when an already-built loose cache
615        // knows the id is absent. Without a cache, use an exact path probe; a
616        // full fanout scan is far more expensive for one-shot packed-object reads.
617        if self.cached_loose_presence(oid) == Some(false) {
618            return Err(GitError::object_not_found_in(
619                *oid,
620                MissingObjectContext::Read,
621            ));
622        }
623        let path = self.object_path(oid)?;
624        let compressed = match fs::read(&path) {
625            Ok(compressed) => compressed,
626            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
627                return Err(GitError::object_not_found_in(
628                    *oid,
629                    MissingObjectContext::Read,
630                ));
631            }
632            Err(err) => return Err(GitError::Io(err.to_string())),
633        };
634        let mut decoder = ZlibDecoder::new(compressed.as_slice());
635        let mut framed = Vec::new();
636        if decoder.read_to_end(&mut framed).is_err() {
637            emit_inflate_diagnostic(&compressed);
638            // A stream that dies before the framing header materializes is git's
639            // ULHR_BAD ("unable to unpack <oid> header"); with the header intact,
640            // the body is what broke (`unpack_loose_rest`'s "corrupt loose
641            // object").
642            if !framed_loose_header_terminated(&framed) {
643                return Err(loose_unpack_header_failed(oid));
644            }
645            return Err(GitError::InvalidObject(format!(
646                "corrupt loose object '{oid}'"
647            )));
648        }
649        // git only inflates the first `MAX_LOOSE_HEADER_LEN` bytes looking for the
650        // header's NUL terminator before parsing the type; an over-long header is
651        // rejected here (with git's diagnostic) rather than failing later as an
652        // "unknown object type". Mirror that so `cat-file -p` matches upstream.
653        if framed
654            .iter()
655            .take(MAX_LOOSE_HEADER_LEN)
656            .all(|byte| *byte != 0)
657        {
658            return Err(loose_header_too_long(oid));
659        }
660        let object = parse_framed_object(&framed)?;
661        // Trust the loose object's on-disk name rather than re-hashing its full body
662        // on every read (see `verify_reads_enabled`); use `validate`/fsck or
663        // `SLEY_VERIFY_READS` for an explicit integrity check.
664        if verify_reads_enabled() {
665            let actual = object.object_id(self.format)?;
666            if &actual != oid {
667                return Err(GitError::InvalidObject(format!(
668                    "loose object {} hashes to {actual}",
669                    path.display()
670                )));
671            }
672        }
673        Ok(Arc::new(object))
674    }
675}
676
677impl ObjectWriter for LooseObjectStore {
678    fn write_object(&self, object: EncodedObject) -> Result<ObjectId> {
679        let oid = object.object_id(self.format)?;
680        let path = self.object_path(&oid)?;
681        if path.exists() {
682            self.note_loose_write(oid);
683            return Ok(oid);
684        }
685        let parent = path
686            .parent()
687            .ok_or_else(|| GitError::InvalidPath("loose object path has no parent".into()))?;
688        fs::create_dir_all(parent)?;
689        let temp_path = unique_temp_path(parent);
690        let write_result = (|| -> Result<()> {
691            let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
692            encoder.write_all(&object.framed_bytes())?;
693            let compressed = encoder.finish()?;
694            {
695                let mut file = fs::OpenOptions::new()
696                    .write(true)
697                    .create_new(true)
698                    .open(&temp_path)?;
699                file.write_all(&compressed)?;
700                // No fsync: git's default `core.fsync=none` fsyncs nothing on the
701                // loose-object write path (object-file.c writes the temp file and
702                // renames it without syncing unless `core.fsync` names
703                // `loose-object`/`objects`/`all`, which it does not by default).
704                // A per-object sync_all() here made `git add` of N files cost N
705                // fsyncs — the dominant term in sley#27's 10x `add -u` slowdown —
706                // for durability git itself does not provide by default. The
707                // create_new temp + atomic rename below still guarantees the
708                // object never appears half-written under its final name.
709            }
710            match fs::rename(&temp_path, &path) {
711                Ok(()) => Ok(()),
712                Err(_) if path.exists() => {
713                    let _ = fs::remove_file(&temp_path);
714                    Ok(())
715                }
716                Err(err) => Err(GitError::Io(err.to_string())),
717            }
718        })();
719        if write_result.is_err() {
720            let _ = fs::remove_file(&temp_path);
721        }
722        write_result?;
723        self.note_loose_write(oid);
724        Ok(oid)
725    }
726}