Skip to main content

git_lfs_pointer/
lib.rs

1//! Parsing and encoding for git-lfs pointer files.
2//!
3//! See `docs/spec.md` for the format. Briefly: a pointer is a tiny UTF-8 text
4//! file whose lines are sorted `key value` pairs, with `version` always first
5//! and the rest in alphabetical order, terminated by `\n`. The whole file
6//! must be < 1024 bytes.
7//!
8//! ```
9//! use git_lfs_pointer::{Oid, Pointer};
10//!
11//! let oid: Oid = "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393"
12//!     .parse()
13//!     .unwrap();
14//! let pointer = Pointer::new(oid, 12345);
15//!
16//! let encoded = pointer.encode();
17//! let parsed = Pointer::parse(encoded.as_bytes()).unwrap();
18//! assert_eq!(parsed.oid, oid);
19//! assert_eq!(parsed.size, 12345);
20//! assert!(parsed.canonical);
21//! ```
22
23mod oid;
24
25pub use oid::{EMPTY_HEX, Oid, OidParseError};
26
27/// The version URL we always emit. Older aliases parse but re-encode to this.
28pub const VERSION_LATEST: &str = "https://git-lfs.github.com/spec/v1";
29
30/// Pointer files must be **smaller** than this (per `docs/spec.md`).
31/// Inputs of this size or larger are not pointers.
32pub const MAX_POINTER_SIZE: usize = 1024;
33
34/// Recognized version URLs we accept on the read path.
35const VERSION_ALIASES: &[&str] = &[
36    "http://git-media.io/v/2",            // alpha
37    "https://hawser.github.com/spec/v1",  // pre-release
38    "https://git-lfs.github.com/spec/v1", // current
39];
40
41/// A parsed git-lfs pointer.
42///
43/// A pointer with `size == 0` is an *empty pointer*: it represents an empty
44/// file and serializes to the empty byte string. The `oid` field of an empty
45/// pointer is conventionally [`Oid::EMPTY`] (SHA-256 of zero bytes).
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Pointer {
48    pub oid: Oid,
49    pub size: u64,
50    /// Sorted by `priority` ascending. May be empty.
51    pub extensions: Vec<Extension>,
52    /// `true` if this was decoded from input that exactly matched the
53    /// canonical encoding, or if it was constructed programmatically.
54    /// Re-encoding a non-canonical parse produces canonical bytes.
55    pub canonical: bool,
56}
57
58/// A pointer extension (see `docs/extensions.md`).
59///
60/// Extensions appear between the `version` and `oid` lines in the encoded
61/// form, sorted by `priority`. Priorities are single decimal digits (0–9).
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Extension {
64    pub name: String,
65    pub priority: u8,
66    pub oid: Oid,
67}
68
69impl Pointer {
70    /// Build a non-empty pointer with no extensions.
71    pub fn new(oid: Oid, size: u64) -> Self {
72        Self {
73            oid,
74            size,
75            extensions: Vec::new(),
76            canonical: true,
77        }
78    }
79
80    /// The empty pointer (size 0, OID [`Oid::EMPTY`], no extensions). This is
81    /// the parse result for empty input and the pointer representation of an
82    /// empty file.
83    pub fn empty() -> Self {
84        Self {
85            oid: Oid::EMPTY,
86            size: 0,
87            extensions: Vec::new(),
88            canonical: true,
89        }
90    }
91
92    /// `true` if this is the empty pointer (size 0).
93    pub fn is_empty(&self) -> bool {
94        self.size == 0
95    }
96
97    /// Encode to canonical text form. The empty pointer encodes to `""`.
98    ///
99    /// Extensions are emitted sorted by priority. The version line is always
100    /// [`VERSION_LATEST`], regardless of what the source used.
101    pub fn encode(&self) -> String {
102        use std::fmt::Write as _;
103        if self.size == 0 {
104            return String::new();
105        }
106        let mut exts: Vec<&Extension> = self.extensions.iter().collect();
107        exts.sort_by_key(|e| e.priority);
108
109        let mut out = String::with_capacity(160 + 80 * exts.len());
110        writeln!(out, "version {VERSION_LATEST}").unwrap();
111        for ext in exts {
112            writeln!(out, "ext-{}-{} sha256:{}", ext.priority, ext.name, ext.oid).unwrap();
113        }
114        writeln!(out, "oid sha256:{}", self.oid).unwrap();
115        writeln!(out, "size {}", self.size).unwrap();
116        out
117    }
118
119    /// Parse a pointer from the raw bytes of a blob.
120    ///
121    /// Returns [`DecodeError::NotAPointer`] if the input doesn't look like a
122    /// pointer at all (callers like the smudge filter should pass the bytes
123    /// through unchanged), or [`DecodeError::Malformed`] if the input has
124    /// pointer shape but invalid contents (callers should error out).
125    pub fn parse(input: &[u8]) -> Result<Self, DecodeError> {
126        if input.is_empty() {
127            return Ok(Self::empty());
128        }
129        if input.len() >= MAX_POINTER_SIZE {
130            return Err(DecodeError::NotAPointer(NotAPointerReason::TooLarge {
131                size: input.len(),
132            }));
133        }
134        let text = std::str::from_utf8(input)
135            .map_err(|_| DecodeError::NotAPointer(NotAPointerReason::NotUtf8))?;
136        if !contains_spec_marker(text) {
137            return Err(DecodeError::NotAPointer(NotAPointerReason::MissingHeader));
138        }
139
140        let mut pointer = parse_lines(text.trim())?;
141        pointer.canonical = pointer.encode().as_bytes() == input;
142        Ok(pointer)
143    }
144}
145
146fn contains_spec_marker(text: &str) -> bool {
147    text.contains("git-lfs") || text.contains("git-media") || text.contains("hawser")
148}
149
150fn parse_lines(text: &str) -> Result<Pointer, DecodeError> {
151    const REQUIRED: [&str; 3] = ["version", "oid", "size"];
152    let mut filled: [Option<&str>; 3] = [None, None, None];
153    let mut consumed = 0usize;
154    let mut extensions: Vec<Extension> = Vec::new();
155
156    for (line_no, raw_line) in text.split('\n').enumerate() {
157        // Tolerate CRLF: bufio.Scanner does this in upstream, so we match.
158        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
159        if line.is_empty() {
160            continue;
161        }
162
163        let (key, value) = line.split_once(' ').ok_or(DecodeError::NotAPointer(
164            NotAPointerReason::MalformedLine { line: line_no },
165        ))?;
166
167        if consumed == REQUIRED.len() {
168            return Err(DecodeError::NotAPointer(NotAPointerReason::ExtraLine {
169                line: line_no,
170                content: line.into(),
171            }));
172        }
173
174        let expected = REQUIRED[consumed];
175        if key == expected {
176            filled[consumed] = Some(value);
177            consumed += 1;
178            continue;
179        }
180
181        // Mismatch: try to parse as an extension.
182        if let Some((priority, name)) = parse_extension_key(key) {
183            let ext_oid =
184                parse_oid_value(value).map_err(DecodeError::Malformed)?;
185            extensions.push(Extension {
186                name: name.to_owned(),
187                priority,
188                oid: ext_oid,
189            });
190            continue;
191        }
192
193        // Not a required key, not an extension. If this happens before the
194        // version line, treat as NotAPointer (matches upstream's
195        // StandardizeBadPointerError); otherwise it's a malformed pointer.
196        return Err(if expected == "version" {
197            DecodeError::NotAPointer(NotAPointerReason::NotVersionFirst { got: key.into() })
198        } else {
199            DecodeError::Malformed(MalformedReason::UnexpectedKey {
200                expected,
201                got: key.into(),
202            })
203        });
204    }
205
206    let version = filled[0].ok_or(DecodeError::NotAPointer(NotAPointerReason::MissingVersion))?;
207    if !VERSION_ALIASES.contains(&version) {
208        return Err(DecodeError::Malformed(MalformedReason::InvalidVersion(
209            version.into(),
210        )));
211    }
212
213    let oid_value = filled[1]
214        .ok_or(DecodeError::Malformed(MalformedReason::MissingField("oid")))?;
215    let oid = parse_oid_value(oid_value).map_err(DecodeError::Malformed)?;
216
217    let size_value = filled[2]
218        .ok_or(DecodeError::Malformed(MalformedReason::MissingField("size")))?;
219    let size = parse_size(size_value).map_err(DecodeError::Malformed)?;
220
221    extensions.sort_by_key(|e| e.priority);
222    for w in extensions.windows(2) {
223        if w[0].priority == w[1].priority {
224            return Err(DecodeError::Malformed(
225                MalformedReason::DuplicateExtensionPriority(w[0].priority),
226            ));
227        }
228    }
229
230    Ok(Pointer {
231        oid,
232        size,
233        extensions,
234        canonical: true, // overwritten by Pointer::parse
235    })
236}
237
238fn parse_oid_value(value: &str) -> Result<Oid, MalformedReason> {
239    let (oid_type, hash) = value
240        .split_once(':')
241        .ok_or_else(|| MalformedReason::MalformedOidValue(value.into()))?;
242    if oid_type != "sha256" {
243        return Err(MalformedReason::UnsupportedOidType(oid_type.into()));
244    }
245    Oid::from_hex(hash).map_err(MalformedReason::InvalidOidHash)
246}
247
248fn parse_size(value: &str) -> Result<u64, MalformedReason> {
249    // u64 parse already rejects leading '-', '+', whitespace, and non-digits.
250    value
251        .parse::<u64>()
252        .map_err(|_| MalformedReason::InvalidSize(value.into()))
253}
254
255/// Returns `Some((priority, name))` if `key` is a valid extension key in the
256/// form `ext-<digit>-<word>`. Word characters are ASCII alphanumeric or `_`.
257fn parse_extension_key(key: &str) -> Option<(u8, &str)> {
258    let rest = key.strip_prefix("ext-")?;
259    let bytes = rest.as_bytes();
260    if bytes.len() < 3 {
261        return None;
262    }
263    if !bytes[0].is_ascii_digit() || bytes[1] != b'-' {
264        return None;
265    }
266    let name = &rest[2..];
267    if !name
268        .bytes()
269        .all(|b| b.is_ascii_alphanumeric() || b == b'_')
270    {
271        return None;
272    }
273    Some((bytes[0] - b'0', name))
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
277pub enum DecodeError {
278    /// The input does not look like a pointer at all.
279    #[error("not a git-lfs pointer: {0}")]
280    NotAPointer(NotAPointerReason),
281    /// The input has pointer shape but is invalid.
282    #[error("malformed git-lfs pointer: {0}")]
283    Malformed(MalformedReason),
284}
285
286impl DecodeError {
287    /// `true` if the input doesn't look like a pointer; the smudge filter
288    /// should pass the bytes through unchanged in this case.
289    pub fn is_not_a_pointer(&self) -> bool {
290        matches!(self, DecodeError::NotAPointer(_))
291    }
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
295pub enum NotAPointerReason {
296    #[error("size {size} bytes is not below the {MAX_POINTER_SIZE}-byte cutoff")]
297    TooLarge { size: usize },
298    #[error("input is not valid UTF-8")]
299    NotUtf8,
300    #[error("missing git-lfs spec marker")]
301    MissingHeader,
302    #[error("line {line} has no key/value separator")]
303    MalformedLine { line: usize },
304    #[error("missing version line")]
305    MissingVersion,
306    #[error("first key is {got:?}, expected version")]
307    NotVersionFirst { got: String },
308    #[error("extra content on line {line}: {content:?}")]
309    ExtraLine { line: usize, content: String },
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
313pub enum MalformedReason {
314    #[error("unrecognized version: {0:?}")]
315    InvalidVersion(String),
316    #[error("expected key {expected:?}, got {got:?}")]
317    UnexpectedKey {
318        expected: &'static str,
319        got: String,
320    },
321    #[error("missing required {0:?} line")]
322    MissingField(&'static str),
323    #[error("oid value {0:?} is not in the form <type>:<hash>")]
324    MalformedOidValue(String),
325    #[error("unsupported oid type {0:?}; only sha256 is supported")]
326    UnsupportedOidType(String),
327    #[error("invalid oid hash: {0}")]
328    InvalidOidHash(#[source] OidParseError),
329    #[error("size value {0:?} is not a non-negative integer")]
330    InvalidSize(String),
331    #[error("duplicate extension priority {0}")]
332    DuplicateExtensionPriority(u8),
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn sha(hex: &str) -> Oid {
340        Oid::from_hex(hex).unwrap()
341    }
342
343    const SAMPLE_OID_HEX: &str =
344        "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393";
345
346    // ---------- encode ----------
347
348    #[test]
349    fn encode_simple() {
350        let p = Pointer::new(sha(SAMPLE_OID_HEX), 12345);
351        let expected = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\n");
352        assert_eq!(p.encode(), expected);
353    }
354
355    #[test]
356    fn encode_empty() {
357        // Per spec, the empty pointer encodes to the empty string.
358        assert_eq!(Pointer::empty().encode(), "");
359        // Any pointer with size 0 also encodes to "" (matches upstream).
360        let p = Pointer::new(sha(SAMPLE_OID_HEX), 0);
361        assert_eq!(p.encode(), "");
362    }
363
364    #[test]
365    fn encode_extensions_sorted_on_output() {
366        let exts = vec![
367            Extension {
368                name: "baz".into(),
369                priority: 2,
370                oid: sha("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
371            },
372            Extension {
373                name: "foo".into(),
374                priority: 0,
375                oid: sha("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
376            },
377            Extension {
378                name: "bar".into(),
379                priority: 1,
380                oid: sha("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
381            },
382        ];
383        let p = Pointer {
384            oid: sha(SAMPLE_OID_HEX),
385            size: 12345,
386            extensions: exts,
387            canonical: true,
388        };
389        let expected = format!(
390            "version {VERSION_LATEST}\n\
391             ext-0-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
392             ext-1-bar sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
393             ext-2-baz sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
394             oid sha256:{SAMPLE_OID_HEX}\n\
395             size 12345\n",
396        );
397        assert_eq!(p.encode(), expected);
398    }
399
400    // ---------- parse: happy paths ----------
401
402    #[test]
403    fn parse_standard() {
404        let input = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\n");
405        let p = Pointer::parse(input.as_bytes()).unwrap();
406        assert_eq!(p.oid, sha(SAMPLE_OID_HEX));
407        assert_eq!(p.size, 12345);
408        assert!(p.extensions.is_empty());
409        assert!(p.canonical);
410    }
411
412    #[test]
413    fn parse_empty_input_is_empty_pointer() {
414        let p = Pointer::parse(b"").unwrap();
415        assert_eq!(p, Pointer::empty());
416        assert!(p.canonical);
417    }
418
419    #[test]
420    fn parse_extensions_sorted() {
421        let input = format!(
422            "version {VERSION_LATEST}\n\
423             ext-0-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
424             ext-1-bar sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
425             ext-2-baz sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
426             oid sha256:{SAMPLE_OID_HEX}\n\
427             size 12345\n",
428        );
429        let p = Pointer::parse(input.as_bytes()).unwrap();
430        assert_eq!(p.extensions.len(), 3);
431        assert_eq!(p.extensions[0].name, "foo");
432        assert_eq!(p.extensions[0].priority, 0);
433        assert_eq!(p.extensions[1].name, "bar");
434        assert_eq!(p.extensions[2].name, "baz");
435        assert!(p.canonical);
436    }
437
438    #[test]
439    fn parse_unsorted_extensions_sorts_and_marks_noncanonical() {
440        // Same content, but ext-2 listed first.
441        let input = format!(
442            "version {VERSION_LATEST}\n\
443             ext-2-baz sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
444             ext-0-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
445             ext-1-bar sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
446             oid sha256:{SAMPLE_OID_HEX}\n\
447             size 12345\n",
448        );
449        let p = Pointer::parse(input.as_bytes()).unwrap();
450        assert_eq!(p.extensions[0].priority, 0);
451        assert_eq!(p.extensions[1].priority, 1);
452        assert_eq!(p.extensions[2].priority, 2);
453        assert!(!p.canonical);
454    }
455
456    #[test]
457    fn parse_pre_release_version_alias() {
458        let input = format!(
459            "version https://hawser.github.com/spec/v1\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\n"
460        );
461        let p = Pointer::parse(input.as_bytes()).unwrap();
462        assert_eq!(p.size, 12345);
463        // Re-encoding rewrites version to latest, so input is NOT canonical.
464        assert!(!p.canonical);
465        assert!(p.encode().starts_with(&format!("version {VERSION_LATEST}\n")));
466    }
467
468    #[test]
469    fn parse_round_trip() {
470        let p = Pointer::new(sha(SAMPLE_OID_HEX), 12345);
471        let encoded = p.encode();
472        let parsed = Pointer::parse(encoded.as_bytes()).unwrap();
473        assert_eq!(parsed.oid, p.oid);
474        assert_eq!(parsed.size, p.size);
475        assert!(parsed.canonical);
476    }
477
478    // ---------- canonical bytes ----------
479
480    #[test]
481    fn canonical_examples() {
482        // Standard form, with trailing \n.
483        let s = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\n");
484        assert!(Pointer::parse(s.as_bytes()).unwrap().canonical);
485
486        // Empty input.
487        assert!(Pointer::parse(b"").unwrap().canonical);
488    }
489
490    #[test]
491    fn non_canonical_examples() {
492        let cases: &[&str] = &[
493            // missing trailing newline
494            "version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 12345",
495            // CRLF line endings
496            "version https://git-lfs.github.com/spec/v1\r\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\r\nsize 12345\r\n",
497            // trailing whitespace on a line
498            "version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 12345   \n",
499        ];
500        for case in cases {
501            let p = Pointer::parse(case.as_bytes())
502                .unwrap_or_else(|e| panic!("failed to parse {case:?}: {e}"));
503            assert!(!p.canonical, "expected non-canonical for {case:?}");
504        }
505    }
506
507    // ---------- parse: NotAPointer ----------
508
509    #[test]
510    fn tiny_non_pointer_is_not_a_pointer() {
511        let err = Pointer::parse(b"this is not a git-lfs file!").unwrap_err();
512        assert!(err.is_not_a_pointer(), "expected NotAPointer, got {err:?}");
513    }
514
515    #[test]
516    fn header_only_is_not_a_pointer() {
517        // Mentions git-media so passes the marker check, but no key/value.
518        let err = Pointer::parse(b"# git-media").unwrap_err();
519        assert!(err.is_not_a_pointer(), "expected NotAPointer, got {err:?}");
520    }
521
522    #[test]
523    fn oversized_input_is_not_a_pointer() {
524        let big = vec![b'x'; MAX_POINTER_SIZE + 1];
525        let err = Pointer::parse(&big).unwrap_err();
526        assert!(matches!(
527            err,
528            DecodeError::NotAPointer(NotAPointerReason::TooLarge { .. })
529        ));
530    }
531
532    #[test]
533    fn exactly_max_size_is_not_a_pointer() {
534        // Spec: pointer files must be *less than* 1024 bytes. At-cutoff is too large.
535        let exact = vec![b'x'; MAX_POINTER_SIZE];
536        let err = Pointer::parse(&exact).unwrap_err();
537        assert!(matches!(
538            err,
539            DecodeError::NotAPointer(NotAPointerReason::TooLarge { .. })
540        ));
541    }
542
543    #[test]
544    fn equals_separator_is_not_a_pointer() {
545        // From upstream's TestDecodeInvalid: bad `key value` format using '='.
546        let s = "version=https://git-lfs.github.com/spec/v1\n\
547                 oid=sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\n\
548                 size=fif";
549        let err = Pointer::parse(s.as_bytes()).unwrap_err();
550        assert!(err.is_not_a_pointer());
551    }
552
553    #[test]
554    fn no_marker_is_not_a_pointer() {
555        let err = Pointer::parse(b"version=http://wat.io/v/2\noid=foo\nsize=fif").unwrap_err();
556        assert!(matches!(
557            err,
558            DecodeError::NotAPointer(NotAPointerReason::MissingHeader)
559        ));
560    }
561
562    #[test]
563    fn missing_version_first_is_not_a_pointer() {
564        // OID line first, no version. From upstream's "no version" case.
565        let s = format!("oid sha256:{SAMPLE_OID_HEX}\nsize 12345\n");
566        let err = Pointer::parse(s.as_bytes()).unwrap_err();
567        assert!(err.is_not_a_pointer(), "got {err:?}");
568    }
569
570    #[test]
571    fn extra_line_after_size_is_not_a_pointer() {
572        let s = format!(
573            "version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\nwat wat\n"
574        );
575        let err = Pointer::parse(s.as_bytes()).unwrap_err();
576        assert!(matches!(
577            err,
578            DecodeError::NotAPointer(NotAPointerReason::ExtraLine { .. })
579        ));
580    }
581
582    // ---------- parse: Malformed ----------
583
584    #[test]
585    fn invalid_version_is_malformed() {
586        // Non-empty version that isn't an alias.
587        let s = format!(
588            "version http://git-media.io/v/whatever\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\n"
589        );
590        let err = Pointer::parse(s.as_bytes()).unwrap_err();
591        assert!(matches!(
592            err,
593            DecodeError::Malformed(MalformedReason::InvalidVersion(_))
594        ));
595    }
596
597    #[test]
598    fn missing_oid_is_malformed() {
599        let s = format!("version {VERSION_LATEST}\nsize 12345\n");
600        let err = Pointer::parse(s.as_bytes()).unwrap_err();
601        assert!(matches!(err, DecodeError::Malformed(_)));
602    }
603
604    #[test]
605    fn missing_size_is_malformed() {
606        let s = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\n");
607        let err = Pointer::parse(s.as_bytes()).unwrap_err();
608        assert!(matches!(
609            err,
610            DecodeError::Malformed(MalformedReason::MissingField("size"))
611        ));
612    }
613
614    #[test]
615    fn keys_out_of_order_is_malformed() {
616        let s = format!("version {VERSION_LATEST}\nsize 12345\noid sha256:{SAMPLE_OID_HEX}\n");
617        let err = Pointer::parse(s.as_bytes()).unwrap_err();
618        assert!(matches!(
619            err,
620            DecodeError::Malformed(MalformedReason::UnexpectedKey { .. })
621        ));
622    }
623
624    #[test]
625    fn bad_oid_hex_is_malformed() {
626        let s = format!("version {VERSION_LATEST}\noid sha256:boom\nsize 12345\n");
627        let err = Pointer::parse(s.as_bytes()).unwrap_err();
628        assert!(matches!(
629            err,
630            DecodeError::Malformed(MalformedReason::InvalidOidHash(_))
631        ));
632    }
633
634    #[test]
635    fn bad_oid_type_is_malformed() {
636        let s = format!("version {VERSION_LATEST}\noid shazam:{SAMPLE_OID_HEX}\nsize 12345\n");
637        let err = Pointer::parse(s.as_bytes()).unwrap_err();
638        assert!(matches!(
639            err,
640            DecodeError::Malformed(MalformedReason::UnsupportedOidType(_))
641        ));
642    }
643
644    #[test]
645    fn bad_size_is_malformed() {
646        let s = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize fif\n");
647        let err = Pointer::parse(s.as_bytes()).unwrap_err();
648        assert!(matches!(
649            err,
650            DecodeError::Malformed(MalformedReason::InvalidSize(_))
651        ));
652    }
653
654    #[test]
655    fn negative_size_is_malformed() {
656        let s = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize -1\n");
657        let err = Pointer::parse(s.as_bytes()).unwrap_err();
658        assert!(matches!(
659            err,
660            DecodeError::Malformed(MalformedReason::InvalidSize(_))
661        ));
662    }
663
664    #[test]
665    fn oid_with_trailing_garbage_is_malformed() {
666        let s = format!(
667            "version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}&\nsize 177735\n"
668        );
669        let err = Pointer::parse(s.as_bytes()).unwrap_err();
670        assert!(matches!(
671            err,
672            DecodeError::Malformed(MalformedReason::InvalidOidHash(_))
673        ));
674    }
675
676    // ---------- parse: extensions ----------
677
678    #[test]
679    fn ext_priority_over_9_is_malformed() {
680        // ext-10-foo: priority must be a single digit (matches upstream regex).
681        let s = format!(
682            "version {VERSION_LATEST}\n\
683             ext-10-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
684             oid sha256:{SAMPLE_OID_HEX}\n\
685             size 12345\n",
686        );
687        let err = Pointer::parse(s.as_bytes()).unwrap_err();
688        assert!(matches!(err, DecodeError::Malformed(_)), "got {err:?}");
689    }
690
691    #[test]
692    fn ext_with_non_digit_priority_is_malformed() {
693        let s = format!(
694            "version {VERSION_LATEST}\n\
695             ext-#-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
696             oid sha256:{SAMPLE_OID_HEX}\n\
697             size 12345\n",
698        );
699        let err = Pointer::parse(s.as_bytes()).unwrap_err();
700        assert!(matches!(err, DecodeError::Malformed(_)), "got {err:?}");
701    }
702
703    #[test]
704    fn ext_with_non_word_name_is_malformed() {
705        let s = format!(
706            "version {VERSION_LATEST}\n\
707             ext-0-$$$$ sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
708             oid sha256:{SAMPLE_OID_HEX}\n\
709             size 12345\n",
710        );
711        let err = Pointer::parse(s.as_bytes()).unwrap_err();
712        assert!(matches!(err, DecodeError::Malformed(_)), "got {err:?}");
713    }
714
715    #[test]
716    fn ext_bad_oid_is_malformed() {
717        let s = format!(
718            "version {VERSION_LATEST}\n\
719             ext-0-foo sha256:boom\n\
720             oid sha256:{SAMPLE_OID_HEX}\n\
721             size 12345\n",
722        );
723        let err = Pointer::parse(s.as_bytes()).unwrap_err();
724        assert!(matches!(
725            err,
726            DecodeError::Malformed(MalformedReason::InvalidOidHash(_))
727        ));
728    }
729
730    #[test]
731    fn ext_bad_oid_type_is_malformed() {
732        let s = format!(
733            "version {VERSION_LATEST}\n\
734             ext-0-foo boom:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
735             oid sha256:{SAMPLE_OID_HEX}\n\
736             size 12345\n",
737        );
738        let err = Pointer::parse(s.as_bytes()).unwrap_err();
739        assert!(matches!(
740            err,
741            DecodeError::Malformed(MalformedReason::UnsupportedOidType(_))
742        ));
743    }
744
745    #[test]
746    fn duplicate_ext_priority_is_malformed() {
747        let s = format!(
748            "version {VERSION_LATEST}\n\
749             ext-0-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
750             ext-0-bar sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
751             oid sha256:{SAMPLE_OID_HEX}\n\
752             size 12345\n",
753        );
754        let err = Pointer::parse(s.as_bytes()).unwrap_err();
755        assert!(matches!(
756            err,
757            DecodeError::Malformed(MalformedReason::DuplicateExtensionPriority(0))
758        ));
759    }
760}