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    // SRI is `<algo>-<base64-of-digest>`; the base64 length for
161    // n bytes is 4 * ceil(n / 3), padded to a multiple of 4.
162    let sri_prefix = format!("{}-", algo.as_nix_str());
163
164    if let Some(b64) = raw.strip_prefix(&sri_prefix) {
165        let bytes = base64_decode(b64)?;
166        if bytes.len() != want {
167            return Err(HashError::InvalidEncoding);
168        }
169        return Ok(bytes);
170    }
171
172    if raw.len() == hex_len
173        && raw.chars().all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
174    {
175        let bytes = hex::decode(raw)?;
176        return Ok(bytes);
177    }
178
179    if raw.len() == base32_len {
180        // Nix-base32 alphabet (note: 'e', 'o', 't', 'u' are not in
181        // the alphabet — they were excluded to avoid confusion).
182        let alphabet = "0123456789abcdfghijklmnpqrsvwxyz";
183        if raw.chars().all(|c| alphabet.contains(c)) {
184            let bytes = decode_nix_base32(raw, want)?;
185            return Ok(bytes);
186        }
187    }
188
189    Err(HashError::InvalidEncoding)
190}
191
192/// Decode a nix-base32 string of expected byte length `want`.
193///
194/// The encoder writes the most-significant 5-bit group first; the
195/// decoder must walk the input in reverse to recover bytes in
196/// natural order.  See [`crate::store_path::nix_base32_decode`]
197/// for the same algorithm specialized to a 20-byte compressed
198/// hash; this variant accepts arbitrary `want` lengths so it can
199/// service every supported [`HashAlgorithm`].
200fn decode_nix_base32(input: &str, want: usize) -> Result<Vec<u8>, HashError> {
201    let alphabet = b"0123456789abcdfghijklmnpqrsvwxyz";
202    let mut digit_value = [0u8; 128];
203    for (i, &c) in alphabet.iter().enumerate() {
204        digit_value[c as usize] = i as u8;
205    }
206    let mut out = vec![0u8; want];
207    let bytes = input.as_bytes();
208    for (n, &c) in bytes.iter().rev().enumerate() {
209        let d = digit_value[c as usize] as usize;
210        let b = n * 5;
211        let i = b / 8;
212        let j = b % 8;
213        out[i] |= ((d << j) & 0xff) as u8;
214        if i + 1 < want {
215            out[i + 1] |= (d >> (8 - j)) as u8;
216        } else if (d >> (8 - j)) != 0 {
217            // High bits set past the end of the digest → not a
218            // valid base32 encoding of a `want`-byte value.
219            return Err(HashError::InvalidEncoding);
220        }
221    }
222    Ok(out)
223}
224
225impl std::fmt::Display for NixHash {
226    /// Formats as the Nix string representation (`algo:hex`).
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        write!(f, "{}:{}", self.algorithm, hex::encode(&self.digest))
229    }
230}
231
232/// Base64 encode bytes (delegates to the `base64` crate).
233#[must_use]
234pub fn base64_encode(input: &[u8]) -> String {
235    use base64::Engine;
236    base64::engine::general_purpose::STANDARD.encode(input)
237}
238
239/// Base64 decode a string (delegates to the `base64` crate).
240pub fn base64_decode(input: &str) -> Result<Vec<u8>, HashError> {
241    use base64::Engine;
242    base64::engine::general_purpose::STANDARD
243        .decode(input)
244        .map_err(|_| HashError::InvalidEncoding)
245}
246
247/// Base64 encode bytes (alias for [`base64_encode`] kept for backward compatibility).
248#[must_use]
249pub fn minimal_base64_encode(input: &[u8]) -> String {
250    base64_encode(input)
251}
252
253/// Minimal hex encoding (avoids external dep for now).
254pub(crate) mod hex {
255    /// Encode bytes as lowercase hexadecimal.
256    #[must_use]
257    pub fn encode(bytes: &[u8]) -> String {
258        let mut s = String::with_capacity(bytes.len() * 2);
259        for b in bytes {
260            s.push_str(&format!("{b:02x}"));
261        }
262        s
263    }
264
265    /// Decode a lowercase hexadecimal string to bytes.
266    pub fn decode(s: &str) -> Result<Vec<u8>, super::HashError> {
267        if !s.len().is_multiple_of(2) {
268            return Err(super::HashError::InvalidEncoding);
269        }
270        (0..s.len())
271            .step_by(2)
272            .map(|i| {
273                u8::from_str_radix(&s[i..i + 2], 16)
274                    .map_err(|_| super::HashError::InvalidEncoding)
275            })
276            .collect()
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use proptest::prelude::*;
284
285    #[test]
286    fn parse_any_accepts_64_char_hex_sha256() {
287        let raw = "0".repeat(64);
288        let h = NixHash::parse_any(HashAlgorithm::Sha256, &raw).unwrap();
289        assert_eq!(h.digest, vec![0u8; 32]);
290    }
291
292    #[test]
293    fn parse_any_accepts_52_char_base32_sha256() {
294        let raw = "0".repeat(52);
295        let h = NixHash::parse_any(HashAlgorithm::Sha256, &raw).unwrap();
296        assert_eq!(h.digest, vec![0u8; 32]);
297    }
298
299    #[test]
300    fn parse_any_accepts_sri_sha256() {
301        // SRI of 32 zero bytes.
302        let sri = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
303        let h = NixHash::parse_any(HashAlgorithm::Sha256, sri).unwrap();
304        assert_eq!(h.digest, vec![0u8; 32]);
305    }
306
307    #[test]
308    fn parse_any_rejects_wrong_length() {
309        // 50 chars: neither valid hex nor base32 sha256 length.
310        let bad = "0".repeat(50);
311        assert!(NixHash::parse_any(HashAlgorithm::Sha256, &bad).is_err());
312    }
313
314    #[test]
315    fn parse_any_rejects_invalid_alphabet() {
316        // 'e' is not in the nix-base32 alphabet at the right position
317        // length to look like base32; lead with a char that breaks both
318        // hex (uppercase) and base32 (out-of-alphabet) attempts.
319        let bad = format!("Z{}", "0".repeat(51));
320        assert!(NixHash::parse_any(HashAlgorithm::Sha256, &bad).is_err());
321    }
322
323    proptest! {
324        /// All three input formats decode to the same digest.
325        #[test]
326        fn parse_any_all_formats_agree(digest in proptest::collection::vec(any::<u8>(), 32..=32)) {
327            let nh = NixHash::new(HashAlgorithm::Sha256, digest.clone());
328            let hex_str = hex::encode(&digest);
329            let sri = nh.to_sri();
330            // Build the nix-base32 form using the existing encoder
331            // (digest length must be 32 → base32 length = 52).
332            let mut b32 = String::with_capacity(52);
333            // Re-implement encoder inline to avoid coupling to
334            // store_path::nix_base32_encode (which is specialized to 20-byte input).
335            let alphabet = b"0123456789abcdfghijklmnpqrsvwxyz";
336            for n in (0..52usize).rev() {
337                let b = n * 5;
338                let i = b / 8;
339                let j = b % 8;
340                let mut v = (digest[i] as usize) >> j;
341                if i + 1 < 32 {
342                    v |= (digest[i + 1] as usize) << (8 - j);
343                }
344                b32.push(alphabet[v & 0x1f] as char);
345            }
346            let parsed_hex = NixHash::parse_any(HashAlgorithm::Sha256, &hex_str).unwrap();
347            let parsed_b32 = NixHash::parse_any(HashAlgorithm::Sha256, &b32).unwrap();
348            let parsed_sri = NixHash::parse_any(HashAlgorithm::Sha256, &sri).unwrap();
349            prop_assert_eq!(&parsed_hex.digest, &digest);
350            prop_assert_eq!(&parsed_b32.digest, &digest);
351            prop_assert_eq!(&parsed_sri.digest, &digest);
352        }
353    }
354
355    #[test]
356    fn algorithm_roundtrip() {
357        for algo in [HashAlgorithm::Sha256, HashAlgorithm::Sha512, HashAlgorithm::Sha1, HashAlgorithm::Md5] {
358            let parsed = HashAlgorithm::from_nix_str(algo.as_nix_str()).unwrap();
359            assert_eq!(parsed, algo);
360        }
361    }
362
363    #[test]
364    fn nix_string_format() {
365        let hash = NixHash::new(HashAlgorithm::Sha256, vec![0xab; 32]);
366        let s = hash.to_nix_string();
367        assert!(s.starts_with("sha256:"));
368        assert_eq!(s.len(), 7 + 64); // "sha256:" + 64 hex chars
369    }
370
371    #[test]
372    fn sri_roundtrip() {
373        // Encode as SRI then parse back through base64 decode
374        let digest = vec![0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
375                          0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
376                          0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
377                          0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef];
378        let hash = NixHash::new(HashAlgorithm::Sha256, digest.clone());
379        let sri = hash.to_sri();
380        assert!(sri.starts_with("sha256-"));
381        // The base64 portion should decode back to the original digest
382        let b64_part = sri.strip_prefix("sha256-").unwrap();
383        // Manually verify the base64 is non-empty and well-formed
384        assert!(!b64_part.is_empty());
385        // 32 bytes -> ceil(32/3)*4 = 44 base64 chars
386        assert_eq!(b64_part.len(), 44);
387    }
388
389    #[test]
390    fn invalid_algorithm_string() {
391        assert!(HashAlgorithm::from_nix_str("blake2b").is_err());
392        assert!(HashAlgorithm::from_nix_str("SHA256").is_err());
393        assert!(HashAlgorithm::from_nix_str("").is_err());
394        assert!(HashAlgorithm::from_nix_str("sha-256").is_err());
395
396        match HashAlgorithm::from_nix_str("unknown") {
397            Err(HashError::UnsupportedAlgorithm(s)) => assert_eq!(s, "unknown"),
398            other => panic!("expected UnsupportedAlgorithm, got {other:?}"),
399        }
400    }
401
402    #[test]
403    fn hash_digest_length_per_algorithm() {
404        assert_eq!(HashAlgorithm::Sha256.digest_len(), 32);
405        assert_eq!(HashAlgorithm::Sha512.digest_len(), 64);
406        assert_eq!(HashAlgorithm::Sha1.digest_len(), 20);
407        assert_eq!(HashAlgorithm::Md5.digest_len(), 16);
408    }
409
410    #[test]
411    fn hex_encode_decode_roundtrip() {
412        let original = vec![0x00, 0x11, 0x22, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
413        let encoded = hex::encode(&original);
414        assert_eq!(encoded, "001122aabbccddeeff");
415        let decoded = hex::decode(&encoded).unwrap();
416        assert_eq!(decoded, original);
417    }
418
419    #[test]
420    fn hex_decode_odd_length() {
421        assert!(hex::decode("abc").is_err());
422    }
423
424    #[test]
425    fn hex_decode_invalid_chars() {
426        assert!(hex::decode("zzzz").is_err());
427        assert!(hex::decode("gg").is_err());
428    }
429
430    #[test]
431    fn hex_roundtrip_all_byte_values() {
432        let all_bytes: Vec<u8> = (0..=255).collect();
433        let encoded = hex::encode(&all_bytes);
434        let decoded = hex::decode(&encoded).unwrap();
435        assert_eq!(decoded, all_bytes);
436    }
437
438    #[test]
439    fn empty_digest_handling() {
440        let hash = NixHash::new(HashAlgorithm::Sha256, vec![]);
441        let nix_str = hash.to_nix_string();
442        assert_eq!(nix_str, "sha256:");
443
444        let sri = hash.to_sri();
445        assert_eq!(sri, "sha256-");
446    }
447
448    #[test]
449    fn base64_encode_known_vectors() {
450        // RFC 4648 test vectors
451        assert_eq!(minimal_base64_encode(b""), "");
452        assert_eq!(minimal_base64_encode(b"f"), "Zg==");
453        assert_eq!(minimal_base64_encode(b"fo"), "Zm8=");
454        assert_eq!(minimal_base64_encode(b"foo"), "Zm9v");
455        assert_eq!(minimal_base64_encode(b"foob"), "Zm9vYg==");
456        assert_eq!(minimal_base64_encode(b"fooba"), "Zm9vYmE=");
457        assert_eq!(minimal_base64_encode(b"foobar"), "Zm9vYmFy");
458    }
459
460    #[test]
461    fn nix_string_with_all_algorithms() {
462        for algo in [HashAlgorithm::Sha256, HashAlgorithm::Sha512, HashAlgorithm::Sha1, HashAlgorithm::Md5] {
463            let digest = vec![0xab; algo.digest_len()];
464            let hash = NixHash::new(algo, digest);
465            let s = hash.to_nix_string();
466            let expected_prefix = format!("{}:", algo.as_nix_str());
467            assert!(s.starts_with(&expected_prefix), "failed for {algo:?}");
468            let hex_part = s.strip_prefix(&expected_prefix).unwrap();
469            assert_eq!(hex_part.len(), algo.digest_len() * 2);
470        }
471    }
472
473    // ── base64_decode ────────────────────────────────────
474
475    #[test]
476    fn base64_decode_known_vectors() {
477        assert_eq!(base64_decode("").unwrap(), b"");
478        assert_eq!(base64_decode("Zg==").unwrap(), b"f");
479        assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
480        assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
481        assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
482    }
483
484    #[test]
485    fn base64_decode_invalid_input() {
486        assert!(base64_decode("!!!invalid!!!").is_err());
487    }
488
489    #[test]
490    fn base64_roundtrip_binary() {
491        let data: Vec<u8> = (0..=255).collect();
492        let encoded = base64_encode(&data);
493        let decoded = base64_decode(&encoded).unwrap();
494        assert_eq!(decoded, data);
495    }
496
497    // ── SRI format ───────────────────────────────────────
498
499    #[test]
500    fn sri_format_all_algorithms() {
501        for algo in [HashAlgorithm::Sha256, HashAlgorithm::Sha512, HashAlgorithm::Sha1, HashAlgorithm::Md5] {
502            let digest = vec![0x42; algo.digest_len()];
503            let hash = NixHash::new(algo, digest);
504            let sri = hash.to_sri();
505            let prefix = format!("{}-", algo.as_nix_str());
506            assert!(sri.starts_with(&prefix), "SRI for {algo:?} should start with {prefix}");
507        }
508    }
509
510    #[test]
511    fn sri_base64_decode_matches_digest() {
512        let digest = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04,
513                          0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
514                          0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
515                          0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C];
516        let hash = NixHash::new(HashAlgorithm::Sha256, digest.clone());
517        let sri = hash.to_sri();
518        let b64_part = sri.strip_prefix("sha256-").unwrap();
519        let decoded = base64_decode(b64_part).unwrap();
520        assert_eq!(decoded, digest);
521    }
522
523    // ── hex edge cases ───────────────────────────────────
524
525    #[test]
526    fn hex_encode_empty() {
527        assert_eq!(hex::encode(&[]), "");
528    }
529
530    #[test]
531    fn hex_decode_empty() {
532        assert_eq!(hex::decode("").unwrap(), Vec::<u8>::new());
533    }
534
535    #[test]
536    fn hex_decode_accepts_uppercase() {
537        assert_eq!(hex::decode("AABB").unwrap(), vec![0xAA, 0xBB]);
538    }
539
540    // ── NixHash equality ─────────────────────────────────
541
542    #[test]
543    fn nix_hash_equality() {
544        let h1 = NixHash::new(HashAlgorithm::Sha256, vec![1; 32]);
545        let h2 = NixHash::new(HashAlgorithm::Sha256, vec![1; 32]);
546        let h3 = NixHash::new(HashAlgorithm::Sha256, vec![2; 32]);
547        let h4 = NixHash::new(HashAlgorithm::Sha1, vec![1; 20]);
548        assert_eq!(h1, h2);
549        assert_ne!(h1, h3);
550        assert_ne!(h1, h4);
551    }
552
553    // ── HashAlgorithm Copy + Clone ───────────────────────
554
555    #[test]
556    fn hash_algorithm_is_copy() {
557        let a = HashAlgorithm::Sha256;
558        let b = a;
559        assert_eq!(a, b);
560    }
561
562    // ── HashAlgorithm Display ────────────────────────────
563
564    #[test]
565    fn hash_algorithm_display_strings() {
566        assert_eq!(format!("{}", HashAlgorithm::Sha256), "sha256");
567        assert_eq!(format!("{}", HashAlgorithm::Sha512), "sha512");
568        assert_eq!(format!("{}", HashAlgorithm::Sha1), "sha1");
569        assert_eq!(format!("{}", HashAlgorithm::Md5), "md5");
570    }
571
572    #[test]
573    fn hash_algorithm_from_str_via_parse() {
574        // Tests the FromStr impl path
575        let algo: HashAlgorithm = "sha256".parse().unwrap();
576        assert_eq!(algo, HashAlgorithm::Sha256);
577
578        let algo: HashAlgorithm = "sha512".parse().unwrap();
579        assert_eq!(algo, HashAlgorithm::Sha512);
580
581        let algo: HashAlgorithm = "sha1".parse().unwrap();
582        assert_eq!(algo, HashAlgorithm::Sha1);
583
584        let algo: HashAlgorithm = "md5".parse().unwrap();
585        assert_eq!(algo, HashAlgorithm::Md5);
586
587        let result: Result<HashAlgorithm, _> = "blake2b".parse();
588        assert!(result.is_err());
589    }
590
591    // ── NixHash Display ──────────────────────────────────
592
593    #[test]
594    fn nix_hash_display_format_matches_to_nix_string() {
595        let hash = NixHash::new(HashAlgorithm::Sha256, vec![0xab, 0xcd, 0xef]);
596        let displayed = format!("{hash}");
597        let manual = hash.to_nix_string();
598        assert_eq!(displayed, manual);
599        assert_eq!(displayed, "sha256:abcdef");
600    }
601
602    // ── NixHash::new constructor ─────────────────────────
603
604    #[test]
605    fn nix_hash_new_stores_fields() {
606        let h = NixHash::new(HashAlgorithm::Sha512, vec![1, 2, 3]);
607        assert_eq!(h.algorithm, HashAlgorithm::Sha512);
608        assert_eq!(h.digest, vec![1, 2, 3]);
609    }
610
611    // ── minimal_base64_encode is alias ───────────────────
612
613    #[test]
614    fn minimal_base64_encode_matches_base64_encode() {
615        let data = b"hello world";
616        assert_eq!(minimal_base64_encode(data), base64_encode(data));
617    }
618
619    // ── base64 with padding patterns ─────────────────────
620
621    #[test]
622    fn base64_encode_no_padding_needed() {
623        // 3 bytes → 4 chars, no padding
624        assert_eq!(base64_encode(b"abc"), "YWJj");
625    }
626
627    #[test]
628    fn base64_encode_one_padding() {
629        // 4 bytes → "==" pattern? actually 4 bytes → 8 chars with no padding (mod 3 = 1)
630        // 1 byte → "Zg==" (2 padding)
631        assert_eq!(base64_encode(b"f"), "Zg==");
632    }
633
634    #[test]
635    fn base64_encode_two_padding() {
636        // 2 bytes → 4 chars with 1 padding
637        assert_eq!(base64_encode(b"fo"), "Zm8=");
638    }
639
640    // ── hex error variants ───────────────────────────────
641
642    #[test]
643    fn hex_decode_odd_length_error_variant() {
644        match hex::decode("a") {
645            Err(HashError::InvalidEncoding) => {}
646            other => panic!("expected InvalidEncoding, got {other:?}"),
647        }
648    }
649
650    #[test]
651    fn hex_decode_invalid_chars_error_variant() {
652        match hex::decode("zz") {
653            Err(HashError::InvalidEncoding) => {}
654            other => panic!("expected InvalidEncoding, got {other:?}"),
655        }
656    }
657
658    // ── HashError Display ────────────────────────────────
659
660    #[test]
661    fn hash_error_display_strings() {
662        let err = HashError::UnsupportedAlgorithm("blake3".to_string());
663        let s = format!("{err}");
664        assert!(s.contains("blake3"));
665
666        let err = HashError::InvalidEncoding;
667        let s = format!("{err}");
668        assert!(s.contains("invalid"));
669    }
670
671    // ── digest length matches each algo ──────────────────
672
673    #[test]
674    fn digest_len_for_all_algorithms() {
675        let cases = [
676            (HashAlgorithm::Md5, 16),
677            (HashAlgorithm::Sha1, 20),
678            (HashAlgorithm::Sha256, 32),
679            (HashAlgorithm::Sha512, 64),
680        ];
681        for (algo, expected_len) in cases {
682            assert_eq!(algo.digest_len(), expected_len);
683        }
684    }
685
686    // ── Roundtrip via Display + FromStr ──────────────────
687
688    #[test]
689    fn algorithm_display_roundtrip_through_from_nix_str() {
690        for algo in [
691            HashAlgorithm::Sha256,
692            HashAlgorithm::Sha512,
693            HashAlgorithm::Sha1,
694            HashAlgorithm::Md5,
695        ] {
696            let s = format!("{algo}");
697            let parsed = HashAlgorithm::from_nix_str(&s).unwrap();
698            assert_eq!(parsed, algo);
699        }
700    }
701
702    // ── Hex roundtrip varied lengths ─────────────────────
703
704    #[test]
705    fn hex_roundtrip_lengths_5_10_20_32_64() {
706        for len in [5, 10, 20, 32, 64] {
707            let data: Vec<u8> = (0..len).map(|i| i as u8).collect();
708            let encoded = hex::encode(&data);
709            assert_eq!(encoded.len(), len * 2);
710            let decoded = hex::decode(&encoded).unwrap();
711            assert_eq!(decoded, data);
712        }
713    }
714
715    // ── base64 roundtrip varied lengths ──────────────────
716
717    #[test]
718    fn base64_roundtrip_lengths_1_through_10() {
719        for len in 1..=10 {
720            let data: Vec<u8> = (0..len).map(|i| i as u8).collect();
721            let encoded = base64_encode(&data);
722            let decoded = base64_decode(&encoded).unwrap();
723            assert_eq!(decoded, data, "failed for length {len}");
724        }
725    }
726
727    // ── HashAlgorithm hash + equality ────────────────────
728
729    #[test]
730    fn hash_algorithm_in_hashset() {
731        use std::collections::HashSet;
732        let mut set = HashSet::new();
733        set.insert(HashAlgorithm::Sha256);
734        set.insert(HashAlgorithm::Sha256);
735        set.insert(HashAlgorithm::Md5);
736        assert_eq!(set.len(), 2);
737        assert!(set.contains(&HashAlgorithm::Sha256));
738        assert!(set.contains(&HashAlgorithm::Md5));
739        assert!(!set.contains(&HashAlgorithm::Sha512));
740    }
741
742    // ── NixHash with empty digest still serializes ──────
743
744    #[test]
745    fn nix_hash_empty_digest_to_sri() {
746        let hash = NixHash::new(HashAlgorithm::Md5, vec![]);
747        let sri = hash.to_sri();
748        assert_eq!(sri, "md5-");
749    }
750
751    #[test]
752    fn nix_hash_empty_digest_display() {
753        let hash = NixHash::new(HashAlgorithm::Sha1, vec![]);
754        assert_eq!(format!("{hash}"), "sha1:");
755    }
756
757    // ── hex::encode known vectors ────────────────────────
758
759    #[test]
760    fn hex_encode_known_vectors() {
761        assert_eq!(hex::encode(b"\x00"), "00");
762        assert_eq!(hex::encode(b"\xff"), "ff");
763        assert_eq!(hex::encode(b"\x01\x02\x03\x04"), "01020304");
764        assert_eq!(hex::encode(b"\xde\xad\xbe\xef"), "deadbeef");
765    }
766
767    #[test]
768    fn hex_encode_lowercase_only() {
769        let encoded = hex::encode(&[0xAB, 0xCD, 0xEF]);
770        // The output should be all lowercase
771        assert_eq!(encoded, "abcdef");
772        assert!(encoded.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
773    }
774
775    // ── base64_decode error variant ──────────────────────
776
777    #[test]
778    fn base64_decode_invalid_returns_invalid_encoding() {
779        match base64_decode("@@@") {
780            Err(HashError::InvalidEncoding) => {}
781            other => panic!("expected InvalidEncoding, got {other:?}"),
782        }
783    }
784
785    // ── Sha512 known sri/hex output ──────────────────────
786
787    #[test]
788    fn sha512_full_digest_sri_length() {
789        let hash = NixHash::new(HashAlgorithm::Sha512, vec![0xAA; 64]);
790        let sri = hash.to_sri();
791        let b64 = sri.strip_prefix("sha512-").unwrap();
792        // 64 bytes → ceil(64/3)*4 = 88 chars
793        assert_eq!(b64.len(), 88);
794    }
795
796    #[test]
797    fn sha1_full_digest_hex_length() {
798        let hash = NixHash::new(HashAlgorithm::Sha1, vec![0x55; 20]);
799        let hex_part = hash.to_nix_string();
800        let h = hex_part.strip_prefix("sha1:").unwrap();
801        assert_eq!(h.len(), 40); // 20 bytes * 2 hex chars
802    }
803}