keyhog_core/git_lfs.rs
1//! Git-LFS pointer recognition.
2//!
3//! A file tracked by [Git LFS](https://git-lfs.github.com) is committed not as
4//! its real bytes but as a tiny text *pointer*, the actual blob lives in LFS
5//! storage and is only materialised on `git lfs pull`. A canonical pointer is:
6//!
7//! ```text
8//! version https://git-lfs.github.com/spec/v1
9//! oid sha256:<64 lowercase hex>
10//! size <decimal bytes>
11//! ```
12//!
13//! The spec fixes the `version` line first and then lists the remaining keys in
14//! alphabetical order, so `oid` always precedes `size`; optional `ext-*` lines
15//! may appear between `version` and `oid`.
16//!
17//! Two consumers share this recognition, which is why it lives in `core`:
18//! * the scanner suppresses the pointer's 64-hex `oid` (it is a content hash,
19//! not a leaked secret, yet matches a generic high-entropy hex shape), and
20//! * a source records a coverage gap, the pointer's real blob (`size` bytes)
21//! was NOT scanned, so a repo of unmaterialised LFS pointers is not
22//! reported as a false-clean.
23//!
24//! Recognition is deliberately strict (all three well-formed lines, in order):
25//! a false positive would suppress a real credential, and a whole-file pointer
26//! is unambiguous, so strictness costs no recall.
27
28/// The exact first line of every Git-LFS pointer. Compared case-insensitively
29/// because the recognition is content-classification, not byte-exact parsing.
30pub const GIT_LFS_VERSION_LINE: &str = "version https://git-lfs.github.com/spec/v1";
31
32/// The number of hex characters in a `sha256` object id.
33///
34/// Canonical owner for the whole `keyhog-core` crate: a `sha256` digest is 32
35/// bytes, i.e. 64 lowercase hex characters, whether it names a Git-LFS blob
36/// (here) or a compiled-pattern cache file (`hardening.rs`).
37pub const SHA256_HEX_LEN: usize = 64;
38
39/// True if `line` (ignoring surrounding ASCII whitespace) is the Git-LFS
40/// `version` line.
41pub fn is_git_lfs_version_line(line: &[u8]) -> bool {
42 line.trim_ascii()
43 .eq_ignore_ascii_case(GIT_LFS_VERSION_LINE.as_bytes())
44}
45
46/// True if `line` is a Git-LFS `oid sha256:<64 hex>` line. The 64-hex body is
47/// what the scanner must NOT flag as a secret.
48pub fn is_git_lfs_oid_line(line: &[u8]) -> bool {
49 let Some(rest) = line.trim_ascii().strip_prefix(b"oid sha256:") else {
50 return false;
51 };
52 rest.len() == SHA256_HEX_LEN && rest.iter().all(u8::is_ascii_hexdigit)
53}
54
55/// True if `line` is a Git-LFS `size <decimal>` line.
56pub fn is_git_lfs_size_line(line: &[u8]) -> bool {
57 let Some(rest) = line.trim_ascii().strip_prefix(b"size ") else {
58 return false;
59 };
60 !rest.is_empty() && rest.iter().all(u8::is_ascii_digit)
61}
62
63/// True if `content` is a whole Git-LFS pointer file: a `version` line, then an
64/// `oid` line, then a `size` line, in that spec-mandated order. Lines that are
65/// none of the three (e.g. optional `ext-*` lines, or blank lines) are tolerated
66/// between the anchors, matching real pointers.
67///
68/// Cheap: an O(n) single pass over the (tiny, <200 byte) pointer, and callers
69/// that scan large files should gate on the size/prefix first, a real pointer
70/// begins with [`GIT_LFS_VERSION_LINE`].
71pub fn is_git_lfs_pointer(content: &[u8]) -> bool {
72 let mut has_version = false;
73 let mut has_oid = false;
74 for line in content.split(|&b| b == b'\n' || b == b'\r') {
75 if !has_version {
76 has_version = is_git_lfs_version_line(line);
77 continue;
78 }
79 if !has_oid {
80 has_oid = is_git_lfs_oid_line(line);
81 continue;
82 }
83 if is_git_lfs_size_line(line) {
84 return true;
85 }
86 }
87 false
88}
89
90// Tests live in `tests/unit/git_lfs_sha256_hex_len_owner.rs` (KH-GAP-004: no
91// inline test modules in `src/`).