Skip to main content

git_policy_cli/
lfs_pointer.rs

1//! What:
2//!  Whether a blob is a Git LFS pointer, decided the way `git-lfs` 3.8.1 decides it when it scans a push.
3//! Why:
4//!  The upload step finds the LFS objects the pushed commits introduce without starting `git-lfs`. A blob counts when
5//!  the scanner would hand it to the transfer queue: smaller than the cutoff (`lfs/scanner.go`, `BlobSizeCutoff`), a
6//!  header that names `git-media`, `hawser` or `git-lfs` (`lfs/pointer.go`, `matcherRE`), lines in the order `version`,
7//!  `oid`, `size` with a known version, an `sha256:` object name of 64 lower-case hexadecimal digits and a non-negative
8//!  decimal size; a pointer of size zero is skipped by `prepareUpload` (`commands/uploader.go`). The path of the blob and
9//!  `.gitattributes` play no part. A spelling the port is not sure about (extension lines, non-ASCII edges) is a
10//!  decline, so the caller falls back to `git-lfs` itself.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const verdict = decodePointer(blob); // { kind: 'not-a-pointer' } | { kind: 'pointer', oid, size } | { kind: 'decline' }
15//! ```
16
17/// The blob size from which a blob is never read as a pointer.
18pub const BLOB_SIZE_CUTOFF: usize = 1024;
19
20/// The versions `git-lfs` accepts (`v1Aliases`).
21const VERSIONS: [&[u8]; 3] = [b"http://git-media.io/v/2", b"https://hawser.github.com/spec/v1", b"https://git-lfs.github.com/spec/v1"];
22
23/// The keys of a pointer in their order.
24const KEYS: [&[u8]; 3] = [b"version", b"oid", b"size"];
25
26/// The words one of which a pointer's text must hold (`matcherRE`).
27const MATCHERS: [&[u8]; 3] = [b"git-media", b"hawser", b"git-lfs"];
28
29/// What a blob is.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub enum Verdict {
32    /// `git-lfs` would not upload anything for this blob.
33    NotUploadable,
34    /// `git-lfs` would upload the object with this name (lower-case hexadecimal SHA-256).
35    Pointer(String),
36    /// The port cannot be sure; ask `git-lfs`.
37    Decline,
38}
39
40/// Whether `haystack` holds `needle`.
41fn contains(haystack: &[u8], needle: &[u8]) -> bool {
42    return haystack.windows(needle.len()).any(|window| {
43        return window == needle;
44    });
45}
46
47/// The object name of an `sha256:<64 digits>` value.
48fn parse_oid(value: &[u8]) -> Option<String> {
49    let digits: &[u8] = value.strip_prefix(b"sha256:")?;
50    let valid: bool = digits.len() == 64 && digits.iter().all(|byte| {
51        return matches!(byte, b'0'..=b'9' | b'a'..=b'f');
52    });
53    return valid.then(|| {
54        return String::from_utf8_lossy(digits).into_owned();
55    });
56}
57
58/// Whether a key starts like an extension key, `ext-` and one digit (`extRE`, which `git-lfs` matches as a prefix).
59fn is_extension_key(key: &[u8]) -> bool {
60    return key.len() > 6
61        && key.starts_with(b"ext-")
62        && key[4].is_ascii_digit()
63        && key[5] == b'-'
64        && (key[6].is_ascii_alphanumeric() || key[6] == b'_');
65}
66
67/// What:
68///  The verdict for the content of one blob.
69/// Why:
70///   One pass over at most [`BLOB_SIZE_CUTOFF`] bytes.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// function decodePointer(blob: Uint8Array): Verdict;
75/// ```
76pub fn decode_pointer(blob: &[u8]) -> Verdict {
77    if blob.len() >= BLOB_SIZE_CUTOFF || blob.is_empty() {
78        return Verdict::NotUploadable;
79    }
80    if !MATCHERS.iter().any(|word| {
81        return contains(blob, word);
82    }) {
83        return Verdict::NotUploadable;
84    }
85    let trimmed: &[u8] = blob.trim_ascii();
86    // Go trims Unicode white space too; a non-ASCII edge is not worth guessing about.
87    let odd_edge: bool = trimmed.first().is_some_and(|byte| {
88        return !byte.is_ascii();
89    }) || trimmed.last().is_some_and(|byte| {
90        return !byte.is_ascii();
91    });
92    let mut values: [Option<&[u8]>; 3] = [None; 3];
93    let mut line_number: usize = 0;
94    for raw in trimmed.split(|byte| {
95        return *byte == b'\n';
96    }) {
97        let line: &[u8] = raw.strip_suffix(b"\r").unwrap_or(raw);
98        if line.is_empty() {
99            continue;
100        }
101        let Some(space) = line.iter().position(|byte| {
102            return *byte == b' ';
103        }) else {
104            return if odd_edge { Verdict::Decline } else { Verdict::NotUploadable };
105        };
106        let (key, value): (&[u8], &[u8]) = (&line[..space], &line[space + 1..]);
107        if line_number >= KEYS.len() {
108            return if odd_edge { Verdict::Decline } else { Verdict::NotUploadable };
109        }
110        if key != KEYS[line_number] {
111            // Extension lines carry their own objects and priorities; leave them to `git-lfs`.
112            return if is_extension_key(key) || odd_edge { Verdict::Decline } else { Verdict::NotUploadable };
113        }
114        values[line_number] = Some(value);
115        line_number += 1;
116    }
117    let [Some(version), Some(oid), Some(size)] = values else {
118        return if odd_edge { Verdict::Decline } else { Verdict::NotUploadable };
119    };
120    let size_value: Option<i64> = std::str::from_utf8(size).ok().and_then(|text| {
121        return text.parse::<i64>().ok();
122    });
123    let (Some(name), Some(bytes)) = (parse_oid(oid), size_value.filter(|number| {
124        return *number >= 0;
125    })) else {
126        return if odd_edge { Verdict::Decline } else { Verdict::NotUploadable };
127    };
128    if !VERSIONS.contains(&version) {
129        return Verdict::NotUploadable;
130    }
131    return if bytes == 0 { Verdict::NotUploadable } else { Verdict::Pointer(name) };
132}
133
134/// The decoding is controlled in the test module.
135#[cfg(test)]
136#[path = "lfs_pointer_tests.rs"]
137mod tests;