use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
use crate::SpecError;
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
#[tatara(keyword = "defhash-algorithm")]
pub struct HashAlgorithm {
pub name: String,
#[serde(rename = "bitLength")]
pub bit_length: u32,
pub weakness: HashWeakness,
#[serde(rename = "nixPrefix")]
pub nix_prefix: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashWeakness {
Strong,
Deprecated,
Broken,
}
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
#[tatara(keyword = "defhash-encoding")]
pub struct HashEncoding {
pub name: String,
pub alphabet: String,
#[serde(default, rename = "preferredByNixFor")]
pub preferred_by_nix_for: Vec<NixHashContext>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NixHashContext {
NarHash,
StorePathHash,
NarSignature,
FodOutputHash,
FlakeInputNarHash,
FlakeInputSri,
}
fn from_base16(s: &str) -> Result<Vec<u8>, SpecError> {
if s.len() % 2 != 0 {
return Err(SpecError::Interp {
phase: "hash-decode".into(),
message: format!("base16 string `{s}` has odd length {}", s.len()),
});
}
let mut out = Vec::with_capacity(s.len() / 2);
let chars: Vec<char> = s.chars().collect();
for chunk in chars.chunks(2) {
let pair: String = chunk.iter().collect();
let byte = u8::from_str_radix(&pair, 16).map_err(|e| SpecError::Interp {
phase: "hash-decode".into(),
message: format!("invalid hex byte `{pair}`: {e}"),
})?;
out.push(byte);
}
Ok(out)
}
fn to_base16(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn from_nix_base32(s: &str) -> Result<Vec<u8>, SpecError> {
const ALPHABET: &[u8] = b"0123456789abcdfghijklmnpqrsvwxyz";
let mut idx_table = [0xff_u8; 256];
for (i, b) in ALPHABET.iter().enumerate() {
idx_table[*b as usize] = i as u8;
}
let n = (s.len() * 5) / 8;
let mut bytes = vec![0u8; n];
let n_chars = s.len();
for (n_offset, c) in s.chars().enumerate() {
let digit = idx_table[c as usize] as u16;
if (digit as u8) == 0xff {
return Err(SpecError::Interp {
phase: "hash-decode".into(),
message: format!("char `{c}` not in nix-base32 alphabet"),
});
}
let encoder_n = n_chars - 1 - n_offset;
let b = encoder_n * 5;
let i = b / 8;
let j = b % 8;
if i < n {
bytes[i] |= digit.wrapping_shl(j as u32) as u8 & 0xff;
}
if j + 5 > 8 && i + 1 < n {
bytes[i + 1] |= digit.wrapping_shr((8 - j) as u32) as u8;
}
}
Ok(bytes)
}
fn to_nix_base32(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"0123456789abcdfghijklmnpqrsvwxyz";
let n_chars = (bytes.len() * 8).div_ceil(5);
let mut out = String::with_capacity(n_chars);
for n in (0..n_chars).rev() {
let b = n * 5;
let i = b / 8;
let j = b % 8;
let c = (bytes[i] as u16).wrapping_shr(j as u32)
| if i + 1 < bytes.len() {
(bytes[i + 1] as u16).wrapping_shl((8 - j) as u32)
} else { 0 };
out.push(ALPHABET[(c & 0x1f) as usize] as char);
}
out
}
fn from_base64(s: &str) -> Result<Vec<u8>, SpecError> {
use base64::Engine as _;
let s = s.trim_end_matches('=');
base64::engine::general_purpose::STANDARD_NO_PAD
.decode(s.as_bytes())
.map_err(|e| SpecError::Interp {
phase: "hash-decode".into(),
message: format!("invalid base64 `{s}`: {e}"),
})
}
fn to_base64(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub fn decode_hash(input: &str) -> Result<(String, Vec<u8>), SpecError> {
if let Some(rest) = input.find(':') {
let algo = &input[..rest];
let payload = &input[rest + 1..];
if payload.chars().all(|c| c.is_ascii_hexdigit())
&& payload.len() % 2 == 0
{
return Ok((algo.into(), from_base16(payload)?));
}
return Ok((algo.into(), from_nix_base32(payload)?));
}
if let Some(dash) = input.find('-') {
let algo = &input[..dash];
let payload = &input[dash + 1..];
return Ok((algo.into(), from_base64(payload)?));
}
Ok((String::new(), from_base16(input)?))
}
pub fn encode_hash(
algorithm: &str,
encoding: &str,
bytes: &[u8],
) -> Result<String, SpecError> {
match encoding {
"base16" => Ok(to_base16(bytes)),
"nix-base32" => Ok(format!("{algorithm}:{}", to_nix_base32(bytes))),
"base64" => Ok(format!("{algorithm}:{}", to_base64(bytes))),
"sri" => Ok(format!("{algorithm}-{}", to_base64(bytes))),
_ => Err(SpecError::Interp {
phase: "hash-encode".into(),
message: format!("unknown encoding `{encoding}`"),
}),
}
}
pub fn apply_conversion(
from_encoding: &str,
to_encoding: &str,
input: &str,
) -> Result<String, SpecError> {
let _ = from_encoding;
let (algo, bytes) = decode_hash(input)?;
encode_hash(&algo, to_encoding, &bytes)
}
pub const CANONICAL_HASH_LISP: &str = include_str!("../specs/hash.lisp");
pub fn load_canonical_algorithms() -> Result<Vec<HashAlgorithm>, SpecError> {
crate::loader::load_all::<HashAlgorithm>(CANONICAL_HASH_LISP)
}
pub fn load_canonical_encodings() -> Result<Vec<HashEncoding>, SpecError> {
crate::loader::load_all::<HashEncoding>(CANONICAL_HASH_LISP)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn canonical_algorithms_parse() {
let algos = load_canonical_algorithms().unwrap();
assert!(!algos.is_empty());
let names: HashSet<&str> = algos.iter().map(|a| a.name.as_str()).collect();
for required in ["sha1", "sha256", "sha512", "md5", "blake3"] {
assert!(
names.contains(required),
"canonical hash algos missing `{required}`",
);
}
}
#[test]
fn sha256_is_strong() {
let algos = load_canonical_algorithms().unwrap();
let sha256 = algos.iter().find(|a| a.name == "sha256").unwrap();
assert_eq!(sha256.weakness, HashWeakness::Strong);
assert_eq!(sha256.bit_length, 256);
}
#[test]
fn md5_is_broken() {
let algos = load_canonical_algorithms().unwrap();
let md5 = algos.iter().find(|a| a.name == "md5").unwrap();
assert_eq!(md5.weakness, HashWeakness::Broken);
}
#[test]
fn canonical_encodings_parse() {
let encs = load_canonical_encodings().unwrap();
let names: HashSet<&str> = encs.iter().map(|e| e.name.as_str()).collect();
for required in ["base16", "nix-base32", "base64", "sri"] {
assert!(
names.contains(required),
"canonical encodings missing `{required}`",
);
}
}
#[test]
fn nix_base32_is_preferred_for_storepath() {
let encs = load_canonical_encodings().unwrap();
let b32 = encs.iter().find(|e| e.name == "nix-base32").unwrap();
assert!(
b32.preferred_by_nix_for.contains(&NixHashContext::StorePathHash),
"nix-base32 must be the canonical store-path encoding",
);
}
#[test]
fn sri_is_preferred_for_flake_input() {
let encs = load_canonical_encodings().unwrap();
let sri = encs.iter().find(|e| e.name == "sri").unwrap();
assert!(
sri.preferred_by_nix_for.contains(&NixHashContext::FlakeInputSri),
"sri must be the canonical flake-input hash format",
);
}
#[test]
fn base16_roundtrip() {
let bytes = b"hello world";
let hex = to_base16(bytes);
let back = from_base16(&hex).unwrap();
assert_eq!(back, bytes);
}
#[test]
fn base16_rejects_odd_length() {
let err = from_base16("abc").unwrap_err();
match err {
SpecError::Interp { phase, .. } => assert_eq!(phase, "hash-decode"),
_ => panic!("expected hash-decode"),
}
}
#[test]
fn base16_rejects_invalid_chars() {
let err = from_base16("xyzw").unwrap_err();
match err {
SpecError::Interp { phase, .. } => assert_eq!(phase, "hash-decode"),
_ => panic!("expected hash-decode"),
}
}
#[test]
fn base64_roundtrip() {
let bytes = b"some bytes here";
let b64 = to_base64(bytes);
let back = from_base64(&b64).unwrap();
assert_eq!(back, bytes);
}
#[test]
fn nix_base32_uses_alphabet_only() {
const ALPHABET: &[u8] = b"0123456789abcdfghijklmnpqrsvwxyz";
let bytes = b"some test bytes";
let b32 = to_nix_base32(bytes);
for c in b32.bytes() {
assert!(
ALPHABET.contains(&c),
"char `{}` not in nix-base32 alphabet",
c as char,
);
}
let expected_len = (bytes.len() * 8).div_ceil(5);
assert_eq!(b32.len(), expected_len);
}
#[test]
fn decode_hash_handles_colon_hex() {
let (algo, bytes) = decode_hash("sha256:deadbeef").unwrap();
assert_eq!(algo, "sha256");
assert_eq!(bytes, [0xde, 0xad, 0xbe, 0xef]);
}
#[test]
fn decode_hash_handles_sri() {
let bytes_in = b"some bytes";
let b64 = to_base64(bytes_in);
let sri = format!("sha256-{b64}");
let (algo, bytes) = decode_hash(&sri).unwrap();
assert_eq!(algo, "sha256");
assert_eq!(bytes, bytes_in);
}
#[test]
fn encode_hash_emits_sri_with_dash() {
let bytes = b"x";
let out = encode_hash("sha256", "sri", bytes).unwrap();
assert!(out.starts_with("sha256-"));
}
#[test]
fn encode_hash_emits_nix_base32_with_colon() {
let bytes = b"x";
let out = encode_hash("sha256", "nix-base32", bytes).unwrap();
assert!(out.starts_with("sha256:"));
}
#[test]
fn encode_unknown_encoding_errors() {
let err = encode_hash("sha256", "rot13", b"x").unwrap_err();
match err {
SpecError::Interp { phase, .. } => assert_eq!(phase, "hash-encode"),
_ => panic!("expected hash-encode"),
}
}
#[test]
fn convert_hex_to_sri() {
let out = apply_conversion("base16", "sri", "sha256:deadbeef").unwrap();
assert!(out.starts_with("sha256-"));
assert!(out.len() > 10);
}
}