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