Skip to main content

girt/objects/
listing.rs

1//! Enumeration of the stores, packs and object identities an [`Objects`] reader can see.
2//!
3//! Packed identities come from the reader's pinned indexes: listing them performs no I/O and
4//! cannot fail. Loose identities come from scanning live fan-out directories, so that scan is
5//! bounded by [`ListLimits`], cancellable and fallible.
6
7use std::fs::{self, ReadDir};
8use std::io;
9use std::iter::FusedIterator;
10use std::ops::Range;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::AtomicBool;
13
14use super::{Objects, Store};
15use crate::pack::FilePack;
16use crate::pack::reader::check_cancelled;
17use crate::{ObjectFormat, ObjectId, ObjectReadError};
18
19/// Marker files beside a pack, observed when the reader opened or refreshed.
20#[derive(Debug, Clone, Copy, Default)]
21pub(super) struct PackMarks {
22    pub(super) keep: bool,
23    pub(super) promisor: bool,
24}
25
26/// Bounds for enumerating loose objects and collecting identities.
27///
28/// `max_entries` counts every directory entry a loose scan reads, including fan-out
29/// directories and non-object files, and every identity [`Objects::object_ids`] collects. The
30/// default suits repositories with tens of millions of objects; lower it for untrusted input.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[non_exhaustive]
33pub struct ListLimits {
34    /// Maximum directory entries read plus identities collected by one operation.
35    pub max_entries: usize,
36}
37
38impl ListLimits {
39    /// Sets [`Self::max_entries`].
40    #[must_use]
41    pub fn with_max_entries(mut self, max_entries: usize) -> Self {
42        self.max_entries = max_entries;
43        self
44    }
45}
46
47impl Default for ListLimits {
48    fn default() -> Self {
49        Self {
50            max_entries: 1 << 27,
51        }
52    }
53}
54
55impl Objects {
56    /// Returns this reader's object stores in search order: the primary store, then alternates.
57    ///
58    /// ```
59    /// use girt::{InitKind, ObjectFormat, PackLimits, Repository};
60    ///
61    /// let directory = tempfile::tempdir()?;
62    /// let repo = Repository::init(
63    ///     ObjectFormat::Sha1,
64    ///     directory.path().join("r.git"),
65    ///     InitKind::Bare,
66    /// )?;
67    ///
68    /// let objects = repo.objects_with(PackLimits::default())?;
69    /// let stores: Vec<_> = objects.stores().collect();
70    /// assert_eq!(stores.len(), 1);
71    /// assert!(!stores[0].is_alternate());
72    /// # Ok::<(), Box<dyn std::error::Error>>(())
73    /// ```
74    pub fn stores(&self) -> impl ExactSizeIterator<Item = ObjectStore<'_>> {
75        let format = self.format;
76        self.stores
77            .iter()
78            .enumerate()
79            .map(move |(position, store)| ObjectStore {
80                store,
81                format,
82                alternate: position != 0,
83            })
84    }
85
86    /// Returns every pinned pack in search order, across the primary store and alternates.
87    ///
88    /// Equivalent to flattening [`ObjectStore::packs`] over [`Self::stores`].
89    pub fn packs(&self) -> impl Iterator<Item = IndexedPack<'_>> {
90        self.stores().flat_map(|store| store.packs())
91    }
92
93    /// Collects every object identity visible to this reader, sorted and without duplicates.
94    ///
95    /// Includes all pinned packs and the current loose objects of every store, alternates
96    /// included, as [`git cat-file --batch-all-objects`] lists them. The implicit empty tree is
97    /// not included unless it is stored. See [`Self::object_ids_controlled`] for limits.
98    ///
99    /// [`git cat-file --batch-all-objects`]: https://git-scm.com/docs/git-cat-file
100    ///
101    /// # Errors
102    ///
103    /// Returns the errors of [`Self::object_ids_controlled`].
104    ///
105    /// ```
106    /// use girt::{InitKind, ListLimits, ObjectFormat, PackLimits, Repository};
107    /// let directory = tempfile::tempdir()?;
108    /// let repo = Repository::init(
109    ///     ObjectFormat::Sha1,
110    ///     directory.path().join("r.git"),
111    ///     InitKind::Bare,
112    /// )?;
113    /// let id = repo.loose_objects().write_blob(b"listed\n")?;
114    /// let objects = repo.objects_with(PackLimits::default())?;
115    /// assert_eq!(objects.object_ids(ListLimits::default())?, [id]);
116    /// # Ok::<(), Box<dyn std::error::Error>>(())
117    /// ```
118    pub fn object_ids(&self, limits: ListLimits) -> Result<Vec<ObjectId>, ObjectReadError> {
119        self.object_ids_controlled(limits, &AtomicBool::new(false))
120    }
121
122    /// Collects every visible identity as [`Self::object_ids`] does, checking `cancelled` between
123    /// directory entries.
124    ///
125    /// Packed identities and loose directory entries both charge [`ListLimits::max_entries`].
126    /// The loose scan has the live semantics of [`ObjectStore::loose_objects`].
127    ///
128    /// # Errors
129    ///
130    /// Returns [`ObjectReadError::Limit`] when the entry budget runs out,
131    /// [`ObjectReadError::Cancelled`], or a loose directory I/O error with its path.
132    pub fn object_ids_controlled(
133        &self,
134        limits: ListLimits,
135        cancelled: &AtomicBool,
136    ) -> Result<Vec<ObjectId>, ObjectReadError> {
137        let mut remaining = limits.max_entries;
138        let mut ids = Vec::new();
139        for store in self.stores() {
140            for pack in store.packs() {
141                check_cancelled(cancelled)?;
142                remaining = remaining
143                    .checked_sub(pack.object_count())
144                    .ok_or(ObjectReadError::Limit("listed object entries"))?;
145                ids.extend(pack.ids());
146            }
147            let mut loose = store.loose_objects_controlled(
148                ListLimits::default().with_max_entries(remaining),
149                cancelled,
150            );
151            for object in &mut loose {
152                ids.push(object?.id());
153            }
154            remaining = loose.remaining;
155        }
156        ids.sort_unstable();
157        ids.dedup();
158        Ok(ids)
159    }
160}
161
162/// One object directory of an [`Objects`] reader: the primary store or an alternate.
163///
164/// Alternates are borrowed storage; their contents are never pruning candidates for this
165/// repository.
166#[derive(Debug, Clone, Copy)]
167pub struct ObjectStore<'a> {
168    store: &'a Store,
169    format: ObjectFormat,
170    alternate: bool,
171}
172
173impl<'a> ObjectStore<'a> {
174    /// Canonical path of this `objects` directory.
175    pub fn directory(&self) -> &'a Path {
176        &self.store.directory
177    }
178
179    /// Whether this store is an alternate rather than the repository's own primary store.
180    pub fn is_alternate(&self) -> bool {
181        self.alternate
182    }
183
184    /// Returns this store's pinned packs in search order (sorted by index filename).
185    pub fn packs(&self) -> impl ExactSizeIterator<Item = IndexedPack<'a>> + use<'a> {
186        let store = self.store;
187        let format = self.format;
188        store
189            .packs
190            .iter()
191            .zip(&store.pack_paths)
192            .zip(&store.pack_marks)
193            .map(move |((pack, path), marks)| IndexedPack {
194                pack,
195                path,
196                marks: *marks,
197                format,
198            })
199    }
200
201    /// Scans this store's loose objects with [`ListLimits::default`] and no cancellation.
202    ///
203    /// See [`Self::loose_objects_controlled`].
204    pub fn loose_objects(&self) -> LooseIds<'a> {
205        self.loose_objects_controlled(ListLimits::default(), &NEVER_CANCELLED)
206    }
207
208    /// Scans this store's loose objects lazily, one directory entry at a time.
209    ///
210    /// Yields each regular file under a two-hex-digit fan-out directory whose name completes a
211    /// full identity in this store's format. Other names, such as temporary files, and
212    /// directories or symlinks with object names are skipped without error, as Git skips them.
213    /// Order follows the file system and is unspecified; sort if order matters. No file is
214    /// opened, so a yielded entry proves only that its name exists, not that its contents are
215    /// valid.
216    ///
217    /// The scan is live, unlike the reader's pinned packs: files written or removed during the
218    /// scan may or may not appear, and a fan-out directory removed meanwhile is skipped. A
219    /// missing `objects` directory yields nothing. Every directory entry read charges
220    /// [`ListLimits::max_entries`], and `cancelled` is checked before each one.
221    ///
222    /// The iterator yields an error at most once, then ends: [`ObjectReadError::Limit`],
223    /// [`ObjectReadError::Cancelled`], or [`ObjectReadError::Path`] for directory I/O.
224    pub fn loose_objects_controlled(
225        &self,
226        limits: ListLimits,
227        cancelled: &'a AtomicBool,
228    ) -> LooseIds<'a> {
229        LooseIds {
230            directory: &self.store.directory,
231            format: self.format,
232            cancelled,
233            remaining: limits.max_entries,
234            state: State::Start,
235        }
236    }
237}
238
239static NEVER_CANCELLED: AtomicBool = AtomicBool::new(false);
240
241/// A pack/index pair pinned by an [`Objects`] reader.
242///
243/// Every value describes the snapshot taken when the reader opened or last refreshed, not the
244/// current file system.
245#[derive(Debug, Clone, Copy)]
246pub struct IndexedPack<'a> {
247    pack: &'a FilePack,
248    path: &'a PathBuf,
249    marks: PackMarks,
250    format: ObjectFormat,
251}
252
253impl<'a> IndexedPack<'a> {
254    /// Path of the `.pack` file.
255    pub fn path(&self) -> &'a Path {
256        self.path
257    }
258
259    /// Path of the `.idx` file paired with [`Self::path`].
260    pub fn index_path(&self) -> PathBuf {
261        self.path.with_extension("idx")
262    }
263
264    /// Number of objects the index lists.
265    pub fn object_count(&self) -> usize {
266        self.pack.object_count()
267    }
268
269    /// Size of the `.pack` file in bytes.
270    pub fn pack_bytes(&self) -> u64 {
271        self.pack.artifact_bytes().0
272    }
273
274    /// Size of the `.idx` file in bytes.
275    pub fn index_bytes(&self) -> u64 {
276        self.pack.artifact_bytes().1
277    }
278
279    /// Pack checksum from the trailer, which Git also uses to name the pack.
280    ///
281    /// Opening checks that the index and pack trailers agree; it does not hash the pack. Use
282    /// [`Objects::verify_packs`] for that proof.
283    pub fn checksum(&self) -> ObjectId {
284        self.pack.checksum()
285    }
286
287    /// Whether a `.keep` file protected this pack from repacking.
288    pub fn is_kept(&self) -> bool {
289        self.marks.keep
290    }
291
292    /// Whether a `.promisor` file marked this pack as fetched from a promisor remote.
293    pub fn is_promisor(&self) -> bool {
294        self.marks.promisor
295    }
296
297    /// Object format of the identities in this pack.
298    pub fn object_format(&self) -> ObjectFormat {
299        self.format
300    }
301
302    /// Whether this pack's index lists `id`, found by binary search of the pinned index.
303    ///
304    /// Reads no pack file and does not check that the packed entry is readable.
305    ///
306    /// # Errors
307    ///
308    /// Index structure is checked lazily, so a search that reaches a corrupt or truncated part of
309    /// the index returns an [`ObjectReadError`] rather than reporting the object absent.
310    pub fn contains(&self, id: ObjectId) -> Result<bool, ObjectReadError> {
311        Ok(self.pack.find(id)?.is_some())
312    }
313
314    /// Iterates this pack's identities in index order, which is ascending identity order.
315    ///
316    /// Reads only the pinned in-memory index: no I/O, no allocation per identity.
317    pub fn ids(&self) -> PackIds<'a> {
318        PackIds {
319            pack: self.pack,
320            positions: 0..self.pack.object_count(),
321        }
322    }
323}
324
325/// Identities of one [`IndexedPack`] in ascending order; see [`IndexedPack::ids`].
326#[derive(Debug, Clone)]
327pub struct PackIds<'a> {
328    pack: &'a FilePack,
329    positions: Range<usize>,
330}
331
332impl Iterator for PackIds<'_> {
333    type Item = ObjectId;
334
335    fn next(&mut self) -> Option<ObjectId> {
336        self.positions
337            .next()
338            .map(|position| self.pack.identity_at(position))
339    }
340
341    fn size_hint(&self) -> (usize, Option<usize>) {
342        self.positions.size_hint()
343    }
344
345    fn nth(&mut self, n: usize) -> Option<ObjectId> {
346        self.positions
347            .nth(n)
348            .map(|position| self.pack.identity_at(position))
349    }
350}
351
352impl DoubleEndedIterator for PackIds<'_> {
353    fn next_back(&mut self) -> Option<ObjectId> {
354        self.positions
355            .next_back()
356            .map(|position| self.pack.identity_at(position))
357    }
358}
359
360impl ExactSizeIterator for PackIds<'_> {}
361impl FusedIterator for PackIds<'_> {}
362
363/// A loose object file found by [`ObjectStore::loose_objects`].
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct LooseObject {
366    id: ObjectId,
367    path: PathBuf,
368}
369
370impl LooseObject {
371    /// Identity named by the file's path.
372    pub fn id(&self) -> ObjectId {
373        self.id
374    }
375
376    /// Path of the loose object file; it may have been removed since the scan read it.
377    pub fn path(&self) -> &Path {
378        &self.path
379    }
380}
381
382/// Lazy, bounded scan of one store's loose objects; see [`ObjectStore::loose_objects_controlled`].
383#[derive(Debug)]
384pub struct LooseIds<'a> {
385    directory: &'a Path,
386    format: ObjectFormat,
387    cancelled: &'a AtomicBool,
388    remaining: usize,
389    state: State,
390}
391
392#[derive(Debug)]
393enum State {
394    Start,
395    Fanouts(ReadDir),
396    Objects {
397        fanouts: ReadDir,
398        prefix: String,
399        directory: PathBuf,
400        // Boxed: a Windows `ReadDir` holds its find data inline, so two would dwarf the others.
401        entries: Box<ReadDir>,
402    },
403    Done,
404}
405
406impl LooseIds<'_> {
407    fn charge(&mut self) -> Result<(), ObjectReadError> {
408        check_cancelled(self.cancelled)?;
409        self.remaining = self
410            .remaining
411            .checked_sub(1)
412            .ok_or(ObjectReadError::Limit("loose directory entries"))?;
413        Ok(())
414    }
415
416    fn advance(&mut self) -> Result<Option<LooseObject>, ObjectReadError> {
417        loop {
418            match std::mem::replace(&mut self.state, State::Done) {
419                State::Done => return Ok(None),
420                State::Start => match fs::read_dir(self.directory) {
421                    Ok(fanouts) => self.state = State::Fanouts(fanouts),
422                    Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
423                    Err(source) => return Err(at(self.directory, source)),
424                },
425                State::Fanouts(mut fanouts) => {
426                    let Some(entry) = fanouts.next() else {
427                        return Ok(None);
428                    };
429                    self.charge()?;
430                    let entry = entry.map_err(|source| at(self.directory, source))?;
431                    let name = entry.file_name();
432                    let prefix = name
433                        .to_str()
434                        .filter(|name| name.len() == 2 && name.bytes().all(is_lower_hex));
435                    let directory = entry.path();
436                    let entries = match prefix.map(|_| fs::read_dir(&directory)) {
437                        Some(Ok(entries)) => Some(entries),
438                        // Not a directory, or removed since listing: neither holds objects.
439                        Some(Err(error))
440                            if matches!(
441                                error.kind(),
442                                io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
443                            ) =>
444                        {
445                            None
446                        }
447                        Some(Err(source)) => return Err(at(&directory, source)),
448                        None => None,
449                    };
450                    self.state = match (prefix, entries) {
451                        (Some(prefix), Some(entries)) => State::Objects {
452                            fanouts,
453                            prefix: prefix.to_owned(),
454                            directory,
455                            entries: Box::new(entries),
456                        },
457                        _ => State::Fanouts(fanouts),
458                    };
459                }
460                State::Objects {
461                    fanouts,
462                    mut prefix,
463                    directory,
464                    mut entries,
465                } => {
466                    let Some(entry) = entries.next() else {
467                        self.state = State::Fanouts(fanouts);
468                        continue;
469                    };
470                    self.charge()?;
471                    let entry = entry.map_err(|source| at(&directory, source))?;
472                    let name = entry.file_name();
473                    let found = name
474                        .to_str()
475                        .filter(|suffix| {
476                            suffix.len() == self.format.digest_len() * 2 - 2
477                                && suffix.bytes().all(is_lower_hex)
478                        })
479                        .and_then(|suffix| {
480                            prefix.push_str(suffix);
481                            let id = ObjectId::from_hex(self.format, &prefix).ok();
482                            prefix.truncate(2);
483                            id
484                        });
485                    // Git counts directories and symlinks with object names as garbage.
486                    let found = match found {
487                        Some(id) => entry
488                            .file_type()
489                            .map_err(|source| at(&directory, source))?
490                            .is_file()
491                            .then_some(id),
492                        None => None,
493                    };
494                    let path = found.is_some().then(|| entry.path());
495                    self.state = State::Objects {
496                        fanouts,
497                        prefix,
498                        directory,
499                        entries,
500                    };
501                    if let (Some(id), Some(path)) = (found, path) {
502                        return Ok(Some(LooseObject { id, path }));
503                    }
504                }
505            }
506        }
507    }
508}
509
510impl Iterator for LooseIds<'_> {
511    type Item = Result<LooseObject, ObjectReadError>;
512
513    fn next(&mut self) -> Option<Self::Item> {
514        // `advance` leaves `Done` behind on errors, so an error ends the scan.
515        self.advance().transpose()
516    }
517}
518
519impl FusedIterator for LooseIds<'_> {}
520
521fn is_lower_hex(byte: u8) -> bool {
522    matches!(byte, b'0'..=b'9' | b'a'..=b'f')
523}
524
525fn at(path: &Path, source: io::Error) -> ObjectReadError {
526    ObjectReadError::Path {
527        path: path.to_owned(),
528        source,
529    }
530}