Skip to main content

grit_lib/odb/
peel.rs

1//! Following annotated tags to the object they ultimately name.
2//!
3//! Every caller that turns a ref tip into "the commit it means" (revision walks, fetch
4//! negotiation, bundles, bitmaps, commit-graph tips, `packed-refs` and `ls-remote` peel lines,
5//! `--with-tree`) shares these [`Odb`] methods instead of its own tag loop.
6
7use std::collections::HashSet;
8
9use crate::error::{Error, Result};
10use crate::objects::{tag_object_line_oid, CommitRef, ObjectId, ObjectKind};
11
12use super::Odb;
13
14impl Odb {
15    /// Follow annotated tags starting at `oid` until reaching an object that is not a tag.
16    ///
17    /// A non-tag `oid` is returned unchanged. Only tag objects are fully read, and only their
18    /// `object` header is parsed (other headers and the message may be absent or not UTF-8);
19    /// the final object's kind comes from [`Self::read_info`], so peeling to a large blob or
20    /// tree does not inflate it.
21    ///
22    /// # Parameters
23    ///
24    /// - `oid`: the object to start from, usually a ref tip.
25    ///
26    /// # Returns
27    ///
28    /// The id and kind of the first non-tag object in the chain.
29    ///
30    /// # Errors
31    ///
32    /// - [`Error::ObjectNotFound`] when `oid` or any object the chain names is missing.
33    /// - [`Error::CorruptObject`] when a tag has no valid `object` header, or the chain names
34    ///   a tag it already passed through. Chains of any length are followed; content-addressed
35    ///   ids make a cycle impossible in a real repository, but a pluggable store can serve one.
36    /// - Any other error [`Self::read`] / [`Self::read_info`] reports for the objects read.
37    pub fn peel_tags(&self, oid: ObjectId) -> Result<(ObjectId, ObjectKind)> {
38        let mut current = oid;
39        let mut visited: Option<HashSet<ObjectId>> = None;
40        loop {
41            let kind = self.read_info(&current)?.kind;
42            if kind != ObjectKind::Tag {
43                return Ok((current, kind));
44            }
45            if !visited.get_or_insert_with(HashSet::new).insert(current) {
46                return Err(Error::CorruptObject(format!(
47                    "tag chain from {oid} loops back to {current}"
48                )));
49            }
50            let tag = self.read(&current)?;
51            current = tag_object_line_oid(&tag.data).ok_or_else(|| {
52                Error::CorruptObject(format!("tag {current} has no valid object header"))
53            })?;
54        }
55    }
56
57    /// Peel `oid` through annotated tags to a commit.
58    ///
59    /// # Parameters
60    ///
61    /// - `oid`: a commit, or an annotated tag (possibly nested) naming one.
62    ///
63    /// # Returns
64    ///
65    /// `Some(commit)` when the chain ends at a commit, `None` when it ends at a tree or blob.
66    ///
67    /// # Errors
68    ///
69    /// Same as [`Self::peel_tags`]: missing objects, unparsable tags and overlong chains are
70    /// errors rather than `None`, so callers can tell "not a commit" from "broken repository".
71    pub fn peel_to_commit(&self, oid: ObjectId) -> Result<Option<ObjectId>> {
72        let (peeled, kind) = self.peel_tags(oid)?;
73        Ok((kind == ObjectKind::Commit).then_some(peeled))
74    }
75
76    /// The object an annotated tag ultimately names: the `^{}` value of a ref advertisement or
77    /// a `packed-refs` peel line.
78    ///
79    /// Returns `None` when `oid` is not a tag. Unlike [`Self::peel_tags`], each tag's `type`
80    /// header decides whether the chain continues, so the final object is never read and may
81    /// be missing. Git computes these values the same way, and listing many tags pays for one
82    /// object read per tag instead of two.
83    ///
84    /// # Errors
85    ///
86    /// - [`Error::ObjectNotFound`] when `oid` or an intermediate tag is missing.
87    /// - [`Error::CorruptObject`] when a tag has no valid `object` header, a `type tag` header
88    ///   names an object that is not a tag, or the chain loops.
89    pub(crate) fn peel_tag_target(&self, oid: ObjectId) -> Result<Option<ObjectId>> {
90        self.peel_tag_target_from(oid, false)
91    }
92
93    /// Like [`Self::peel_tag_target`], but only reads objects stored in this database, so a
94    /// partial clone never lazy-fetches from its promisor remote.
95    ///
96    /// Returns `None` when `oid` is not a tag, or when `oid` or an intermediate tag of the
97    /// chain is not stored locally. The final target is taken from the last tag's header and
98    /// may be missing.
99    ///
100    /// # Errors
101    ///
102    /// Same as [`Self::peel_tag_target`], except that missing objects yield `Ok(None)`; a
103    /// failing object store is reported as by [`Self::contains`].
104    pub(crate) fn peel_stored_tag_target(&self, oid: ObjectId) -> Result<Option<ObjectId>> {
105        self.peel_tag_target_from(oid, true)
106    }
107
108    fn peel_tag_target_from(&self, oid: ObjectId, stored_only: bool) -> Result<Option<ObjectId>> {
109        if stored_only && !self.contains(&oid)? {
110            return Ok(None);
111        }
112        if self.read_info(&oid)?.kind != ObjectKind::Tag {
113            return Ok(None);
114        }
115        let mut current = oid;
116        let mut visited: Option<HashSet<ObjectId>> = None;
117        loop {
118            if stored_only && !self.contains(&current)? {
119                return Ok(None);
120            }
121            let tag = self.read(&current)?;
122            if tag.kind != ObjectKind::Tag {
123                return Err(Error::CorruptObject(format!(
124                    "{current} is named as a tag but is a {}",
125                    tag.kind
126                )));
127            }
128            let (target, names_tag) = tag_target_header(&tag.data);
129            let target = target.ok_or_else(|| {
130                Error::CorruptObject(format!("tag {current} has no valid object header"))
131            })?;
132            if !names_tag {
133                return Ok(Some(target));
134            }
135            if !visited.get_or_insert_with(HashSet::new).insert(current) {
136                return Err(Error::CorruptObject(format!(
137                    "tag chain from {oid} loops back to {current}"
138                )));
139            }
140            current = target;
141        }
142    }
143
144    /// Peel `oid` through annotated tags and a commit to a tree.
145    ///
146    /// Returns `None` when the chain ends at a blob. Errors are those of [`Self::peel_tags`],
147    /// plus [`Error::CorruptObject`] for a commit without a valid `tree` header.
148    pub(crate) fn peel_to_tree(&self, oid: ObjectId) -> Result<Option<ObjectId>> {
149        match self.peel_tags(oid)? {
150            (tree, ObjectKind::Tree) => Ok(Some(tree)),
151            (commit, ObjectKind::Commit) => {
152                let obj = self.read(&commit)?;
153                Ok(Some(CommitRef::parse(&obj.data)?.tree()))
154            }
155            _ => Ok(None),
156        }
157    }
158}
159
160/// The first `object` header of a tag and whether its first `type` header is `tag`, read
161/// without allocating.
162fn tag_target_header(data: &[u8]) -> (Option<ObjectId>, bool) {
163    let mut target: Option<Option<ObjectId>> = None;
164    let mut names_tag = None;
165    for line in data.split(|&b| b == b'\n') {
166        if line.is_empty() || (target.is_some() && names_tag.is_some()) {
167            break;
168        }
169        if let Some(hex) = line.strip_prefix(b"object ") {
170            target.get_or_insert_with(|| {
171                std::str::from_utf8(hex)
172                    .ok()
173                    .and_then(|s| s.trim().parse().ok())
174            });
175        } else if let Some(kind) = line.strip_prefix(b"type ") {
176            names_tag.get_or_insert(kind.trim_ascii() == b"tag");
177        }
178    }
179    (target.flatten(), names_tag.unwrap_or(false))
180}
181
182#[cfg(test)]
183mod tests {
184    #![allow(clippy::expect_used, clippy::unwrap_used)]
185
186    use super::*;
187    use crate::objects::HashAlgo;
188    use crate::odb::{MemoryStore, OdbBuilder};
189    use std::sync::Arc;
190    use tempfile::TempDir;
191
192    fn tag(odb: &Odb, target: ObjectId, kind: &str, name: &str) -> ObjectId {
193        let body = format!(
194            "object {target}\ntype {kind}\ntag {name}\ntagger T <t@example.com> 0 +0000\n\nmsg\n"
195        );
196        odb.write(ObjectKind::Tag, body.as_bytes()).unwrap()
197    }
198
199    fn commit(odb: &Odb) -> ObjectId {
200        let tree = odb.write(ObjectKind::Tree, b"").unwrap();
201        let body = format!(
202            "tree {tree}\nauthor A <a@example.com> 0 +0000\ncommitter A <a@example.com> 0 +0000\n\nc\n"
203        );
204        odb.write(ObjectKind::Commit, body.as_bytes()).unwrap()
205    }
206
207    #[test]
208    fn non_tag_is_returned_unchanged() {
209        let dir = TempDir::new().unwrap();
210        let odb = Odb::new(dir.path());
211        let c = commit(&odb);
212        let blob = odb.write(ObjectKind::Blob, b"data").unwrap();
213        assert_eq!(odb.peel_tags(c).unwrap(), (c, ObjectKind::Commit));
214        assert_eq!(odb.peel_tags(blob).unwrap(), (blob, ObjectKind::Blob));
215        assert_eq!(odb.peel_to_commit(c).unwrap(), Some(c));
216        assert_eq!(odb.peel_to_commit(blob).unwrap(), None);
217    }
218
219    #[test]
220    fn nested_tags_peel_to_the_innermost_object() {
221        let dir = TempDir::new().unwrap();
222        let odb = Odb::new(dir.path());
223        let c = commit(&odb);
224        let inner = tag(&odb, c, "commit", "inner");
225        let outer = tag(&odb, inner, "tag", "outer");
226        assert_eq!(odb.peel_tags(outer).unwrap(), (c, ObjectKind::Commit));
227        assert_eq!(odb.peel_to_commit(outer).unwrap(), Some(c));
228
229        let blob = odb.write(ObjectKind::Blob, b"data").unwrap();
230        let tag_of_blob = tag(&odb, blob, "blob", "b");
231        assert_eq!(
232            odb.peel_tags(tag_of_blob).unwrap(),
233            (blob, ObjectKind::Blob)
234        );
235        assert_eq!(odb.peel_to_commit(tag_of_blob).unwrap(), None);
236    }
237
238    #[test]
239    fn missing_objects_are_errors() {
240        let dir = TempDir::new().unwrap();
241        let odb = Odb::new(dir.path());
242        let missing = ObjectId::from_hex("1111111111111111111111111111111111111111").unwrap();
243        assert!(matches!(
244            odb.peel_tags(missing),
245            Err(Error::ObjectNotFound(_))
246        ));
247        let dangling = tag(&odb, missing, "commit", "dangling");
248        assert!(matches!(
249            odb.peel_to_commit(dangling),
250            Err(Error::ObjectNotFound(_))
251        ));
252    }
253
254    #[test]
255    fn only_the_object_header_is_required() {
256        let dir = TempDir::new().unwrap();
257        let odb = Odb::new(dir.path());
258        let c = commit(&odb);
259        let mut body = format!("object {c}\ntype commit\n\n").into_bytes();
260        body.extend_from_slice(b"latin-1 message \xe9\n");
261        let bare = odb.write(ObjectKind::Tag, &body).unwrap();
262        assert_eq!(odb.peel_to_commit(bare).unwrap(), Some(c));
263    }
264
265    #[test]
266    fn unparsable_tag_is_corrupt() {
267        let dir = TempDir::new().unwrap();
268        let odb = Odb::new(dir.path());
269        let bad = odb.write(ObjectKind::Tag, b"not a tag\n").unwrap();
270        assert!(matches!(odb.peel_tags(bad), Err(Error::CorruptObject(_))));
271    }
272
273    #[test]
274    fn long_chains_peel_without_a_depth_limit() {
275        let dir = TempDir::new().unwrap();
276        let odb = Odb::new(dir.path());
277        let c = commit(&odb);
278        let mut tip = tag(&odb, c, "commit", "t0");
279        for i in 1..200 {
280            tip = tag(&odb, tip, "tag", &format!("t{i}"));
281        }
282        assert_eq!(odb.peel_to_commit(tip).unwrap(), Some(c));
283    }
284
285    #[test]
286    fn peel_tag_target_names_the_innermost_object_of_tags_only() {
287        let dir = TempDir::new().unwrap();
288        let odb = Odb::new(dir.path());
289        let c = commit(&odb);
290        let inner = tag(&odb, c, "commit", "inner");
291        let outer = tag(&odb, inner, "tag", "outer");
292        let blob = odb.write(ObjectKind::Blob, b"data").unwrap();
293        let tag_of_blob = tag(&odb, blob, "blob", "b");
294        assert_eq!(odb.peel_tag_target(c).unwrap(), None);
295        assert_eq!(odb.peel_tag_target(blob).unwrap(), None);
296        assert_eq!(odb.peel_tag_target(inner).unwrap(), Some(c));
297        assert_eq!(odb.peel_tag_target(outer).unwrap(), Some(c));
298        assert_eq!(odb.peel_tag_target(tag_of_blob).unwrap(), Some(blob));
299
300        let missing = ObjectId::from_hex("1111111111111111111111111111111111111111").unwrap();
301        let dangling = tag(&odb, missing, "commit", "dangling");
302        assert_eq!(odb.peel_tag_target(dangling).unwrap(), Some(missing));
303        let dangling_outer = tag(&odb, missing, "tag", "dangling-outer");
304        assert!(matches!(
305            odb.peel_tag_target(dangling_outer),
306            Err(Error::ObjectNotFound(_))
307        ));
308        assert!(matches!(
309            odb.peel_tag_target(missing),
310            Err(Error::ObjectNotFound(_))
311        ));
312    }
313
314    #[test]
315    fn peel_stored_tag_target_reads_only_stored_tags() {
316        let dir = TempDir::new().unwrap();
317        let odb = Odb::new(dir.path());
318        let c = commit(&odb);
319        let inner = tag(&odb, c, "commit", "inner");
320        let outer = tag(&odb, inner, "tag", "outer");
321        assert_eq!(odb.peel_stored_tag_target(outer).unwrap(), Some(c));
322        assert_eq!(odb.peel_stored_tag_target(c).unwrap(), None);
323
324        let missing = ObjectId::from_hex("1111111111111111111111111111111111111111").unwrap();
325        let on_missing_blob = tag(&odb, missing, "blob", "promised");
326        assert_eq!(
327            odb.peel_stored_tag_target(on_missing_blob).unwrap(),
328            Some(missing),
329            "the final target comes from the header and may be missing"
330        );
331        let on_missing_tag = tag(&odb, missing, "tag", "broken-chain");
332        assert_eq!(odb.peel_stored_tag_target(on_missing_tag).unwrap(), None);
333        assert_eq!(odb.peel_stored_tag_target(missing).unwrap(), None);
334    }
335
336    #[test]
337    fn peel_tag_target_rejects_a_type_tag_header_naming_a_non_tag() {
338        let dir = TempDir::new().unwrap();
339        let odb = Odb::new(dir.path());
340        let c = commit(&odb);
341        let lying = tag(&odb, c, "tag", "lying");
342        assert!(matches!(
343            odb.peel_tag_target(lying),
344            Err(Error::CorruptObject(_))
345        ));
346    }
347
348    #[test]
349    fn peel_tag_target_detects_a_tag_cycle() {
350        let store = Arc::new(MemoryStore::new(HashAlgo::Sha1));
351        let a = ObjectId::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
352        let b = ObjectId::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
353        for (from, to) in [(a, b), (b, a)] {
354            let body = format!("object {to}\ntype tag\ntag cycle\n\n");
355            store.insert_object(from, ObjectKind::Tag, Arc::from(body.into_bytes()));
356        }
357        let dir = TempDir::new().unwrap();
358        let odb = OdbBuilder::files(dir.path())
359            .primary(store)
360            .alternates(false)
361            .build();
362        assert!(matches!(
363            odb.peel_tag_target(a),
364            Err(Error::CorruptObject(_))
365        ));
366    }
367
368    #[test]
369    fn peel_to_tree_follows_tags_and_commits() {
370        let dir = TempDir::new().unwrap();
371        let odb = Odb::new(dir.path());
372        let tree = odb.write(ObjectKind::Tree, b"").unwrap();
373        let c = commit(&odb);
374        let outer = tag(&odb, tag(&odb, c, "commit", "inner"), "tag", "outer");
375        let tag_of_tree = tag(&odb, tree, "tree", "t");
376        let blob = odb.write(ObjectKind::Blob, b"data").unwrap();
377        let tag_of_blob = tag(&odb, blob, "blob", "b");
378        assert_eq!(odb.peel_to_tree(tree).unwrap(), Some(tree));
379        assert_eq!(odb.peel_to_tree(c).unwrap(), Some(tree));
380        assert_eq!(odb.peel_to_tree(outer).unwrap(), Some(tree));
381        assert_eq!(odb.peel_to_tree(tag_of_tree).unwrap(), Some(tree));
382        assert_eq!(odb.peel_to_tree(blob).unwrap(), None);
383        assert_eq!(odb.peel_to_tree(tag_of_blob).unwrap(), None);
384    }
385
386    #[test]
387    fn peel_to_tree_rejects_a_commit_without_a_tree_header() {
388        let dir = TempDir::new().unwrap();
389        let odb = Odb::new(dir.path());
390        let bad = odb
391            .write(
392                ObjectKind::Commit,
393                b"author A <a@example.com> 0 +0000\n\nno tree\n",
394            )
395            .unwrap();
396        assert!(matches!(
397            odb.peel_to_tree(bad),
398            Err(Error::CorruptObject(_))
399        ));
400    }
401
402    #[test]
403    fn a_store_serving_a_tag_cycle_is_corrupt() {
404        let store = Arc::new(MemoryStore::new(HashAlgo::Sha1));
405        let a = ObjectId::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
406        let b = ObjectId::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
407        for (from, to) in [(a, b), (b, a)] {
408            let body = format!("object {to}\ntype tag\ntag cycle\n\n");
409            store.insert_object(from, ObjectKind::Tag, Arc::from(body.into_bytes()));
410        }
411        let dir = TempDir::new().unwrap();
412        let odb = OdbBuilder::files(dir.path())
413            .primary(store)
414            .alternates(false)
415            .build();
416        assert!(matches!(odb.peel_tags(a), Err(Error::CorruptObject(_))));
417    }
418}