//! Parser for Git LFS pointer files (spec v1, https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md). //! A pointer is a small text blob: //! //! ```text //! version https://git-lfs.github.com/spec/v1 //! oid sha256:<64 lowercase hex> //! size //! ``` //! //! Extra `key value` lines are allowed. The parser is pure and accepts nothing else. use crate::lfs::is_valid_oid; /// Pointer files are about 130 bytes. Anything larger is never treated as a pointer. pub const MAX_POINTER_BYTES: usize = 1024; const VERSION_LINE: &str = "version https://git-lfs.github.com/spec/v1"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Pointer { /// The SHA-256 of the object, lowercase hex, without the `sha256:` prefix. pub oid: String, /// Object size in bytes. pub size: u64, } /// Returns the pointer described by `data`, or `None` when `data` is not a valid pointer. pub fn parse(data: &[u8]) -> Option { if data.len() > MAX_POINTER_BYTES { return None; } let text = std::str::from_utf8(data).ok()?; let mut lines = text.strip_suffix('\n').unwrap_or(text).split('\n'); if lines.next()? != VERSION_LINE { return None; } let mut oid = None; let mut size = None; for line in lines { let (key, value) = line.split_once(' ')?; match key { "oid" => { let hex = value.strip_prefix("sha256:")?; if oid.is_some() || !is_valid_oid(hex) { return None; } oid = Some(hex.to_owned()); } "size" => { if size.is_some() || value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { return None; } size = Some(value.parse::().ok()?); } // Other keys (such as `ext-0-...`) are allowed and ignored. _ => {} } } Some(Pointer { oid: oid?, size: size?, }) } #[cfg(test)] mod tests { use super::*; const OID: &str = "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393"; fn pointer(oid: &str, size: &str) -> String { format!("{VERSION_LINE}\noid sha256:{oid}\nsize {size}\n") } #[test] fn parses_a_standard_pointer() { let got = parse(pointer(OID, "12345").as_bytes()); assert_eq!( got, Some(Pointer { oid: OID.to_owned(), size: 12345 }) ); } #[test] fn accepts_missing_trailing_newline_and_extra_keys() { let text = format!("{VERSION_LINE}\next-0-x sha256:abc\noid sha256:{OID}\nsize 0"); assert_eq!( parse(text.as_bytes()), Some(Pointer { oid: OID.to_owned(), size: 0 }) ); } #[test] fn rejects_wrong_version_line() { let text = format!("version https://example.com/spec/v1\noid sha256:{OID}\nsize 1\n"); assert_eq!(parse(text.as_bytes()), None); let text = format!("{VERSION_LINE} \noid sha256:{OID}\nsize 1\n"); assert_eq!(parse(text.as_bytes()), None); } #[test] fn rejects_bad_oid() { assert_eq!(parse(pointer(&OID.to_uppercase(), "1").as_bytes()), None); assert_eq!(parse(pointer(&OID[..63], "1").as_bytes()), None); assert_eq!(parse(pointer(&format!("{OID}0"), "1").as_bytes()), None); let text = format!("{VERSION_LINE}\noid md5:{OID}\nsize 1\n"); assert_eq!(parse(text.as_bytes()), None); } #[test] fn rejects_bad_size() { assert_eq!(parse(pointer(OID, "").as_bytes()), None); assert_eq!(parse(pointer(OID, "-1").as_bytes()), None); assert_eq!(parse(pointer(OID, "1.5").as_bytes()), None); assert_eq!(parse(pointer(OID, "0x10").as_bytes()), None); assert_eq!( parse(pointer(OID, "99999999999999999999999").as_bytes()), None ); } #[test] fn rejects_missing_or_duplicate_fields() { let no_size = format!("{VERSION_LINE}\noid sha256:{OID}\n"); assert_eq!(parse(no_size.as_bytes()), None); let no_oid = format!("{VERSION_LINE}\nsize 1\n"); assert_eq!(parse(no_oid.as_bytes()), None); let twice = format!("{VERSION_LINE}\noid sha256:{OID}\noid sha256:{OID}\nsize 1\n"); assert_eq!(parse(twice.as_bytes()), None); } #[test] fn rejects_oversized_and_non_text_input() { let big = format!("{VERSION_LINE}\n{}", "x".repeat(MAX_POINTER_BYTES)); assert_eq!(parse(big.as_bytes()), None); assert_eq!(parse(&[0xff, 0xfe, 0x00]), None); assert_eq!(parse(b""), None); assert_eq!(parse(b"hello\nworld\n"), None); } }