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