girt 0.17.0

An incremental, idiomatic Rust implementation of Git
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! Bounded annotated-tag resolution over verified objects.
use std::sync::atomic::{AtomicBool, Ordering};

use crate::{
    IdSet, Object, ObjectId, ObjectKind, ObjectReadError, Objects, ReadLimits, Tag, TagError,
};

/// Work limits for resolving one object through a chain of annotated tags.
///
/// Choose edge count, total decoded payload and per-object work independently. A shallow tag chain
/// can still contain a large annotation; a low depth limit does not cap its bytes.
///
/// ```
/// use girt::{PeelLimits, ReadLimits};
/// let limits = PeelLimits::default()
///     .with_max_tags(8)
///     .with_max_bytes(1024 * 1024)
///     .with_read(ReadLimits::default().with_max_object_bytes(256 * 1024));
/// assert_eq!(limits.max_tags, 8);
/// assert!(limits.read.max_object_bytes < limits.max_bytes);
/// ```
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct PeelLimits {
    /// Maximum tag edges followed. Zero still permits reading a terminal object.
    pub max_tags: usize,
    /// Total payload bytes read, including the terminal object.
    pub max_bytes: usize,
    /// Per-read object and delta-work limits.
    pub read: ReadLimits,
}

impl PeelLimits {
    /// Sets [`Self::max_tags`].
    #[must_use]
    pub fn with_max_tags(mut self, max_tags: usize) -> Self {
        self.max_tags = max_tags;
        self
    }

    /// Sets [`Self::max_bytes`].
    #[must_use]
    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
        self.max_bytes = max_bytes;
        self
    }

    /// Sets [`Self::read`].
    #[must_use]
    pub fn with_read(mut self, read: ReadLimits) -> Self {
        self.read = read;
        self
    }
}
impl Default for PeelLimits {
    fn default() -> Self {
        Self {
            max_tags: 32,
            max_bytes: 64 * 1024 * 1024,
            read: ReadLimits::default(),
        }
    }
}

impl PeelLimits {
    /// Returns generous finite bounds for caller-approved tag chains.
    ///
    /// This permits 10,000 tag objects and uses [`ReadLimits::trusted`] for each object. Large byte
    /// limits can exceed available memory. Choose this after accepting that cost. To relax only one
    /// dimension, start from [`Self::default`] instead; the example deliberately uses the generous
    /// byte/read policy while keeping a smaller tag count.
    ///
    /// ```
    /// use girt::PeelLimits;
    /// let limits = PeelLimits::trusted().with_max_tags(100);
    /// assert_eq!(limits.max_tags, 100);
    /// ```
    pub const fn trusted() -> Self {
        Self {
            max_tags: 10_000,
            max_bytes: usize::MAX / 4,
            read: ReadLimits::trusted(),
        }
    }
}

/// Resolved identity and kind, retaining the original tip and every intervening tag identity.
///
/// ```
/// use std::sync::atomic::AtomicBool;
///
/// use girt::{InitKind, ObjectFormat, ObjectKind, PackLimits, PeelLimits, Repository};
///
/// let directory = tempfile::tempdir()?;
/// let repo = Repository::init(
///     ObjectFormat::Sha1,
///     directory.path().join("r.git"),
///     InitKind::Bare,
/// )?;
///
/// let blob = repo.loose_objects().write_blob(b"manifest")?;
/// let objects = repo.objects_with(PackLimits::default())?;
/// let result = objects.peel(blob, PeelLimits::default(), &AtomicBool::new(false))?;
///
/// assert_eq!(result.original, blob);
/// assert_eq!(result.target, blob);
/// assert_eq!(result.kind, ObjectKind::Blob);
/// assert!(result.tags.is_empty()); // An already-terminal object has no intervening tags.
///
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct PeeledObject {
    /// Original requested identity, also retained when it was already a terminal object.
    pub original: ObjectId,
    /// Tag identities in outermost-to-innermost order.
    pub tags: Vec<ObjectId>,
    /// First non-tag identity.
    pub target: ObjectId,
    /// Verified storage kind of the terminal target.
    pub kind: ObjectKind,
}

/// A failed read-only resolution, with the exact link being examined.
///
/// Use the original tip, failing ID, parent tag and depth together when reporting a failed chain.
/// No partial successful target is returned. Inspect the source category before deciding whether
/// storage recovery, a larger budget, or cancellation policy applies.
///
/// ```
/// use girt::{InitKind, ObjectFormat, ObjectId, PackLimits, PeelFailure, PeelLimits, Repository};
///
/// let directory = tempfile::tempdir()?;
/// let repo = Repository::init(
///     ObjectFormat::Sha1,
///     directory.path().join("r.git"),
///     InitKind::Bare,
/// )?;
///
/// let objects = repo.objects_with(PackLimits::default())?;
/// let missing = ObjectId::Sha1([1; 20]);
/// let error = objects
///     .peel(
///         missing,
///         PeelLimits::default(),
///         &std::sync::atomic::AtomicBool::new(false),
///     )
///     .expect_err("not stored");
///
/// assert_eq!(error.id, missing);
/// assert!(matches!(error.source.as_ref(), PeelFailure::Missing));
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[derive(Debug, thiserror::Error)]
#[error("peeling {original} at {id}")]
#[non_exhaustive]
pub struct PeelError {
    /// Original requested tip.
    pub original: ObjectId,
    /// Object whose read or interpretation failed, or the next object at cancellation/cycle.
    pub id: ObjectId,
    /// Tag declaring this target; absent while examining the original tip.
    pub from: Option<ObjectId>,
    /// Number of tag edges already followed.
    pub depth: usize,
    /// Structured reason for failure.
    #[source]
    pub source: Box<PeelFailure>,
}

/// Reasons a tag chain cannot be resolved. No failure changes storage.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PeelFailure {
    /// The requested object is absent.
    /// Obtain the missing object or report incomplete storage; no missing-object fetch is implicit.
    #[error("missing object")]
    Missing,
    /// Object verification, I/O, format, or per-read resource failure.
    /// Inspect the storage source and distinguish its I/O, corruption, and budget failures.
    #[error(transparent)]
    Read(#[from] ObjectReadError),
    /// A tag's mandatory target records cannot be decoded.
    /// Report the offending tag ID and parser source; do not silently skip the tag.
    #[error(transparent)]
    Tag(#[from] TagError),
    /// A terminal commit's graph records cannot be decoded.
    /// Report the terminal commit ID and unreadable graph records.
    #[error(transparent)]
    Commit(#[from] crate::CommitError),
    /// Stored kind disagrees with the referring tag's declaration.
    /// Inspect the referring tag and actual target; neither the declaration nor a ref hint
    /// overrides verified kind.
    #[error("expected {expected:?}, found {actual:?}")]
    Kind {
        /// Declared kind.
        expected: ObjectKind,
        /// Kind verified by storage.
        actual: ObjectKind,
    },
    /// The next tag edge exceeds `max_tags`.
    /// Choose a larger tag-edge budget only after considering the input and resource cost.
    #[error("tag depth limit")]
    Depth,
    /// The total payload budget is exhausted.
    /// Choose an aggregate payload budget independently of each per-read limit.
    #[error("tag-chain byte limit")]
    Bytes,
    /// A previously visited identity was encountered again.
    /// Report the cyclic tag graph; restarting with larger limits cannot resolve it.
    #[error("tag cycle")]
    Cycle,
    /// Cancellation observed before or after a synchronous object read.
    /// Retry only with a new cancellation decision; no partial target was returned.
    #[error("tag peeling cancelled")]
    Cancelled,
}

impl Objects {
    /// Resolves tags to a terminal object, checking each declared target kind.
    ///
    /// Reads verified storage objects and decodes tag targets in the store's format. Terminal
    /// commits must have decodable graph records; terminal trees remain uninterpreted, matching
    /// Git peeling. Metadata and canonical construction rules are separate. Cancellation is checked
    /// before and after each synchronous read, not during it. Memory is bounded by tag count
    /// and one per-read payload; total payload and per-read delta work are bounded separately.
    /// No files or references are changed and no partial result is returned.
    ///
    /// # Errors
    ///
    /// Returns contextual [`PeelError`] for missing/corrupt/wrong-kind targets, malformed tags,
    /// cycles, limits or cancellation. Existing corrupt objects never become an absent result.
    ///
    /// ```
    /// use std::sync::atomic::AtomicBool;
    ///
    /// use girt::{
    ///     InitKind, ObjectFormat, ObjectKind, PackLimits, PeelLimits, Repository, Tag, TagFields,
    /// };
    ///
    /// let directory = tempfile::tempdir()?;
    /// let repo = Repository::init(
    ///     ObjectFormat::Sha1,
    ///     directory.path().join("r.git"),
    ///     InitKind::Bare,
    /// )?;
    ///
    /// let loose = repo.loose_objects();
    /// let blob = loose.write_blob(b"manifest")?;
    /// let tag = Tag::new(TagFields {
    ///     target: blob,
    ///     target_kind: ObjectKind::Blob,
    ///     name: b"v1".to_vec(),
    ///     tagger: None,
    ///     extra_headers: vec![],
    ///     message: b"Reviewed".to_vec(),
    /// })?;
    /// let inner = loose.write_tag(&tag)?;
    ///
    /// let outer = Tag::new(TagFields {
    ///     target: inner,
    ///     target_kind: ObjectKind::Tag,
    ///     name: b"release".to_vec(),
    ///     tagger: None,
    ///     extra_headers: vec![],
    ///     message: b"Release pointer".to_vec(),
    /// })?;
    /// let tip = loose.write_tag(&outer)?;
    ///
    /// let objects = repo.objects_with(PackLimits::default())?;
    /// let peeled = objects.peel(tip, PeelLimits::default(), &AtomicBool::new(false))?;
    ///
    /// assert_eq!(peeled.tags, [tip, inner]);
    /// assert_eq!(peeled.target, blob);
    /// assert_eq!(peeled.kind, ObjectKind::Blob);
    /// # Ok::<(), Box<dyn std::error::Error>>(())
    /// ```
    pub fn peel(
        &self,
        id: ObjectId,
        limits: PeelLimits,
        cancel: &AtomicBool,
    ) -> Result<PeeledObject, PeelError> {
        #[cfg(feature = "tracing")]
        let span = tracing::debug_span!(target: "girt", "objects.peel",
            outcome = "incomplete", failure_class = tracing::field::Empty);
        let operation = || peel(id, limits, cancel, |id, read| self.read_with(id, read));
        #[cfg(feature = "tracing")]
        let result = span.in_scope(operation);
        #[cfg(not(feature = "tracing"))]
        let result = operation();
        #[cfg(feature = "tracing")]
        crate::trace::finish(&span, &result, crate::trace::peel);
        result
    }
}

fn peel(
    original: ObjectId,
    limits: PeelLimits,
    cancel: &AtomicBool,
    mut read: impl FnMut(ObjectId, ReadLimits) -> Result<Option<Object>, ObjectReadError>,
) -> Result<PeeledObject, PeelError> {
    let mut id = original;
    let mut tags = Vec::new();
    let mut seen = IdSet::default();
    let mut expected = None;
    let mut remaining = limits.max_bytes;
    loop {
        let context = |source| PeelError {
            original,
            id,
            from: tags.last().copied(),
            depth: tags.len(),
            source: Box::new(source),
        };
        if cancel.load(Ordering::Relaxed) {
            return Err(context(PeelFailure::Cancelled));
        }
        if !seen.insert(id) {
            return Err(context(PeelFailure::Cycle));
        }
        let mut read_limits = limits.read;
        read_limits.max_object_bytes = read_limits.max_object_bytes.min(remaining);
        let object = read(id, read_limits);
        if cancel.load(Ordering::Relaxed) {
            return Err(context(PeelFailure::Cancelled));
        }
        let object = object
            .map_err(|e| context(PeelFailure::Read(e)))?
            .ok_or_else(|| context(PeelFailure::Missing))?;
        remaining = remaining
            .checked_sub(object.data().len())
            .ok_or_else(|| context(PeelFailure::Bytes))?;
        if let Some(expected) = expected
            && expected != object.kind()
        {
            return Err(context(PeelFailure::Kind {
                expected,
                actual: object.kind(),
            }));
        }
        if object.kind() != ObjectKind::Tag {
            if object.kind() == ObjectKind::Commit {
                crate::Commit::parse_graph(id.format(), object.data())
                    .map_err(|e| context(PeelFailure::Commit(e)))?;
            }
            return Ok(PeeledObject {
                original,
                tags,
                target: id,
                kind: object.kind(),
            });
        }
        if tags.len() == limits.max_tags {
            return Err(context(PeelFailure::Depth));
        }
        let (target, kind) = Tag::parse_target(id.format(), object.data())
            .map_err(|e| context(PeelFailure::Tag(e)))?;
        expected = Some(kind);
        tags.push(id);
        id = target;
    }
}

#[cfg(test)]
mod tests {
    use rstest::rstest;

    use super::*;
    use crate::{ObjectFormat, TagFields};

    fn link(format: ObjectFormat, target: ObjectId) -> Object {
        let tag = Tag::new(TagFields {
            target,
            target_kind: ObjectKind::Tag,
            name: b"v".to_vec(),
            tagger: None,
            extra_headers: vec![],
            message: vec![],
        })
        .unwrap();
        Object {
            format,
            kind: ObjectKind::Tag,
            data: tag.encode().into(),
        }
    }

    // Verified content-addressed cycles cannot feasibly be manufactured. Inject a reader only at
    // this private boundary to exercise the traversal guard without weakening public storage.
    #[rstest]
    fn cycle_retains_failing_link(
        #[values(ObjectFormat::Sha1, ObjectFormat::Sha256)] format: ObjectFormat,
    ) {
        let id = ObjectId::null(format);
        let error = peel(
            id,
            PeelLimits::default(),
            &AtomicBool::new(false),
            |_, _| Ok(Some(link(format, id))),
        )
        .unwrap_err();
        assert_eq!(
            (error.original, error.id, error.from, error.depth),
            (id, id, Some(id), 1)
        );
        assert!(matches!(*error.source, PeelFailure::Cycle));
    }

    #[test]
    fn cancellation_after_read_prevents_target_following() {
        let cancel = AtomicBool::new(false);
        let id = ObjectId::null(ObjectFormat::Sha1);
        let error = peel(id, PeelLimits::default(), &cancel, |_, _| {
            cancel.store(true, Ordering::Relaxed);
            Ok(Some(link(ObjectFormat::Sha1, id)))
        })
        .unwrap_err();
        assert_eq!(error.depth, 0);
        assert!(matches!(*error.source, PeelFailure::Cancelled));
    }

    #[test]
    fn byte_budget_is_checked_even_for_a_misbehaving_reader() {
        let id = ObjectId::null(ObjectFormat::Sha1);
        let error = peel(
            id,
            PeelLimits {
                max_bytes: 0,
                ..PeelLimits::default()
            },
            &AtomicBool::new(false),
            |_, _| Ok(Some(link(ObjectFormat::Sha1, id))),
        )
        .unwrap_err();
        assert!(matches!(*error.source, PeelFailure::Bytes));
    }
}