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