Skip to main content

grit_lib/odb/store/
loose.rs

1//! Loose-object storage under `objects/xx/<suffix>` (zlib-compressed Git objects).
2
3use std::collections::{HashMap, HashSet};
4use std::fs::{self, File};
5use std::io::{self, Read, Write};
6use std::ops::ControlFlow;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::SystemTime;
11
12use flate2::write::ZlibEncoder;
13use flate2::Compression;
14use flate2::{Decompress, FlushDecompress, Status};
15
16use super::{BoundedRead, ObjectStore, ObjectStream, WritableObjectStore};
17use crate::error::{Error, Result};
18use crate::git_dir::io_error::{map_objects_dir_io, GitDirPathOperation};
19use crate::hash::{self, ObjectPayloadHasher};
20use crate::objects::{HashAlgo, Object, ObjectId, ObjectIdPrefix, ObjectInfo, ObjectKind};
21use crate::odb::WriteOptions;
22use crate::zlib_inflate::{
23    with_loose_scratch, LooseFileReadBudget, LOOSE_INFLATE_INPUT_CHUNK, LOOSE_INFLATE_OUTPUT_CHUNK,
24};
25/// Payload bytes read from a source stream per step when writing a loose object.
26const STREAM_CHUNK: usize = 128 * 1024;
27
28fn objects_dir_mtime(objects_dir: &Path) -> SystemTime {
29    #[cfg(test)]
30    crate::hot_path_test_metrics::record_loose_objects_root_metadata_for_active_scope();
31    fs::metadata(objects_dir)
32        .and_then(|m| m.modified())
33        .unwrap_or(SystemTime::UNIX_EPOCH)
34}
35
36fn fanout_dir_mtime(objects_dir: &Path, prefix: &str) -> SystemTime {
37    #[cfg(test)]
38    crate::hot_path_test_metrics::record_loose_fanout_metadata_for_active_scope();
39    fs::metadata(objects_dir.join(prefix))
40        .and_then(|m| m.modified())
41        .unwrap_or(SystemTime::UNIX_EPOCH)
42}
43
44fn loose_fanout_is_dir(objects_dir: &Path, prefix: &str) -> bool {
45    #[cfg(test)]
46    crate::hot_path_test_metrics::record_loose_path_exists_probe_for_active_scope();
47    objects_dir.join(prefix).is_dir()
48}
49
50fn loose_fanout_prefix(objects_dir: &Path, path: &Path) -> Option<String> {
51    path.strip_prefix(objects_dir)
52        .ok()
53        .and_then(|rel| rel.components().next())
54        .and_then(|c| c.as_os_str().to_str())
55        .filter(|s| s.len() == 2 && s.chars().all(|ch| ch.is_ascii_hexdigit()))
56        .map(str::to_owned)
57}
58
59/// Per-`objects/` negative cache for loose read misses (owned by [`LooseStore`] / [`crate::odb::Odb`]).
60#[derive(Debug)]
61pub(crate) struct LooseNegativeCache {
62    root_mtime: SystemTime,
63    fanout_mtime: HashMap<String, SystemTime>,
64    absent_paths: HashSet<PathBuf>,
65    absent_fanout: HashSet<String>,
66}
67
68impl Default for LooseNegativeCache {
69    fn default() -> Self {
70        Self {
71            root_mtime: SystemTime::UNIX_EPOCH,
72            fanout_mtime: HashMap::new(),
73            absent_paths: HashSet::new(),
74            absent_fanout: HashSet::new(),
75        }
76    }
77}
78
79impl LooseNegativeCache {
80    fn sync_root(&mut self, objects_dir: &Path) {
81        let mt = objects_dir_mtime(objects_dir);
82        if self.root_mtime != mt {
83            *self = Self {
84                root_mtime: mt,
85                ..Self::default()
86            };
87        }
88    }
89
90    fn sync_fanout(&mut self, objects_dir: &Path, prefix: &str) {
91        let mt = fanout_dir_mtime(objects_dir, prefix);
92        if self.fanout_mtime.get(prefix) != Some(&mt) {
93            self.fanout_mtime.insert(prefix.to_owned(), mt);
94            let fanout_dir = objects_dir.join(prefix);
95            self.absent_paths.retain(|p| !p.starts_with(&fanout_dir));
96            self.absent_fanout.remove(prefix);
97        }
98    }
99
100    /// Whether `path` is known absent from a prior miss with no filesystem probes.
101    fn peek_known_absent(&self, objects_dir: &Path, path: &Path) -> bool {
102        if self.absent_paths.contains(path) {
103            return true;
104        }
105        if let Some(prefix) = loose_fanout_prefix(objects_dir, path) {
106            if self.absent_fanout.contains(&prefix) {
107                return true;
108            }
109        }
110        false
111    }
112
113    fn may_skip_open(&mut self, objects_dir: &Path, path: &Path) -> bool {
114        if self.peek_known_absent(objects_dir, path) {
115            return true;
116        }
117        self.sync_root(objects_dir);
118        if let Some(prefix) = loose_fanout_prefix(objects_dir, path) {
119            self.sync_fanout(objects_dir, &prefix);
120            if self.absent_fanout.contains(&prefix) {
121                return true;
122            }
123        }
124        self.absent_paths.contains(path)
125    }
126
127    fn record_absent(&mut self, objects_dir: &Path, path: &Path) {
128        self.sync_root(objects_dir);
129        if let Some(prefix) = loose_fanout_prefix(objects_dir, path) {
130            self.sync_fanout(objects_dir, &prefix);
131        }
132        self.absent_paths.insert(path.to_path_buf());
133        if let Some(prefix) = loose_fanout_prefix(objects_dir, path) {
134            if !loose_fanout_is_dir(objects_dir, &prefix) {
135                self.absent_fanout.insert(prefix);
136            }
137        }
138    }
139}
140
141pub(crate) type SharedLooseNegativeCache = Arc<Mutex<LooseNegativeCache>>;
142
143/// On-disk loose object store for one `objects/` directory.
144#[derive(Debug, Clone)]
145pub struct LooseStore {
146    objects_dir: PathBuf,
147    hash_algo: HashAlgo,
148    compression: Compression,
149    negative_cache: SharedLooseNegativeCache,
150}
151
152impl LooseStore {
153    /// Open a loose store at `objects_dir` using `hash_algo` and zlib `compression`.
154    #[must_use]
155    pub fn new(objects_dir: PathBuf, hash_algo: HashAlgo, compression: Compression) -> Self {
156        Self::new_with_negative_cache(
157            objects_dir,
158            hash_algo,
159            compression,
160            Arc::new(Mutex::new(LooseNegativeCache::default())),
161        )
162    }
163
164    pub(crate) fn new_with_negative_cache(
165        objects_dir: PathBuf,
166        hash_algo: HashAlgo,
167        compression: Compression,
168        negative_cache: SharedLooseNegativeCache,
169    ) -> Self {
170        Self {
171            objects_dir,
172            hash_algo,
173            compression,
174            negative_cache,
175        }
176    }
177
178    fn invalidate_negative_cache(&self) {
179        if let Ok(mut guard) = self.negative_cache.lock() {
180            *guard = LooseNegativeCache::default();
181        }
182    }
183
184    fn open_may_be_absent(&self, path: &Path) -> bool {
185        self.negative_cache
186            .lock()
187            .ok()
188            .is_some_and(|mut guard| guard.may_skip_open(&self.objects_dir, path))
189    }
190
191    /// Memory-only negative-cache hit for `oid` (no filesystem metadata).
192    pub(crate) fn cached_absent_loose_path(&self, oid: &ObjectId) -> bool {
193        let path = self.object_path(oid);
194        self.negative_cache
195            .lock()
196            .ok()
197            .is_some_and(|guard| guard.peek_known_absent(&self.objects_dir, &path))
198    }
199
200    fn record_absent_path(&self, path: &Path) {
201        if let Ok(mut guard) = self.negative_cache.lock() {
202            guard.record_absent(&self.objects_dir, path);
203        }
204    }
205
206    /// Path to the `objects/` root.
207    #[must_use]
208    pub fn objects_dir(&self) -> &Path {
209        &self.objects_dir
210    }
211
212    /// Filesystem path for a loose object file.
213    #[must_use]
214    pub fn object_path(&self, oid: &ObjectId) -> PathBuf {
215        oid.loose_path_in(&self.objects_dir)
216    }
217
218    /// Create all 256 loose-object prefix directories (`objects/xx/`).
219    ///
220    /// # Errors
221    ///
222    /// Returns [`Error::Io`] if a directory cannot be created.
223    pub fn ensure_all_loose_prefix_dirs(&self) -> Result<()> {
224        for i in 0u8..=255 {
225            let prefix = self.objects_dir.join(format!("{i:02x}"));
226            fs::create_dir_all(&prefix).map_err(|source| {
227                map_objects_dir_io(
228                    &self.objects_dir,
229                    &prefix,
230                    GitDirPathOperation::CreateDir,
231                    source,
232                )
233            })?;
234        }
235        Ok(())
236    }
237
238    /// Zlib-compress canonical store bytes at this store's compression level.
239    ///
240    /// # Errors
241    ///
242    /// Returns [`Error::Zlib`] when compression fails.
243    pub fn zlib_compress_store_bytes(&self, store_bytes: &[u8]) -> Result<Vec<u8>> {
244        zlib_compress_store_bytes(store_bytes, self.compression)
245    }
246
247    /// When a loose file already exists, freshen (unless silent) and return its id.
248    pub(crate) fn try_finish_if_loose_present(
249        &self,
250        oid: &ObjectId,
251        options: WriteOptions,
252    ) -> Result<Option<ObjectId>> {
253        let path = self.object_path(oid);
254        if path.is_file() {
255            if !options.silent {
256                let _ = self.freshen(oid)?;
257            }
258            return Ok(Some(*oid));
259        }
260        Ok(None)
261    }
262
263    /// Write canonical store bytes for a precomputed `oid` (hashing skipped).
264    ///
265    /// # Errors
266    ///
267    /// Same as [`WritableObjectStore::write`].
268    pub fn write_store_prehashed(
269        &self,
270        oid: &ObjectId,
271        store_bytes: &[u8],
272        options: WriteOptions,
273    ) -> Result<ObjectId> {
274        parse_object_bytes(store_bytes)?;
275        if oid.algo() != self.hash_algo {
276            return Err(Error::InvalidObjectId(oid.to_hex()));
277        }
278        if let Some(existing) = self.try_finish_if_loose_present(oid, options)? {
279            return Ok(existing);
280        }
281        let path = self.object_path(oid);
282        let prefix_dir = path
283            .parent()
284            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
285        let zlib = zlib_compress_store_bytes(store_bytes, self.compression)?;
286        publish_loose_object(&self.objects_dir, &path, prefix_dir, false, |mut file| {
287            file.write_all(&zlib).map_err(|source| {
288                map_objects_dir_io(
289                    &self.objects_dir,
290                    &path,
291                    GitDirPathOperation::WriteObject,
292                    source,
293                )
294            })
295        })?;
296        self.invalidate_negative_cache();
297        Ok(*oid)
298    }
299
300    /// Write zlib-compressed store bytes for a precomputed `oid`.
301    ///
302    /// # Errors
303    ///
304    /// Same as [`WritableObjectStore::write`].
305    pub fn write_zlib_prehashed(
306        &self,
307        oid: &ObjectId,
308        zlib_store: &[u8],
309        options: WriteOptions,
310    ) -> Result<ObjectId> {
311        if oid.algo() != self.hash_algo {
312            return Err(Error::InvalidObjectId(oid.to_hex()));
313        }
314        if let Some(existing) = self.try_finish_if_loose_present(oid, options)? {
315            return Ok(existing);
316        }
317        let path = self.object_path(oid);
318        let prefix_dir = path
319            .parent()
320            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
321        publish_loose_object(
322            &self.objects_dir,
323            &path,
324            prefix_dir,
325            options.loose_prefix_dirs_ready,
326            |mut file| {
327                file.write_all(zlib_store).map_err(|source| {
328                    map_objects_dir_io(
329                        &self.objects_dir,
330                        &path,
331                        GitDirPathOperation::WriteObject,
332                        source,
333                    )
334                })
335            },
336        )?;
337        self.invalidate_negative_cache();
338        Ok(*oid)
339    }
340
341    /// Write `data` as object `oid` of `kind` (hash already computed by the caller),
342    /// deflating straight into the temporary loose file so no compressed copy is held in
343    /// memory.
344    ///
345    /// # Errors
346    ///
347    /// Same as [`WritableObjectStore::write`].
348    pub(crate) fn write_prehashed(
349        &self,
350        oid: &ObjectId,
351        kind: ObjectKind,
352        data: &[u8],
353        options: WriteOptions,
354    ) -> Result<ObjectId> {
355        if oid.algo() != self.hash_algo {
356            return Err(Error::InvalidObjectId(oid.to_hex()));
357        }
358        if let Some(existing) = self.try_finish_if_loose_present(oid, options)? {
359            return Ok(existing);
360        }
361        let path = self.object_path(oid);
362        let prefix_dir = path
363            .parent()
364            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
365        publish_loose_object(
366            &self.objects_dir,
367            &path,
368            prefix_dir,
369            options.loose_prefix_dirs_ready,
370            |file| deflate_object_into(file, kind, data, self.compression),
371        )?;
372        self.invalidate_negative_cache();
373        Ok(*oid)
374    }
375
376    /// Read a loose object at `path`, verifying the payload hashes to `expected_oid`.
377    ///
378    /// # Errors
379    ///
380    /// - [`Error::Zlib`] — decompression failed.
381    /// - [`Error::CorruptObject`] — header is malformed.
382    /// - [`Error::LooseHashMismatch`] — payload OID does not match `expected_oid`.
383    pub fn read_verify_oid(path: &Path, expected_oid: &ObjectId) -> Result<Object> {
384        let file = fs::File::open(path).map_err(Error::Io)?;
385        let raw = match read_zlib_loose_payload(file) {
386            Ok(raw) => raw,
387            Err(err) => {
388                return Err(Error::wrap_loose_read_failure(
389                    *expected_oid,
390                    path.to_path_buf(),
391                    err,
392                ));
393            }
394        };
395        let obj = match parse_object_bytes_with_oid(&raw, expected_oid) {
396            Ok(obj) => obj,
397            Err(err) => {
398                return Err(Error::wrap_loose_read_failure(
399                    *expected_oid,
400                    path.to_path_buf(),
401                    err,
402                ));
403            }
404        };
405        let computed = hash::hash_object(expected_oid.algo(), obj.kind, &obj.data)?;
406        if computed != *expected_oid {
407            return Err(Error::LooseHashMismatch {
408                path: path.display().to_string(),
409                real_oid: computed.to_hex(),
410            });
411        }
412        Ok(obj)
413    }
414
415    /// Read `oid` unless its payload is larger than `limit` bytes, opening the file once.
416    ///
417    /// Inflation stops as soon as the header declares a payload larger than `limit`.
418    /// `Ok(None)` when there is no loose file.
419    ///
420    /// # Errors
421    ///
422    /// Same as [`ObjectStore::read`]: [`Error::Io`], [`Error::Zlib`] or
423    /// [`Error::CorruptObject`] for unreadable or malformed objects.
424    pub(crate) fn read_unless_larger(
425        &self,
426        oid: &ObjectId,
427        limit: u64,
428    ) -> Result<Option<BoundedRead>> {
429        let mut file = match fs::File::open(self.object_path(oid)) {
430            Ok(f) => f,
431            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
432            Err(err) => return Err(Error::Io(err)),
433        };
434        let mut buf = Vec::new();
435        let complete =
436            with_loose_scratch(|s| s.inflate_loose_file_unless_larger(&mut file, &mut buf, limit))?;
437        if !complete {
438            return Ok(Some(BoundedRead::Larger));
439        }
440        let kind = strip_decompressed_object_header(&mut buf, Some(oid))?;
441        Ok(Some(BoundedRead::Object(Object::new(kind, buf))))
442    }
443
444    fn touch_mtime(path: &Path) -> bool {
445        crate::fs_readonly::set_file_times_now(path).is_some()
446    }
447}
448
449impl ObjectStore for LooseStore {
450    fn hash_algo(&self) -> HashAlgo {
451        self.hash_algo
452    }
453
454    fn read(&self, oid: &ObjectId) -> Result<Option<Object>> {
455        let mut data = Vec::new();
456        Ok(self
457            .read_into(oid, &mut data)?
458            .map(|kind| Object::new(kind, data)))
459    }
460
461    fn read_into(&self, oid: &ObjectId, buf: &mut Vec<u8>) -> Result<Option<ObjectKind>> {
462        let path = self.object_path(oid);
463        if self.open_may_be_absent(&path) {
464            return Ok(None);
465        }
466        let file = match fs::File::open(&path) {
467            Ok(f) => f,
468            Err(err) if err.kind() == io::ErrorKind::NotFound => {
469                self.record_absent_path(&path);
470                return Ok(None);
471            }
472            Err(err) => return Err(Error::Io(err)),
473        };
474        if let Err(err) = read_zlib_loose_payload_into(file, buf) {
475            return Err(Error::wrap_loose_read_failure(*oid, path, err));
476        }
477        let kind = match strip_decompressed_object_header(buf, Some(oid)) {
478            Ok(kind) => kind,
479            Err(err) => return Err(Error::wrap_loose_read_failure(*oid, path, err)),
480        };
481        Ok(Some(kind))
482    }
483
484    fn read_info(&self, oid: &ObjectId) -> Result<Option<ObjectInfo>> {
485        let path = self.object_path(oid);
486        if self.open_may_be_absent(&path) {
487            return Ok(None);
488        }
489        let mut file = match fs::File::open(&path) {
490            Ok(f) => f,
491            Err(err) if err.kind() == io::ErrorKind::NotFound => {
492                self.record_absent_path(&path);
493                return Ok(None);
494            }
495            Err(err) => return Err(Error::Io(err)),
496        };
497        match loose_object_info_from_file(&mut file) {
498            Err(Error::Io(err)) if err.kind() == io::ErrorKind::IsADirectory => Ok(None),
499            Err(err) => Err(Error::wrap_loose_read_failure(*oid, path, err)),
500            Ok(info) => Ok(Some(info)),
501        }
502    }
503
504    fn open_stream(&self, oid: &ObjectId) -> Result<Option<ObjectStream<'_>>> {
505        let path = self.object_path(oid);
506        if self.open_may_be_absent(&path) {
507            return Ok(None);
508        }
509        let file = match fs::File::open(&path) {
510            Ok(f) => f,
511            Err(err) if err.kind() == io::ErrorKind::NotFound => {
512                self.record_absent_path(&path);
513                return Ok(None);
514            }
515            Err(err) => return Err(Error::Io(err)),
516        };
517        let reader = match LoosePayloadReader::open(file) {
518            Ok(reader) => reader,
519            Err(err) => return Err(Error::wrap_loose_read_failure(*oid, path, err)),
520        };
521        Ok(Some(ObjectStream {
522            kind: reader.kind,
523            size: reader.payload_size,
524            reader: Box::new(reader),
525        }))
526    }
527
528    fn for_each_object(&self, f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>) -> Result<()> {
529        for_each_loose_object_id(&self.objects_dir, self.hash_algo, f)
530    }
531
532    fn lookup_prefix(&self, prefix: &str, limit: usize, out: &mut Vec<ObjectId>) -> Result<()> {
533        let prefix = ObjectIdPrefix::from_hex(prefix, self.hash_algo)?;
534        let start_len = out.len();
535        let mut collect = |oid: &ObjectId| {
536            if prefix.matches(oid) {
537                out.push(*oid);
538                if limit != 0 && out.len() - start_len >= limit {
539                    return ControlFlow::Break(());
540                }
541            }
542            ControlFlow::Continue(())
543        };
544        if prefix.nibbles() < 2 {
545            return for_each_loose_object_id(&self.objects_dir, self.hash_algo, &mut collect);
546        }
547        let fanout = format!("{:02x}", prefix.lowest().as_bytes()[0]);
548        let fanout_dir = self.objects_dir.join(&fanout);
549        for_each_object_id_in_fanout_dir(&fanout_dir, &fanout, self.hash_algo, &mut collect)
550            .map(drop)
551    }
552}
553
554impl WritableObjectStore for LooseStore {
555    fn write(&self, kind: ObjectKind, data: &[u8], options: WriteOptions) -> Result<ObjectId> {
556        let oid = hash::hash_object(self.hash_algo, kind, data)?;
557        if let Some(existing) = self.try_finish_if_loose_present(&oid, options)? {
558            return Ok(existing);
559        }
560        let path = self.object_path(&oid);
561        let prefix_dir = path
562            .parent()
563            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
564        publish_loose_object(&self.objects_dir, &path, prefix_dir, false, |file| {
565            deflate_object_into(file, kind, data, self.compression)
566        })?;
567        self.invalidate_negative_cache();
568        Ok(oid)
569    }
570
571    fn write_stream(
572        &self,
573        kind: ObjectKind,
574        size: u64,
575        reader: &mut dyn Read,
576        options: WriteOptions,
577    ) -> Result<ObjectId> {
578        self.write_payload_stream(kind, size, reader, options)
579    }
580
581    fn freshen(&self, oid: &ObjectId) -> Result<bool> {
582        let path = self.object_path(oid);
583        if path.is_file() {
584            return Ok(LooseStore::touch_mtime(&path));
585        }
586        Ok(false)
587    }
588}
589
590/// Enumerate loose `(oid, path)` pairs under `objects_dir`.
591pub fn enumerate_loose_objects(
592    objects_dir: &Path,
593    hash_algo: HashAlgo,
594) -> Result<Vec<(ObjectId, PathBuf)>> {
595    let mut out = Vec::new();
596    for_each_loose_object_id(objects_dir, hash_algo, &mut |oid| {
597        let path = oid.loose_path_in(objects_dir);
598        out.push((*oid, path));
599        ControlFlow::Continue(())
600    })?;
601    Ok(out)
602}
603
604/// Invoke `f` for each loose object id under `objects_dir`.
605pub fn for_each_loose_object_id(
606    objects_dir: &Path,
607    hash_algo: HashAlgo,
608    f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>,
609) -> Result<()> {
610    let top = match fs::read_dir(objects_dir) {
611        Ok(rd) => rd,
612        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
613        Err(e) => return Err(Error::Io(e)),
614    };
615
616    for top_entry in top {
617        let top_entry = top_entry.map_err(Error::Io)?;
618        let name = top_entry.file_name();
619        let Some(prefix) = name.to_str() else {
620            continue;
621        };
622        if prefix.len() != 2 || !prefix.bytes().all(|b| b.is_ascii_hexdigit()) {
623            continue;
624        }
625        if !top_entry.file_type().map_err(Error::Io)?.is_dir() {
626            continue;
627        }
628        if for_each_object_id_in_fanout_dir(&top_entry.path(), prefix, hash_algo, f)?.is_break() {
629            return Ok(());
630        }
631    }
632    Ok(())
633}
634
635/// Visit the loose ids in one `objects/<fanout>` directory; a missing directory (or a
636/// non-directory at that path) holds no objects.
637pub(crate) fn for_each_object_id_in_fanout_dir(
638    fanout_dir: &Path,
639    fanout: &str,
640    hash_algo: HashAlgo,
641    f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>,
642) -> Result<ControlFlow<()>> {
643    let sub = match fs::read_dir(fanout_dir) {
644        Ok(rd) => rd,
645        Err(e)
646            if matches!(
647                e.kind(),
648                io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
649            ) =>
650        {
651            return Ok(ControlFlow::Continue(()));
652        }
653        Err(e) => return Err(Error::Io(e)),
654    };
655    for sub_entry in sub {
656        let sub_entry = sub_entry.map_err(Error::Io)?;
657        if !sub_entry.file_type().map_err(Error::Io)?.is_file() {
658            continue;
659        }
660        let suffix_name = sub_entry.file_name();
661        let Some(suffix) = suffix_name.to_str() else {
662            continue;
663        };
664        if !is_loose_suffix_for_algo(suffix, hash_algo) {
665            continue;
666        }
667        let hex = format!("{fanout}{suffix}");
668        let Ok(oid) = ObjectId::from_hex(&hex) else {
669            continue;
670        };
671        if f(&oid).is_break() {
672            return Ok(ControlFlow::Break(()));
673        }
674    }
675    Ok(ControlFlow::Continue(()))
676}
677
678/// Decompress a zlib-wrapped loose object payload from an open file.
679pub fn read_zlib_loose_payload(file: File) -> Result<Vec<u8>> {
680    let mut out = Vec::new();
681    read_zlib_loose_payload_into(file, &mut out)?;
682    Ok(out)
683}
684
685/// Decompress a loose object into `buf`, reusing its capacity.
686pub(crate) fn read_zlib_loose_payload_into(mut file: File, buf: &mut Vec<u8>) -> Result<()> {
687    with_loose_scratch(|scratch| scratch.inflate_loose_file(&mut file, buf))
688}
689
690/// Remove the Git object header from decompressed bytes in `buf`, leaving only the payload.
691pub(crate) fn strip_decompressed_object_header(
692    buf: &mut Vec<u8>,
693    oid_hint: Option<&ObjectId>,
694) -> Result<ObjectKind> {
695    let nul = buf
696        .iter()
697        .position(|&b| b == 0)
698        .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
699    let (kind, size) = parse_object_header_prefix(buf)?;
700    let payload_start = nul + 1;
701    let size_usize = usize::try_from(size)
702        .map_err(|_| Error::CorruptObject(format!("invalid object size: {size}")))?;
703    if buf.len().saturating_sub(payload_start) != size_usize {
704        let oid_str = oid_hint.map(ObjectId::to_hex).unwrap_or_else(|| {
705            hash_algo_digest_hex(HashAlgo::Sha1, buf).unwrap_or_else(|_| "?".to_owned())
706        });
707        return Err(Error::CorruptObject(format!(
708            "object {oid_str} size mismatch: header says {size}, got {}",
709            buf.len().saturating_sub(payload_start)
710        )));
711    }
712    if payload_start > 0 {
713        buf.copy_within(payload_start.., 0);
714    }
715    buf.truncate(size_usize);
716    Ok(kind)
717}
718
719/// Read kind and size from a loose object file without loading the full payload.
720pub fn read_loose_object_info(path: &Path) -> Result<ObjectInfo> {
721    let mut file = fs::File::open(path).map_err(Error::Io)?;
722    match loose_object_info_from_file(&mut file) {
723        Ok(info) => Ok(info),
724        Err(err) => {
725            if let Some(oid) = oid_from_loose_object_path(path) {
726                Err(Error::wrap_loose_read_failure(oid, path.to_path_buf(), err))
727            } else {
728                Err(err)
729            }
730        }
731    }
732}
733
734fn oid_from_loose_object_path(path: &Path) -> Option<ObjectId> {
735    let file_name = path.file_name()?.to_str()?;
736    if !ObjectId::is_loose_suffix_len(file_name.len()) {
737        return None;
738    }
739    let parent = path.parent()?.file_name()?.to_str()?;
740    if parent.len() != 2 {
741        return None;
742    }
743    let hex = format!("{parent}{file_name}");
744    ObjectId::from_hex(&hex).ok()
745}
746
747fn loose_object_info_from_file(file: &mut File) -> Result<ObjectInfo> {
748    let prefix = with_loose_scratch(|scratch| scratch.inflate_loose_header(file))?;
749    let (kind, size) = parse_object_header_prefix(&prefix)?;
750    Ok(ObjectInfo { kind, size })
751}
752
753/// Parse decompressed object bytes (`"<type> <size>\0<data>"`) into an [`Object`].
754pub(crate) fn parse_object_bytes(raw: &[u8]) -> Result<Object> {
755    parse_object_bytes_inner(raw, None)
756}
757
758pub(crate) fn parse_object_bytes_with_oid(raw: &[u8], oid: &ObjectId) -> Result<Object> {
759    parse_object_bytes_inner(raw, Some(oid))
760}
761
762pub(crate) fn zlib_compress_store_bytes(
763    store_bytes: &[u8],
764    compression: Compression,
765) -> Result<Vec<u8>> {
766    let mut out = Vec::new();
767    let mut encoder = ZlibEncoder::new(&mut out, compression);
768    encoder
769        .write_all(store_bytes)
770        .map_err(|e| Error::Zlib(e.to_string()))?;
771    encoder.finish().map_err(|e| Error::Zlib(e.to_string()))?;
772    Ok(out)
773}
774
775pub(crate) fn decompress_zlib_loose_bytes(zlib: &[u8]) -> Result<Vec<u8>> {
776    let mut decoder = flate2::read::ZlibDecoder::new(zlib);
777    let mut raw = Vec::new();
778    decoder
779        .read_to_end(&mut raw)
780        .map_err(|e| Error::Zlib(e.to_string()))?;
781    Ok(raw)
782}
783
784/// Parse `"<type> <size>\0"` from decompressed bytes that include at least the header prefix.
785pub(crate) fn parse_object_header_prefix(raw: &[u8]) -> Result<(ObjectKind, u64)> {
786    let nul = raw
787        .iter()
788        .position(|&b| b == 0)
789        .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
790
791    let header = &raw[..nul];
792    let sp = header
793        .iter()
794        .position(|&b| b == b' ')
795        .ok_or_else(|| Error::CorruptObject("missing space in object header".to_owned()))?;
796
797    if sp > 32 {
798        return Err(Error::ObjectHeaderTooLong {
799            oid: hash_algo_digest_hex(HashAlgo::Sha1, raw).unwrap_or_else(|_| "?".to_owned()),
800        });
801    }
802
803    let kind = ObjectKind::from_bytes(&header[..sp])?;
804    let size_str = std::str::from_utf8(&header[sp + 1..])
805        .map_err(|_| Error::CorruptObject("non-UTF-8 object size".to_owned()))?;
806    let size: u64 = size_str
807        .parse()
808        .map_err(|_| Error::CorruptObject(format!("invalid object size: {size_str}")))?;
809    Ok((kind, size))
810}
811
812fn parse_object_bytes_inner(raw: &[u8], oid_hint: Option<&ObjectId>) -> Result<Object> {
813    let nul = raw
814        .iter()
815        .position(|&b| b == 0)
816        .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
817
818    let header = &raw[..nul];
819    let data = raw[nul + 1..].to_vec();
820
821    let sp = header
822        .iter()
823        .position(|&b| b == b' ')
824        .ok_or_else(|| Error::CorruptObject("missing space in object header".to_owned()))?;
825
826    if sp > 32 {
827        let oid_str = oid_hint.map(|o| o.to_hex()).unwrap_or_else(|| {
828            hash_algo_digest_hex(HashAlgo::Sha1, raw).unwrap_or_else(|_| "?".to_owned())
829        });
830        return Err(Error::ObjectHeaderTooLong { oid: oid_str });
831    }
832
833    let kind = ObjectKind::from_bytes(&header[..sp])?;
834
835    let size_str = std::str::from_utf8(&header[sp + 1..])
836        .map_err(|_| Error::CorruptObject("non-UTF-8 object size".to_owned()))?;
837    let size: usize = size_str
838        .parse()
839        .map_err(|_| Error::CorruptObject(format!("invalid object size: {size_str}")))?;
840
841    if data.len() != size {
842        return Err(Error::CorruptObject(format!(
843            "object size mismatch: header says {size} but got {}",
844            data.len()
845        )));
846    }
847
848    Ok(Object::new(kind, data))
849}
850
851fn hash_algo_digest_hex(algo: HashAlgo, data: &[u8]) -> Result<String> {
852    Ok(algo.digest(data)?.to_hex())
853}
854
855/// Per-process sequence that, with the pid, makes every loose temp file name unique, so
856/// concurrent writers of the same object (threads, handles or processes) never share one.
857static LOOSE_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
858
859/// A fresh `tmp_obj_<pid>_<seq>` path in `prefix_dir`; the `tmp_` prefix keeps it out of
860/// loose-object enumeration.
861fn unique_loose_tmp_path(prefix_dir: &Path) -> PathBuf {
862    let seq = LOOSE_TMP_SEQ.fetch_add(1, Ordering::Relaxed);
863    prefix_dir.join(format!("tmp_obj_{}_{seq}", std::process::id()))
864}
865
866/// Deflate `"<kind> <len>\0"` and `data` into `file` without copying `data` behind the header.
867fn deflate_object_into(
868    file: File,
869    kind: ObjectKind,
870    data: &[u8],
871    compression: Compression,
872) -> Result<()> {
873    let zlib_err = |e: io::Error| Error::Zlib(e.to_string());
874    let mut encoder = ZlibEncoder::new(file, compression);
875    write!(encoder, "{} {}\0", kind, data.len()).map_err(zlib_err)?;
876    encoder.write_all(data).map_err(zlib_err)?;
877    encoder.finish().map_err(zlib_err)?;
878    Ok(())
879}
880
881fn read_payload_chunks(
882    reader: &mut dyn Read,
883    size: u64,
884    mut sink: impl FnMut(&[u8]) -> Result<()>,
885) -> Result<()> {
886    let mut remaining = size;
887    let mut buf = [0u8; STREAM_CHUNK];
888    while remaining > 0 {
889        let want = usize::try_from(remaining.min(STREAM_CHUNK as u64)).map_err(|_| {
890            Error::CorruptObject(format!("object size {size} exceeds address space"))
891        })?;
892        let n = match reader.read(&mut buf[..want]) {
893            Ok(0) => {
894                return Err(Error::ObjectStreamTruncated {
895                    expected: size,
896                    actual: size - remaining,
897                })
898            }
899            Ok(n) => n,
900            Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
901            Err(e) => return Err(Error::Io(e)),
902        };
903        sink(&buf[..n])?;
904        remaining -= n as u64;
905    }
906    Ok(())
907}
908
909impl LooseStore {
910    /// Store `size` uncompressed payload bytes of `kind` read from `reader` as a loose object.
911    ///
912    /// Hashes and deflates in fixed-size chunks so memory use does not grow with `size`.
913    pub(crate) fn write_payload_stream(
914        &self,
915        kind: ObjectKind,
916        size: u64,
917        reader: &mut dyn Read,
918        options: WriteOptions,
919    ) -> Result<ObjectId> {
920        let (first, tail) = crate::odb::stream::read_first_payload_chunk(reader, size)?;
921        let compression =
922            crate::odb::stream::compression_after_first_chunk(&first, self.compression);
923        self.write_payload_stream_with(kind, size, options, compression, |chunk_sink| {
924            chunk_sink(&first)?;
925            if tail > 0 {
926                read_payload_chunks(reader, tail, chunk_sink)
927            } else {
928                Ok(())
929            }
930        })
931    }
932
933    /// Store a payload supplied chunk-by-chunk through `supply` (used by pack unpack).
934    pub(crate) fn write_payload_stream_with(
935        &self,
936        kind: ObjectKind,
937        size: u64,
938        options: WriteOptions,
939        compression: Compression,
940        mut supply: impl FnMut(&mut dyn FnMut(&[u8]) -> Result<()>) -> Result<()>,
941    ) -> Result<ObjectId> {
942        let prefix_dir = &self.objects_dir;
943        let mut hasher = ObjectPayloadHasher::new(self.hash_algo, kind, size);
944        let tmp_path = unique_loose_tmp_path(prefix_dir);
945        fs::create_dir_all(prefix_dir).map_err(Error::Io)?;
946        let file = fs::OpenOptions::new()
947            .write(true)
948            .create_new(true)
949            .open(&tmp_path)
950            .map_err(Error::Io)?;
951        let zlib_err = |e: io::Error| Error::Zlib(e.to_string());
952        let mut encoder = ZlibEncoder::new(file, compression);
953        if let Err(e) = (|| {
954            write!(encoder, "{} {}\0", kind, size).map_err(zlib_err)?;
955            supply(&mut |chunk| {
956                hasher.update(chunk);
957                encoder.write_all(chunk).map_err(zlib_err)
958            })?;
959            encoder.finish().map_err(zlib_err)?;
960            Ok::<(), Error>(())
961        })() {
962            let _ = fs::remove_file(&tmp_path);
963            return Err(e);
964        }
965        let oid = match hasher.finish() {
966            Ok(oid) => oid,
967            Err(e) => {
968                let _ = fs::remove_file(&tmp_path);
969                return Err(e);
970            }
971        };
972        if let Some(existing) = self.try_finish_if_loose_present(&oid, options)? {
973            let _ = fs::remove_file(&tmp_path);
974            return Ok(existing);
975        }
976        let path = self.object_path(&oid);
977        let dest_prefix = path
978            .parent()
979            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
980        if let Err(e) = fs::create_dir_all(dest_prefix) {
981            let _ = fs::remove_file(&tmp_path);
982            return Err(Error::Io(e));
983        }
984        match fs::rename(&tmp_path, &path) {
985            Ok(()) => {
986                set_loose_object_mode(&path);
987                Ok(oid)
988            }
989            Err(e) => {
990                let _ = fs::remove_file(&tmp_path);
991                if path.is_file() {
992                    return Ok(oid);
993                }
994                Err(Error::Io(e))
995            }
996        }
997    }
998}
999
1000/// Publish a loose object at `path`: `fill` writes the zlib stream into a new, uniquely
1001/// named temp file in `prefix_dir`, which is then renamed into place.
1002///
1003/// Publishing is idempotent: when the rename fails but `path` exists, another writer
1004/// published the same object and this returns `Ok`. A missing `prefix_dir` (not yet
1005/// created, or pruned by a concurrent process) is recreated and the write retried; with
1006/// `prefix_dir_ready` the first attempt skips the `create_dir_all`.
1007///
1008/// # Errors
1009///
1010/// Returns [`Error::Io`] when the temp file cannot be created or renamed after the
1011/// retries, or whatever `fill` returns.
1012fn publish_loose_object(
1013    objects_dir: &Path,
1014    path: &Path,
1015    prefix_dir: &Path,
1016    prefix_dir_ready: bool,
1017    mut fill: impl FnMut(File) -> Result<()>,
1018) -> Result<()> {
1019    const MAX_ATTEMPTS: u32 = 4;
1020    let mut attempt = 0;
1021    loop {
1022        attempt += 1;
1023        let can_retry = attempt < MAX_ATTEMPTS;
1024        if !prefix_dir_ready || attempt > 1 {
1025            fs::create_dir_all(prefix_dir).map_err(|source| {
1026                map_objects_dir_io(
1027                    objects_dir,
1028                    prefix_dir,
1029                    GitDirPathOperation::CreateDir,
1030                    source,
1031                )
1032            })?;
1033        }
1034        let tmp_path = unique_loose_tmp_path(prefix_dir);
1035        let file = match fs::OpenOptions::new()
1036            .write(true)
1037            .create_new(true)
1038            .open(&tmp_path)
1039        {
1040            Ok(file) => file,
1041            Err(e)
1042                if can_retry
1043                    && matches!(
1044                        e.kind(),
1045                        io::ErrorKind::NotFound | io::ErrorKind::AlreadyExists
1046                    ) =>
1047            {
1048                continue;
1049            }
1050            Err(e) => {
1051                return Err(map_objects_dir_io(
1052                    objects_dir,
1053                    &tmp_path,
1054                    GitDirPathOperation::WriteObject,
1055                    e,
1056                ));
1057            }
1058        };
1059        if let Err(e) = fill(file) {
1060            let _ = fs::remove_file(&tmp_path);
1061            return Err(e);
1062        }
1063        match fs::rename(&tmp_path, path) {
1064            Ok(()) => {
1065                set_loose_object_mode(path);
1066                return Ok(());
1067            }
1068            Err(e) => {
1069                let _ = fs::remove_file(&tmp_path);
1070                if path.is_file() {
1071                    return Ok(());
1072                }
1073                if can_retry && e.kind() == io::ErrorKind::NotFound {
1074                    continue;
1075                }
1076                return Err(map_objects_dir_io(
1077                    objects_dir,
1078                    path,
1079                    GitDirPathOperation::WriteObject,
1080                    e,
1081                ));
1082            }
1083        }
1084    }
1085}
1086
1087fn set_loose_object_mode(path: &Path) {
1088    #[cfg(unix)]
1089    {
1090        use std::os::unix::fs::PermissionsExt;
1091        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o444));
1092    }
1093    let _ = path;
1094}
1095
1096fn is_loose_suffix_for_algo(suffix: &str, algo: HashAlgo) -> bool {
1097    suffix.len() == algo.hex_len() - 2 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
1098}
1099
1100/// Incremental zlib reader yielding only the object payload (after the Git header).
1101struct LoosePayloadReader {
1102    file: File,
1103    read_budget: LooseFileReadBudget,
1104    dec: Decompress,
1105    /// Compressed input read from `file`; bytes before `pending_pos` are already consumed.
1106    pending: Vec<u8>,
1107    pending_pos: usize,
1108    /// All compressed input from the file has been read.
1109    input_exhausted: bool,
1110    /// Inflater reached `Status::StreamEnd` (payload + zlib trailer validated).
1111    zlib_complete: bool,
1112    preset_dictionary: bool,
1113    kind: ObjectKind,
1114    payload_size: u64,
1115    payload_remaining: u64,
1116    phase: LooseReadPhase,
1117    header_buf: Vec<u8>,
1118    /// Payload bytes already inflated while reading the header (after the NUL).
1119    extra_payload: Vec<u8>,
1120}
1121
1122enum LooseReadPhase {
1123    Header,
1124    Payload,
1125}
1126
1127impl LoosePayloadReader {
1128    fn open(mut file: File) -> Result<Self> {
1129        let read_budget = LooseFileReadBudget::new(&file)?;
1130        let mut hdr = [0u8; 2];
1131        file.read_exact(&mut hdr).map_err(Error::Io)?;
1132        let cmf_flg = u16::from(hdr[0]) << 8 | u16::from(hdr[1]);
1133        let looks_like_zlib_header = cmf_flg != 0 && cmf_flg % 31 == 0;
1134        let preset_dictionary = looks_like_zlib_header && (hdr[1] & 0x20) != 0;
1135        let mut read_budget = read_budget;
1136        read_budget.note_consumed(2);
1137        let mut reader = Self {
1138            file,
1139            read_budget,
1140            dec: Decompress::new(true),
1141            pending: hdr.to_vec(),
1142            pending_pos: 0,
1143            input_exhausted: false,
1144            zlib_complete: false,
1145            preset_dictionary,
1146            kind: ObjectKind::Blob,
1147            payload_size: 0,
1148            payload_remaining: 0,
1149            phase: LooseReadPhase::Header,
1150            header_buf: Vec::with_capacity(64),
1151            extra_payload: Vec::new(),
1152        };
1153        reader.parse_header()?;
1154        Ok(reader)
1155    }
1156
1157    fn parse_header(&mut self) -> Result<()> {
1158        let mut header_buf = std::mem::take(&mut self.header_buf);
1159        loop {
1160            self.pump_zlib(&mut |bytes| {
1161                header_buf.extend_from_slice(bytes);
1162            })?;
1163            if header_buf.contains(&0) || header_buf.len() >= 128 {
1164                break;
1165            }
1166            if self.input_exhausted && self.pending_pos == self.pending.len() {
1167                break;
1168            }
1169        }
1170        let nul = header_buf
1171            .iter()
1172            .position(|&b| b == 0)
1173            .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
1174        let (kind, size) = parse_object_header_prefix(&header_buf[..=nul])?;
1175        self.extra_payload = header_buf[nul + 1..].to_vec();
1176        self.header_buf = header_buf;
1177        self.kind = kind;
1178        self.payload_size = size;
1179        self.payload_remaining = size;
1180        self.phase = LooseReadPhase::Payload;
1181        Ok(())
1182    }
1183
1184    fn pump_zlib(&mut self, on_out: &mut dyn FnMut(&[u8])) -> Result<()> {
1185        let mut scratch = [0u8; LOOSE_INFLATE_OUTPUT_CHUNK];
1186        let produced = self.inflate_into(&mut scratch)?;
1187        if produced > 0 {
1188            on_out(&scratch[..produced]);
1189        }
1190        Ok(())
1191    }
1192
1193    /// Inflate into `out`, reading more compressed input when the pending input is used up.
1194    ///
1195    /// Returns the bytes produced, which is zero once the zlib stream has ended.
1196    fn inflate_into(&mut self, out: &mut [u8]) -> Result<usize> {
1197        if self.zlib_complete {
1198            return Ok(0);
1199        }
1200        let cap = out.len().min(LOOSE_INFLATE_OUTPUT_CHUNK);
1201        let out = &mut out[..cap];
1202        if self.pending_pos == self.pending.len() && !self.input_exhausted {
1203            let mut buf = [0u8; LOOSE_INFLATE_INPUT_CHUNK];
1204            let n = self.read_budget.read(&mut self.file, &mut buf)?;
1205            self.pending.clear();
1206            self.pending.extend_from_slice(&buf[..n]);
1207            self.pending_pos = 0;
1208            self.input_exhausted = n == 0;
1209        }
1210        let flush = if self.input_exhausted && self.pending_pos == self.pending.len() {
1211            FlushDecompress::Finish
1212        } else {
1213            FlushDecompress::None
1214        };
1215        let before_in = self.dec.total_in();
1216        let before_out = self.dec.total_out();
1217        let status = match self
1218            .dec
1219            .decompress(&self.pending[self.pending_pos..], out, flush)
1220        {
1221            Ok(s) => s,
1222            Err(e) => {
1223                if self.preset_dictionary {
1224                    return Err(Error::Zlib("needs dictionary".to_owned()));
1225                }
1226                return Err(Error::Zlib(e.to_string()));
1227            }
1228        };
1229        // Both are bounded by the slices just passed to the inflater.
1230        self.pending_pos += (self.dec.total_in() - before_in) as usize;
1231        let produced = (self.dec.total_out() - before_out) as usize;
1232        if matches!(status, Status::StreamEnd) {
1233            self.zlib_complete = true;
1234        }
1235        Ok(produced)
1236    }
1237
1238    /// After the declared payload is delivered, drain the zlib trailer and reject truncation.
1239    fn ensure_zlib_complete(&mut self) -> io::Result<()> {
1240        if self.zlib_complete {
1241            return Ok(());
1242        }
1243        if !self.extra_payload.is_empty() {
1244            return Err(io::Error::other(
1245                "corrupt object: inflated bytes remain after declared payload size",
1246            ));
1247        }
1248        loop {
1249            let mut excess = 0usize;
1250            self.pump_zlib(&mut |bytes| {
1251                excess += bytes.len();
1252            })
1253            .map_err(|e| io::Error::other(e.to_string()))?;
1254            if excess > 0 {
1255                return Err(io::Error::other(
1256                    "corrupt object: excess decompressed data after declared payload size",
1257                ));
1258            }
1259            if self.zlib_complete {
1260                return Ok(());
1261            }
1262            if self.input_exhausted && self.pending_pos == self.pending.len() {
1263                return Err(io::Error::other(
1264                    "corrupt object: zlib stream ended before trailer validation",
1265                ));
1266            }
1267        }
1268    }
1269}
1270
1271impl Read for LoosePayloadReader {
1272    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1273        if !matches!(self.phase, LooseReadPhase::Payload) {
1274            return Ok(0);
1275        }
1276        if self.payload_remaining == 0 {
1277            self.ensure_zlib_complete()?;
1278            return Ok(0);
1279        }
1280        if buf.is_empty() {
1281            return Ok(0);
1282        }
1283        let mut delivered = 0usize;
1284        while delivered < buf.len() && self.payload_remaining > 0 {
1285            if !self.extra_payload.is_empty() {
1286                let take = self
1287                    .extra_payload
1288                    .len()
1289                    .min(buf.len() - delivered)
1290                    .min(self.payload_remaining as usize);
1291                buf[delivered..delivered + take].copy_from_slice(&self.extra_payload[..take]);
1292                self.extra_payload.drain(..take);
1293                delivered += take;
1294                self.payload_remaining -= take as u64;
1295                continue;
1296            }
1297            let want = usize::try_from(self.payload_remaining)
1298                .map_or(buf.len() - delivered, |r| r.min(buf.len() - delivered));
1299            let produced = self
1300                .inflate_into(&mut buf[delivered..delivered + want])
1301                .map_err(|e| io::Error::other(e.to_string()))?;
1302            if produced == 0 {
1303                if self.zlib_complete
1304                    || (self.input_exhausted && self.pending_pos == self.pending.len())
1305                {
1306                    return Err(io::Error::other(format!(
1307                        "corrupt object: zlib stream ended with {} payload bytes remaining",
1308                        self.payload_remaining
1309                    )));
1310                }
1311                continue;
1312            }
1313            delivered += produced;
1314            self.payload_remaining -= produced as u64;
1315        }
1316        if self.payload_remaining == 0 {
1317            self.ensure_zlib_complete()?;
1318        }
1319        Ok(delivered)
1320    }
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325    use super::*;
1326    use std::io::Read;
1327
1328    use super::super::{ObjectStore, WritableObjectStore};
1329
1330    const READ_CHUNK: usize = LOOSE_INFLATE_INPUT_CHUNK;
1331
1332    #[test]
1333    fn lookup_prefix_scans_single_fanout_dir() {
1334        let dir = tempfile::tempdir().unwrap();
1335        let store = LooseStore::new(
1336            dir.path().join("objects"),
1337            HashAlgo::Sha1,
1338            Compression::default(),
1339        );
1340        fs::create_dir_all(store.objects_dir()).unwrap();
1341        let oid =
1342            WritableObjectStore::write(&store, ObjectKind::Blob, b"x", WriteOptions::default())
1343                .unwrap();
1344        let mut out = Vec::new();
1345        ObjectStore::lookup_prefix(&store, &oid.to_hex()[..4], 0, &mut out).unwrap();
1346        assert_eq!(out, vec![oid]);
1347    }
1348
1349    fn temp_store() -> (tempfile::TempDir, LooseStore) {
1350        let dir = tempfile::tempdir().unwrap();
1351        let store = LooseStore::new(
1352            dir.path().join("objects"),
1353            HashAlgo::Sha1,
1354            Compression::default(),
1355        );
1356        fs::create_dir_all(store.objects_dir()).unwrap();
1357        (dir, store)
1358    }
1359
1360    fn tmp_entries(store: &LooseStore) -> Vec<PathBuf> {
1361        fs::read_dir(store.objects_dir())
1362            .unwrap()
1363            .flatten()
1364            .filter(|shard| shard.file_type().is_ok_and(|t| t.is_dir()))
1365            .flat_map(|shard| fs::read_dir(shard.path()).unwrap().flatten())
1366            .map(|entry| entry.path())
1367            .filter(|path| {
1368                path.file_name()
1369                    .is_some_and(|n| n.to_string_lossy().starts_with("tmp_"))
1370            })
1371            .collect()
1372    }
1373
1374    #[test]
1375    fn identical_writes_through_separate_handles_all_succeed() {
1376        let (_dir, store) = temp_store();
1377        let threads = 8;
1378        let barrier = std::sync::Arc::new(std::sync::Barrier::new(threads));
1379        let payloads: Vec<Vec<u8>> = (0..200)
1380            .map(|j| format!("same {j}").into_bytes())
1381            .chain(std::iter::once(vec![0x5a; 1024 * 1024]))
1382            .collect();
1383        let payloads = std::sync::Arc::new(payloads);
1384        let handles: Vec<_> = (0..threads)
1385            .map(|_| {
1386                let store = store.clone();
1387                let barrier = std::sync::Arc::clone(&barrier);
1388                let payloads = std::sync::Arc::clone(&payloads);
1389                std::thread::spawn(move || {
1390                    barrier.wait();
1391                    payloads
1392                        .iter()
1393                        .map(|data| {
1394                            WritableObjectStore::write(
1395                                &store,
1396                                ObjectKind::Blob,
1397                                data,
1398                                WriteOptions::default(),
1399                            )
1400                        })
1401                        .collect::<Vec<_>>()
1402                })
1403            })
1404            .collect();
1405        for handle in handles {
1406            for (data, result) in payloads.iter().zip(handle.join().unwrap()) {
1407                let oid = result.expect("concurrent identical write succeeds");
1408                let obj = ObjectStore::read(&store, &oid).unwrap().expect("object");
1409                assert!(obj.data == *data);
1410            }
1411        }
1412        assert!(tmp_entries(&store).is_empty());
1413    }
1414
1415    #[test]
1416    fn prehashed_write_recreates_pruned_fanout_dir_marked_ready() {
1417        let (_dir, store) = temp_store();
1418        let data = b"fanout pruned under a ready writer";
1419        let oid = hash::hash_object(HashAlgo::Sha1, ObjectKind::Blob, data).unwrap();
1420        let zlib = crate::odb::zlib_compress_blob_loose(data, Compression::default()).unwrap();
1421        let options = WriteOptions {
1422            loose_prefix_dirs_ready: true,
1423            ..WriteOptions::default()
1424        };
1425        assert!(!store.object_path(&oid).parent().unwrap().exists());
1426        let written = store.write_zlib_prehashed(&oid, &zlib, options).unwrap();
1427        assert_eq!(written, oid);
1428        assert_eq!(ObjectStore::read(&store, &oid).unwrap().unwrap().data, data);
1429        assert!(tmp_entries(&store).is_empty());
1430    }
1431
1432    #[test]
1433    fn unique_tmp_paths_differ_and_are_not_loose_names() {
1434        let prefix = Path::new("objects/ab");
1435        let a = unique_loose_tmp_path(prefix);
1436        let b = unique_loose_tmp_path(prefix);
1437        assert_ne!(a, b);
1438        for path in [a, b] {
1439            let name = path.file_name().unwrap().to_str().unwrap();
1440            assert!(name.starts_with("tmp_obj_"), "{name}");
1441            assert!(!is_loose_suffix_for_algo(name, HashAlgo::Sha1));
1442            assert!(!is_loose_suffix_for_algo(name, HashAlgo::Sha256));
1443        }
1444    }
1445
1446    #[test]
1447    fn read_info_and_read_agree_for_object_spanning_read_chunks() {
1448        let (_dir, store) = temp_store();
1449        let data: Vec<u8> = (0..3 * READ_CHUNK as u32)
1450            .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
1451            .collect();
1452        let oid =
1453            WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1454                .unwrap();
1455        let info = ObjectStore::read_info(&store, &oid).unwrap().expect("info");
1456        assert_eq!(info.kind, ObjectKind::Blob);
1457        assert_eq!(info.size, data.len() as u64);
1458        let obj = ObjectStore::read(&store, &oid).unwrap().expect("object");
1459        assert_eq!(obj.kind, ObjectKind::Blob);
1460        assert_eq!(obj.data, data);
1461        let mut buf = Vec::new();
1462        let kind = ObjectStore::read_into(&store, &oid, &mut buf).unwrap();
1463        assert_eq!(kind, Some(ObjectKind::Blob));
1464        assert_eq!(buf, data);
1465    }
1466
1467    #[test]
1468    fn read_unless_larger_reads_small_and_skips_large_objects() {
1469        let (_dir, store) = temp_store();
1470        let data = vec![b'x'; 3 * READ_CHUNK];
1471        let oid =
1472            WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1473                .unwrap();
1474        match store.read_unless_larger(&oid, data.len() as u64).unwrap() {
1475            Some(BoundedRead::Object(obj)) => {
1476                assert_eq!(obj.kind, ObjectKind::Blob);
1477                assert_eq!(obj.data, data);
1478            }
1479            other => panic!("expected the object, got {other:?}"),
1480        }
1481        assert!(matches!(
1482            store
1483                .read_unless_larger(&oid, data.len() as u64 - 1)
1484                .unwrap(),
1485            Some(BoundedRead::Larger)
1486        ));
1487        let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").unwrap();
1488        assert!(store.read_unless_larger(&missing, 0).unwrap().is_none());
1489    }
1490
1491    #[test]
1492    fn stream_reader_delivers_payload_through_tiny_and_huge_buffers() {
1493        let (_dir, store) = temp_store();
1494        let data: Vec<u8> = (0..5 * READ_CHUNK as u32)
1495            .map(|i| (i.wrapping_mul(2_654_435_761) >> 11) as u8)
1496            .collect();
1497        let oid =
1498            WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1499                .unwrap();
1500        for chunk_len in [1usize, 7, 4096, 10 * READ_CHUNK] {
1501            let mut stream = ObjectStore::open_stream(&store, &oid)
1502                .unwrap()
1503                .expect("stream");
1504            let mut out = Vec::new();
1505            let mut chunk = vec![0u8; chunk_len];
1506            loop {
1507                let n = stream.reader.read(&mut chunk).unwrap();
1508                if n == 0 {
1509                    break;
1510                }
1511                out.extend_from_slice(&chunk[..n]);
1512            }
1513            assert!(out == data, "chunk length {chunk_len}");
1514        }
1515    }
1516
1517    #[test]
1518    fn read_info_is_none_for_missing_object_or_directory_at_its_path() {
1519        let (_dir, store) = temp_store();
1520        let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").unwrap();
1521        assert!(ObjectStore::read_info(&store, &missing).unwrap().is_none());
1522        fs::create_dir_all(store.object_path(&missing)).unwrap();
1523        assert!(ObjectStore::read_info(&store, &missing).unwrap().is_none());
1524    }
1525
1526    #[test]
1527    fn lookup_prefix_one_hex_char_does_not_overflow() {
1528        let dir = tempfile::tempdir().unwrap();
1529        let store = LooseStore::new(
1530            dir.path().join("objects"),
1531            HashAlgo::Sha1,
1532            Compression::default(),
1533        );
1534        fs::create_dir_all(store.objects_dir()).unwrap();
1535        let oid =
1536            WritableObjectStore::write(&store, ObjectKind::Blob, b"x", WriteOptions::default())
1537                .unwrap();
1538        let mut out = Vec::new();
1539        ObjectStore::lookup_prefix(&store, &oid.to_hex()[..1], 0, &mut out).unwrap();
1540        assert!(out.contains(&oid));
1541    }
1542
1543    #[test]
1544    fn truncated_zlib_stream_errors_on_read() {
1545        let dir = tempfile::tempdir().unwrap();
1546        let store = LooseStore::new(
1547            dir.path().join("objects"),
1548            HashAlgo::Sha1,
1549            Compression::default(),
1550        );
1551        fs::create_dir_all(store.objects_dir()).unwrap();
1552        let payload = vec![0u8; 100_000];
1553        let oid =
1554            WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1555                .unwrap();
1556        let path = store.object_path(&oid);
1557        let mut bytes = fs::read(&path).unwrap();
1558        bytes.truncate(bytes.len() / 2);
1559        #[cfg(unix)]
1560        {
1561            use std::os::unix::fs::PermissionsExt;
1562            fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1563        }
1564        fs::write(&path, &bytes).unwrap();
1565        let mut stream = ObjectStore::open_stream(&store, &oid)
1566            .unwrap()
1567            .expect("stream");
1568        let mut out = Vec::new();
1569        let err = stream.reader.read_to_end(&mut out).unwrap_err();
1570        assert!(
1571            err.to_string().contains("payload bytes remaining")
1572                || err.to_string().contains("corrupt object"),
1573            "unexpected error: {err}"
1574        );
1575        assert!(out.len() < payload.len());
1576    }
1577
1578    #[test]
1579    fn truncated_zlib_checksum_only_errors_on_read() {
1580        let dir = tempfile::tempdir().unwrap();
1581        let store = LooseStore::new(
1582            dir.path().join("objects"),
1583            HashAlgo::Sha1,
1584            Compression::default(),
1585        );
1586        fs::create_dir_all(store.objects_dir()).unwrap();
1587        let payload = vec![0u8; 100_000];
1588        let oid =
1589            WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1590                .unwrap();
1591        let path = store.object_path(&oid);
1592        let mut bytes = fs::read(&path).unwrap();
1593        assert!(bytes.len() > 1);
1594        bytes.pop();
1595        #[cfg(unix)]
1596        {
1597            use std::os::unix::fs::PermissionsExt;
1598            fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1599        }
1600        fs::write(&path, &bytes).unwrap();
1601        match ObjectStore::read(&store, &oid) {
1602            Ok(None) => {}
1603            Ok(Some(_)) => panic!("full read must not succeed on checksum-truncated loose object"),
1604            Err(_) => {}
1605        }
1606        let mut stream = ObjectStore::open_stream(&store, &oid)
1607            .expect("open_stream")
1608            .expect("stream hit");
1609        let mut out = Vec::new();
1610        let err = stream.reader.read_to_end(&mut out).unwrap_err();
1611        assert!(
1612            err.to_string().contains("trailer validation")
1613                || err.to_string().contains("corrupt object"),
1614            "unexpected error: {err}"
1615        );
1616    }
1617
1618    #[test]
1619    fn stream_sixteen_mib_peak_buffer_under_one_mib() {
1620        let dir = tempfile::tempdir().unwrap();
1621        let store = LooseStore::new(
1622            dir.path().join("objects"),
1623            HashAlgo::Sha1,
1624            Compression::default(),
1625        );
1626        fs::create_dir_all(store.objects_dir()).unwrap();
1627        let payload = vec![0xCDu8; 16 * 1024 * 1024];
1628        let oid =
1629            WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1630                .unwrap();
1631        let mut stream = ObjectStore::open_stream(&store, &oid)
1632            .unwrap()
1633            .expect("stream");
1634        assert_eq!(stream.size, payload.len() as u64);
1635        let mut out = Vec::new();
1636        let mut chunk = [0u8; 64 * 1024];
1637        let mut peak = 0usize;
1638        loop {
1639            let n = stream.reader.read(&mut chunk).unwrap();
1640            if n == 0 {
1641                break;
1642            }
1643            peak = peak.max(n);
1644            out.extend_from_slice(&chunk[..n]);
1645        }
1646        assert_eq!(out, payload);
1647        assert!(
1648            peak < 1024 * 1024,
1649            "peak in-flight buffer {peak} must stay under 1 MiB"
1650        );
1651    }
1652
1653    #[test]
1654    fn external_loose_write_in_fanout_visible_after_negative_cache() {
1655        use std::process::{Command, Stdio};
1656
1657        use crate::odb::Odb;
1658
1659        let dir = tempfile::tempdir().unwrap();
1660        let objects = dir.path().join("objects");
1661        fs::create_dir_all(&objects).unwrap();
1662        let odb = Odb::new(&objects);
1663        let payload = b"written externally into an existing fanout dir\n";
1664        let oid = odb.hash(ObjectKind::Blob, payload).unwrap();
1665        assert!(matches!(odb.read(&oid), Err(Error::ObjectNotFound(_))));
1666
1667        let mut child = Command::new("git")
1668            .args(["hash-object", "-w", "--stdin"])
1669            .stdin(Stdio::piped())
1670            .stdout(Stdio::piped())
1671            .env("GIT_OBJECT_DIRECTORY", &objects)
1672            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1673            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1674            .spawn()
1675            .expect("git hash-object");
1676        if let Some(mut stdin) = child.stdin.take() {
1677            use std::io::Write;
1678            stdin.write_all(payload).unwrap();
1679        }
1680        let out = child.wait_with_output().expect("git wait");
1681        assert!(
1682            out.status.success(),
1683            "git hash-object: {}",
1684            String::from_utf8_lossy(&out.stderr)
1685        );
1686        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), oid.to_hex());
1687
1688        let obj = odb
1689            .read(&oid)
1690            .expect("external loose write must be visible");
1691        assert_eq!(obj.data, payload);
1692    }
1693}