Skip to main content

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, &lt;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/`).