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::{
20    with_loose_scratch, LooseFileReadBudget, LOOSE_INFLATE_INPUT_CHUNK, LOOSE_INFLATE_OUTPUT_CHUNK,
21};
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        let (first, tail) = crate::odb::stream::read_first_payload_chunk(reader, size)?;
688        let compression =
689            crate::odb::stream::compression_after_first_chunk(&first, self.compression);
690        self.write_payload_stream_with(kind, size, options, compression, |chunk_sink| {
691            chunk_sink(&first)?;
692            if tail > 0 {
693                read_payload_chunks(reader, tail, chunk_sink)
694            } else {
695                Ok(())
696            }
697        })
698    }
699
700    /// Store a payload supplied chunk-by-chunk through `supply` (used by pack unpack).
701    pub(crate) fn write_payload_stream_with(
702        &self,
703        kind: ObjectKind,
704        size: u64,
705        options: WriteOptions,
706        compression: Compression,
707        mut supply: impl FnMut(&mut dyn FnMut(&[u8]) -> Result<()>) -> Result<()>,
708    ) -> Result<ObjectId> {
709        let prefix_dir = &self.objects_dir;
710        let mut hasher = ObjectPayloadHasher::new(self.hash_algo, kind, size);
711        let tmp_path = unique_loose_tmp_path(prefix_dir);
712        fs::create_dir_all(prefix_dir).map_err(Error::Io)?;
713        let file = fs::OpenOptions::new()
714            .write(true)
715            .create_new(true)
716            .open(&tmp_path)
717            .map_err(Error::Io)?;
718        let zlib_err = |e: io::Error| Error::Zlib(e.to_string());
719        let mut encoder = ZlibEncoder::new(file, compression);
720        if let Err(e) = (|| {
721            write!(encoder, "{} {}\0", kind, size).map_err(zlib_err)?;
722            supply(&mut |chunk| {
723                hasher.update(chunk);
724                encoder.write_all(chunk).map_err(zlib_err)
725            })?;
726            encoder.finish().map_err(zlib_err)?;
727            Ok::<(), Error>(())
728        })() {
729            let _ = fs::remove_file(&tmp_path);
730            return Err(e);
731        }
732        let oid = match hasher.finish() {
733            Ok(oid) => oid,
734            Err(e) => {
735                let _ = fs::remove_file(&tmp_path);
736                return Err(e);
737            }
738        };
739        if let Some(existing) = self.try_finish_if_loose_present(&oid, options)? {
740            let _ = fs::remove_file(&tmp_path);
741            return Ok(existing);
742        }
743        let path = self.object_path(&oid);
744        let dest_prefix = path
745            .parent()
746            .ok_or_else(|| Error::PathError("object path has no parent".to_owned()))?;
747        if let Err(e) = fs::create_dir_all(dest_prefix) {
748            let _ = fs::remove_file(&tmp_path);
749            return Err(Error::Io(e));
750        }
751        match fs::rename(&tmp_path, &path) {
752            Ok(()) => {
753                set_loose_object_mode(&path);
754                Ok(oid)
755            }
756            Err(e) => {
757                let _ = fs::remove_file(&tmp_path);
758                if path.is_file() {
759                    return Ok(oid);
760                }
761                Err(Error::Io(e))
762            }
763        }
764    }
765}
766
767/// Publish a loose object at `path`: `fill` writes the zlib stream into a new, uniquely
768/// named temp file in `prefix_dir`, which is then renamed into place.
769///
770/// Publishing is idempotent: when the rename fails but `path` exists, another writer
771/// published the same object and this returns `Ok`. A missing `prefix_dir` (not yet
772/// created, or pruned by a concurrent process) is recreated and the write retried; with
773/// `prefix_dir_ready` the first attempt skips the `create_dir_all`.
774///
775/// # Errors
776///
777/// Returns [`Error::Io`] when the temp file cannot be created or renamed after the
778/// retries, or whatever `fill` returns.
779fn publish_loose_object(
780    objects_dir: &Path,
781    path: &Path,
782    prefix_dir: &Path,
783    prefix_dir_ready: bool,
784    mut fill: impl FnMut(File) -> Result<()>,
785) -> Result<()> {
786    const MAX_ATTEMPTS: u32 = 4;
787    let mut attempt = 0;
788    loop {
789        attempt += 1;
790        let can_retry = attempt < MAX_ATTEMPTS;
791        if !prefix_dir_ready || attempt > 1 {
792            fs::create_dir_all(prefix_dir).map_err(|source| {
793                map_objects_dir_io(
794                    objects_dir,
795                    prefix_dir,
796                    GitDirPathOperation::CreateDir,
797                    source,
798                )
799            })?;
800        }
801        let tmp_path = unique_loose_tmp_path(prefix_dir);
802        let file = match fs::OpenOptions::new()
803            .write(true)
804            .create_new(true)
805            .open(&tmp_path)
806        {
807            Ok(file) => file,
808            Err(e)
809                if can_retry
810                    && matches!(
811                        e.kind(),
812                        io::ErrorKind::NotFound | io::ErrorKind::AlreadyExists
813                    ) =>
814            {
815                continue;
816            }
817            Err(e) => {
818                return Err(map_objects_dir_io(
819                    objects_dir,
820                    &tmp_path,
821                    GitDirPathOperation::WriteObject,
822                    e,
823                ));
824            }
825        };
826        if let Err(e) = fill(file) {
827            let _ = fs::remove_file(&tmp_path);
828            return Err(e);
829        }
830        match fs::rename(&tmp_path, path) {
831            Ok(()) => {
832                set_loose_object_mode(path);
833                return Ok(());
834            }
835            Err(e) => {
836                let _ = fs::remove_file(&tmp_path);
837                if path.is_file() {
838                    return Ok(());
839                }
840                if can_retry && e.kind() == io::ErrorKind::NotFound {
841                    continue;
842                }
843                return Err(map_objects_dir_io(
844                    objects_dir,
845                    path,
846                    GitDirPathOperation::WriteObject,
847                    e,
848                ));
849            }
850        }
851    }
852}
853
854fn set_loose_object_mode(path: &Path) {
855    #[cfg(unix)]
856    {
857        use std::os::unix::fs::PermissionsExt;
858        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o444));
859    }
860    let _ = path;
861}
862
863fn is_loose_suffix_for_algo(suffix: &str, algo: HashAlgo) -> bool {
864    suffix.len() == algo.hex_len() - 2 && suffix.bytes().all(|b| b.is_ascii_hexdigit())
865}
866
867/// Incremental zlib reader yielding only the object payload (after the Git header).
868struct LoosePayloadReader {
869    file: File,
870    read_budget: LooseFileReadBudget,
871    dec: Decompress,
872    /// Compressed input read from `file`; bytes before `pending_pos` are already consumed.
873    pending: Vec<u8>,
874    pending_pos: usize,
875    /// All compressed input from the file has been read.
876    input_exhausted: bool,
877    /// Inflater reached `Status::StreamEnd` (payload + zlib trailer validated).
878    zlib_complete: bool,
879    preset_dictionary: bool,
880    kind: ObjectKind,
881    payload_size: u64,
882    payload_remaining: u64,
883    phase: LooseReadPhase,
884    header_buf: Vec<u8>,
885    /// Payload bytes already inflated while reading the header (after the NUL).
886    extra_payload: Vec<u8>,
887}
888
889enum LooseReadPhase {
890    Header,
891    Payload,
892}
893
894impl LoosePayloadReader {
895    fn open(mut file: File) -> Result<Self> {
896        let read_budget = LooseFileReadBudget::new(&file)?;
897        let mut hdr = [0u8; 2];
898        file.read_exact(&mut hdr).map_err(Error::Io)?;
899        let cmf_flg = u16::from(hdr[0]) << 8 | u16::from(hdr[1]);
900        let looks_like_zlib_header = cmf_flg != 0 && cmf_flg % 31 == 0;
901        let preset_dictionary = looks_like_zlib_header && (hdr[1] & 0x20) != 0;
902        let mut read_budget = read_budget;
903        read_budget.note_consumed(2);
904        let mut reader = Self {
905            file,
906            read_budget,
907            dec: Decompress::new(true),
908            pending: hdr.to_vec(),
909            pending_pos: 0,
910            input_exhausted: false,
911            zlib_complete: false,
912            preset_dictionary,
913            kind: ObjectKind::Blob,
914            payload_size: 0,
915            payload_remaining: 0,
916            phase: LooseReadPhase::Header,
917            header_buf: Vec::with_capacity(64),
918            extra_payload: Vec::new(),
919        };
920        reader.parse_header()?;
921        Ok(reader)
922    }
923
924    fn parse_header(&mut self) -> Result<()> {
925        let mut header_buf = std::mem::take(&mut self.header_buf);
926        loop {
927            self.pump_zlib(&mut |bytes| {
928                header_buf.extend_from_slice(bytes);
929            })?;
930            if header_buf.contains(&0) || header_buf.len() >= 128 {
931                break;
932            }
933            if self.input_exhausted && self.pending_pos == self.pending.len() {
934                break;
935            }
936        }
937        let nul = header_buf
938            .iter()
939            .position(|&b| b == 0)
940            .ok_or_else(|| Error::CorruptObject("missing NUL in object header".to_owned()))?;
941        let (kind, size) = parse_object_header_prefix(&header_buf[..=nul])?;
942        self.extra_payload = header_buf[nul + 1..].to_vec();
943        self.header_buf = header_buf;
944        self.kind = kind;
945        self.payload_size = size;
946        self.payload_remaining = size;
947        self.phase = LooseReadPhase::Payload;
948        Ok(())
949    }
950
951    fn pump_zlib(&mut self, on_out: &mut dyn FnMut(&[u8])) -> Result<()> {
952        let mut scratch = [0u8; LOOSE_INFLATE_OUTPUT_CHUNK];
953        let produced = self.inflate_into(&mut scratch)?;
954        if produced > 0 {
955            on_out(&scratch[..produced]);
956        }
957        Ok(())
958    }
959
960    /// Inflate into `out`, reading more compressed input when the pending input is used up.
961    ///
962    /// Returns the bytes produced, which is zero once the zlib stream has ended.
963    fn inflate_into(&mut self, out: &mut [u8]) -> Result<usize> {
964        if self.zlib_complete {
965            return Ok(0);
966        }
967        let cap = out.len().min(LOOSE_INFLATE_OUTPUT_CHUNK);
968        let out = &mut out[..cap];
969        if self.pending_pos == self.pending.len() && !self.input_exhausted {
970            let mut buf = [0u8; LOOSE_INFLATE_INPUT_CHUNK];
971            let n = self.read_budget.read(&mut self.file, &mut buf)?;
972            self.pending.clear();
973            self.pending.extend_from_slice(&buf[..n]);
974            self.pending_pos = 0;
975            self.input_exhausted = n == 0;
976        }
977        let flush = if self.input_exhausted && self.pending_pos == self.pending.len() {
978            FlushDecompress::Finish
979        } else {
980            FlushDecompress::None
981        };
982        let before_in = self.dec.total_in();
983        let before_out = self.dec.total_out();
984        let status = match self
985            .dec
986            .decompress(&self.pending[self.pending_pos..], out, flush)
987        {
988            Ok(s) => s,
989            Err(e) => {
990                if self.preset_dictionary {
991                    return Err(Error::Zlib("needs dictionary".to_owned()));
992                }
993                return Err(Error::Zlib(e.to_string()));
994            }
995        };
996        // Both are bounded by the slices just passed to the inflater.
997        self.pending_pos += (self.dec.total_in() - before_in) as usize;
998        let produced = (self.dec.total_out() - before_out) as usize;
999        if matches!(status, Status::StreamEnd) {
1000            self.zlib_complete = true;
1001        }
1002        Ok(produced)
1003    }
1004
1005    /// After the declared payload is delivered, drain the zlib trailer and reject truncation.
1006    fn ensure_zlib_complete(&mut self) -> io::Result<()> {
1007        if self.zlib_complete {
1008            return Ok(());
1009        }
1010        if !self.extra_payload.is_empty() {
1011            return Err(io::Error::other(
1012                "corrupt object: inflated bytes remain after declared payload size",
1013            ));
1014        }
1015        loop {
1016            let mut excess = 0usize;
1017            self.pump_zlib(&mut |bytes| {
1018                excess += bytes.len();
1019            })
1020            .map_err(|e| io::Error::other(e.to_string()))?;
1021            if excess > 0 {
1022                return Err(io::Error::other(
1023                    "corrupt object: excess decompressed data after declared payload size",
1024                ));
1025            }
1026            if self.zlib_complete {
1027                return Ok(());
1028            }
1029            if self.input_exhausted && self.pending_pos == self.pending.len() {
1030                return Err(io::Error::other(
1031                    "corrupt object: zlib stream ended before trailer validation",
1032                ));
1033            }
1034        }
1035    }
1036}
1037
1038impl Read for LoosePayloadReader {
1039    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1040        if !matches!(self.phase, LooseReadPhase::Payload) {
1041            return Ok(0);
1042        }
1043        if self.payload_remaining == 0 {
1044            self.ensure_zlib_complete()?;
1045            return Ok(0);
1046        }
1047        if buf.is_empty() {
1048            return Ok(0);
1049        }
1050        let mut delivered = 0usize;
1051        while delivered < buf.len() && self.payload_remaining > 0 {
1052            if !self.extra_payload.is_empty() {
1053                let take = self
1054                    .extra_payload
1055                    .len()
1056                    .min(buf.len() - delivered)
1057                    .min(self.payload_remaining as usize);
1058                buf[delivered..delivered + take].copy_from_slice(&self.extra_payload[..take]);
1059                self.extra_payload.drain(..take);
1060                delivered += take;
1061                self.payload_remaining -= take as u64;
1062                continue;
1063            }
1064            let want = usize::try_from(self.payload_remaining)
1065                .map_or(buf.len() - delivered, |r| r.min(buf.len() - delivered));
1066            let produced = self
1067                .inflate_into(&mut buf[delivered..delivered + want])
1068                .map_err(|e| io::Error::other(e.to_string()))?;
1069            if produced == 0 {
1070                if self.zlib_complete
1071                    || (self.input_exhausted && self.pending_pos == self.pending.len())
1072                {
1073                    return Err(io::Error::other(format!(
1074                        "corrupt object: zlib stream ended with {} payload bytes remaining",
1075                        self.payload_remaining
1076                    )));
1077                }
1078                continue;
1079            }
1080            delivered += produced;
1081            self.payload_remaining -= produced as u64;
1082        }
1083        if self.payload_remaining == 0 {
1084            self.ensure_zlib_complete()?;
1085        }
1086        Ok(delivered)
1087    }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093    use std::io::Read;
1094
1095    use super::super::{ObjectStore, WritableObjectStore};
1096
1097    const READ_CHUNK: usize = LOOSE_INFLATE_INPUT_CHUNK;
1098
1099    #[test]
1100    fn lookup_prefix_scans_single_fanout_dir() {
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        let oid =
1109            WritableObjectStore::write(&store, ObjectKind::Blob, b"x", WriteOptions::default())
1110                .unwrap();
1111        let mut out = Vec::new();
1112        ObjectStore::lookup_prefix(&store, &oid.to_hex()[..4], 0, &mut out).unwrap();
1113        assert_eq!(out, vec![oid]);
1114    }
1115
1116    fn temp_store() -> (tempfile::TempDir, LooseStore) {
1117        let dir = tempfile::tempdir().unwrap();
1118        let store = LooseStore::new(
1119            dir.path().join("objects"),
1120            HashAlgo::Sha1,
1121            Compression::default(),
1122        );
1123        fs::create_dir_all(store.objects_dir()).unwrap();
1124        (dir, store)
1125    }
1126
1127    fn tmp_entries(store: &LooseStore) -> Vec<PathBuf> {
1128        fs::read_dir(store.objects_dir())
1129            .unwrap()
1130            .flatten()
1131            .filter(|shard| shard.file_type().is_ok_and(|t| t.is_dir()))
1132            .flat_map(|shard| fs::read_dir(shard.path()).unwrap().flatten())
1133            .map(|entry| entry.path())
1134            .filter(|path| {
1135                path.file_name()
1136                    .is_some_and(|n| n.to_string_lossy().starts_with("tmp_"))
1137            })
1138            .collect()
1139    }
1140
1141    #[test]
1142    fn identical_writes_through_separate_handles_all_succeed() {
1143        let (_dir, store) = temp_store();
1144        let threads = 8;
1145        let barrier = std::sync::Arc::new(std::sync::Barrier::new(threads));
1146        let payloads: Vec<Vec<u8>> = (0..200)
1147            .map(|j| format!("same {j}").into_bytes())
1148            .chain(std::iter::once(vec![0x5a; 1024 * 1024]))
1149            .collect();
1150        let payloads = std::sync::Arc::new(payloads);
1151        let handles: Vec<_> = (0..threads)
1152            .map(|_| {
1153                let store = store.clone();
1154                let barrier = std::sync::Arc::clone(&barrier);
1155                let payloads = std::sync::Arc::clone(&payloads);
1156                std::thread::spawn(move || {
1157                    barrier.wait();
1158                    payloads
1159                        .iter()
1160                        .map(|data| {
1161                            WritableObjectStore::write(
1162                                &store,
1163                                ObjectKind::Blob,
1164                                data,
1165                                WriteOptions::default(),
1166                            )
1167                        })
1168                        .collect::<Vec<_>>()
1169                })
1170            })
1171            .collect();
1172        for handle in handles {
1173            for (data, result) in payloads.iter().zip(handle.join().unwrap()) {
1174                let oid = result.expect("concurrent identical write succeeds");
1175                let obj = ObjectStore::read(&store, &oid).unwrap().expect("object");
1176                assert!(obj.data == *data);
1177            }
1178        }
1179        assert!(tmp_entries(&store).is_empty());
1180    }
1181
1182    #[test]
1183    fn prehashed_write_recreates_pruned_fanout_dir_marked_ready() {
1184        let (_dir, store) = temp_store();
1185        let data = b"fanout pruned under a ready writer";
1186        let oid = hash::hash_object(HashAlgo::Sha1, ObjectKind::Blob, data).unwrap();
1187        let zlib = crate::odb::zlib_compress_blob_loose(data, Compression::default()).unwrap();
1188        let options = WriteOptions {
1189            loose_prefix_dirs_ready: true,
1190            ..WriteOptions::default()
1191        };
1192        assert!(!store.object_path(&oid).parent().unwrap().exists());
1193        let written = store.write_zlib_prehashed(&oid, &zlib, options).unwrap();
1194        assert_eq!(written, oid);
1195        assert_eq!(ObjectStore::read(&store, &oid).unwrap().unwrap().data, data);
1196        assert!(tmp_entries(&store).is_empty());
1197    }
1198
1199    #[test]
1200    fn unique_tmp_paths_differ_and_are_not_loose_names() {
1201        let prefix = Path::new("objects/ab");
1202        let a = unique_loose_tmp_path(prefix);
1203        let b = unique_loose_tmp_path(prefix);
1204        assert_ne!(a, b);
1205        for path in [a, b] {
1206            let name = path.file_name().unwrap().to_str().unwrap();
1207            assert!(name.starts_with("tmp_obj_"), "{name}");
1208            assert!(!is_loose_suffix_for_algo(name, HashAlgo::Sha1));
1209            assert!(!is_loose_suffix_for_algo(name, HashAlgo::Sha256));
1210        }
1211    }
1212
1213    #[test]
1214    fn read_info_and_read_agree_for_object_spanning_read_chunks() {
1215        let (_dir, store) = temp_store();
1216        let data: Vec<u8> = (0..3 * READ_CHUNK as u32)
1217            .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
1218            .collect();
1219        let oid =
1220            WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1221                .unwrap();
1222        let info = ObjectStore::read_info(&store, &oid).unwrap().expect("info");
1223        assert_eq!(info.kind, ObjectKind::Blob);
1224        assert_eq!(info.size, data.len() as u64);
1225        let obj = ObjectStore::read(&store, &oid).unwrap().expect("object");
1226        assert_eq!(obj.kind, ObjectKind::Blob);
1227        assert_eq!(obj.data, data);
1228        let mut buf = Vec::new();
1229        let kind = ObjectStore::read_into(&store, &oid, &mut buf).unwrap();
1230        assert_eq!(kind, Some(ObjectKind::Blob));
1231        assert_eq!(buf, data);
1232    }
1233
1234    #[test]
1235    fn read_unless_larger_reads_small_and_skips_large_objects() {
1236        let (_dir, store) = temp_store();
1237        let data = vec![b'x'; 3 * READ_CHUNK];
1238        let oid =
1239            WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1240                .unwrap();
1241        match store.read_unless_larger(&oid, data.len() as u64).unwrap() {
1242            Some(BoundedRead::Object(obj)) => {
1243                assert_eq!(obj.kind, ObjectKind::Blob);
1244                assert_eq!(obj.data, data);
1245            }
1246            other => panic!("expected the object, got {other:?}"),
1247        }
1248        assert!(matches!(
1249            store
1250                .read_unless_larger(&oid, data.len() as u64 - 1)
1251                .unwrap(),
1252            Some(BoundedRead::Larger)
1253        ));
1254        let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").unwrap();
1255        assert!(store.read_unless_larger(&missing, 0).unwrap().is_none());
1256    }
1257
1258    #[test]
1259    fn stream_reader_delivers_payload_through_tiny_and_huge_buffers() {
1260        let (_dir, store) = temp_store();
1261        let data: Vec<u8> = (0..5 * READ_CHUNK as u32)
1262            .map(|i| (i.wrapping_mul(2_654_435_761) >> 11) as u8)
1263            .collect();
1264        let oid =
1265            WritableObjectStore::write(&store, ObjectKind::Blob, &data, WriteOptions::default())
1266                .unwrap();
1267        for chunk_len in [1usize, 7, 4096, 10 * READ_CHUNK] {
1268            let mut stream = ObjectStore::open_stream(&store, &oid)
1269                .unwrap()
1270                .expect("stream");
1271            let mut out = Vec::new();
1272            let mut chunk = vec![0u8; chunk_len];
1273            loop {
1274                let n = stream.reader.read(&mut chunk).unwrap();
1275                if n == 0 {
1276                    break;
1277                }
1278                out.extend_from_slice(&chunk[..n]);
1279            }
1280            assert!(out == data, "chunk length {chunk_len}");
1281        }
1282    }
1283
1284    #[test]
1285    fn read_info_is_none_for_missing_object_or_directory_at_its_path() {
1286        let (_dir, store) = temp_store();
1287        let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").unwrap();
1288        assert!(ObjectStore::read_info(&store, &missing).unwrap().is_none());
1289        fs::create_dir_all(store.object_path(&missing)).unwrap();
1290        assert!(ObjectStore::read_info(&store, &missing).unwrap().is_none());
1291    }
1292
1293    #[test]
1294    fn lookup_prefix_one_hex_char_does_not_overflow() {
1295        let dir = tempfile::tempdir().unwrap();
1296        let store = LooseStore::new(
1297            dir.path().join("objects"),
1298            HashAlgo::Sha1,
1299            Compression::default(),
1300        );
1301        fs::create_dir_all(store.objects_dir()).unwrap();
1302        let oid =
1303            WritableObjectStore::write(&store, ObjectKind::Blob, b"x", WriteOptions::default())
1304                .unwrap();
1305        let mut out = Vec::new();
1306        ObjectStore::lookup_prefix(&store, &oid.to_hex()[..1], 0, &mut out).unwrap();
1307        assert!(out.contains(&oid));
1308    }
1309
1310    #[test]
1311    fn truncated_zlib_stream_errors_on_read() {
1312        let dir = tempfile::tempdir().unwrap();
1313        let store = LooseStore::new(
1314            dir.path().join("objects"),
1315            HashAlgo::Sha1,
1316            Compression::default(),
1317        );
1318        fs::create_dir_all(store.objects_dir()).unwrap();
1319        let payload = vec![0u8; 100_000];
1320        let oid =
1321            WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1322                .unwrap();
1323        let path = store.object_path(&oid);
1324        let mut bytes = fs::read(&path).unwrap();
1325        bytes.truncate(bytes.len() / 2);
1326        #[cfg(unix)]
1327        {
1328            use std::os::unix::fs::PermissionsExt;
1329            fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1330        }
1331        fs::write(&path, &bytes).unwrap();
1332        let mut stream = ObjectStore::open_stream(&store, &oid)
1333            .unwrap()
1334            .expect("stream");
1335        let mut out = Vec::new();
1336        let err = stream.reader.read_to_end(&mut out).unwrap_err();
1337        assert!(
1338            err.to_string().contains("payload bytes remaining")
1339                || err.to_string().contains("corrupt object"),
1340            "unexpected error: {err}"
1341        );
1342        assert!(out.len() < payload.len());
1343    }
1344
1345    #[test]
1346    fn truncated_zlib_checksum_only_errors_on_read() {
1347        let dir = tempfile::tempdir().unwrap();
1348        let store = LooseStore::new(
1349            dir.path().join("objects"),
1350            HashAlgo::Sha1,
1351            Compression::default(),
1352        );
1353        fs::create_dir_all(store.objects_dir()).unwrap();
1354        let payload = vec![0u8; 100_000];
1355        let oid =
1356            WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1357                .unwrap();
1358        let path = store.object_path(&oid);
1359        let mut bytes = fs::read(&path).unwrap();
1360        assert!(bytes.len() > 1);
1361        bytes.pop();
1362        #[cfg(unix)]
1363        {
1364            use std::os::unix::fs::PermissionsExt;
1365            fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1366        }
1367        fs::write(&path, &bytes).unwrap();
1368        match ObjectStore::read(&store, &oid) {
1369            Ok(None) => {}
1370            Ok(Some(_)) => panic!("full read must not succeed on checksum-truncated loose object"),
1371            Err(_) => {}
1372        }
1373        let mut stream = ObjectStore::open_stream(&store, &oid)
1374            .expect("open_stream")
1375            .expect("stream hit");
1376        let mut out = Vec::new();
1377        let err = stream.reader.read_to_end(&mut out).unwrap_err();
1378        assert!(
1379            err.to_string().contains("trailer validation")
1380                || err.to_string().contains("corrupt object"),
1381            "unexpected error: {err}"
1382        );
1383    }
1384
1385    #[test]
1386    fn stream_sixteen_mib_peak_buffer_under_one_mib() {
1387        let dir = tempfile::tempdir().unwrap();
1388        let store = LooseStore::new(
1389            dir.path().join("objects"),
1390            HashAlgo::Sha1,
1391            Compression::default(),
1392        );
1393        fs::create_dir_all(store.objects_dir()).unwrap();
1394        let payload = vec![0xCDu8; 16 * 1024 * 1024];
1395        let oid =
1396            WritableObjectStore::write(&store, ObjectKind::Blob, &payload, WriteOptions::default())
1397                .unwrap();
1398        let mut stream = ObjectStore::open_stream(&store, &oid)
1399            .unwrap()
1400            .expect("stream");
1401        assert_eq!(stream.size, payload.len() as u64);
1402        let mut out = Vec::new();
1403        let mut chunk = [0u8; 64 * 1024];
1404        let mut peak = 0usize;
1405        loop {
1406            let n = stream.reader.read(&mut chunk).unwrap();
1407            if n == 0 {
1408                break;
1409            }
1410            peak = peak.max(n);
1411            out.extend_from_slice(&chunk[..n]);
1412        }
1413        assert_eq!(out, payload);
1414        assert!(
1415            peak < 1024 * 1024,
1416            "peak in-flight buffer {peak} must stay under 1 MiB"
1417        );
1418    }
1419}