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
//! 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.
#[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 limits for a repository the caller trusts as Git trusts the user's own.
///
/// Git bounds none of these values. The defaults suit untrusted input; these are large enough
/// that only available memory bounds the work, while keeping headroom for internal arithmetic.
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.
#[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.
#[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.
#[error("missing object")]
Missing,
/// Object verification, I/O, format, or per-read resource failure.
#[error(transparent)]
Read(#[from] ObjectReadError),
/// A tag's mandatory target records cannot be decoded.
#[error(transparent)]
Tag(#[from] TagError),
/// A terminal commit's graph records cannot be decoded.
#[error(transparent)]
Commit(#[from] crate::CommitError),
/// Stored kind disagrees with the referring tag's declaration.
#[error("expected {expected:?}, found {actual:?}")]
Kind {
/// Declared kind.
expected: ObjectKind,
/// Kind verified by storage.
actual: ObjectKind,
},
/// The next tag edge exceeds `max_tags`.
#[error("tag depth limit")]
Depth,
/// The total payload budget is exhausted.
#[error("tag-chain byte limit")]
Bytes,
/// A previously visited identity was encountered again.
#[error("tag cycle")]
Cycle,
/// Cancellation observed before or after a synchronous object read.
#[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.
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(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));
}
}