use thiserror::Error;
#[derive(Debug, Error)]
pub enum HashError {
#[error("unsupported hash algorithm: {0}")]
UnsupportedAlgorithm(String),
#[error("invalid hash encoding")]
InvalidEncoding,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum HashAlgorithm {
Sha256,
Sha512,
Sha1,
Md5,
}
impl HashAlgorithm {
pub fn from_nix_str(s: &str) -> Result<Self, HashError> {
s.parse()
}
#[must_use]
pub fn as_nix_str(&self) -> &'static str {
match self {
Self::Sha256 => "sha256",
Self::Sha512 => "sha512",
Self::Sha1 => "sha1",
Self::Md5 => "md5",
}
}
#[must_use]
pub fn digest_len(&self) -> usize {
match self {
Self::Sha256 => 32,
Self::Sha512 => 64,
Self::Sha1 => 20,
Self::Md5 => 16,
}
}
}
impl std::fmt::Display for HashAlgorithm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_nix_str())
}
}
impl std::str::FromStr for HashAlgorithm {
type Err = HashError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"sha256" => Ok(Self::Sha256),
"sha512" => Ok(Self::Sha512),
"sha1" => Ok(Self::Sha1),
"md5" => Ok(Self::Md5),
_ => Err(HashError::UnsupportedAlgorithm(s.to_string())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NixHash {
pub algorithm: HashAlgorithm,
pub digest: Vec<u8>,
}
impl NixHash {
pub fn new(algorithm: HashAlgorithm, digest: Vec<u8>) -> Self {
Self { algorithm, digest }
}
#[must_use]
pub fn to_nix_string(&self) -> String {
format!("{}:{}", self.algorithm, hex::encode(&self.digest))
}
#[must_use]
pub fn to_sri(&self) -> String {
format!("{}-{}", self.algorithm, base64_encode(&self.digest))
}
#[must_use]
pub fn to_hex(&self) -> String {
hex::encode(&self.digest)
}
pub fn parse_any(
algorithm: HashAlgorithm,
raw: &str,
) -> Result<Self, HashError> {
let digest = decode_hash_any(algorithm, raw)?;
Ok(Self::new(algorithm, digest))
}
}
pub fn decode_hash_any(
algo: HashAlgorithm,
raw: &str,
) -> Result<Vec<u8>, HashError> {
let want = algo.digest_len();
let hex_len = want * 2;
let base32_len = (want * 8 + 4) / 5;
let sri_prefix = format!("{}-", algo.as_nix_str());
if let Some(b64) = raw.strip_prefix(&sri_prefix) {
let bytes = base64_decode(b64)?;
if bytes.len() != want {
return Err(HashError::InvalidEncoding);
}
return Ok(bytes);
}
if raw.len() == hex_len
&& raw.chars().all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
{
let bytes = hex::decode(raw)?;
return Ok(bytes);
}
if raw.len() == base32_len {
let alphabet = "0123456789abcdfghijklmnpqrsvwxyz";
if raw.chars().all(|c| alphabet.contains(c)) {
let bytes = decode_nix_base32(raw, want)?;
return Ok(bytes);
}
}
Err(HashError::InvalidEncoding)
}
fn decode_nix_base32(input: &str, want: usize) -> Result<Vec<u8>, HashError> {
let alphabet = b"0123456789abcdfghijklmnpqrsvwxyz";
let mut digit_value = [0u8; 128];
for (i, &c) in alphabet.iter().enumerate() {
digit_value[c as usize] = i as u8;
}
let mut out = vec![0u8; want];
let bytes = input.as_bytes();
for (n, &c) in bytes.iter().rev().enumerate() {
let d = digit_value[c as usize] as usize;
let b = n * 5;
let i = b / 8;
let j = b % 8;
out[i] |= ((d << j) & 0xff) as u8;
if i + 1 < want {
out[i + 1] |= (d >> (8 - j)) as u8;
} else if (d >> (8 - j)) != 0 {
return Err(HashError::InvalidEncoding);
}
}
Ok(out)
}
impl std::fmt::Display for NixHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.algorithm, hex::encode(&self.digest))
}
}
#[must_use]
pub fn base64_encode(input: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(input)
}
pub fn base64_decode(input: &str) -> Result<Vec<u8>, HashError> {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(input)
.map_err(|_| HashError::InvalidEncoding)
}
#[must_use]
pub fn minimal_base64_encode(input: &[u8]) -> String {
base64_encode(input)
}
pub(crate) mod hex {
#[must_use]
pub fn encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
pub fn decode(s: &str) -> Result<Vec<u8>, super::HashError> {
if !s.len().is_multiple_of(2) {
return Err(super::HashError::InvalidEncoding);
}
(0..s.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&s[i..i + 2], 16)
.map_err(|_| super::HashError::InvalidEncoding)
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn parse_any_accepts_64_char_hex_sha256() {
let raw = "0".repeat(64);
let h = NixHash::parse_any(HashAlgorithm::Sha256, &raw).unwrap();
assert_eq!(h.digest, vec![0u8; 32]);
}
#[test]
fn parse_any_accepts_52_char_base32_sha256() {
let raw = "0".repeat(52);
let h = NixHash::parse_any(HashAlgorithm::Sha256, &raw).unwrap();
assert_eq!(h.digest, vec![0u8; 32]);
}
#[test]
fn parse_any_accepts_sri_sha256() {
let sri = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
let h = NixHash::parse_any(HashAlgorithm::Sha256, sri).unwrap();
assert_eq!(h.digest, vec![0u8; 32]);
}
#[test]
fn parse_any_rejects_wrong_length() {
let bad = "0".repeat(50);
assert!(NixHash::parse_any(HashAlgorithm::Sha256, &bad).is_err());
}
#[test]
fn parse_any_rejects_invalid_alphabet() {
let bad = format!("Z{}", "0".repeat(51));
assert!(NixHash::parse_any(HashAlgorithm::Sha256, &bad).is_err());
}
proptest! {
#[test]
fn parse_any_all_formats_agree(digest in proptest::collection::vec(any::<u8>(), 32..=32)) {
let nh = NixHash::new(HashAlgorithm::Sha256, digest.clone());
let hex_str = hex::encode(&digest);
let sri = nh.to_sri();
let mut b32 = String::with_capacity(52);
let alphabet = b"0123456789abcdfghijklmnpqrsvwxyz";
for n in (0..52usize).rev() {
let b = n * 5;
let i = b / 8;
let j = b % 8;
let mut v = (digest[i] as usize) >> j;
if i + 1 < 32 {
v |= (digest[i + 1] as usize) << (8 - j);
}
b32.push(alphabet[v & 0x1f] as char);
}
let parsed_hex = NixHash::parse_any(HashAlgorithm::Sha256, &hex_str).unwrap();
let parsed_b32 = NixHash::parse_any(HashAlgorithm::Sha256, &b32).unwrap();
let parsed_sri = NixHash::parse_any(HashAlgorithm::Sha256, &sri).unwrap();
prop_assert_eq!(&parsed_hex.digest, &digest);
prop_assert_eq!(&parsed_b32.digest, &digest);
prop_assert_eq!(&parsed_sri.digest, &digest);
}
}
#[test]
fn algorithm_roundtrip() {
for algo in [HashAlgorithm::Sha256, HashAlgorithm::Sha512, HashAlgorithm::Sha1, HashAlgorithm::Md5] {
let parsed = HashAlgorithm::from_nix_str(algo.as_nix_str()).unwrap();
assert_eq!(parsed, algo);
}
}
#[test]
fn nix_string_format() {
let hash = NixHash::new(HashAlgorithm::Sha256, vec![0xab; 32]);
let s = hash.to_nix_string();
assert!(s.starts_with("sha256:"));
assert_eq!(s.len(), 7 + 64); }
#[test]
fn sri_roundtrip() {
let digest = vec![0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef];
let hash = NixHash::new(HashAlgorithm::Sha256, digest.clone());
let sri = hash.to_sri();
assert!(sri.starts_with("sha256-"));
let b64_part = sri.strip_prefix("sha256-").unwrap();
assert!(!b64_part.is_empty());
assert_eq!(b64_part.len(), 44);
}
#[test]
fn invalid_algorithm_string() {
assert!(HashAlgorithm::from_nix_str("blake2b").is_err());
assert!(HashAlgorithm::from_nix_str("SHA256").is_err());
assert!(HashAlgorithm::from_nix_str("").is_err());
assert!(HashAlgorithm::from_nix_str("sha-256").is_err());
match HashAlgorithm::from_nix_str("unknown") {
Err(HashError::UnsupportedAlgorithm(s)) => assert_eq!(s, "unknown"),
other => panic!("expected UnsupportedAlgorithm, got {other:?}"),
}
}
#[test]
fn hash_digest_length_per_algorithm() {
assert_eq!(HashAlgorithm::Sha256.digest_len(), 32);
assert_eq!(HashAlgorithm::Sha512.digest_len(), 64);
assert_eq!(HashAlgorithm::Sha1.digest_len(), 20);
assert_eq!(HashAlgorithm::Md5.digest_len(), 16);
}
#[test]
fn hex_encode_decode_roundtrip() {
let original = vec![0x00, 0x11, 0x22, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
let encoded = hex::encode(&original);
assert_eq!(encoded, "001122aabbccddeeff");
let decoded = hex::decode(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn hex_decode_odd_length() {
assert!(hex::decode("abc").is_err());
}
#[test]
fn hex_decode_invalid_chars() {
assert!(hex::decode("zzzz").is_err());
assert!(hex::decode("gg").is_err());
}
#[test]
fn hex_roundtrip_all_byte_values() {
let all_bytes: Vec<u8> = (0..=255).collect();
let encoded = hex::encode(&all_bytes);
let decoded = hex::decode(&encoded).unwrap();
assert_eq!(decoded, all_bytes);
}
#[test]
fn empty_digest_handling() {
let hash = NixHash::new(HashAlgorithm::Sha256, vec![]);
let nix_str = hash.to_nix_string();
assert_eq!(nix_str, "sha256:");
let sri = hash.to_sri();
assert_eq!(sri, "sha256-");
}
#[test]
fn base64_encode_known_vectors() {
assert_eq!(minimal_base64_encode(b""), "");
assert_eq!(minimal_base64_encode(b"f"), "Zg==");
assert_eq!(minimal_base64_encode(b"fo"), "Zm8=");
assert_eq!(minimal_base64_encode(b"foo"), "Zm9v");
assert_eq!(minimal_base64_encode(b"foob"), "Zm9vYg==");
assert_eq!(minimal_base64_encode(b"fooba"), "Zm9vYmE=");
assert_eq!(minimal_base64_encode(b"foobar"), "Zm9vYmFy");
}
#[test]
fn nix_string_with_all_algorithms() {
for algo in [HashAlgorithm::Sha256, HashAlgorithm::Sha512, HashAlgorithm::Sha1, HashAlgorithm::Md5] {
let digest = vec![0xab; algo.digest_len()];
let hash = NixHash::new(algo, digest);
let s = hash.to_nix_string();
let expected_prefix = format!("{}:", algo.as_nix_str());
assert!(s.starts_with(&expected_prefix), "failed for {algo:?}");
let hex_part = s.strip_prefix(&expected_prefix).unwrap();
assert_eq!(hex_part.len(), algo.digest_len() * 2);
}
}
#[test]
fn base64_decode_known_vectors() {
assert_eq!(base64_decode("").unwrap(), b"");
assert_eq!(base64_decode("Zg==").unwrap(), b"f");
assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
}
#[test]
fn base64_decode_invalid_input() {
assert!(base64_decode("!!!invalid!!!").is_err());
}
#[test]
fn base64_roundtrip_binary() {
let data: Vec<u8> = (0..=255).collect();
let encoded = base64_encode(&data);
let decoded = base64_decode(&encoded).unwrap();
assert_eq!(decoded, data);
}
#[test]
fn sri_format_all_algorithms() {
for algo in [HashAlgorithm::Sha256, HashAlgorithm::Sha512, HashAlgorithm::Sha1, HashAlgorithm::Md5] {
let digest = vec![0x42; algo.digest_len()];
let hash = NixHash::new(algo, digest);
let sri = hash.to_sri();
let prefix = format!("{}-", algo.as_nix_str());
assert!(sri.starts_with(&prefix), "SRI for {algo:?} should start with {prefix}");
}
}
#[test]
fn sri_base64_decode_matches_digest() {
let digest = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C];
let hash = NixHash::new(HashAlgorithm::Sha256, digest.clone());
let sri = hash.to_sri();
let b64_part = sri.strip_prefix("sha256-").unwrap();
let decoded = base64_decode(b64_part).unwrap();
assert_eq!(decoded, digest);
}
#[test]
fn hex_encode_empty() {
assert_eq!(hex::encode(&[]), "");
}
#[test]
fn hex_decode_empty() {
assert_eq!(hex::decode("").unwrap(), Vec::<u8>::new());
}
#[test]
fn hex_decode_accepts_uppercase() {
assert_eq!(hex::decode("AABB").unwrap(), vec![0xAA, 0xBB]);
}
#[test]
fn nix_hash_equality() {
let h1 = NixHash::new(HashAlgorithm::Sha256, vec![1; 32]);
let h2 = NixHash::new(HashAlgorithm::Sha256, vec![1; 32]);
let h3 = NixHash::new(HashAlgorithm::Sha256, vec![2; 32]);
let h4 = NixHash::new(HashAlgorithm::Sha1, vec![1; 20]);
assert_eq!(h1, h2);
assert_ne!(h1, h3);
assert_ne!(h1, h4);
}
#[test]
fn hash_algorithm_is_copy() {
let a = HashAlgorithm::Sha256;
let b = a;
assert_eq!(a, b);
}
#[test]
fn hash_algorithm_display_strings() {
assert_eq!(format!("{}", HashAlgorithm::Sha256), "sha256");
assert_eq!(format!("{}", HashAlgorithm::Sha512), "sha512");
assert_eq!(format!("{}", HashAlgorithm::Sha1), "sha1");
assert_eq!(format!("{}", HashAlgorithm::Md5), "md5");
}
#[test]
fn hash_algorithm_from_str_via_parse() {
let algo: HashAlgorithm = "sha256".parse().unwrap();
assert_eq!(algo, HashAlgorithm::Sha256);
let algo: HashAlgorithm = "sha512".parse().unwrap();
assert_eq!(algo, HashAlgorithm::Sha512);
let algo: HashAlgorithm = "sha1".parse().unwrap();
assert_eq!(algo, HashAlgorithm::Sha1);
let algo: HashAlgorithm = "md5".parse().unwrap();
assert_eq!(algo, HashAlgorithm::Md5);
let result: Result<HashAlgorithm, _> = "blake2b".parse();
assert!(result.is_err());
}
#[test]
fn nix_hash_display_format_matches_to_nix_string() {
let hash = NixHash::new(HashAlgorithm::Sha256, vec![0xab, 0xcd, 0xef]);
let displayed = format!("{hash}");
let manual = hash.to_nix_string();
assert_eq!(displayed, manual);
assert_eq!(displayed, "sha256:abcdef");
}
#[test]
fn nix_hash_new_stores_fields() {
let h = NixHash::new(HashAlgorithm::Sha512, vec![1, 2, 3]);
assert_eq!(h.algorithm, HashAlgorithm::Sha512);
assert_eq!(h.digest, vec![1, 2, 3]);
}
#[test]
fn minimal_base64_encode_matches_base64_encode() {
let data = b"hello world";
assert_eq!(minimal_base64_encode(data), base64_encode(data));
}
#[test]
fn base64_encode_no_padding_needed() {
assert_eq!(base64_encode(b"abc"), "YWJj");
}
#[test]
fn base64_encode_one_padding() {
assert_eq!(base64_encode(b"f"), "Zg==");
}
#[test]
fn base64_encode_two_padding() {
assert_eq!(base64_encode(b"fo"), "Zm8=");
}
#[test]
fn hex_decode_odd_length_error_variant() {
match hex::decode("a") {
Err(HashError::InvalidEncoding) => {}
other => panic!("expected InvalidEncoding, got {other:?}"),
}
}
#[test]
fn hex_decode_invalid_chars_error_variant() {
match hex::decode("zz") {
Err(HashError::InvalidEncoding) => {}
other => panic!("expected InvalidEncoding, got {other:?}"),
}
}
#[test]
fn hash_error_display_strings() {
let err = HashError::UnsupportedAlgorithm("blake3".to_string());
let s = format!("{err}");
assert!(s.contains("blake3"));
let err = HashError::InvalidEncoding;
let s = format!("{err}");
assert!(s.contains("invalid"));
}
#[test]
fn digest_len_for_all_algorithms() {
let cases = [
(HashAlgorithm::Md5, 16),
(HashAlgorithm::Sha1, 20),
(HashAlgorithm::Sha256, 32),
(HashAlgorithm::Sha512, 64),
];
for (algo, expected_len) in cases {
assert_eq!(algo.digest_len(), expected_len);
}
}
#[test]
fn algorithm_display_roundtrip_through_from_nix_str() {
for algo in [
HashAlgorithm::Sha256,
HashAlgorithm::Sha512,
HashAlgorithm::Sha1,
HashAlgorithm::Md5,
] {
let s = format!("{algo}");
let parsed = HashAlgorithm::from_nix_str(&s).unwrap();
assert_eq!(parsed, algo);
}
}
#[test]
fn hex_roundtrip_lengths_5_10_20_32_64() {
for len in [5, 10, 20, 32, 64] {
let data: Vec<u8> = (0..len).map(|i| i as u8).collect();
let encoded = hex::encode(&data);
assert_eq!(encoded.len(), len * 2);
let decoded = hex::decode(&encoded).unwrap();
assert_eq!(decoded, data);
}
}
#[test]
fn base64_roundtrip_lengths_1_through_10() {
for len in 1..=10 {
let data: Vec<u8> = (0..len).map(|i| i as u8).collect();
let encoded = base64_encode(&data);
let decoded = base64_decode(&encoded).unwrap();
assert_eq!(decoded, data, "failed for length {len}");
}
}
#[test]
fn hash_algorithm_in_hashset() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(HashAlgorithm::Sha256);
set.insert(HashAlgorithm::Sha256);
set.insert(HashAlgorithm::Md5);
assert_eq!(set.len(), 2);
assert!(set.contains(&HashAlgorithm::Sha256));
assert!(set.contains(&HashAlgorithm::Md5));
assert!(!set.contains(&HashAlgorithm::Sha512));
}
#[test]
fn nix_hash_empty_digest_to_sri() {
let hash = NixHash::new(HashAlgorithm::Md5, vec![]);
let sri = hash.to_sri();
assert_eq!(sri, "md5-");
}
#[test]
fn nix_hash_empty_digest_display() {
let hash = NixHash::new(HashAlgorithm::Sha1, vec![]);
assert_eq!(format!("{hash}"), "sha1:");
}
#[test]
fn hex_encode_known_vectors() {
assert_eq!(hex::encode(b"\x00"), "00");
assert_eq!(hex::encode(b"\xff"), "ff");
assert_eq!(hex::encode(b"\x01\x02\x03\x04"), "01020304");
assert_eq!(hex::encode(b"\xde\xad\xbe\xef"), "deadbeef");
}
#[test]
fn hex_encode_lowercase_only() {
let encoded = hex::encode(&[0xAB, 0xCD, 0xEF]);
assert_eq!(encoded, "abcdef");
assert!(encoded.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
}
#[test]
fn base64_decode_invalid_returns_invalid_encoding() {
match base64_decode("@@@") {
Err(HashError::InvalidEncoding) => {}
other => panic!("expected InvalidEncoding, got {other:?}"),
}
}
#[test]
fn sha512_full_digest_sri_length() {
let hash = NixHash::new(HashAlgorithm::Sha512, vec![0xAA; 64]);
let sri = hash.to_sri();
let b64 = sri.strip_prefix("sha512-").unwrap();
assert_eq!(b64.len(), 88);
}
#[test]
fn sha1_full_digest_hex_length() {
let hash = NixHash::new(HashAlgorithm::Sha1, vec![0x55; 20]);
let hex_part = hash.to_nix_string();
let h = hex_part.strip_prefix("sha1:").unwrap();
assert_eq!(h.len(), 40); }
}