Skip to main content

sui_spec/
hash.rs

1//! Typed border for nix's hash representation.
2//!
3//! Every hash in nix has an algorithm (sha1/sha256/sha512/md5/blake3)
4//! and an encoding (base16 hex, nix-base32, RFC4648 base64, SRI
5//! `<algo>-<base64>=` format).  cppnix accepts hashes in any of these
6//! shapes; the canonical wire form depends on context (NAR signatures
7//! use base32, narinfo `NarHash:` uses sha256-base32, SRI is the
8//! flake-input default).
9//!
10//! This module names the algorithm registry + encoding registry as
11//! typed Lisp specs so conversion code rides on a typed contract.
12//!
13//! ## Authoring surface
14//!
15//! ```lisp
16//! (defhash-algorithm
17//!   :name "sha256"
18//!   :bit-length 256
19//!   :weakness Strong
20//!   :nix-prefix "sha256")
21//!
22//! (defhash-encoding
23//!   :name "nix-base32"
24//!   :alphabet "0123456789abcdfghijklmnpqrsvwxyz"
25//!   :preferred-by-nix-for (NarHash StorePathHash))
26//! ```
27
28use serde::{Deserialize, Serialize};
29use tatara_lisp::DeriveTataraDomain;
30
31use crate::SpecError;
32
33// ── Typed border — algorithms ──────────────────────────────────────
34
35/// One hash algorithm.
36#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
37#[tatara(keyword = "defhash-algorithm")]
38pub struct HashAlgorithm {
39    pub name: String,
40    #[serde(rename = "bitLength")]
41    pub bit_length: u32,
42    pub weakness: HashWeakness,
43    #[serde(rename = "nixPrefix")]
44    pub nix_prefix: String,
45}
46
47/// Cryptographic weakness — informational, drives some sui-policy
48/// decisions (e.g. refusing weak-hash flake-input signatures).
49#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
50pub enum HashWeakness {
51    /// Sha256, Sha512, Blake3.
52    Strong,
53    /// Sha1 — collision attacks demonstrated; accepted only when
54    /// already-encoded in legacy artifacts.
55    Deprecated,
56    /// Md5 — broken; accept only for backward-compat reads of
57    /// pre-Nix-2 derivations.
58    Broken,
59}
60
61// ── Typed border — encodings ───────────────────────────────────────
62
63/// One hash encoding (the format the hash bytes are rendered as).
64#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
65#[tatara(keyword = "defhash-encoding")]
66pub struct HashEncoding {
67    pub name: String,
68    /// Alphabet (display order).  For SRI, this is the field name
69    /// since the alphabet is base64 + the `=` padding.
70    pub alphabet: String,
71    /// Contexts where this encoding is the canonical nix wire shape.
72    #[serde(default, rename = "preferredByNixFor")]
73    pub preferred_by_nix_for: Vec<NixHashContext>,
74}
75
76/// The contexts in which nix renders a hash.
77#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
78pub enum NixHashContext {
79    /// `NarHash:` line in narinfo.
80    NarHash,
81    /// Store-path hash component (`abc123...` in `/nix/store/abc123-name`).
82    StorePathHash,
83    /// `Sig:` line in narinfo.
84    NarSignature,
85    /// `outputHash` of a fixed-output derivation.
86    FodOutputHash,
87    /// `narHash` of a flake input in flake.lock.
88    FlakeInputNarHash,
89    /// SRI hash on a flake input (`sha256-...`).
90    FlakeInputSri,
91}
92
93// ── Spec interpreter (M3.0 minimal — encoding conversion) ─────────
94
95/// Parse a hex string into raw bytes.  Case-insensitive.
96fn from_base16(s: &str) -> Result<Vec<u8>, SpecError> {
97    if s.len() % 2 != 0 {
98        return Err(SpecError::Interp {
99            phase: "hash-decode".into(),
100            message: format!("base16 string `{s}` has odd length {}", s.len()),
101        });
102    }
103    let mut out = Vec::with_capacity(s.len() / 2);
104    let chars: Vec<char> = s.chars().collect();
105    for chunk in chars.chunks(2) {
106        let pair: String = chunk.iter().collect();
107        let byte = u8::from_str_radix(&pair, 16).map_err(|e| SpecError::Interp {
108            phase: "hash-decode".into(),
109            message: format!("invalid hex byte `{pair}`: {e}"),
110        })?;
111        out.push(byte);
112    }
113    Ok(out)
114}
115
116fn to_base16(bytes: &[u8]) -> String {
117    let mut s = String::with_capacity(bytes.len() * 2);
118    for b in bytes {
119        s.push_str(&format!("{b:02x}"));
120    }
121    s
122}
123
124/// Convert from nix-base32 (Nix's custom alphabet — note: NOT
125/// RFC 4648 base32).  Nix encodes hashes LSB-first with this
126/// alphabet: "0123456789abcdfghijklmnpqrsvwxyz" (note no 'e', 'o',
127/// 't', 'u').
128fn from_nix_base32(s: &str) -> Result<Vec<u8>, SpecError> {
129    const ALPHABET: &[u8] = b"0123456789abcdfghijklmnpqrsvwxyz";
130    let mut idx_table = [0xff_u8; 256];
131    for (i, b) in ALPHABET.iter().enumerate() {
132        idx_table[*b as usize] = i as u8;
133    }
134    // Output length = floor(chars * 5 / 8) — inverse of the
135    // encoder's ceil(bytes * 8 / 5).  Using ceil here over-
136    // allocates one byte for lengths where 8N % 5 != 0
137    // (sha256/32 bytes → 52 chars → would yield 33 instead of 32).
138    let n = (s.len() * 5) / 8;
139    let mut bytes = vec![0u8; n];
140    // The encoder writes the highest-position char first, so
141    // reverse the iteration order to keep encode⇄decode inverse.
142    let n_chars = s.len();
143    for (n_offset, c) in s.chars().enumerate() {
144        let digit = idx_table[c as usize] as u16;
145        if (digit as u8) == 0xff {
146            return Err(SpecError::Interp {
147                phase: "hash-decode".into(),
148                message: format!("char `{c}` not in nix-base32 alphabet"),
149            });
150        }
151        let encoder_n = n_chars - 1 - n_offset;
152        let b = encoder_n * 5;
153        let i = b / 8;
154        let j = b % 8;
155        if i < n {
156            bytes[i] |= digit.wrapping_shl(j as u32) as u8 & 0xff;
157        }
158        if j + 5 > 8 && i + 1 < n {
159            bytes[i + 1] |= digit.wrapping_shr((8 - j) as u32) as u8;
160        }
161    }
162    Ok(bytes)
163}
164
165fn to_nix_base32(bytes: &[u8]) -> String {
166    const ALPHABET: &[u8] = b"0123456789abcdfghijklmnpqrsvwxyz";
167    let n_chars = (bytes.len() * 8).div_ceil(5);
168    let mut out = String::with_capacity(n_chars);
169    for n in (0..n_chars).rev() {
170        let b = n * 5;
171        let i = b / 8;
172        let j = b % 8;
173        let c = (bytes[i] as u16).wrapping_shr(j as u32)
174            | if i + 1 < bytes.len() {
175                (bytes[i + 1] as u16).wrapping_shl((8 - j) as u32)
176            } else { 0 };
177        out.push(ALPHABET[(c & 0x1f) as usize] as char);
178    }
179    out
180}
181
182fn from_base64(s: &str) -> Result<Vec<u8>, SpecError> {
183    use base64::Engine as _;
184    let s = s.trim_end_matches('=');
185    base64::engine::general_purpose::STANDARD_NO_PAD
186        .decode(s.as_bytes())
187        .map_err(|e| SpecError::Interp {
188            phase: "hash-decode".into(),
189            message: format!("invalid base64 `{s}`: {e}"),
190        })
191}
192
193fn to_base64(bytes: &[u8]) -> String {
194    use base64::Engine as _;
195    base64::engine::general_purpose::STANDARD.encode(bytes)
196}
197
198/// Parse a hash string in any supported encoding, returning the
199/// raw bytes.  Auto-detects the encoding from the string format:
200///
201/// - `<alg>:<base16>` (cppnix legacy)
202/// - `<alg>:<nix-base32>` (cppnix store-path encoding)
203/// - `<alg>-<base64>` (SRI)
204/// - bare base16 (`abcd...`)
205///
206/// Returns `(algorithm-name-or-empty, raw-bytes)`.
207///
208/// # Errors
209///
210/// `SpecError::Interp { phase: "hash-decode" }` for malformed
211/// input.
212pub fn decode_hash(input: &str) -> Result<(String, Vec<u8>), SpecError> {
213    if let Some(rest) = input.find(':') {
214        // <alg>:<hex|nix32>
215        let algo = &input[..rest];
216        let payload = &input[rest + 1..];
217        // Try hex first.  If the length matches the algo's
218        // bit-length / 4 and chars are all hex, it's hex.
219        if payload.chars().all(|c| c.is_ascii_hexdigit())
220            && payload.len() % 2 == 0
221        {
222            return Ok((algo.into(), from_base16(payload)?));
223        }
224        // Otherwise it's nix-base32.
225        return Ok((algo.into(), from_nix_base32(payload)?));
226    }
227    if let Some(dash) = input.find('-') {
228        // SRI: `<alg>-<base64>`.
229        let algo = &input[..dash];
230        let payload = &input[dash + 1..];
231        return Ok((algo.into(), from_base64(payload)?));
232    }
233    // No separator — assume bare hex.
234    Ok((String::new(), from_base16(input)?))
235}
236
237/// Re-encode a hash to the requested encoding.  `algorithm` is
238/// stamped into the output for prefixed encodings (`<alg>:<...>`
239/// or SRI).
240///
241/// # Errors
242///
243/// `SpecError::Interp { phase: "hash-encode" }` if the encoding
244/// name isn't recognised.
245pub fn encode_hash(
246    algorithm: &str,
247    encoding: &str,
248    bytes: &[u8],
249) -> Result<String, SpecError> {
250    match encoding {
251        "base16" => Ok(to_base16(bytes)),
252        "nix-base32" => Ok(format!("{algorithm}:{}", to_nix_base32(bytes))),
253        "base64" => Ok(format!("{algorithm}:{}", to_base64(bytes))),
254        "sri" => Ok(format!("{algorithm}-{}", to_base64(bytes))),
255        _ => Err(SpecError::Interp {
256            phase: "hash-encode".into(),
257            message: format!("unknown encoding `{encoding}`"),
258        }),
259    }
260}
261
262/// Convert a hash from one encoding to another.  Roundtrips
263/// through raw bytes.
264///
265/// # Errors
266///
267/// Either of the decode or encode failures.
268pub fn apply_conversion(
269    from_encoding: &str,
270    to_encoding: &str,
271    input: &str,
272) -> Result<String, SpecError> {
273    // We use auto-detect via decode_hash rather than honoring
274    // from_encoding strictly — most callers know the input's
275    // shape but the auto-detect is forgiving and matches cppnix.
276    let _ = from_encoding;
277    let (algo, bytes) = decode_hash(input)?;
278    encode_hash(&algo, to_encoding, &bytes)
279}
280
281// ── Canonical specs ────────────────────────────────────────────────
282
283pub const CANONICAL_HASH_LISP: &str = include_str!("../specs/hash.lisp");
284
285/// Compile every authored hash algorithm.
286///
287/// # Errors
288///
289/// Returns an error if the Lisp source fails to parse.
290pub fn load_canonical_algorithms() -> Result<Vec<HashAlgorithm>, SpecError> {
291    crate::loader::load_all::<HashAlgorithm>(CANONICAL_HASH_LISP)
292}
293
294/// Compile every authored hash encoding.
295///
296/// # Errors
297///
298/// Returns an error if the Lisp source fails to parse.
299pub fn load_canonical_encodings() -> Result<Vec<HashEncoding>, SpecError> {
300    crate::loader::load_all::<HashEncoding>(CANONICAL_HASH_LISP)
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use std::collections::HashSet;
307
308    #[test]
309    fn canonical_algorithms_parse() {
310        let algos = load_canonical_algorithms().unwrap();
311        assert!(!algos.is_empty());
312        let names: HashSet<&str> = algos.iter().map(|a| a.name.as_str()).collect();
313        for required in ["sha1", "sha256", "sha512", "md5", "blake3"] {
314            assert!(
315                names.contains(required),
316                "canonical hash algos missing `{required}`",
317            );
318        }
319    }
320
321    #[test]
322    fn sha256_is_strong() {
323        let algos = load_canonical_algorithms().unwrap();
324        let sha256 = algos.iter().find(|a| a.name == "sha256").unwrap();
325        assert_eq!(sha256.weakness, HashWeakness::Strong);
326        assert_eq!(sha256.bit_length, 256);
327    }
328
329    #[test]
330    fn md5_is_broken() {
331        let algos = load_canonical_algorithms().unwrap();
332        let md5 = algos.iter().find(|a| a.name == "md5").unwrap();
333        assert_eq!(md5.weakness, HashWeakness::Broken);
334    }
335
336    #[test]
337    fn canonical_encodings_parse() {
338        let encs = load_canonical_encodings().unwrap();
339        let names: HashSet<&str> = encs.iter().map(|e| e.name.as_str()).collect();
340        for required in ["base16", "nix-base32", "base64", "sri"] {
341            assert!(
342                names.contains(required),
343                "canonical encodings missing `{required}`",
344            );
345        }
346    }
347
348    #[test]
349    fn nix_base32_is_preferred_for_storepath() {
350        let encs = load_canonical_encodings().unwrap();
351        let b32 = encs.iter().find(|e| e.name == "nix-base32").unwrap();
352        assert!(
353            b32.preferred_by_nix_for.contains(&NixHashContext::StorePathHash),
354            "nix-base32 must be the canonical store-path encoding",
355        );
356    }
357
358    #[test]
359    fn sri_is_preferred_for_flake_input() {
360        let encs = load_canonical_encodings().unwrap();
361        let sri = encs.iter().find(|e| e.name == "sri").unwrap();
362        assert!(
363            sri.preferred_by_nix_for.contains(&NixHashContext::FlakeInputSri),
364            "sri must be the canonical flake-input hash format",
365        );
366    }
367
368    // ── M3.0 conversion tests ──────────────────────────────────
369
370    #[test]
371    fn base16_roundtrip() {
372        let bytes = b"hello world";
373        let hex = to_base16(bytes);
374        let back = from_base16(&hex).unwrap();
375        assert_eq!(back, bytes);
376    }
377
378    #[test]
379    fn base16_rejects_odd_length() {
380        let err = from_base16("abc").unwrap_err();
381        match err {
382            SpecError::Interp { phase, .. } => assert_eq!(phase, "hash-decode"),
383            _ => panic!("expected hash-decode"),
384        }
385    }
386
387    #[test]
388    fn base16_rejects_invalid_chars() {
389        let err = from_base16("xyzw").unwrap_err();
390        match err {
391            SpecError::Interp { phase, .. } => assert_eq!(phase, "hash-decode"),
392            _ => panic!("expected hash-decode"),
393        }
394    }
395
396    #[test]
397    fn base64_roundtrip() {
398        let bytes = b"some bytes here";
399        let b64 = to_base64(bytes);
400        let back = from_base64(&b64).unwrap();
401        assert_eq!(back, bytes);
402    }
403
404    #[test]
405    fn nix_base32_uses_alphabet_only() {
406        // M3.0 nix-base32 impl is approximate (the exact cppnix
407        // bit-pack lands in M3.1).  For now we verify the output
408        // alphabet matches cppnix's convention.
409        const ALPHABET: &[u8] = b"0123456789abcdfghijklmnpqrsvwxyz";
410        let bytes = b"some test bytes";
411        let b32 = to_nix_base32(bytes);
412        for c in b32.bytes() {
413            assert!(
414                ALPHABET.contains(&c),
415                "char `{}` not in nix-base32 alphabet",
416                c as char,
417            );
418        }
419        // Length should be ⌈8N/5⌉.
420        let expected_len = (bytes.len() * 8).div_ceil(5);
421        assert_eq!(b32.len(), expected_len);
422    }
423
424    #[test]
425    fn decode_hash_handles_colon_hex() {
426        let (algo, bytes) = decode_hash("sha256:deadbeef").unwrap();
427        assert_eq!(algo, "sha256");
428        assert_eq!(bytes, [0xde, 0xad, 0xbe, 0xef]);
429    }
430
431    #[test]
432    fn decode_hash_handles_sri() {
433        let bytes_in = b"some bytes";
434        let b64 = to_base64(bytes_in);
435        let sri = format!("sha256-{b64}");
436        let (algo, bytes) = decode_hash(&sri).unwrap();
437        assert_eq!(algo, "sha256");
438        assert_eq!(bytes, bytes_in);
439    }
440
441    #[test]
442    fn encode_hash_emits_sri_with_dash() {
443        let bytes = b"x";
444        let out = encode_hash("sha256", "sri", bytes).unwrap();
445        assert!(out.starts_with("sha256-"));
446    }
447
448    #[test]
449    fn encode_hash_emits_nix_base32_with_colon() {
450        let bytes = b"x";
451        let out = encode_hash("sha256", "nix-base32", bytes).unwrap();
452        assert!(out.starts_with("sha256:"));
453    }
454
455    #[test]
456    fn encode_unknown_encoding_errors() {
457        let err = encode_hash("sha256", "rot13", b"x").unwrap_err();
458        match err {
459            SpecError::Interp { phase, .. } => assert_eq!(phase, "hash-encode"),
460            _ => panic!("expected hash-encode"),
461        }
462    }
463
464    #[test]
465    fn convert_hex_to_sri() {
466        let out = apply_conversion("base16", "sri", "sha256:deadbeef").unwrap();
467        assert!(out.starts_with("sha256-"));
468        // SRI of 0xdeadbeef = 4 bytes → 8-char base64 with padding.
469        // Just verify shape, not exact bytes (covered by roundtrip).
470        assert!(out.len() > 10);
471    }
472}