Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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());
    }
}