Skip to main content

sui_compat/
hash.rs

1//! Nix hash types and encodings.
2
3use thiserror::Error;
4
5#[derive(Debug, Error)]
6pub enum HashError {
7    #[error("unsupported hash algorithm: {0}")]
8    UnsupportedAlgorithm(String),
9    #[error("invalid hash encoding")]
10    InvalidEncoding,
11}
12
13/// Hash algorithms supported by Nix.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum HashAlgorithm {
17    Sha256,
18    Sha512,
19    Sha1,
20    Md5,
21}
22
23impl HashAlgorithm {
24    /// Parse from the string representation used in Nix.
25    pub fn from_nix_str(s: &str) -> Result<Self, HashError> {
26        s.parse()
27    }
28
29    /// The Nix string representation.
30    #[must_use]
31    pub fn as_nix_str(&self) -> &'static str {
32        match self {
33            Self::Sha256 => "sha256",
34            Self::Sha512 => "sha512",
35            Self::Sha1 => "sha1",
36            Self::Md5 => "md5",
37        }
38    }
39
40    /// Digest length in bytes.
41    #[must_use]
42    pub fn digest_len(&self) -> usize {
43        match self {
44            Self::Sha256 => 32,
45            Self::Sha512 => 64,
46            Self::Sha1 => 20,
47            Self::Md5 => 16,
48        }
49    }
50}
51
52impl std::fmt::Display for HashAlgorithm {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.write_str(self.as_nix_str())
55    }
56}
57
58impl std::str::FromStr for HashAlgorithm {
59    type Err = HashError;
60
61    fn from_str(s: &str) -> Result<Self, Self::Err> {
62        match s {
63            "sha256" => Ok(Self::Sha256),
64            "sha512" => Ok(Self::Sha512),
65            "sha1" => Ok(Self::Sha1),
66            "md5" => Ok(Self::Md5),
67            _ => Err(HashError::UnsupportedAlgorithm(s.to_string())),
68        }
69    }
70}
71
72/// A typed hash value.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct NixHash {
75    pub algorithm: HashAlgorithm,
76    pub digest: Vec<u8>,
77}
78
79impl NixHash {
80    /// Create a new hash from algorithm and raw digest bytes.
81    pub fn new(algorithm: HashAlgorithm, digest: Vec<u8>) -> Self {
82        Self { algorithm, digest }
83    }
84
85    /// Encode as `<algo>:<base16>` (Nix's default display format).
86    #[must_use]
87    pub fn to_nix_string(&self) -> String {
88        format!("{}:{}", self.algorithm, hex::encode(&self.digest))
89    }
90
91    /// Encode as SRI format: `<algo>-<base64>`.
92    #[must_use]
93    pub fn to_sri(&self) -> String {
94        format!("{}-{}", self.algorithm, base64_encode(&self.digest))
95    }
96
97    /// Lower-case hex of the digest (no `<algo>:` prefix).  Useful
98    /// for embedding into the `fixed:out:<algo>:<hex>:` content-
99    /// address fingerprints CppNix uses to compute fixed-output
100    /// store paths.
101    #[must_use]
102    pub fn to_hex(&self) -> String {
103        hex::encode(&self.digest)
104    }
105
106    /// Decode a user-supplied hash string under `algorithm`,
107    /// accepting any of the three formats CppNix tolerates as
108    /// `outputHash` input:
109    ///
110    /// 1. **lowercase hex** — `<digest_len * 2>` characters
111    ///    (e.g. 64 chars for sha256).
112    /// 2. **nix-base32** — `(digest_len * 8 + 4) / 5` characters
113    ///    using the alphabet `0123456789abcdfghijklmnpqrsvwxyz`
114    ///    (52 chars for sha256, 32 for sha1).
115    /// 3. **SRI** — `<algo>-<standard-base64-of-digest>` (e.g.
116    ///    `sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=`).
117    ///
118    /// The parser chooses by length + alphabet without backtracking.
119    /// Returns [`HashError::InvalidEncoding`] for any malformed or
120    /// length-mismatched input.
121    ///
122    /// This is the canonical hash-ingress primitive: every callsite
123    /// that takes a hash string from user code (derivation
124    /// `outputHash`, builtin `fetchurl`, flake-input `narHash`)
125    /// **must** normalize through here before computing store paths
126    /// — the `fixed:out:<algo>:<hex>:` fingerprint embedded in the
127    /// store-path digest is byte-equivalent to CppNix only when the
128    /// hash is canonicalized to lowercase hex first.
129    ///
130    /// # Errors
131    ///
132    /// - [`HashError::InvalidEncoding`] if the string can't be
133    ///   decoded as any of the three formats, or if the decoded
134    ///   length doesn't match the algorithm's `digest_len()`.
135    pub fn parse_any(
136        algorithm: HashAlgorithm,
137        raw: &str,
138    ) -> Result<Self, HashError> {
139        let digest = decode_hash_any(algorithm, raw)?;
140        Ok(Self::new(algorithm, digest))
141    }
142}
143
144/// Decode a hash string under `algo` from hex / nix-base32 / SRI
145/// into raw digest bytes.  Helper for [`NixHash::parse_any`] kept
146/// public so callers that just want the bytes (e.g. CppNix
147/// `fixed:out:` fingerprint construction) don't pay a
148/// [`NixHash`] allocation.
149///
150/// # Errors
151///
152/// See [`NixHash::parse_any`].
153pub fn decode_hash_any(
154    algo: HashAlgorithm,
155    raw: &str,
156) -> Result<Vec<u8>, HashError> {
157    let want = algo.digest_len();
158    let hex_len = want * 2;
159    let base32_len = (want * 8 + 4) / 5;
160    // CppNix `Hash::parseAny` also accepts the legacy `<algo>:<hash>`
161    // COLON-prefixed form (as opposed to the SRI `<algo>-<base64>`
162    // DASH form), where `<hash>` is hex / nix-base32 / base64. nixpkgs
163    // writes it in `fetchurl { hash = "sha256:<hex>"; }` (e.g. neovim's
164    // treesitter-parsers). Strip a matching `<algo>:` prefix so the
165    // body decodes through the same hex/base32/base64 ladder below.
166    // Without this, the FOD `outputHash` throws at drv-construction,
167    // stdenv swallows the throw, `src` is dropped, and the drv diverges.
168    let colon_prefix = format!("{}:", algo.as_nix_str());
169    let raw = raw.strip_prefix(&colon_prefix).unwrap_or(raw);
170    // SRI is `<algo>-<base64-of-digest>`; the base64 length for
171    // n bytes is 4 * ceil(n / 3), padded to a multiple of 4.
172    let sri_prefix = format!("{}-", algo.as_nix_str());
173
174    if let Some(b64) = raw.strip_prefix(&sri_prefix) {
175        // CppNix accepts SRI base64 whether or not it carries `=`
176        // padding (nixpkgs `fetchFromGitLab`/`fetchurl` hashes are
177        // frequently written UNPADDED, e.g. `sha256-…/go` = 43 chars).
178        // Standard base64 decode requires padding, so add it.
179        let bytes = base64_decode_padtolerant(b64)?;
180        if bytes.len() != want {
181            return Err(HashError::InvalidEncoding);
182        }
183        return Ok(bytes);
184    }
185
186    if raw.len() == hex_len
187        && raw.chars().all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
188    {
189        let bytes = hex::decode(raw)?;
190        return Ok(bytes);
191    }
192
193    if raw.len() == base32_len {
194        // Nix-base32 alphabet (note: 'e', 'o', 't', 'u' are not in
195        // the alphabet — they were excluded to avoid confusion).
196        let alphabet = "0123456789abcdfghijklmnpqrsvwxyz";
197        if raw.chars().all(|c| alphabet.contains(c)) {
198            let bytes = decode_nix_base32(raw, want)?;
199            return Ok(bytes);
200        }
201    }
202
203    // 4. **bare (un-prefixed) standard base64** — CppNix's
204    //    `Hash::parseNonSRIUnprefixed` accepts a padded standard-base64
205    //    digest with NO `<algo>-` prefix (the historical `sha256 = "…="`
206    //    form used all over nixpkgs `fetchpatch`/`fetchurl`). The length
207    //    is `4 * ceil(digest_len / 3)` rounded up to a multiple of 4 —
208    //    distinct from the hex and base32 lengths for every algorithm, so
209    //    length alone disambiguates (no backtracking). Without this branch
210    //    a FOD whose `outputHash` is bare base64 throws at drv-construction;
211    //    stdenv `mkDerivation` swallows the throw and silently drops the
212    //    whole attribute (e.g. `patches`), diverging the drv from CppNix.
213    let base64_len = (4 * want / 3 + 3) & !3usize;
214    // Accept both the padded length and the unpadded length
215    // (`4 * ceil(want*8/6)`, i.e. padded minus trailing `=`s). Both
216    // remain distinct from hex_len / base32_len for every algorithm.
217    let base64_len_unpadded = (want * 8).div_ceil(6);
218    if raw.len() == base64_len || raw.len() == base64_len_unpadded {
219        if let Ok(bytes) = base64_decode_padtolerant(raw) {
220            if bytes.len() == want {
221                return Ok(bytes);
222            }
223        }
224    }
225
226    Err(HashError::InvalidEncoding)
227}
228
229/// Decode a nix-base32 string of expected byte length `want`.
230///
231/// The encoder writes the most-significant 5-bit group first; the
232/// decoder must walk the input in reverse to recover bytes in
233/// natural order.  See [`crate::store_path::nix_base32_decode`]
234/// for the same algorithm specialized to a 20-byte compressed
235/// hash; this variant accepts arbitrary `want` lengths so it can
236/// service every supported [`HashAlgorithm`].
237fn decode_nix_base32(input: &str, want: usize) -> Result<Vec<u8>, HashError> {
238    let alphabet = b"0123456789abcdfghijklmnpqrsvwxyz";
239    let mut digit_value = [0u8; 128];
240    for (i, &c) in alphabet.iter().enumerate() {
241        digit_value[c as usize] = i as u8;
242    }
243    let mut out = vec![0u8; want];
244    let bytes = input.as_bytes();
245    for (n, &c) in bytes.iter().rev().enumerate() {
246        let d = digit_value[c as usize] as usize;
247        let b = n * 5;
248        let i = b / 8;
249        let j = b % 8;
250        out[i] |= ((d << j) & 0xff) as u8;
251        if i + 1 < want {
252            out[i + 1] |= (d >> (8 - j)) as u8;
253        } else if (d >> (8 - j)) != 0 {
254            // High bits set past the end of the digest → not a
255            // valid base32 encoding of a `want`-byte value.
256            return Err(HashError::InvalidEncoding);
257        }
258    }
259    Ok(out)
260}
261
262impl std::fmt::Display for NixHash {
263    /// Formats as the Nix string representation (`algo:hex`).
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        write!(f, "{}:{}", self.algorithm, hex::encode(&self.digest))
266    }
267}
268
269/// Base64 encode bytes (delegates to the `base64` crate).
270#[must_use]
271pub fn base64_encode(input: &[u8]) -> String {
272    use base64::Engine;
273    base64::engine::general_purpose::STANDARD.encode(input)
274}
275
276/// Base64 decode a string (delegates to the `base64` crate).
277pub fn base64_decode(input: &str) -> Result<Vec<u8>, HashError> {
278    use base64::Engine;
279    base64::engine::general_purpose::STANDARD
280        .decode(input)
281        .map_err(|_| HashError::InvalidEncoding)
282}
283
284/// Base64 decode a string, tolerating MISSING `=` padding.
285///
286/// CppNix's hash parser accepts both padded and unpadded standard
287/// base64 (nixpkgs `fetchFromGitLab`/`fetchurl` hashes are frequently
288/// written unpadded, e.g. `sha256-…/go`). Standard base64 decode
289/// requires padding, so re-pad to a multiple of 4 before decoding.
290pub fn base64_decode_padtolerant(input: &str) -> Result<Vec<u8>, HashError> {
291    let rem = input.len() % 4;
292    if rem == 0 {
293        return base64_decode(input);
294    }
295    // A base64 group of 1 char is never valid; 2→1 byte, 3→2 bytes.
296    let mut padded = String::with_capacity(input.len() + (4 - rem));
297    padded.push_str(input);
298    for _ in 0..(4 - rem) {
299        padded.push('=');
300    }
301    base64_decode(&padded)
302}
303
304/// Base64 encode bytes (alias for [`base64_encode`] kept for backward compatibility).
305#[must_use]
306pub fn minimal_base64_encode(input: &[u8]) -> String {
307    base64_encode(input)
308}
309
310/// Minimal hex encoding (avoids external dep for now).
311pub(crate) mod hex {
312    /// Encode bytes as lowercase hexadecimal.
313    #[must_use]
314    pub fn encode(bytes: &[u8]) -> String {
315        let mut s = String::with_capacity(bytes.len() * 2);
316        for b in bytes {
317            s.push_str(&format!("{b:02x}"));
318        }
319        s
320    }
321
322    /// Decode a lowercase hexadecimal string to bytes.
323    pub fn decode(s: &str) -> Result<Vec<u8>, super::HashError> {
324        if !s.len().is_multiple_of(2) {
325            return Err(super::HashError::InvalidEncoding);
326        }
327        (0..s.len())
328            .step_by(2)
329            .map(|i| {
330                u8::from_str_radix(&s[i..i + 2], 16)
331                    .map_err(|_| super::HashError::InvalidEncoding)
332            })
333            .collect()
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use proptest::prelude::*;
341
342    #[test]
343    fn parse_any_accepts_64_char_hex_sha256() {
344        let raw = "0".repeat(64);
345        let h = NixHash::parse_any(HashAlgorithm::Sha256, &raw).unwrap();
346        assert_eq!(h.digest, vec![0u8; 32]);
347    }
348
349    #[test]
350    fn parse_any_accepts_52_char_base32_sha256() {
351        let raw = "0".repeat(52);
352        let h = NixHash::parse_any(HashAlgorithm::Sha256, &raw).unwrap();
353        assert_eq!(h.digest, vec![0u8; 32]);
354    }
355
356    #[test]
357    fn parse_any_accepts_sri_sha256() {
358        // SRI of 32 zero bytes.
359        let sri = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
360        let h = NixHash::parse_any(HashAlgorithm::Sha256, sri).unwrap();
361        assert_eq!(h.digest, vec![0u8; 32]);
362    }
363
364    #[test]
365    fn parse_any_accepts_colon_prefixed_hex_sha256() {
366        // Legacy `<algo>:<hex>` colon form (neovim treesitter-parsers
367        // `fetchurl { hash = "sha256:<hex>"; }`). Must decode the body
368        // through the same hex ladder as the bare form.
369        let raw = format!("sha256:{}", "0".repeat(64));
370        let h = NixHash::parse_any(HashAlgorithm::Sha256, &raw).unwrap();
371        assert_eq!(h.digest, vec![0u8; 32]);
372    }
373
374    #[test]
375    fn parse_any_accepts_colon_prefixed_base32_sha256() {
376        let raw = format!("sha256:{}", "0".repeat(52));
377        let h = NixHash::parse_any(HashAlgorithm::Sha256, &raw).unwrap();
378        assert_eq!(h.digest, vec![0u8; 32]);
379    }
380
381    #[test]
382    fn parse_any_rejects_wrong_length() {
383        // 50 chars: neither valid hex nor base32 sha256 length.
384        let bad = "0".repeat(50);
385        assert!(NixHash::parse_any(HashAlgorithm::Sha256, &bad).is_err());
386    }
387
388    #[test]
389    fn parse_any_rejects_invalid_alphabet() {
390        // 'e' is not in the nix-base32 alphabet at the right position
391        // length to look like base32; lead with a char that breaks both
392        // hex (uppercase) and base32 (out-of-alphabet) attempts.
393        let bad = format!("Z{}", "0".repeat(51));
394        assert!(NixHash::parse_any(HashAlgorithm::Sha256, &bad).is_err());
395    }
396
397    proptest! {
398        /// All three input formats decode to the same digest.
399        #[test]
400        fn parse_any_all_formats_agree(digest in proptest::collection::vec(any::<u8>(), 32..=32)) {
401            let nh = NixHash::new(HashAlgorithm::Sha256, digest.clone());
402            let hex_str = hex::encode(&digest);
403            let sri = nh.to_sri();
404            // Build the nix-base32 form using the existing encoder
405            // (digest length must be 32 → base32 length = 52).
406            let mut b32 = String::with_capacity(52);
407            // Re-implement encoder inline to avoid coupling to
408            // store_path::nix_base32_encode (which is specialized to 20-byte input).
409            let alphabet = b"0123456789abcdfghijklmnpqrsvwxyz";
410            for n in (0..52usize).rev() {
411                let b = n * 5;
412                let i = b / 8;
413                let j = b % 8;
414                let mut v = (digest[i] as usize) >> j;
415                if i + 1 < 32 {
416                    v |= (digest[i + 1] as usize) << (8 - j);
417                }
418                b32.push(alphabet[v & 0x1f] as char);
419            }
420            let parsed_hex = NixHash::parse_any(HashAlgorithm::Sha256, &hex_str).unwrap();
421            let parsed_b32 = NixHash::parse_any(HashAlgorithm::Sha256, &b32).unwrap();
422            let parsed_sri = NixHash::parse_any(HashAlgorithm::Sha256, &sri).unwrap();
423            prop_assert_eq!(&parsed_hex.digest, &digest);
424            prop_assert_eq!(&parsed_b32.digest, &digest);
425            prop_assert_eq!(&parsed_sri.digest, &digest);
426        }
427    }
428
429    #[test]
430    fn algorithm_roundtrip() {
431        for algo in [HashAlgorithm::Sha256, HashAlgorithm::Sha512, HashAlgorithm::Sha1, HashAlgorithm::Md5] {
432            let parsed = HashAlgorithm::from_nix_str(algo.as_nix_str()).unwrap();
433            assert_eq!(parsed, algo);
434        }
435    }
436
437    #[test]
438    fn nix_string_format() {
439        let hash = NixHash::new(HashAlgorithm::Sha256, vec![0xab; 32]);
440        let s = hash.to_nix_string();
441        assert!(s.starts_with("sha256:"));
442        assert_eq!(s.len(), 7 + 64); // "sha256:" + 64 hex chars
443    }
444
445    #[test]
446    fn sri_roundtrip() {
447        // Encode as SRI then parse back through base64 decode
448        let digest = vec![0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
449                          0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
450                          0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
451                          0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef];
452        let hash = NixHash::new(HashAlgorithm::Sha256, digest.clone());
453        let sri = hash.to_sri();
454        assert!(sri.starts_with("sha256-"));
455        // The base64 portion should decode back to the original digest
456        let b64_part = sri.strip_prefix("sha256-").unwrap();
457        // Manually verify the base64 is non-empty and well-formed
458        assert!(!b64_part.is_empty());
459        // 32 bytes -> ceil(32/3)*4 = 44 base64 chars
460        assert_eq!(b64_part.len(), 44);
461    }
462
463    #[test]
464    fn invalid_algorithm_string() {
465        assert!(HashAlgorithm::from_nix_str("blake2b").is_err());
466        assert!(HashAlgorithm::from_nix_str("SHA256").is_err());
467        assert!(HashAlgorithm::from_nix_str("").is_err());
468        assert!(HashAlgorithm::from_nix_str("sha-256").is_err());
469
470        match HashAlgorithm::from_nix_str("unknown") {
471            Err(HashError::UnsupportedAlgorithm(s)) => assert_eq!(s, "unknown"),
472            other => panic!("expected UnsupportedAlgorithm, got {other:?}"),
473        }
474    }
475
476    #[test]
477    fn hash_digest_length_per_algorithm() {
478        assert_eq!(HashAlgorithm::Sha256.digest_len(), 32);
479        assert_eq!(HashAlgorithm::Sha512.digest_len(), 64);
480        assert_eq!(HashAlgorithm::Sha1.digest_len(), 20);
481        assert_eq!(HashAlgorithm::Md5.digest_len(), 16);
482    }
483
484    #[test]
485    fn hex_encode_decode_roundtrip() {
486        let original = vec![0x00, 0x11, 0x22, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
487        let encoded = hex::encode(&original);
488        assert_eq!(encoded, "001122aabbccddeeff");
489        let decoded = hex::decode(&encoded).unwrap();
490        assert_eq!(decoded, original);
491    }
492
493    #[test]
494    fn hex_decode_odd_length() {
495        assert!(hex::decode("abc").is_err());
496    }
497
498    #[test]
499    fn hex_decode_invalid_chars() {
500        assert!(hex::decode("zzzz").is_err());
501        assert!(hex::decode("gg").is_err());
502    }
503
504    #[test]
505    fn hex_roundtrip_all_byte_values() {
506        let all_bytes: Vec<u8> = (0..=255).collect();
507        let encoded = hex::encode(&all_bytes);
508        let decoded = hex::decode(&encoded).unwrap();
509        assert_eq!(decoded, all_bytes);
510    }
511
512    #[test]
513    fn empty_digest_handling() {
514        let hash = NixHash::new(HashAlgorithm::Sha256, vec![]);
515        let nix_str = hash.to_nix_string();
516        assert_eq!(nix_str, "sha256:");
517
518        let sri = hash.to_sri();
519        assert_eq!(sri, "sha256-");
520    }
521
522    #[test]
523    fn base64_encode_known_vectors() {
524        // RFC 4648 test vectors
525        assert_eq!(minimal_base64_encode(b""), "");
526        assert_eq!(minimal_base64_encode(b"f"), "Zg==");
527        assert_eq!(minimal_base64_encode(b"fo"), "Zm8=");
528        assert_eq!(minimal_base64_encode(b"foo"), "Zm9v");
529        assert_eq!(minimal_base64_encode(b"foob"), "Zm9vYg==");
530        assert_eq!(minimal_base64_encode(b"fooba"), "Zm9vYmE=");
531        assert_eq!(minimal_base64_encode(b"foobar"), "Zm9vYmFy");
532    }
533
534    #[test]
535    fn nix_string_with_all_algorithms() {
536        for algo in [HashAlgorithm::Sha256, HashAlgorithm::Sha512, HashAlgorithm::Sha1, HashAlgorithm::Md5] {
537            let digest = vec![0xab; algo.digest_len()];
538            let hash = NixHash::new(algo, digest);
539            let s = hash.to_nix_string();
540            let expected_prefix = format!("{}:", algo.as_nix_str());
541            assert!(s.starts_with(&expected_prefix), "failed for {algo:?}");
542            let hex_part = s.strip_prefix(&expected_prefix).unwrap();
543            assert_eq!(hex_part.len(), algo.digest_len() * 2);
544        }
545    }
546
547    // ── base64_decode ────────────────────────────────────
548
549    #[test]
550    fn base64_decode_known_vectors() {
551        assert_eq!(base64_decode("").unwrap(), b"");
552        assert_eq!(base64_decode("Zg==").unwrap(), b"f");
553        assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
554        assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
555        assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
556    }
557
558    #[test]
559    fn base64_decode_invalid_input() {
560        assert!(base64_decode("!!!invalid!!!").is_err());
561    }
562
563    #[test]
564    fn base64_roundtrip_binary() {
565        let data: Vec<u8> = (0..=255).collect();
566        let encoded = base64_encode(&data);
567        let decoded = base64_decode(&encoded).unwrap();
568        assert_eq!(decoded, data);
569    }
570
571    // ── SRI format ───────────────────────────────────────
572
573    #[test]
574    fn sri_format_all_algorithms() {
575        for algo in [HashAlgorithm::Sha256, HashAlgorithm::Sha512, HashAlgorithm::Sha1, HashAlgorithm::Md5] {
576            let digest = vec![0x42; algo.digest_len()];
577            let hash = NixHash::new(algo, digest);
578            let sri = hash.to_sri();
579            let prefix = format!("{}-", algo.as_nix_str());
580            assert!(sri.starts_with(&prefix), "SRI for {algo:?} should start with {prefix}");
581        }
582    }
583
584    #[test]
585    fn sri_base64_decode_matches_digest() {
586        let digest = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04,
587                          0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
588                          0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
589                          0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C];
590        let hash = NixHash::new(HashAlgorithm::Sha256, digest.clone());
591        let sri = hash.to_sri();
592        let b64_part = sri.strip_prefix("sha256-").unwrap();
593        let decoded = base64_decode(b64_part).unwrap();
594        assert_eq!(decoded, digest);
595    }
596
597    // ── hex edge cases ───────────────────────────────────
598
599    #[test]
600    fn hex_encode_empty() {
601        assert_eq!(hex::encode(&[]), "");
602    }
603
604    #[test]
605    fn hex_decode_empty() {
606        assert_eq!(hex::decode("").unwrap(), Vec::<u8>::new());
607    }
608
609    #[test]
610    fn hex_decode_accepts_uppercase() {
611        assert_eq!(hex::decode("AABB").unwrap(), vec![0xAA, 0xBB]);
612    }
613
614    // ── NixHash equality ─────────────────────────────────
615
616    #[test]
617    fn nix_hash_equality() {
618        let h1 = NixHash::new(HashAlgorithm::Sha256, vec![1; 32]);
619        let h2 = NixHash::new(HashAlgorithm::Sha256, vec![1; 32]);
620        let h3 = NixHash::new(HashAlgorithm::Sha256, vec![2; 32]);
621        let h4 = NixHash::new(HashAlgorithm::Sha1, vec![1; 20]);
622        assert_eq!(h1, h2);
623        assert_ne!(h1, h3);
624        assert_ne!(h1, h4);
625    }
626
627    // ── HashAlgorithm Copy + Clone ───────────────────────
628
629    #[test]
630    fn hash_algorithm_is_copy() {
631        let a = HashAlgorithm::Sha256;
632        let b = a;
633        assert_eq!(a, b);
634    }
635
636    // ── HashAlgorithm Display ────────────────────────────
637
638    #[test]
639    fn hash_algorithm_display_strings() {
640        assert_eq!(format!("{}", HashAlgorithm::Sha256), "sha256");
641        assert_eq!(format!("{}", HashAlgorithm::Sha512), "sha512");
642        assert_eq!(format!("{}", HashAlgorithm::Sha1), "sha1");
643        assert_eq!(format!("{}", HashAlgorithm::Md5), "md5");
644    }
645
646    #[test]
647    fn hash_algorithm_from_str_via_parse() {
648        // Tests the FromStr impl path
649        let algo: HashAlgorithm = "sha256".parse().unwrap();
650        assert_eq!(algo, HashAlgorithm::Sha256);
651
652        let algo: HashAlgorithm = "sha512".parse().unwrap();
653        assert_eq!(algo, HashAlgorithm::Sha512);
654
655        let algo: HashAlgorithm = "sha1".parse().unwrap();
656        assert_eq!(algo, HashAlgorithm::Sha1);
657
658        let algo: HashAlgorithm = "md5".parse().unwrap();
659        assert_eq!(algo, HashAlgorithm::Md5);
660
661        let result: Result<HashAlgorithm, _> = "blake2b".parse();
662        assert!(result.is_err());
663    }
664
665    // ── NixHash Display ──────────────────────────────────
666
667    #[test]
668    fn nix_hash_display_format_matches_to_nix_string() {
669        let hash = NixHash::new(HashAlgorithm::Sha256, vec![0xab, 0xcd, 0xef]);
670        let displayed = format!("{hash}");
671        let manual = hash.to_nix_string();
672        assert_eq!(displayed, manual);
673        assert_eq!(displayed, "sha256:abcdef");
674    }
675
676    // ── NixHash::new constructor ─────────────────────────
677
678    #[test]
679    fn nix_hash_new_stores_fields() {
680        let h = NixHash::new(HashAlgorithm::Sha512, vec![1, 2, 3]);
681        assert_eq!(h.algorithm, HashAlgorithm::Sha512);
682        assert_eq!(h.digest, vec![1, 2, 3]);
683    }
684
685    // ── minimal_base64_encode is alias ───────────────────
686
687    #[test]
688    fn minimal_base64_encode_matches_base64_encode() {
689        let data = b"hello world";
690        assert_eq!(minimal_base64_encode(data), base64_encode(data));
691    }
692
693    // ── base64 with padding patterns ─────────────────────
694
695    #[test]
696    fn base64_encode_no_padding_needed() {
697        // 3 bytes → 4 chars, no padding
698        assert_eq!(base64_encode(b"abc"), "YWJj");
699    }
700
701    #[test]
702    fn base64_encode_one_padding() {
703        // 4 bytes → "==" pattern? actually 4 bytes → 8 chars with no padding (mod 3 = 1)
704        // 1 byte → "Zg==" (2 padding)
705        assert_eq!(base64_encode(b"f"), "Zg==");
706    }
707
708    #[test]
709    fn base64_encode_two_padding() {
710        // 2 bytes → 4 chars with 1 padding
711        assert_eq!(base64_encode(b"fo"), "Zm8=");
712    }
713
714    // ── hex error variants ───────────────────────────────
715
716    #[test]
717    fn hex_decode_odd_length_error_variant() {
718        match hex::decode("a") {
719            Err(HashError::InvalidEncoding) => {}
720            other => panic!("expected InvalidEncoding, got {other:?}"),
721        }
722    }
723
724    #[test]
725    fn hex_decode_invalid_chars_error_variant() {
726        match hex::decode("zz") {
727            Err(HashError::InvalidEncoding) => {}
728            other => panic!("expected InvalidEncoding, got {other:?}"),
729        }
730    }
731
732    // ── HashError Display ────────────────────────────────
733
734    #[test]
735    fn hash_error_display_strings() {
736        let err = HashError::UnsupportedAlgorithm("blake3".to_string());
737        let s = format!("{err}");
738        assert!(s.contains("blake3"));
739
740        let err = HashError::InvalidEncoding;
741        let s = format!("{err}");
742        assert!(s.contains("invalid"));
743    }
744
745    // ── digest length matches each algo ──────────────────
746
747    #[test]
748    fn digest_len_for_all_algorithms() {
749        let cases = [
750            (HashAlgorithm::Md5, 16),
751            (HashAlgorithm::Sha1, 20),
752            (HashAlgorithm::Sha256, 32),
753            (HashAlgorithm::Sha512, 64),
754        ];
755        for (algo, expected_len) in cases {
756            assert_eq!(algo.digest_len(), expected_len);
757        }
758    }
759
760    // ── Roundtrip via Display + FromStr ──────────────────
761
762    #[test]
763    fn algorithm_display_roundtrip_through_from_nix_str() {
764        for algo in [
765            HashAlgorithm::Sha256,
766            HashAlgorithm::Sha512,
767            HashAlgorithm::Sha1,
768            HashAlgorithm::Md5,
769        ] {
770            let s = format!("{algo}");
771            let parsed = HashAlgorithm::from_nix_str(&s).unwrap();
772            assert_eq!(parsed, algo);
773        }
774    }
775
776    // ── Hex roundtrip varied lengths ─────────────────────
777
778    #[test]
779    fn hex_roundtrip_lengths_5_10_20_32_64() {
780        for len in [5, 10, 20, 32, 64] {
781            let data: Vec<u8> = (0..len).map(|i| i as u8).collect();
782            let encoded = hex::encode(&data);
783            assert_eq!(encoded.len(), len * 2);
784            let decoded = hex::decode(&encoded).unwrap();
785            assert_eq!(decoded, data);
786        }
787    }
788
789    // ── base64 roundtrip varied lengths ──────────────────
790
791    #[test]
792    fn base64_roundtrip_lengths_1_through_10() {
793        for len in 1..=10 {
794            let data: Vec<u8> = (0..len).map(|i| i as u8).collect();
795            let encoded = base64_encode(&data);
796            let decoded = base64_decode(&encoded).unwrap();
797            assert_eq!(decoded, data, "failed for length {len}");
798        }
799    }
800
801    // ── HashAlgorithm hash + equality ────────────────────
802
803    #[test]
804    fn hash_algorithm_in_hashset() {
805        use std::collections::HashSet;
806        let mut set = HashSet::new();
807        set.insert(HashAlgorithm::Sha256);
808        set.insert(HashAlgorithm::Sha256);
809        set.insert(HashAlgorithm::Md5);
810        assert_eq!(set.len(), 2);
811        assert!(set.contains(&HashAlgorithm::Sha256));
812        assert!(set.contains(&HashAlgorithm::Md5));
813        assert!(!set.contains(&HashAlgorithm::Sha512));
814    }
815
816    // ── NixHash with empty digest still serializes ──────
817
818    #[test]
819    fn nix_hash_empty_digest_to_sri() {
820        let hash = NixHash::new(HashAlgorithm::Md5, vec![]);
821        let sri = hash.to_sri();
822        assert_eq!(sri, "md5-");
823    }
824
825    #[test]
826    fn nix_hash_empty_digest_display() {
827        let hash = NixHash::new(HashAlgorithm::Sha1, vec![]);
828        assert_eq!(format!("{hash}"), "sha1:");
829    }
830
831    // ── hex::encode known vectors ────────────────────────
832
833    #[test]
834    fn hex_encode_known_vectors() {
835        assert_eq!(hex::encode(b"\x00"), "00");
836        assert_eq!(hex::encode(b"\xff"), "ff");
837        assert_eq!(hex::encode(b"\x01\x02\x03\x04"), "01020304");
838        assert_eq!(hex::encode(b"\xde\xad\xbe\xef"), "deadbeef");
839    }
840
841    #[test]
842    fn hex_encode_lowercase_only() {
843        let encoded = hex::encode(&[0xAB, 0xCD, 0xEF]);
844        // The output should be all lowercase
845        assert_eq!(encoded, "abcdef");
846        assert!(encoded.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
847    }
848
849    // ── base64_decode error variant ──────────────────────
850
851    #[test]
852    fn base64_decode_invalid_returns_invalid_encoding() {
853        match base64_decode("@@@") {
854            Err(HashError::InvalidEncoding) => {}
855            other => panic!("expected InvalidEncoding, got {other:?}"),
856        }
857    }
858
859    // ── Sha512 known sri/hex output ──────────────────────
860
861    #[test]
862    fn sha512_full_digest_sri_length() {
863        let hash = NixHash::new(HashAlgorithm::Sha512, vec![0xAA; 64]);
864        let sri = hash.to_sri();
865        let b64 = sri.strip_prefix("sha512-").unwrap();
866        // 64 bytes → ceil(64/3)*4 = 88 chars
867        assert_eq!(b64.len(), 88);
868    }
869
870    #[test]
871    fn sha1_full_digest_hex_length() {
872        let hash = NixHash::new(HashAlgorithm::Sha1, vec![0x55; 20]);
873        let hex_part = hash.to_nix_string();
874        let h = hex_part.strip_prefix("sha1:").unwrap();
875        assert_eq!(h.len(), 40); // 20 bytes * 2 hex chars
876    }
877
878    // ── bare (un-prefixed) standard-base64 ingress ───────
879    // CppNix `Hash::parseNonSRIUnprefixed` accepts a padded standard
880    // base64 digest with NO `<algo>-` prefix (the historical
881    // `sha256 = "…="` form used all over nixpkgs fetchpatch/fetchurl).
882    // Without this branch a FOD whose outputHash is bare base64 throws
883    // at drv-construction; stdenv mkDerivation swallows the throw and
884    // silently drops the whole attribute (e.g. `patches`).
885
886    #[test]
887    fn bare_base64_sha256_round_trips_to_hex() {
888        // Real nixpkgs elfutils fix-aarch64_fregs.patch fetchpatch hash.
889        let raw = "zvncoRkQx3AwPx52ehjA2vcFroF+yDC2MQR5uS6DATs=";
890        let h = NixHash::parse_any(HashAlgorithm::Sha256, raw)
891            .expect("bare base64 sha256 must parse");
892        assert_eq!(h.digest.len(), 32);
893        // And it decodes to the same bytes as the SRI-prefixed form.
894        let sri = format!("sha256-{raw}");
895        let h2 = NixHash::parse_any(HashAlgorithm::Sha256, &sri).unwrap();
896        assert_eq!(h.digest, h2.digest);
897    }
898
899    #[test]
900    fn bare_base64_length_per_algo_disambiguates() {
901        // hex / base32 / base64 lengths are distinct for every algo, so
902        // length alone selects the encoding (no backtracking).
903        for (algo, n) in [
904            (HashAlgorithm::Sha256, 32usize),
905            (HashAlgorithm::Sha512, 64),
906            (HashAlgorithm::Sha1, 20),
907            (HashAlgorithm::Md5, 16),
908        ] {
909            let digest = vec![0x3C; n];
910            let b64 = base64_encode(&digest);
911            let parsed = NixHash::parse_any(algo, &b64)
912                .expect("bare base64 must parse for every algo");
913            assert_eq!(parsed.digest, digest);
914        }
915    }
916
917    #[test]
918    fn bare_base64_wrong_length_rejected() {
919        // A base64 string whose decoded length ≠ digest_len must NOT be
920        // silently accepted (would corrupt the store-path fingerprint).
921        let short = base64_encode(&[0u8; 16]); // 16 bytes, not sha256's 32
922        assert!(matches!(
923            NixHash::parse_any(HashAlgorithm::Sha256, &short),
924            Err(HashError::InvalidEncoding)
925        ));
926    }
927
928    // ── unpadded base64 (SRI + bare) ─────────────────────
929    // nixpkgs `fetchFromGitLab`/`fetchurl` hashes are frequently
930    // written WITHOUT `=` padding (e.g. `sha256-…/go` = 43 chars).
931    // CppNix accepts both; without this the source FOD's outputHash
932    // throws and stdenv drops the whole `src` attr.
933
934    #[test]
935    fn sri_unpadded_base64_parses() {
936        // Real nixpkgs mesa-gl-headers fetchFromGitLab hash (43 chars,
937        // no trailing `=`).
938        let raw = "sha256-UlI+6OMUj5F6uVAw+Mg2wOZrjfdRq73d1qufaXVI/go";
939        let h = NixHash::parse_any(HashAlgorithm::Sha256, raw)
940            .expect("unpadded SRI sha256 must parse");
941        assert_eq!(h.digest.len(), 32);
942        // Same bytes as the padded form.
943        let padded = format!("{raw}=");
944        let h2 = NixHash::parse_any(HashAlgorithm::Sha256, &padded).unwrap();
945        assert_eq!(h.digest, h2.digest);
946    }
947
948    #[test]
949    fn bare_unpadded_base64_parses_every_algo() {
950        for (algo, n) in [
951            (HashAlgorithm::Sha256, 32usize),
952            (HashAlgorithm::Sha512, 64),
953            (HashAlgorithm::Sha1, 20),
954            (HashAlgorithm::Md5, 16),
955        ] {
956            let digest = vec![0x5Au8; n];
957            let padded = base64_encode(&digest);
958            let unpadded = padded.trim_end_matches('=');
959            let parsed = NixHash::parse_any(algo, unpadded)
960                .expect("unpadded bare base64 must parse");
961            assert_eq!(parsed.digest, digest);
962        }
963    }
964
965    #[test]
966    fn pad_tolerant_matches_padded() {
967        let digest = vec![0x11u8; 32];
968        let padded = base64_encode(&digest);
969        let unpadded = padded.trim_end_matches('=');
970        assert_eq!(
971            base64_decode_padtolerant(unpadded).unwrap(),
972            base64_decode(&padded).unwrap()
973        );
974    }
975}