Skip to main content

grit_lib/
pack_map.rs

1//! Memory-mapped pack (and MIDX) file bytes with an owned-buffer fallback.
2//!
3//! This module is the only place that uses `unsafe` for pack backing storage.
4
5use crate::error::{Error, Result};
6use crate::objects::HashAlgo;
7use memmap2::{Mmap, MmapOptions};
8use std::fs::File;
9use std::io::{Read, Seek, SeekFrom};
10use std::ops::Deref;
11use std::path::Path;
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::sync::Arc;
14
15const PACK_SIGNATURE: &[u8; 4] = b"PACK";
16const HEADER_LEN: usize = 12;
17
18/// Files at or below this size are read into an owned `Vec` (tiny packs, tests,
19/// platforms or filesystems where mapping fails).
20const OWNED_READ_THRESHOLD: u64 = 8192;
21
22/// Header plus trailing pack checksum — enough to detect on-disk replacement when
23/// mtime/size are unchanged (same-second repack) without re-reading the whole file.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub(crate) struct PackFingerprint {
26    /// `PACK`, version, and object count (`12` bytes).
27    pub header: [u8; HEADER_LEN],
28    /// Trailer width in bytes (20 for SHA-1, 32 for SHA-256).
29    pub hash_bytes: u8,
30    /// Pack trailer hash (only the first `hash_bytes` entries are meaningful).
31    pub trailer: [u8; 32],
32}
33
34enum Storage {
35    Mapped(Mmap),
36    Owned(Vec<u8>),
37}
38
39/// Immutable bytes of a `.pack`, pack index, commit-graph, MIDX, or `packed-refs` file.
40///
41/// # Safety (memory-mapped storage)
42///
43/// Pack, index, MIDX, and `packed-refs` files are immutable at a stable path: grit
44/// and Git publish updates by writing a temporary file and renaming it into place,
45/// so active readers never map a file that is being truncated in place. If a pack
46/// were truncated while mapped, reads could fault or return stale bytes — the same
47/// risk Git accepts for memory-mapped packfiles. `open` uses a single file
48/// descriptor and `fstat`s that handle before mapping so length and inode stay paired.
49pub struct PackData {
50    storage: Storage,
51}
52
53impl std::fmt::Debug for PackData {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("PackData")
56            .field("len", &self.len())
57            .field(
58                "backing",
59                &match &self.storage {
60                    Storage::Mapped(_) => "mapped",
61                    Storage::Owned(_) => "owned",
62                },
63            )
64            .finish()
65    }
66}
67
68impl Deref for PackData {
69    type Target = [u8];
70
71    fn deref(&self) -> &[u8] {
72        match &self.storage {
73            Storage::Mapped(m) => m.deref(),
74            Storage::Owned(v) => v.as_slice(),
75        }
76    }
77}
78
79impl PackData {
80    /// Map `path` read-only, or read it into an owned buffer when small or mapping fails.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`Error::Io`] when the file cannot be opened or read, and
85    /// [`Error::CorruptObject`] when the file is shorter than a pack header.
86    pub(crate) fn open(path: &Path) -> Result<Arc<Self>> {
87        Self::open_with_min_len(path, HEADER_LEN as u64)
88    }
89
90    /// Map or read `path`, requiring at least `min_len` bytes (pack header vs index fanout).
91    pub(crate) fn open_with_min_len(path: &Path, min_len: u64) -> Result<Arc<Self>> {
92        let file = File::open(path).map_err(Error::Io)?;
93        Self::open_from_file(file, min_len)
94    }
95
96    fn open_from_file(file: File, min_len: u64) -> Result<Arc<Self>> {
97        let len = file.metadata().map_err(Error::Io)?.len();
98        if len < min_len {
99            return Err(Error::CorruptObject(format!(
100                "file is too small ({len} bytes, need at least {min_len})"
101            )));
102        }
103        let len_usize = usize::try_from(len).map_err(|_| {
104            Error::CorruptObject(format!(
105                "pack file length {len} does not fit in address space"
106            ))
107        })?;
108        if len <= OWNED_READ_THRESHOLD {
109            let mut data = Vec::with_capacity(len_usize);
110            let mut reader = file;
111            reader.read_to_end(&mut data).map_err(Error::Io)?;
112            return Ok(Arc::new(Self {
113                storage: Storage::Owned(data),
114            }));
115        }
116        match map_read_only(&file, len_usize) {
117            Ok(mmap) => Ok(Arc::new(Self {
118                storage: Storage::Mapped(mmap),
119            })),
120            Err(_) => {
121                let mut data = Vec::with_capacity(len_usize);
122                let mut reader = file;
123                reader.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
124                reader.read_to_end(&mut data).map_err(Error::Io)?;
125                Ok(Arc::new(Self {
126                    storage: Storage::Owned(data),
127                }))
128            }
129        }
130    }
131
132    /// Wrap an owned buffer (tests, small index files, and stale-byte injection).
133    #[must_use]
134    pub(crate) fn from_owned(data: Vec<u8>) -> Arc<Self> {
135        Arc::new(Self {
136            storage: Storage::Owned(data),
137        })
138    }
139
140    /// Drop this mapping's pages from the process's resident set.
141    ///
142    /// The bytes stay valid: later reads fault the pages back in from the file (usually
143    /// straight from the page cache). Owned buffers and non-Unix platforms are left as is.
144    pub(crate) fn release_resident(&self) {
145        #[cfg(unix)]
146        if let Storage::Mapped(map) = &self.storage {
147            // SAFETY: the mapping is a read-only shared file mapping and the file is never
148            // modified while mapped (see the type docs), so dropping its pages only makes
149            // the next read reload the same bytes from the file; no borrow observes a change.
150            let _ = unsafe {
151                map.unchecked_advise_range(memmap2::UncheckedAdvice::DontNeed, 0, map.len())
152            };
153        }
154    }
155
156    /// True when backing storage is a memory map (not an owned buffer).
157    #[cfg(test)]
158    pub(crate) fn is_mapped_backing(&self) -> bool {
159        matches!(self.storage, Storage::Mapped(_))
160    }
161
162    /// Fingerprint of the bytes currently held in memory.
163    ///
164    /// `hash_bytes` is the repository object-id / pack-trailer width (from the `.idx` or
165    /// repo format), not the pack header version field.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`Error::CorruptObject`] when the buffer is not a well-formed pack prefix/trailer.
170    pub(crate) fn fingerprint(&self, hash_bytes: usize) -> Result<PackFingerprint> {
171        fingerprint_from_slice(self, hash_bytes)
172    }
173}
174
175/// Caps how much of a memory-mapped pack a sequential pass over it keeps resident.
176///
177/// Readers [`charge`](Self::charge) the bytes they read; once the charges reach the
178/// limit, the whole mapping's pages are dropped and counting starts over. Charging is
179/// lock-free and shared by all worker threads. Meant for passes that move forward
180/// through the pack: scattered small reads map whole fault-around windows each, so
181/// they are better served by positioned file reads than by a budget.
182pub(crate) struct ResidentBudget<'a> {
183    pack: Option<&'a PackData>,
184    limit: usize,
185    charged: AtomicUsize,
186}
187
188impl<'a> ResidentBudget<'a> {
189    /// A budget that never releases (the caller owns the bytes, or they are not mapped).
190    #[must_use]
191    pub(crate) fn unbounded() -> Self {
192        Self {
193            pack: None,
194            limit: usize::MAX,
195            charged: AtomicUsize::new(0),
196        }
197    }
198
199    /// Keep at most about `limit` bytes of `pack` resident.
200    #[must_use]
201    pub(crate) fn new(pack: &'a PackData, limit: usize) -> Self {
202        Self {
203            pack: Some(pack),
204            limit,
205            charged: AtomicUsize::new(0),
206        }
207    }
208
209    /// Record a read of `bytes` from the pack, releasing its pages when the limit is reached.
210    pub(crate) fn charge(&self, bytes: usize) {
211        let Some(pack) = self.pack else {
212            return;
213        };
214        let before = self.charged.fetch_add(bytes, Ordering::Relaxed);
215        if before.saturating_add(bytes) < self.limit {
216            return;
217        }
218        // Of several threads crossing the limit together, only the one that resets wins.
219        if self.charged.swap(0, Ordering::Relaxed) >= self.limit {
220            pack.release_resident();
221        }
222    }
223
224    /// Checksum `bytes` with `algo`, charging as it goes so a full pass stays within budget.
225    pub(crate) fn checksum(&self, algo: HashAlgo, bytes: &[u8]) -> Vec<u8> {
226        const CHUNK: usize = 1024 * 1024;
227        let mut hasher = algo.checksum_hasher();
228        for chunk in bytes.chunks(CHUNK) {
229            hasher.update(chunk);
230            self.charge(chunk.len());
231        }
232        hasher.finalize()
233    }
234}
235
236fn map_read_only(file: &File, len: usize) -> std::io::Result<Mmap> {
237    // SAFETY: the mapped file is only read through `&[u8]`; pack/MIDX paths are
238    // replaced atomically via rename, not truncated in place (see module docs).
239    // Length comes from fstat on this same `File` (see `open_from_file`).
240    unsafe { MmapOptions::new().len(len).map(file) }
241}
242
243/// Read only the pack header and trailer from disk for comparison with cached bytes.
244///
245/// # Errors
246///
247/// Returns [`Error::Io`] on read failure and [`Error::CorruptObject`] for malformed packs.
248pub(crate) fn fingerprint_from_file(path: &Path, hash_bytes: usize) -> Result<PackFingerprint> {
249    let mut file = File::open(path).map_err(Error::Io)?;
250    let file_len = file.metadata().map_err(Error::Io)?.len();
251    read_fingerprint_from_open_file(&mut file, file_len, hash_bytes)
252}
253
254fn read_fingerprint_from_open_file(
255    file: &mut File,
256    file_len: u64,
257    hash_bytes: usize,
258) -> Result<PackFingerprint> {
259    if hash_bytes != 20 && hash_bytes != 32 {
260        return Err(Error::CorruptObject(format!(
261            "unsupported pack trailer width {hash_bytes}"
262        )));
263    }
264    let mut header = [0u8; HEADER_LEN];
265    file.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
266    file.read_exact(&mut header).map_err(Error::Io)?;
267    validate_pack_header(&header)?;
268    if file_len < u64::try_from(HEADER_LEN + hash_bytes).unwrap_or(u64::MAX) {
269        return Err(Error::CorruptObject(
270            "pack too small for trailer".to_owned(),
271        ));
272    }
273    let mut trailer = [0u8; 32];
274    file.seek(SeekFrom::Start(file_len - hash_bytes as u64))
275        .map_err(Error::Io)?;
276    file.read_exact(&mut trailer[..hash_bytes])
277        .map_err(Error::Io)?;
278    Ok(PackFingerprint {
279        header,
280        hash_bytes: hash_bytes as u8,
281        trailer,
282    })
283}
284
285pub(crate) fn fingerprint_from_slice(bytes: &[u8], hash_bytes: usize) -> Result<PackFingerprint> {
286    if hash_bytes != 20 && hash_bytes != 32 {
287        return Err(Error::CorruptObject(format!(
288            "unsupported pack trailer width {hash_bytes}"
289        )));
290    }
291    if bytes.len() < HEADER_LEN {
292        return Err(Error::CorruptObject("pack too small".to_owned()));
293    }
294    let mut header = [0u8; HEADER_LEN];
295    header.copy_from_slice(&bytes[..HEADER_LEN]);
296    validate_pack_header(&header)?;
297    if bytes.len() < HEADER_LEN + hash_bytes {
298        return Err(Error::CorruptObject(
299            "pack too small for trailer".to_owned(),
300        ));
301    }
302    let mut trailer = [0u8; 32];
303    trailer[..hash_bytes].copy_from_slice(&bytes[bytes.len() - hash_bytes..]);
304    Ok(PackFingerprint {
305        header,
306        hash_bytes: hash_bytes as u8,
307        trailer,
308    })
309}
310
311fn validate_pack_header(header: &[u8; HEADER_LEN]) -> Result<()> {
312    if header[..4] != *PACK_SIGNATURE {
313        return Err(Error::CorruptObject("bad pack signature".to_owned()));
314    }
315    let version = u32::from_be_bytes(header[4..8].try_into().unwrap_or([0; 4]));
316    if version != 2 && version != 3 {
317        return Err(Error::CorruptObject(format!(
318            "unsupported pack version {version}"
319        )));
320    }
321    Ok(())
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use std::fs;
328    use std::io::Write;
329
330    #[test]
331    fn owned_open_roundtrip() {
332        let dir = tempfile::tempdir().expect("tempdir");
333        let path = dir.path().join("tiny.pack");
334        fs::write(&path, b"PACK\x00\x00\x00\x02\x00\x00\x00\x00").expect("write");
335        let data = PackData::open(&path).expect("open");
336        assert_eq!(&data[..], &b"PACK\x00\x00\x00\x02\x00\x00\x00\x00"[..]);
337    }
338
339    #[test]
340    fn fingerprint_detects_trailer_change() {
341        let dir = tempfile::tempdir().expect("tempdir");
342        let path = dir.path().join("p.pack");
343        let mut pack = Vec::from(b"PACK\x00\x00\x00\x02\x00\x00\x00\x01" as &[u8]);
344        pack.extend_from_slice(&[0u8; 20]);
345        pack.extend_from_slice(b"aaaaaaaaaaaaaaaaaaaa");
346        fs::write(&path, &pack).expect("write");
347        let fp1 = fingerprint_from_file(&path, 20).expect("fp1");
348        let last = pack.len() - 1;
349        pack[last] = b'b';
350        fs::write(&path, &pack).expect("rewrite");
351        let fp2 = fingerprint_from_file(&path, 20).expect("fp2");
352        assert_ne!(fp1, fp2);
353    }
354
355    #[test]
356    fn fingerprint_sha256_trailer_on_pack_version_two() {
357        let dir = tempfile::tempdir().expect("tempdir");
358        let path = dir.path().join("sha256.pack");
359        let mut pack = Vec::from(b"PACK\x00\x00\x00\x02\x00\x00\x00\x00" as &[u8]);
360        let trailer = [0xAB_u8; 32];
361        pack.extend_from_slice(&trailer);
362        fs::write(&path, &pack).expect("write");
363        let fp = fingerprint_from_file(&path, 32).expect("fp");
364        assert_eq!(&fp.trailer[..fp.hash_bytes as usize], &trailer[..]);
365        assert_eq!(fp.header[4..8], 2u32.to_be_bytes());
366    }
367
368    #[test]
369    fn fingerprint_rejects_invalid_trailer_width_and_malformed_header() {
370        let tiny = b"PACK\x00\x00\x00\x02\x00\x00\x00\x00";
371        let owned = PackData::from_owned(tiny.to_vec());
372        assert!(owned.fingerprint(19).is_err());
373        assert!(owned.fingerprint(33).is_err());
374        assert!(fingerprint_from_file(
375            &tempfile::tempdir().expect("d").path().join("missing.pack"),
376            20
377        )
378        .is_err());
379
380        let dir = tempfile::tempdir().expect("tempdir");
381        let path = dir.path().join("bad.pack");
382        fs::write(&path, b"NOTPACK\x00\x00\x00\x02\x00\x00\x00\x00").expect("write");
383        let err = fingerprint_from_file(&path, 20).expect_err("bad sig");
384        assert!(matches!(err, Error::CorruptObject(_)));
385
386        fs::write(&path, b"PACK\x00\x00\x00\x09\x00\x00\x00\x00").expect("bad ver");
387        let err = fingerprint_from_file(&path, 20).expect_err("bad ver");
388        assert!(matches!(err, Error::CorruptObject(_)));
389
390        fs::write(&path, b"PACK\x00\x00\x00\x02").expect("trunc");
391        assert!(matches!(
392            PackData::open(&path),
393            Err(Error::CorruptObject(_))
394        ));
395
396        let mut short_trailer = Vec::from(b"PACK\x00\x00\x00\x02\x00\x00\x00\x00" as &[u8]);
397        short_trailer.push(0);
398        fs::write(&path, &short_trailer).expect("short trailer");
399        let err = fingerprint_from_file(&path, 20).expect_err("short trailer");
400        assert!(matches!(err, Error::CorruptObject(_)));
401    }
402
403    #[test]
404    fn owned_buffer_when_pack_at_or_below_threshold() {
405        let dir = tempfile::tempdir().expect("tempdir");
406        let path = dir.path().join("medium.pack");
407        let mut pack = Vec::from(b"PACK\x00\x00\x00\x02\x00\x00\x00\x00" as &[u8]);
408        pack.resize(8192, 0);
409        pack.extend_from_slice(b"aaaaaaaaaaaaaaaaaaaa");
410        fs::write(&path, &pack).expect("write");
411        let data = PackData::open(&path).expect("open owned");
412        assert_eq!(data.len(), pack.len());
413        let fp = data.fingerprint(20).expect("fp");
414        assert_eq!(&fp.trailer[..20], b"aaaaaaaaaaaaaaaaaaaa");
415    }
416
417    #[test]
418    fn open_rejects_shorter_than_header() {
419        let dir = tempfile::tempdir().expect("tempdir");
420        let path = dir.path().join("short.pack");
421        fs::write(&path, b"PAC").expect("write");
422        assert!(matches!(
423            PackData::open(&path),
424            Err(Error::CorruptObject(_))
425        ));
426    }
427
428    #[test]
429    fn fingerprint_rejects_unsupported_trailer_width() {
430        let dir = tempfile::tempdir().expect("tempdir");
431        let path = dir.path().join("p.pack");
432        fs::write(&path, b"PACK\x00\x00\x00\x02\x00\x00\x00\x00").expect("write");
433        assert!(fingerprint_from_file(&path, 16).is_err());
434        let data = PackData::open(&path).expect("open");
435        assert!(data.fingerprint(16).is_err());
436    }
437
438    #[test]
439    fn fingerprint_accepts_pack_version_three_header() {
440        let dir = tempfile::tempdir().expect("tempdir");
441        let path = dir.path().join("v3.pack");
442        let mut pack = Vec::from(b"PACK\x00\x00\x00\x03\x00\x00\x00\x00" as &[u8]);
443        pack.extend_from_slice(&[0u8; 20]);
444        fs::write(&path, &pack).expect("write");
445        let fp = fingerprint_from_file(&path, 20).expect("fp");
446        assert_eq!(u32::from_be_bytes(fp.header[4..8].try_into().unwrap()), 3);
447    }
448
449    #[test]
450    fn fingerprint_from_slice_rejects_bad_signature() {
451        let bytes = b"NOPE\x00\x00\x00\x02\x00\x00\x00\x00";
452        assert!(matches!(
453            fingerprint_from_slice(bytes, 20),
454            Err(Error::CorruptObject(_))
455        ));
456    }
457
458    /// Resident KiB of the mapping that contains `addr`, from `/proc/self/smaps`.
459    #[cfg(target_os = "linux")]
460    fn mapping_rss_kib(addr: *const u8) -> u64 {
461        let smaps = fs::read_to_string("/proc/self/smaps").expect("smaps");
462        let addr = addr as usize;
463        let mut inside = false;
464        for line in smaps.lines() {
465            let range = line.split_whitespace().next().unwrap_or_default();
466            if let Some((start, end)) = range.split_once('-') {
467                if let (Ok(start), Ok(end)) = (
468                    usize::from_str_radix(start, 16),
469                    usize::from_str_radix(end, 16),
470                ) {
471                    inside = (start..end).contains(&addr);
472                    continue;
473                }
474            }
475            if inside {
476                if let Some(kib) = line.strip_prefix("Rss:") {
477                    return kib
478                        .trim()
479                        .trim_end_matches("kB")
480                        .trim()
481                        .parse()
482                        .expect("kB");
483                }
484            }
485        }
486        panic!("no mapping contains {addr:#x}");
487    }
488
489    #[cfg(target_os = "linux")]
490    fn mapped_test_pack(dir: &Path, len: usize) -> (Arc<PackData>, Vec<u8>) {
491        let path = dir.join("resident.pack");
492        let mut bytes = Vec::from(&b"PACK\x00\x00\x00\x02\x00\x00\x00\x00"[..]);
493        bytes.extend((0..len).map(|i| u8::try_from(i % 251).unwrap_or(0)));
494        fs::write(&path, &bytes).expect("write");
495        let data = PackData::open(&path).expect("open");
496        assert!(data.is_mapped_backing());
497        (data, bytes)
498    }
499
500    #[cfg(target_os = "linux")]
501    #[test]
502    fn release_resident_drops_pages_but_keeps_the_bytes() {
503        let dir = tempfile::tempdir().expect("tempdir");
504        let (data, bytes) = mapped_test_pack(dir.path(), 24 << 20);
505        let digest = HashAlgo::Sha1.checksum_digest(&data);
506        assert!(
507            mapping_rss_kib(data.as_ptr()) > 20 << 10,
508            "a full read maps the file"
509        );
510        data.release_resident();
511        assert!(
512            mapping_rss_kib(data.as_ptr()) < 1 << 10,
513            "release drops the pages"
514        );
515        assert_eq!(&data[..], &bytes[..]);
516        assert_eq!(HashAlgo::Sha1.checksum_digest(&data), digest);
517    }
518
519    #[cfg(target_os = "linux")]
520    #[test]
521    fn resident_budget_bounds_a_full_pass() {
522        let dir = tempfile::tempdir().expect("tempdir");
523        let (data, bytes) = mapped_test_pack(dir.path(), 24 << 20);
524        data.release_resident();
525        let budget = ResidentBudget::new(&data, 4 << 20);
526        assert_eq!(
527            budget.checksum(HashAlgo::Sha1, &data),
528            HashAlgo::Sha1.checksum_digest(&bytes)
529        );
530        let rss = mapping_rss_kib(data.as_ptr());
531        assert!(rss <= 6 << 10, "budgeted pass left {rss} KiB resident");
532
533        data.release_resident();
534        let unbounded = ResidentBudget::unbounded();
535        let _ = unbounded.checksum(HashAlgo::Sha1, &data);
536        assert!(
537            mapping_rss_kib(data.as_ptr()) > 20 << 10,
538            "unbounded never releases"
539        );
540    }
541
542    #[test]
543    fn release_resident_on_owned_bytes_is_a_no_op() {
544        let data = PackData::from_owned(b"PACK\x00\x00\x00\x02\x00\x00\x00\x00".to_vec());
545        data.release_resident();
546        let budget = ResidentBudget::new(&data, 0);
547        budget.charge(1);
548        assert_eq!(&data[..4], b"PACK");
549    }
550
551    #[test]
552    fn mmap_large_sparse_pack() {
553        let dir = tempfile::tempdir().expect("tempdir");
554        let path = dir.path().join("sparse.pack");
555        let mut file = File::create(&path).expect("create");
556        file.write_all(b"PACK\x00\x00\x00\x02\x00\x00\x00\x01")
557            .expect("header");
558        let huge: u64 = 1 << 32;
559        file.seek(SeekFrom::Start(huge)).expect("seek");
560        file.write_all(b"blob payload padding").expect("payload");
561        file.write_all(b"bbbbbbbbbbbbbbbbbbbb").expect("trailer");
562        drop(file);
563        let len = fs::metadata(&path).expect("meta").len();
564        assert!(len > huge);
565        let data = PackData::open(&path).expect("mmap sparse");
566        assert_eq!(&data[..4], b"PACK");
567        assert_eq!(data.len() as u64, len);
568    }
569}