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 = parse_oid_value(value).map_err(DecodeError::Malformed)?;
184            extensions.push(Extension {
185                name: name.to_owned(),
186                priority,
187                oid: ext_oid,
188            });
189            continue;
190        }
191
192        // Not a required key, not an extension. If this happens before the
193        // version line, treat as NotAPointer (matches upstream's
194        // StandardizeBadPointerError); otherwise it's a malformed pointer.
195        return Err(if expected == "version" {
196            DecodeError::NotAPointer(NotAPointerReason::NotVersionFirst { got: key.into() })
197        } else {
198            DecodeError::Malformed(MalformedReason::UnexpectedKey {
199                expected,
200                got: key.into(),
201            })
202        });
203    }
204
205    let version = filled[0].ok_or(DecodeError::NotAPointer(NotAPointerReason::MissingVersion))?;
206    if !VERSION_ALIASES.contains(&version) {
207        return Err(DecodeError::Malformed(MalformedReason::InvalidVersion(
208            version.into(),
209        )));
210    }
211
212    let oid_value =
213        filled[1].ok_or(DecodeError::Malformed(MalformedReason::MissingField("oid")))?;
214    let oid = parse_oid_value(oid_value).map_err(DecodeError::Malformed)?;
215
216    let size_value = filled[2].ok_or(DecodeError::Malformed(MalformedReason::MissingField(
217        "size",
218    )))?;
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.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
268        return None;
269    }
270    Some((bytes[0] - b'0', name))
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
274pub enum DecodeError {
275    /// The input does not look like a pointer at all.
276    #[error("not a git-lfs pointer: {0}")]
277    NotAPointer(NotAPointerReason),
278    /// The input has pointer shape but is invalid.
279    #[error("malformed git-lfs pointer: {0}")]
280    Malformed(MalformedReason),
281}
282
283impl DecodeError {
284    /// `true` if the input doesn't look like a pointer; the smudge filter
285    /// should pass the bytes through unchanged in this case.
286    pub fn is_not_a_pointer(&self) -> bool {
287        matches!(self, DecodeError::NotAPointer(_))
288    }
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
292pub enum NotAPointerReason {
293    #[error("size {size} bytes is not below the {MAX_POINTER_SIZE}-byte cutoff")]
294    TooLarge { size: usize },
295    #[error("input is not valid UTF-8")]
296    NotUtf8,
297    #[error("missing git-lfs spec marker")]
298    MissingHeader,
299    #[error("line {line} has no key/value separator")]
300    MalformedLine { line: usize },
301    #[error("missing version line")]
302    MissingVersion,
303    #[error("first key is {got:?}, expected version")]
304    NotVersionFirst { got: String },
305    #[error("extra content on line {line}: {content:?}")]
306    ExtraLine { line: usize, content: String },
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
310pub enum MalformedReason {
311    #[error("unrecognized version: {0:?}")]
312    InvalidVersion(String),
313    #[error("expected key {expected:?}, got {got:?}")]
314    UnexpectedKey { expected: &'static str, got: String },
315    #[error("missing required {0:?} line")]
316    MissingField(&'static str),
317    #[error("oid value {0:?} is not in the form <type>:<hash>")]
318    MalformedOidValue(String),
319    #[error("unsupported oid type {0:?}; only sha256 is supported")]
320    UnsupportedOidType(String),
321    #[error("invalid oid hash: {0}")]
322    InvalidOidHash(#[source] OidParseError),
323    #[error("size value {0:?} is not a non-negative integer")]
324    InvalidSize(String),
325    #[error("duplicate extension priority {0}")]
326    DuplicateExtensionPriority(u8),
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn sha(hex: &str) -> Oid {
334        Oid::from_hex(hex).unwrap()
335    }
336
337    const SAMPLE_OID_HEX: &str = "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393";
338
339    // ---------- encode ----------
340
341    #[test]
342    fn encode_simple() {
343        let p = Pointer::new(sha(SAMPLE_OID_HEX), 12345);
344        let expected =
345            format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\n");
346        assert_eq!(p.encode(), expected);
347    }
348
349    #[test]
350    fn encode_empty() {
351        // Per spec, the empty pointer encodes to the empty string.
352        assert_eq!(Pointer::empty().encode(), "");
353        // Any pointer with size 0 also encodes to "" (matches upstream).
354        let p = Pointer::new(sha(SAMPLE_OID_HEX), 0);
355        assert_eq!(p.encode(), "");
356    }
357
358    #[test]
359    fn encode_extensions_sorted_on_output() {
360        let exts = vec![
361            Extension {
362                name: "baz".into(),
363                priority: 2,
364                oid: sha("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
365            },
366            Extension {
367                name: "foo".into(),
368                priority: 0,
369                oid: sha("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
370            },
371            Extension {
372                name: "bar".into(),
373                priority: 1,
374                oid: sha("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
375            },
376        ];
377        let p = Pointer {
378            oid: sha(SAMPLE_OID_HEX),
379            size: 12345,
380            extensions: exts,
381            canonical: true,
382        };
383        let expected = format!(
384            "version {VERSION_LATEST}\n\
385             ext-0-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
386             ext-1-bar sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
387             ext-2-baz sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
388             oid sha256:{SAMPLE_OID_HEX}\n\
389             size 12345\n",
390        );
391        assert_eq!(p.encode(), expected);
392    }
393
394    // ---------- parse: happy paths ----------
395
396    #[test]
397    fn parse_standard() {
398        let input = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\n");
399        let p = Pointer::parse(input.as_bytes()).unwrap();
400        assert_eq!(p.oid, sha(SAMPLE_OID_HEX));
401        assert_eq!(p.size, 12345);
402        assert!(p.extensions.is_empty());
403        assert!(p.canonical);
404    }
405
406    #[test]
407    fn parse_empty_input_is_empty_pointer() {
408        let p = Pointer::parse(b"").unwrap();
409        assert_eq!(p, Pointer::empty());
410        assert!(p.canonical);
411    }
412
413    #[test]
414    fn parse_extensions_sorted() {
415        let input = format!(
416            "version {VERSION_LATEST}\n\
417             ext-0-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
418             ext-1-bar sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
419             ext-2-baz sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
420             oid sha256:{SAMPLE_OID_HEX}\n\
421             size 12345\n",
422        );
423        let p = Pointer::parse(input.as_bytes()).unwrap();
424        assert_eq!(p.extensions.len(), 3);
425        assert_eq!(p.extensions[0].name, "foo");
426        assert_eq!(p.extensions[0].priority, 0);
427        assert_eq!(p.extensions[1].name, "bar");
428        assert_eq!(p.extensions[2].name, "baz");
429        assert!(p.canonical);
430    }
431
432    #[test]
433    fn parse_unsorted_extensions_sorts_and_marks_noncanonical() {
434        // Same content, but ext-2 listed first.
435        let input = format!(
436            "version {VERSION_LATEST}\n\
437             ext-2-baz sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
438             ext-0-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
439             ext-1-bar sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
440             oid sha256:{SAMPLE_OID_HEX}\n\
441             size 12345\n",
442        );
443        let p = Pointer::parse(input.as_bytes()).unwrap();
444        assert_eq!(p.extensions[0].priority, 0);
445        assert_eq!(p.extensions[1].priority, 1);
446        assert_eq!(p.extensions[2].priority, 2);
447        assert!(!p.canonical);
448    }
449
450    #[test]
451    fn parse_pre_release_version_alias() {
452        let input = format!(
453            "version https://hawser.github.com/spec/v1\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\n"
454        );
455        let p = Pointer::parse(input.as_bytes()).unwrap();
456        assert_eq!(p.size, 12345);
457        // Re-encoding rewrites version to latest, so input is NOT canonical.
458        assert!(!p.canonical);
459        assert!(
460            p.encode()
461                .starts_with(&format!("version {VERSION_LATEST}\n"))
462        );
463    }
464
465    #[test]
466    fn parse_round_trip() {
467        let p = Pointer::new(sha(SAMPLE_OID_HEX), 12345);
468        let encoded = p.encode();
469        let parsed = Pointer::parse(encoded.as_bytes()).unwrap();
470        assert_eq!(parsed.oid, p.oid);
471        assert_eq!(parsed.size, p.size);
472        assert!(parsed.canonical);
473    }
474
475    // ---------- canonical bytes ----------
476
477    #[test]
478    fn canonical_examples() {
479        // Standard form, with trailing \n.
480        let s = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\n");
481        assert!(Pointer::parse(s.as_bytes()).unwrap().canonical);
482
483        // Empty input.
484        assert!(Pointer::parse(b"").unwrap().canonical);
485    }
486
487    #[test]
488    fn non_canonical_examples() {
489        let cases: &[&str] = &[
490            // missing trailing newline
491            "version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 12345",
492            // CRLF line endings
493            "version https://git-lfs.github.com/spec/v1\r\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\r\nsize 12345\r\n",
494            // trailing whitespace on a line
495            "version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 12345   \n",
496        ];
497        for case in cases {
498            let p = Pointer::parse(case.as_bytes())
499                .unwrap_or_else(|e| panic!("failed to parse {case:?}: {e}"));
500            assert!(!p.canonical, "expected non-canonical for {case:?}");
501        }
502    }
503
504    // ---------- parse: NotAPointer ----------
505
506    #[test]
507    fn tiny_non_pointer_is_not_a_pointer() {
508        let err = Pointer::parse(b"this is not a git-lfs file!").unwrap_err();
509        assert!(err.is_not_a_pointer(), "expected NotAPointer, got {err:?}");
510    }
511
512    #[test]
513    fn header_only_is_not_a_pointer() {
514        // Mentions git-media so passes the marker check, but no key/value.
515        let err = Pointer::parse(b"# git-media").unwrap_err();
516        assert!(err.is_not_a_pointer(), "expected NotAPointer, got {err:?}");
517    }
518
519    #[test]
520    fn oversized_input_is_not_a_pointer() {
521        let big = vec![b'x'; MAX_POINTER_SIZE + 1];
522        let err = Pointer::parse(&big).unwrap_err();
523        assert!(matches!(
524            err,
525            DecodeError::NotAPointer(NotAPointerReason::TooLarge { .. })
526        ));
527    }
528
529    #[test]
530    fn exactly_max_size_is_not_a_pointer() {
531        // Spec: pointer files must be *less than* 1024 bytes. At-cutoff is too large.
532        let exact = vec![b'x'; MAX_POINTER_SIZE];
533        let err = Pointer::parse(&exact).unwrap_err();
534        assert!(matches!(
535            err,
536            DecodeError::NotAPointer(NotAPointerReason::TooLarge { .. })
537        ));
538    }
539
540    #[test]
541    fn equals_separator_is_not_a_pointer() {
542        // From upstream's TestDecodeInvalid: bad `key value` format using '='.
543        let s = "version=https://git-lfs.github.com/spec/v1\n\
544                 oid=sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\n\
545                 size=fif";
546        let err = Pointer::parse(s.as_bytes()).unwrap_err();
547        assert!(err.is_not_a_pointer());
548    }
549
550    #[test]
551    fn no_marker_is_not_a_pointer() {
552        let err = Pointer::parse(b"version=http://wat.io/v/2\noid=foo\nsize=fif").unwrap_err();
553        assert!(matches!(
554            err,
555            DecodeError::NotAPointer(NotAPointerReason::MissingHeader)
556        ));
557    }
558
559    #[test]
560    fn missing_version_first_is_not_a_pointer() {
561        // OID line first, no version. From upstream's "no version" case.
562        let s = format!("oid sha256:{SAMPLE_OID_HEX}\nsize 12345\n");
563        let err = Pointer::parse(s.as_bytes()).unwrap_err();
564        assert!(err.is_not_a_pointer(), "got {err:?}");
565    }
566
567    #[test]
568    fn extra_line_after_size_is_not_a_pointer() {
569        let s =
570            format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\nwat wat\n");
571        let err = Pointer::parse(s.as_bytes()).unwrap_err();
572        assert!(matches!(
573            err,
574            DecodeError::NotAPointer(NotAPointerReason::ExtraLine { .. })
575        ));
576    }
577
578    // ---------- parse: Malformed ----------
579
580    #[test]
581    fn invalid_version_is_malformed() {
582        // Non-empty version that isn't an alias.
583        let s = format!(
584            "version http://git-media.io/v/whatever\noid sha256:{SAMPLE_OID_HEX}\nsize 12345\n"
585        );
586        let err = Pointer::parse(s.as_bytes()).unwrap_err();
587        assert!(matches!(
588            err,
589            DecodeError::Malformed(MalformedReason::InvalidVersion(_))
590        ));
591    }
592
593    #[test]
594    fn missing_oid_is_malformed() {
595        let s = format!("version {VERSION_LATEST}\nsize 12345\n");
596        let err = Pointer::parse(s.as_bytes()).unwrap_err();
597        assert!(matches!(err, DecodeError::Malformed(_)));
598    }
599
600    #[test]
601    fn missing_size_is_malformed() {
602        let s = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\n");
603        let err = Pointer::parse(s.as_bytes()).unwrap_err();
604        assert!(matches!(
605            err,
606            DecodeError::Malformed(MalformedReason::MissingField("size"))
607        ));
608    }
609
610    #[test]
611    fn keys_out_of_order_is_malformed() {
612        let s = format!("version {VERSION_LATEST}\nsize 12345\noid sha256:{SAMPLE_OID_HEX}\n");
613        let err = Pointer::parse(s.as_bytes()).unwrap_err();
614        assert!(matches!(
615            err,
616            DecodeError::Malformed(MalformedReason::UnexpectedKey { .. })
617        ));
618    }
619
620    #[test]
621    fn bad_oid_hex_is_malformed() {
622        let s = format!("version {VERSION_LATEST}\noid sha256:boom\nsize 12345\n");
623        let err = Pointer::parse(s.as_bytes()).unwrap_err();
624        assert!(matches!(
625            err,
626            DecodeError::Malformed(MalformedReason::InvalidOidHash(_))
627        ));
628    }
629
630    #[test]
631    fn bad_oid_type_is_malformed() {
632        let s = format!("version {VERSION_LATEST}\noid shazam:{SAMPLE_OID_HEX}\nsize 12345\n");
633        let err = Pointer::parse(s.as_bytes()).unwrap_err();
634        assert!(matches!(
635            err,
636            DecodeError::Malformed(MalformedReason::UnsupportedOidType(_))
637        ));
638    }
639
640    #[test]
641    fn bad_size_is_malformed() {
642        let s = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize fif\n");
643        let err = Pointer::parse(s.as_bytes()).unwrap_err();
644        assert!(matches!(
645            err,
646            DecodeError::Malformed(MalformedReason::InvalidSize(_))
647        ));
648    }
649
650    #[test]
651    fn negative_size_is_malformed() {
652        let s = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}\nsize -1\n");
653        let err = Pointer::parse(s.as_bytes()).unwrap_err();
654        assert!(matches!(
655            err,
656            DecodeError::Malformed(MalformedReason::InvalidSize(_))
657        ));
658    }
659
660    #[test]
661    fn oid_with_trailing_garbage_is_malformed() {
662        let s = format!("version {VERSION_LATEST}\noid sha256:{SAMPLE_OID_HEX}&\nsize 177735\n");
663        let err = Pointer::parse(s.as_bytes()).unwrap_err();
664        assert!(matches!(
665            err,
666            DecodeError::Malformed(MalformedReason::InvalidOidHash(_))
667        ));
668    }
669
670    // ---------- parse: extensions ----------
671
672    #[test]
673    fn ext_priority_over_9_is_malformed() {
674        // ext-10-foo: priority must be a single digit (matches upstream regex).
675        let s = format!(
676            "version {VERSION_LATEST}\n\
677             ext-10-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
678             oid sha256:{SAMPLE_OID_HEX}\n\
679             size 12345\n",
680        );
681        let err = Pointer::parse(s.as_bytes()).unwrap_err();
682        assert!(matches!(err, DecodeError::Malformed(_)), "got {err:?}");
683    }
684
685    #[test]
686    fn ext_with_non_digit_priority_is_malformed() {
687        let s = format!(
688            "version {VERSION_LATEST}\n\
689             ext-#-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
690             oid sha256:{SAMPLE_OID_HEX}\n\
691             size 12345\n",
692        );
693        let err = Pointer::parse(s.as_bytes()).unwrap_err();
694        assert!(matches!(err, DecodeError::Malformed(_)), "got {err:?}");
695    }
696
697    #[test]
698    fn ext_with_non_word_name_is_malformed() {
699        let s = format!(
700            "version {VERSION_LATEST}\n\
701             ext-0-$$$$ sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
702             oid sha256:{SAMPLE_OID_HEX}\n\
703             size 12345\n",
704        );
705        let err = Pointer::parse(s.as_bytes()).unwrap_err();
706        assert!(matches!(err, DecodeError::Malformed(_)), "got {err:?}");
707    }
708
709    #[test]
710    fn ext_bad_oid_is_malformed() {
711        let s = format!(
712            "version {VERSION_LATEST}\n\
713             ext-0-foo sha256:boom\n\
714             oid sha256:{SAMPLE_OID_HEX}\n\
715             size 12345\n",
716        );
717        let err = Pointer::parse(s.as_bytes()).unwrap_err();
718        assert!(matches!(
719            err,
720            DecodeError::Malformed(MalformedReason::InvalidOidHash(_))
721        ));
722    }
723
724    #[test]
725    fn ext_bad_oid_type_is_malformed() {
726        let s = format!(
727            "version {VERSION_LATEST}\n\
728             ext-0-foo boom:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
729             oid sha256:{SAMPLE_OID_HEX}\n\
730             size 12345\n",
731        );
732        let err = Pointer::parse(s.as_bytes()).unwrap_err();
733        assert!(matches!(
734            err,
735            DecodeError::Malformed(MalformedReason::UnsupportedOidType(_))
736        ));
737    }
738
739    #[test]
740    fn duplicate_ext_priority_is_malformed() {
741        let s = format!(
742            "version {VERSION_LATEST}\n\
743             ext-0-foo sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\
744             ext-0-bar sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
745             oid sha256:{SAMPLE_OID_HEX}\n\
746             size 12345\n",
747        );
748        let err = Pointer::parse(s.as_bytes()).unwrap_err();
749        assert!(matches!(
750            err,
751            DecodeError::Malformed(MalformedReason::DuplicateExtensionPriority(0))
752        ));
753    }
754}