crates/gunnar-lfs/src/oid.rs
//! The LFS object id, and why it is **not** a git object id.
//!
//! git hashes `<kind> <len>\0<content>`; LFS hashes the content and nothing
//! else. The two ids for the same bytes are different values, so
//! [`gunnar_store::ObjectStore`](../../gunnar_store/trait.ObjectStore.html),
//! whose whole contract is "the id git would give this object", cannot hold an
//! LFS object under its own oid. That is the first of the two reasons LFS gets
//! its own store trait; the second is streaming (see [`crate::store`]).
//!
//! # The validation is load-bearing, not tidiness
//!
//! [`Oid::parse`] accepts **exactly 64 lowercase hex characters** and nothing
//! else. The `fs` backend uses the rendered oid directly as three path
//! components, so this check is what makes `../../../etc/passwd` unrepresentable
//! *as a value* rather than something a backend has to remember to reject. Fixed
//! by construction: one writer, one implementation. Not a guard watching two
//! copies agree.
use std::fmt;
/// A git-lfs object id: SHA-256 over the object's raw bytes.
///
/// Constructed only through [`Oid::parse`] or [`Oid::from_digest`], so a value of
/// this type is always 32 bytes that render as 64 lowercase hex characters.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Oid([u8; 32]);
/// Why a string is not an LFS oid.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum OidError {
/// Not 64 characters.
#[error("an lfs oid is 64 hex characters, got {0}")]
Length(usize),
/// A character outside `0-9a-f`.
///
/// **Uppercase is refused deliberately.** git-lfs sends lowercase, and
/// accepting both would give one object two spellings: two directory
/// entries, two catalog keys, and a `contains` that answers differently
/// depending on which spelling it was asked with.
#[error("an lfs oid is lowercase hex; byte {at} is {byte:?}")]
NotHex {
/// Where the offending character is.
at: usize,
/// What it was.
byte: char,
},
}
impl Oid {
/// Parse the wire form: 64 lowercase hex characters.
pub fn parse(text: &str) -> Result<Oid, OidError> {
let bytes = text.as_bytes();
if bytes.len() != 64 {
return Err(OidError::Length(text.chars().count()));
}
let mut out = [0u8; 32];
for (i, pair) in bytes.chunks_exact(2).enumerate() {
let hi = nibble(pair[0]).ok_or(OidError::NotHex {
at: i * 2,
byte: pair[0] as char,
})?;
let lo = nibble(pair[1]).ok_or(OidError::NotHex {
at: i * 2 + 1,
byte: pair[1] as char,
})?;
out[i] = (hi << 4) | lo;
}
Ok(Oid(out))
}
/// The oid of `bytes`: SHA-256 of the raw content, which is what an LFS
/// oid *is*.
///
/// One shot, for a caller that already holds the whole object — a test
/// fixture, or `MemLfsStore::insert`. Anything that streams uses
/// [`Sha256Stream`] through [`crate::VerifyingSink`], because an LFS
/// object is a video and this signature would be the OOM. It exists so
/// nothing outside this crate has to answer *"which SHA-256"* for itself
/// (one implementation).
pub fn of(bytes: &[u8]) -> Oid {
let mut hasher = Sha256Stream::new();
hasher.update(bytes);
hasher.finish()
}
/// Wrap a finished SHA-256 digest.
pub fn from_digest(digest: [u8; 32]) -> Oid {
Oid(digest)
}
/// The raw digest.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
/// The wire form: 64 lowercase hex characters.
pub fn to_hex(self) -> String {
let mut out = String::with_capacity(64);
for byte in self.0 {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
/// The first `n` hex characters, for the `fs` backend's fan-out.
#[cfg(feature = "fs")]
pub(crate) fn hex_prefix(self, n: usize) -> String {
self.to_hex().chars().take(n).collect()
}
}
const HEX: &[u8; 16] = b"0123456789abcdef";
fn nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
_ => None,
}
}
impl fmt::Display for Oid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl fmt::Debug for Oid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Oid({})", self.to_hex())
}
}
/// A streaming SHA-256, the one hasher an LFS object is ever measured with.
///
/// Wrapping `gix_hash` rather than reaching for `sha2` directly keeps gunnar's
/// answer to *"which SHA-256"* in one place, and boxing it keeps this type
/// small: `gix_hash::Hasher` is ~824 bytes because it is sized by its largest
/// variant, and it is held across a whole transfer.
pub(crate) struct Sha256Stream(Box<gix_hash::Hasher>);
impl Sha256Stream {
pub(crate) fn new() -> Self {
Sha256Stream(Box::new(gix_hash::hasher(gix_hash::Kind::Sha256)))
}
pub(crate) fn update(&mut self, bytes: &[u8]) {
self.0.update(bytes);
}
/// The digest, as an [`Oid`].
pub(crate) fn finish(self) -> Oid {
// `try_finalize` is fallible only for SHA-1, where gix runs UBC
// collision detection. SHA-256 has no such check and cannot fail, but
// the `expect` names that, rather than swallowing a real error.
let id = (*self.0)
.try_finalize()
.expect("sha256 finalisation has no failure mode");
match id {
gix_hash::ObjectId::Sha256(bytes) => Oid(bytes),
other => unreachable!("asked for sha256, got {:?}", other.kind()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const REAL: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[test]
fn a_real_oid_round_trips() {
assert_eq!(Oid::parse(REAL).unwrap().to_hex(), REAL);
}
#[test]
fn the_hash_of_the_empty_input_is_the_known_sha256_of_nothing() {
// The known answer, from `printf '' | sha256sum`. Not recomputed here,
// which would only prove gunnar agrees with itself.
let empty = Sha256Stream::new().finish();
assert_eq!(empty.to_hex(), REAL);
// The red half: a hasher that ignored its input would still pass above.
let mut one = Sha256Stream::new();
one.update(b"abc");
assert_eq!(
one.finish().to_hex(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn everything_that_is_not_an_oid_is_refused() {
// The path-traversal shapes the `fs` backend would otherwise have to
// reject for itself. They are not "unlikely input": the oid comes
// straight off the wire and is used as a path component.
for bad in [
"",
"../../../etc/passwd",
"..",
"/",
&REAL[..63],
&format!("{REAL}0"),
&REAL.to_uppercase(),
&format!("{}/{}", &REAL[..32], &REAL[33..]),
&REAL.replace('e', "g"),
] {
assert!(
Oid::parse(bad).is_err(),
"{bad:?} must not parse as an oid; it becomes a path component"
);
}
// The identity-value half: a parser that rejected everything would pass
// the loop above and look identical.
assert!(Oid::parse(REAL).is_ok());
}
}