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}