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