crates/gunnar-pack/src/checksum.rs
//! The hasher a pack **trailer** is computed with, and why it is not the one
//! object ids are computed with.
//!
//! # Read this before "fixing" it
//!
//! `gix_hash::hasher(Kind::Sha1)` is `sha1_checked::Sha1` with UBC collision
//! detection switched on (`gix-hash/src/hasher.rs`), which is exactly what git
//! itself does with `sha1dc`. Detection routes every block through a pure-Rust
//! compression function, so it never reaches this CPU's `sha_ni` unit.
//! MEASURED on oden (Threadripper PRO 3975WX, `sha_ni` present), 2026-08-03:
//! **371 MiB/s with detection vs 1983 MiB/s without, i.e. 5.3×.**
//!
//! **That cost is worth paying for an object id, and this module does not touch
//! object ids.** A SHA-1 object id names content the server was handed by an
//! untrusted client; two colliding blobs with one id is the attack sha1dc
//! exists to catch, and gunnar computes object ids through
//! `gix_object::compute_hash`, which still goes through `gix_hash::hasher`.
//! Nothing here changes that, and nothing here should.
//!
//! **A pack trailer is a different object with a different threat model.** It
//! is a checksum over the bytes the writer itself just produced, appended to
//! those same bytes, and read back by the receiver to detect truncation or
//! corruption in transit. It names nothing and is trusted for nothing. An
//! attacker who could find a second byte string with the same trailer has
//! achieved what he could already achieve for free by sending a different pack
//! with its own honest trailer. The trailer is not a commitment made by anyone
//! but the sender. Collision detection there buys no property, so it is pure
//! cost.
//!
//! The distinction is between two *roles* that happened to share one call, not
//! between "safe" and "fast". Which is also why the fix is a separate hasher
//! for the checksum role and emphatically **not**
//! `sha1_checked::Builder::default().detect_collision(false)` as a global
//! switch: that would weaken the object-id path too, which is the one place the
//! detection earns its keep.
//!
//! # Why this is not a compatibility risk
//!
//! sha1dc and plain SHA-1 compute the *same digest* for every input that is not
//! a collision attack; detection changes the return value, not the hash. So
//! the trailer written here is byte-for-byte the trailer `gix_hash::hasher`
//! would have written, for every real pack. `tests/trailer_hash.rs` pins that
//! against `sha1sum`/`sha256sum` rather than against gunnar's own arithmetic.
//!
//! SHA-256 is left with gix's hasher deliberately: `gix_hash` uses `sha2` there
//! with no detection layer, it already runs on `sha_ni` (measured 1860 MiB/s),
//! and there is nothing to win by deviating.
//!
//! # The one way this gets undone by accident (`P-020`)
//!
//! `P-001`'s prescribed fix says to route `write_pack` through
//! `gix_pack::data::output` "rather than hand-rolling, per LAW 5", and the last
//! stage of that pipeline is `bytes::FromEntriesIter`. Its sink is a **private**
//! `output: gix_hash::io::Write<W>` field built inside `new()`, and
//! `gix_hash::io::Write::new` hardcodes `gix_hash::hasher(object_hash)`. There
//! is no constructor, builder or field access that admits a different hasher,
//! so adopting it hands the trailer straight back to sha1dc, and does so in
//! the level-0 shape where `P-008` measured **4.02×** rather than 1.06×,
//! because pack-copy removes the deflate that used to dominate.
//!
//! Use gix for **entry production only** (`count::objects` →
//! `entry::iter_from_counts`) and keep this writer as the byte emitter. Every
//! type crossing that seam is public; LAW 5 is satisfied by reusing the
//! delta/pack-copy logic, which is the part that is hard.
//!
//! Not a note but a guard:
//! `crates/gunnar-pack/tests/trailer_hash.rs::the_pack_trailer_is_not_paying_collision_detection`
//! fails if a level-0 pack write cannot outrun sha1dc on its own output. The
//! correctness tests in that file cannot see this, because a pack written
//! either way is byte-identical, so that one is the only thing standing between
//! here and a silent 4× regression.
use gix_hash::{Kind, ObjectId};
use crate::Result;
/// A hasher for the *checksum* role: pack and index trailers.
///
/// Never use this where an object id is produced. See the module docs.
pub(crate) enum TrailerHasher {
/// Plain SHA-1, reaching `sha_ni`. Collision detection is deliberately
/// absent; read the module docs before changing this.
Sha1(sha1::Sha1),
/// Everything else (today only SHA-256), straight from `gix_hash`, which
/// is already `sha2` on `sha_ni` there. `gix_hash::Kind` is
/// `#[non_exhaustive]`, so this arm is also what a hash kind added upstream
/// tomorrow lands in: the fallback is *gix's* answer, which is by
/// construction the correct one, merely not the fastest one.
///
/// Boxed because `gix_hash::Hasher` is ~824 bytes. It is sized by its own
/// SHA-1 variant's UBC collision-detection state, which the SHA-256 arm
/// carries and never uses. Inline, that made every `PackWriter` on a SHA-1
/// repository eight times larger than the hasher it actually holds. One
/// allocation per pack is not a cost worth counting next to the writer's
/// 64 KiB deflate scratch.
Gix(Box<gix_hash::Hasher>),
}
impl TrailerHasher {
/// A trailer hasher for a repository using `hash_kind`.
pub(crate) fn new(hash_kind: Kind) -> Self {
match hash_kind {
Kind::Sha1 => TrailerHasher::Sha1(<sha1::Sha1 as sha1::Digest>::new()),
_ => TrailerHasher::Gix(Box::new(gix_hash::hasher(hash_kind))),
}
}
/// Feed `bytes` to the checksum.
pub(crate) fn update(&mut self, bytes: &[u8]) {
match self {
TrailerHasher::Sha1(sha1) => sha1::Digest::update(sha1, bytes),
TrailerHasher::Gix(gix) => gix.update(bytes),
}
}
/// Produce the trailer.
pub(crate) fn finalize(self) -> Result<ObjectId> {
match self {
TrailerHasher::Sha1(sha1) => Ok(ObjectId::Sha1(sha1::Digest::finalize(sha1).into())),
TrailerHasher::Gix(gix) => (*gix).try_finalize().map_err(crate::Error::Hasher),
}
}
}