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}