Skip to main content

sui_compat/
store_path.rs

1//! Nix store path parsing and computation.
2
3use crate::hash;
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6
7/// Length of the hash part in a store path (32 chars in Nix base-32).
8pub const STORE_PATH_HASH_LEN: usize = 32;
9
10/// Default store directory.
11pub const DEFAULT_STORE_DIR: &str = "/nix/store";
12
13/// Nix's custom base-32 alphabet (not RFC 4648).
14const NIX_BASE32_CHARS: &[u8; 32] = b"0123456789abcdfghijklmnpqrsvwxyz";
15
16#[derive(Debug, Error)]
17pub enum StorePathError {
18    #[error("invalid store path: {0}")]
19    Invalid(String),
20    #[error("invalid hash length: expected {expected}, got {got}")]
21    InvalidHashLength { expected: usize, got: usize },
22    #[error("invalid character in hash: {0}")]
23    InvalidHashChar(char),
24    #[error("empty name")]
25    EmptyName,
26}
27
28/// A validated Nix store path.
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub struct StorePath {
31    /// The 20-byte hash digest.
32    pub digest: [u8; 20],
33    /// The human-readable name portion.
34    pub name: String,
35}
36
37impl StorePath {
38    /// Parse a store path from a string like `/nix/store/<hash>-<name>`.
39    pub fn from_absolute_path(path: &str) -> Result<Self, StorePathError> {
40        let rest = path
41            .strip_prefix(DEFAULT_STORE_DIR)
42            .and_then(|s| s.strip_prefix('/'))
43            .ok_or_else(|| StorePathError::Invalid(path.to_string()))?;
44
45        Self::from_basename(rest)
46    }
47
48    /// Parse from just the `<hash>-<name>` portion.
49    pub fn from_basename(basename: &str) -> Result<Self, StorePathError> {
50        if basename.len() < STORE_PATH_HASH_LEN + 2 {
51            return Err(StorePathError::Invalid(basename.to_string()));
52        }
53
54        let hash_str = &basename[..STORE_PATH_HASH_LEN];
55        let sep = basename.as_bytes()[STORE_PATH_HASH_LEN];
56        let name = &basename[STORE_PATH_HASH_LEN + 1..];
57
58        if sep != b'-' {
59            return Err(StorePathError::Invalid(basename.to_string()));
60        }
61        if name.is_empty() {
62            return Err(StorePathError::EmptyName);
63        }
64
65        let digest = nix_base32_decode(hash_str)?;
66
67        Ok(Self {
68            digest,
69            name: name.to_string(),
70        })
71    }
72
73    /// Render the full absolute path.
74    #[must_use]
75    pub fn to_absolute_path(&self) -> String {
76        format!("{}/{}", DEFAULT_STORE_DIR, self.to_basename())
77    }
78
79    /// Render just the `<hash>-<name>` basename.
80    #[must_use]
81    pub fn to_basename(&self) -> String {
82        format!("{}-{}", nix_base32_encode(&self.digest), self.name)
83    }
84
85    /// Return the Nix base-32 hash portion of this store path.
86    ///
87    /// This is the 32-character string used to look up `.narinfo` files in
88    /// binary caches (e.g., `sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6`).
89    #[must_use]
90    pub fn hash(&self) -> String {
91        nix_base32_encode(&self.digest)
92    }
93
94    /// Return the human-readable name portion of this store path
95    /// (e.g., `hello-2.12.1`).
96    #[must_use]
97    pub fn name(&self) -> &str {
98        &self.name
99    }
100}
101
102impl std::fmt::Display for StorePath {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        write!(f, "{}", self.to_absolute_path())
105    }
106}
107
108impl std::str::FromStr for StorePath {
109    type Err = StorePathError;
110
111    fn from_str(s: &str) -> Result<Self, Self::Err> {
112        Self::from_absolute_path(s)
113    }
114}
115
116/// Encode bytes to Nix's custom base-32 encoding.
117///
118/// Matches CppNix `printHash32`: characters are emitted in
119/// most-significant-first order, where the FIRST character
120/// represents the high bits of the LAST input byte. The previous
121/// implementation indexed bytes from the END of the input, which
122/// produced a (different) self-consistent encoding that did NOT
123/// match real Nix store paths — every store-path computation
124/// silently disagreed with CppNix.
125#[must_use]
126pub fn nix_base32_encode(input: &[u8]) -> String {
127    let len = (input.len() * 8).div_ceil(5);
128    let mut out = String::with_capacity(len);
129
130    for n in 0..len {
131        let b = (len - 1 - n) * 5;
132        let i = b / 8;
133        let j = b % 8;
134        let mut c = u16::from(input[i]) >> j;
135        if i + 1 < input.len() {
136            c |= u16::from(input[i + 1]) << (8 - j);
137        }
138        out.push(NIX_BASE32_CHARS[(c & 0x1f) as usize] as char);
139    }
140
141    out
142}
143
144/// Decode Nix's custom base-32 encoding to bytes.
145///
146/// Inverse of [`nix_base32_encode`].
147pub fn nix_base32_decode(input: &str) -> Result<[u8; 20], StorePathError> {
148    let expected_len = 32; // 20 bytes * 8 bits / 5 bits = 32 chars
149    if input.len() != expected_len {
150        return Err(StorePathError::InvalidHashLength {
151            expected: expected_len,
152            got: input.len(),
153        });
154    }
155
156    let mut bytes = [0u8; 20];
157    let total = input.len();
158
159    for (n, c) in input.chars().enumerate() {
160        let digit = NIX_BASE32_CHARS
161            .iter()
162            .position(|&x| x == c as u8)
163            .ok_or(StorePathError::InvalidHashChar(c))?;
164        let b = (total - 1 - n) * 5;
165        let i = b / 8;
166        let j = b % 8;
167        bytes[i] |= ((digit as u16) << j) as u8;
168        if i + 1 < bytes.len() {
169            bytes[i + 1] |= ((digit as u16) >> (8 - j)) as u8;
170        }
171    }
172
173    Ok(bytes)
174}
175
176// ── Store path hash computation ──────────────────────────────
177//
178// CppNix's store path computation works in three layers:
179//
180//   1. An "inner hash" describes the derivation/output identity.
181//      - For text refs (.drv files): SHA-256 of the .drv ATerm content.
182//      - For input-addressed outputs: SHA-256 of the .drv ATerm content
183//        (with the corresponding output path stubbed to "").
184//      - For fixed-output: SHA-256 of `fixed:out:<algo>:<hash>:`.
185//
186//   2. A "fingerprint" string is constructed from the inner hash and metadata,
187//      then SHA-256 hashed to produce a 32-byte digest.
188//
189//   3. The 32-byte digest is XOR-folded ("compressed") to 20 bytes,
190//      then encoded in Nix's custom base-32 alphabet to produce the
191//      32-character hash prefix of the store path.
192
193/// Compress an arbitrary-length hash into `output_len` bytes via XOR folding.
194///
195/// This is the algorithm CppNix calls `compressHash`: each input byte is XORed
196/// into `output[i % output_len]`. For SHA-256 (32 bytes) → 20 bytes, this folds
197/// the back 12 bytes onto the front 12.
198#[must_use]
199pub fn compress_hash(hash: &[u8], output_len: usize) -> Vec<u8> {
200    let mut out = vec![0u8; output_len];
201    for (i, b) in hash.iter().enumerate() {
202        out[i % output_len] ^= *b;
203    }
204    out
205}
206
207/// Hash a fingerprint string and produce a Nix store path.
208///
209/// The fingerprint is hashed with SHA-256, compressed to 20 bytes, encoded
210/// in Nix base-32, and then prefixed with `/nix/store/` and suffixed with
211/// the given `name`.
212#[must_use]
213pub fn compute_store_path_from_fingerprint(fingerprint: &str, name: &str) -> String {
214    let hash = Sha256::digest(fingerprint.as_bytes());
215    let compressed = compress_hash(&hash, 20);
216    let b32 = nix_base32_encode(&compressed);
217    format!("{DEFAULT_STORE_DIR}/{b32}-{name}")
218}
219
220/// Compute the `.drv` store path for a serialized derivation,
221/// without folding any references into the fingerprint.
222///
223/// **This is only correct for source-only derivations** that have
224/// no input store paths whatsoever — every other derivation needs
225/// `compute_drv_path_with_refs`. Real `.drv` files always reference
226/// at least one input source or input derivation, so this function
227/// alone will mismatch CppNix on every realistic input. Kept for
228/// callers that don't have access to a parsed `Derivation`.
229///
230/// The fingerprint is `text:sha256:<hex_inner>:<store>:<name>.drv`.
231#[must_use]
232pub fn compute_drv_path(drv_content: &[u8], name: &str) -> String {
233    compute_drv_path_with_refs(drv_content, name, &[])
234}
235
236/// Compute the `.drv` store path including the derivation's
237/// references in the fingerprint.
238///
239/// CppNix's `makeTextPath` builds the fingerprint as:
240///
241/// ```text
242/// text:<ref1>:<ref2>:...:sha256:<hex_inner>:/nix/store:<name>.drv
243/// ```
244///
245/// where each `<refN>` is a store path mentioned anywhere in the
246/// `.drv` content (every entry of `Derivation::input_derivations`
247/// plus every entry of `Derivation::input_sources`). The list is
248/// sorted lexicographically and de-duplicated. Without these refs,
249/// every real-world drvPath mismatches the on-disk filename.
250///
251/// Pass refs sorted or unsorted — this function sorts and dedups
252/// internally so callers don't have to.
253#[must_use]
254pub fn compute_drv_path_with_refs(drv_content: &[u8], name: &str, refs: &[String]) -> String {
255    let inner = Sha256::digest(drv_content);
256    let inner_hex = hash::hex::encode(&inner);
257    let drv_name = format!("{name}.drv");
258
259    // Sort + dedup refs so two callers with the same set produce
260    // identical fingerprints regardless of input order.
261    let mut sorted_refs: Vec<&String> = refs.iter().collect();
262    sorted_refs.sort();
263    sorted_refs.dedup();
264
265    let mut fingerprint = String::from("text:");
266    for r in sorted_refs {
267        fingerprint.push_str(r);
268        fingerprint.push(':');
269    }
270    fingerprint.push_str("sha256:");
271    fingerprint.push_str(&inner_hex);
272    fingerprint.push(':');
273    fingerprint.push_str(DEFAULT_STORE_DIR);
274    fingerprint.push(':');
275    fingerprint.push_str(&drv_name);
276
277    compute_store_path_from_fingerprint(&fingerprint, &drv_name)
278}
279
280/// Compute an output store path from an inner hash hex string.
281///
282/// The fingerprint is `output:<output_name>:sha256:<inner_hex>:<store>:<full_name>`,
283/// where `full_name` is `name` for the `out` output and `name-<output_name>` otherwise.
284#[must_use]
285pub fn compute_output_path(inner_hash_hex: &str, output_name: &str, name: &str) -> String {
286    let full_name = if output_name == "out" {
287        name.to_string()
288    } else {
289        format!("{name}-{output_name}")
290    };
291    let fingerprint = format!(
292        "output:{output_name}:sha256:{inner_hash_hex}:{DEFAULT_STORE_DIR}:{full_name}"
293    );
294    compute_store_path_from_fingerprint(&fingerprint, &full_name)
295}
296
297/// Compute the output store path for a fixed-output derivation.
298///
299/// CppNix has two distinct branches in `makeFixedOutputPath`:
300///
301/// 1. **Recursive SHA-256** (`r:sha256`, NAR-based content hashing):
302///    the path uses the `"source"` type and the inner hash is the
303///    user's hash *directly* (no `fixed:out:` wrapping):
304///    fingerprint = `source:sha256:<hex>:/nix/store:<name>`
305///
306/// 2. **Everything else** (flat sha256, md5, sha1, sha512, recursive
307///    non-sha256): the path uses the `"output:out"` type and the
308///    inner hash is `sha256(fixed:out:<r:?><algo>:<hex>:)`:
309///    fingerprint = `output:out:sha256:<wrapped_hex>:/nix/store:<name>`
310///
311/// `hash` here is the user-declared content hash in lowercase hex.
312#[must_use]
313pub fn compute_fixed_output_hash(
314    algo: &str,
315    hash: &str,
316    is_recursive: bool,
317    name: &str,
318) -> String {
319    if is_recursive && algo == "sha256" {
320        // "source" branch: the user's NAR hash is the inner hash
321        // directly. No "fixed:out:" wrapping, no sha256-of-sha256.
322        let fingerprint = format!(
323            "source:sha256:{hash}:{DEFAULT_STORE_DIR}:{name}"
324        );
325        return compute_store_path_from_fingerprint(&fingerprint, name);
326    }
327
328    let mode = if is_recursive { "r:" } else { "" };
329    let inner = format!("fixed:out:{mode}{algo}:{hash}:");
330    let inner_hash = Sha256::digest(inner.as_bytes());
331    let inner_hex = hash::hex::encode(&inner_hash);
332    compute_output_path(&inner_hex, "out", name)
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use proptest::prelude::*;
339
340    #[test]
341    fn parse_absolute_path() {
342        let path = "/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-net-hierarchical-0.1.0.1";
343        let sp = StorePath::from_absolute_path(path).unwrap();
344        assert_eq!(sp.name, "net-hierarchical-0.1.0.1");
345        assert_eq!(sp.to_absolute_path(), path);
346    }
347
348    #[test]
349    fn roundtrip_base32() {
350        let input = [0u8; 20];
351        let encoded = nix_base32_encode(&input);
352        assert_eq!(encoded.len(), 32);
353        let decoded = nix_base32_decode(&encoded).unwrap();
354        assert_eq!(decoded, input);
355    }
356
357    #[test]
358    fn reject_invalid_path() {
359        assert!(StorePath::from_absolute_path("/tmp/foo").is_err());
360        assert!(StorePath::from_absolute_path("/nix/store/short").is_err());
361    }
362
363    #[test]
364    fn invalid_base32_chars_e_and_u() {
365        // Nix base32 alphabet is "0123456789abcdfghijklmnpqrsvwxyz"
366        // Letters 'e' and 'u' are NOT in the alphabet
367        let with_e = "00bgd045z0d4icpbc2yye4gx48ak44la";
368        assert!(nix_base32_decode(with_e).is_err());
369
370        let with_u = "00bgd045z0d4icpbc2yyu4gx48ak44la";
371        assert!(nix_base32_decode(with_u).is_err());
372
373        // Verify that 'e' and 'u' produce InvalidHashChar errors
374        match nix_base32_decode("e0000000000000000000000000000000") {
375            Err(StorePathError::InvalidHashChar(c)) => assert_eq!(c, 'e'),
376            other => panic!("expected InvalidHashChar('e'), got {other:?}"),
377        }
378        match nix_base32_decode("u0000000000000000000000000000000") {
379            Err(StorePathError::InvalidHashChar(c)) => assert_eq!(c, 'u'),
380            other => panic!("expected InvalidHashChar('u'), got {other:?}"),
381        }
382    }
383
384    #[test]
385    fn path_with_minimum_valid_name_length() {
386        // Name must be at least 1 character
387        let hash = nix_base32_encode(&[0u8; 20]);
388        let basename = format!("{hash}-x");
389        let sp = StorePath::from_basename(&basename).unwrap();
390        assert_eq!(sp.name, "x");
391    }
392
393    #[test]
394    fn path_with_special_characters_in_name() {
395        let hash = nix_base32_encode(&[1u8; 20]);
396        // Nix names can contain dots, hyphens, underscores, plus
397        let basename = format!("{hash}-my-pkg_v1.2.3+git");
398        let sp = StorePath::from_basename(&basename).unwrap();
399        assert_eq!(sp.name, "my-pkg_v1.2.3+git");
400    }
401
402    #[test]
403    fn basename_roundtrip_real_world_examples() {
404        let examples = [
405            "00bgd045z0d4icpbc2yyz4gx48ak44la-net-hierarchical-0.1.0.1",
406            "3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8",
407            "sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1",
408        ];
409
410        for basename in examples {
411            let sp = StorePath::from_basename(basename).unwrap();
412            assert_eq!(sp.to_basename(), basename, "roundtrip failed for {basename}");
413        }
414    }
415
416    #[test]
417    fn store_path_display_trait() {
418        let path_str = "/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-net-hierarchical-0.1.0.1";
419        let sp = StorePath::from_absolute_path(path_str).unwrap();
420        let displayed = format!("{sp}");
421        assert_eq!(displayed, path_str);
422    }
423
424    #[test]
425    fn empty_name_rejected() {
426        let hash = nix_base32_encode(&[0u8; 20]);
427        // Construct a basename with hash + dash but no name
428        let basename = format!("{hash}-");
429        assert!(StorePath::from_basename(&basename).is_err());
430    }
431
432    #[test]
433    fn base32_encode_decode_roundtrip_various() {
434        let test_cases: [[u8; 20]; 4] = [
435            [0u8; 20],
436            [0xff; 20],
437            [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc,
438             0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xde, 0xad, 0xbe, 0xef],
439            [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
440        ];
441
442        for input in &test_cases {
443            let encoded = nix_base32_encode(input);
444            assert_eq!(encoded.len(), 32);
445            let decoded = nix_base32_decode(&encoded).unwrap();
446            assert_eq!(&decoded, input);
447        }
448    }
449
450    #[test]
451    fn wrong_hash_length_rejected() {
452        // Too short
453        assert!(nix_base32_decode("abc").is_err());
454        // Too long
455        assert!(nix_base32_decode("000000000000000000000000000000000").is_err());
456        // Check error variant
457        match nix_base32_decode("abc") {
458            Err(StorePathError::InvalidHashLength { expected: 32, got: 3 }) => {}
459            other => panic!("expected InvalidHashLength, got {other:?}"),
460        }
461    }
462
463    // ── compress_hash ────────────────────────────────────────
464
465    #[test]
466    fn compress_hash_zero_input_zero_output() {
467        let zeros = [0u8; 32];
468        let out = compress_hash(&zeros, 20);
469        assert_eq!(out, vec![0u8; 20]);
470    }
471
472    #[test]
473    fn compress_hash_xor_fold_layout() {
474        // 32-byte input → 20-byte output: bytes 20..32 fold onto bytes 0..12.
475        let mut input = [0u8; 32];
476        input[0] = 0xAA;
477        input[20] = 0x55;
478        // After fold: out[0] = input[0] ^ input[20] = 0xAA ^ 0x55 = 0xFF.
479        let out = compress_hash(&input, 20);
480        assert_eq!(out[0], 0xFF);
481        // Bytes 12..20 stay as-is (they're not folded onto by anything).
482        for i in 12..20 {
483            assert_eq!(out[i], 0);
484        }
485    }
486
487    #[test]
488    fn compress_hash_identity_when_lengths_match() {
489        // If input length == output length, compress is just a copy.
490        let input: [u8; 20] = [
491            1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
492        ];
493        let out = compress_hash(&input, 20);
494        assert_eq!(out, input.to_vec());
495    }
496
497    #[test]
498    fn compress_hash_output_length_respected() {
499        let input = [0xFFu8; 64];
500        for &target_len in &[1usize, 5, 10, 20, 32] {
501            let out = compress_hash(&input, target_len);
502            assert_eq!(out.len(), target_len);
503        }
504    }
505
506    // ── compute_store_path_from_fingerprint ──────────────────
507
508    #[test]
509    fn fingerprint_path_format() {
510        let path = compute_store_path_from_fingerprint("text:sha256:abc:/nix/store:hello.drv", "hello.drv");
511        // Must start with /nix/store/, have 32-char hash, dash, name.
512        assert!(path.starts_with("/nix/store/"));
513        let basename = path.strip_prefix("/nix/store/").unwrap();
514        assert_eq!(basename.len(), STORE_PATH_HASH_LEN + 1 + "hello.drv".len());
515        assert!(basename.ends_with("-hello.drv"));
516        // Hash portion uses only nix base32 alphabet.
517        let hash = &basename[..STORE_PATH_HASH_LEN];
518        for c in hash.chars() {
519            assert!(NIX_BASE32_CHARS.contains(&(c as u8)), "invalid char: {c}");
520        }
521    }
522
523    #[test]
524    fn fingerprint_path_deterministic() {
525        let p1 = compute_store_path_from_fingerprint("text:sha256:abc:/nix/store:foo", "foo");
526        let p2 = compute_store_path_from_fingerprint("text:sha256:abc:/nix/store:foo", "foo");
527        assert_eq!(p1, p2);
528    }
529
530    #[test]
531    fn fingerprint_path_changes_with_input() {
532        let p1 = compute_store_path_from_fingerprint("a", "x");
533        let p2 = compute_store_path_from_fingerprint("b", "x");
534        assert_ne!(p1, p2);
535    }
536
537    // ── compute_drv_path ─────────────────────────────────────
538
539    #[test]
540    fn drv_path_format() {
541        let path = compute_drv_path(b"some-aterm-content", "hello");
542        assert!(path.starts_with("/nix/store/"));
543        assert!(path.ends_with("-hello.drv"));
544        let basename = path.strip_prefix("/nix/store/").unwrap();
545        assert_eq!(basename.len(), STORE_PATH_HASH_LEN + 1 + "hello.drv".len());
546    }
547
548    #[test]
549    fn drv_path_deterministic() {
550        let p1 = compute_drv_path(b"content", "name");
551        let p2 = compute_drv_path(b"content", "name");
552        assert_eq!(p1, p2);
553    }
554
555    #[test]
556    fn drv_path_changes_with_content() {
557        let p1 = compute_drv_path(b"a", "name");
558        let p2 = compute_drv_path(b"b", "name");
559        assert_ne!(p1, p2);
560    }
561
562    #[test]
563    fn drv_path_changes_with_name() {
564        let p1 = compute_drv_path(b"content", "foo");
565        let p2 = compute_drv_path(b"content", "bar");
566        assert_ne!(p1, p2);
567    }
568
569    // ── compute_output_path ──────────────────────────────────
570
571    #[test]
572    fn output_path_out_uses_bare_name() {
573        let path = compute_output_path("0123456789abcdef", "out", "hello");
574        assert!(path.ends_with("-hello"));
575        // No -out suffix for the default output.
576        assert!(!path.ends_with("-hello-out"));
577    }
578
579    #[test]
580    fn output_path_named_output_uses_suffix() {
581        let path = compute_output_path("0123456789abcdef", "dev", "hello");
582        assert!(path.ends_with("-hello-dev"));
583    }
584
585    #[test]
586    fn output_path_deterministic() {
587        let p1 = compute_output_path("0123456789abcdef", "out", "hello");
588        let p2 = compute_output_path("0123456789abcdef", "out", "hello");
589        assert_eq!(p1, p2);
590    }
591
592    #[test]
593    fn output_path_changes_with_inner_hash() {
594        let p1 = compute_output_path("0000000000000000", "out", "hello");
595        let p2 = compute_output_path("ffffffffffffffff", "out", "hello");
596        assert_ne!(p1, p2);
597    }
598
599    #[test]
600    fn multiple_outputs_produce_distinct_paths() {
601        let inner = "deadbeef";
602        let p_out = compute_output_path(inner, "out", "lib");
603        let p_dev = compute_output_path(inner, "dev", "lib");
604        let p_man = compute_output_path(inner, "man", "lib");
605        assert_ne!(p_out, p_dev);
606        assert_ne!(p_out, p_man);
607        assert_ne!(p_dev, p_man);
608    }
609
610    // ── compute_fixed_output_hash ────────────────────────────
611
612    #[test]
613    fn fixed_output_flat_path_format() {
614        let path = compute_fixed_output_hash(
615            "sha256",
616            "1b0ri5lsf45dknj8bfxi1syz35kmab77apxxg1yrf33la1qm3kc7",
617            false,
618            "src.tar.gz",
619        );
620        assert!(path.starts_with("/nix/store/"));
621        assert!(path.ends_with("-src.tar.gz"));
622    }
623
624    #[test]
625    fn fixed_output_recursive_differs_from_flat() {
626        let flat = compute_fixed_output_hash("sha256", "abc", false, "thing");
627        let rec = compute_fixed_output_hash("sha256", "abc", true, "thing");
628        assert_ne!(flat, rec);
629    }
630
631    #[test]
632    fn fixed_output_deterministic() {
633        let p1 = compute_fixed_output_hash("sha256", "deadbeef", false, "thing");
634        let p2 = compute_fixed_output_hash("sha256", "deadbeef", false, "thing");
635        assert_eq!(p1, p2);
636    }
637
638    #[test]
639    fn fixed_output_changes_with_hash_value() {
640        let p1 = compute_fixed_output_hash("sha256", "aaa", false, "thing");
641        let p2 = compute_fixed_output_hash("sha256", "bbb", false, "thing");
642        assert_ne!(p1, p2);
643    }
644
645    #[test]
646    fn fixed_output_changes_with_algo() {
647        let p1 = compute_fixed_output_hash("sha256", "abc", false, "thing");
648        let p2 = compute_fixed_output_hash("sha1", "abc", false, "thing");
649        assert_ne!(p1, p2);
650    }
651
652    // ── hash::hex::encode ─────────────────────────────────────
653
654    #[test]
655    fn hex_encode_basic() {
656        assert_eq!(crate::hash::hex::encode(&[0x00, 0xff, 0xab]), "00ffab");
657        assert_eq!(crate::hash::hex::encode(&[]), "");
658        assert_eq!(crate::hash::hex::encode(&[0x12, 0x34, 0x56, 0x78]), "12345678");
659    }
660
661    // ── nix_base32_encode: varied byte lengths ──────────────
662
663    #[test]
664    fn base32_encode_output_length_formula() {
665        for input_len in [0, 1, 5, 10, 16, 20, 32, 64] {
666            let input = vec![0xAB_u8; input_len];
667            let encoded = nix_base32_encode(&input);
668            let expected_len = (input_len * 8 + 4) / 5;
669            assert_eq!(
670                encoded.len(),
671                expected_len,
672                "wrong encode length for {input_len}-byte input"
673            );
674        }
675    }
676
677    #[test]
678    fn base32_encode_alphabet_only() {
679        for input_len in [5, 10, 20, 32, 64] {
680            let input = vec![0xFF_u8; input_len];
681            let encoded = nix_base32_encode(&input);
682            for c in encoded.chars() {
683                assert!(
684                    NIX_BASE32_CHARS.contains(&(c as u8)),
685                    "char '{c}' not in nix base32 alphabet (input_len={input_len})"
686                );
687            }
688        }
689    }
690
691    #[test]
692    fn base32_encode_all_zero_bytes() {
693        for input_len in [5, 10, 20, 32, 64] {
694            let input = vec![0x00_u8; input_len];
695            let encoded = nix_base32_encode(&input);
696            assert!(
697                encoded.chars().all(|c| c == '0'),
698                "all-zero {input_len}-byte input should encode to all '0's, got: {encoded}"
699            );
700        }
701    }
702
703    #[test]
704    fn base32_encode_all_ff_bytes() {
705        for input_len in [5, 10, 20, 32, 64] {
706            let input = vec![0xFF_u8; input_len];
707            let encoded = nix_base32_encode(&input);
708            assert!(
709                !encoded.is_empty(),
710                "encoding of all-0xFF input should be non-empty"
711            );
712            assert!(
713                encoded.chars().all(|c| NIX_BASE32_CHARS.contains(&(c as u8))),
714                "all chars must be in alphabet"
715            );
716        }
717    }
718
719    #[test]
720    fn base32_encode_alternating_bytes() {
721        let input: Vec<u8> = (0..32).map(|i| if i % 2 == 0 { 0xAA } else { 0x55 }).collect();
722        let encoded = nix_base32_encode(&input);
723        let expected_len = (32 * 8 + 4) / 5;
724        assert_eq!(encoded.len(), expected_len);
725        for c in encoded.chars() {
726            assert!(NIX_BASE32_CHARS.contains(&(c as u8)));
727        }
728    }
729
730    #[test]
731    fn base32_encode_empty_input() {
732        let encoded = nix_base32_encode(&[]);
733        assert_eq!(encoded, "");
734    }
735
736    #[test]
737    fn base32_encode_single_byte() {
738        let encoded = nix_base32_encode(&[0x42]);
739        assert_eq!(encoded.len(), 2);
740        let decoded_manual = nix_base32_encode(&[0x42]);
741        assert_eq!(encoded, decoded_manual);
742    }
743
744    #[test]
745    fn base32_roundtrip_20_byte_boundary_cases() {
746        let cases: Vec<[u8; 20]> = vec![
747            [0x00; 20],
748            [0xFF; 20],
749            [0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55,
750             0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55],
751            {
752                let mut a = [0u8; 20];
753                a[0] = 0x01;
754                a
755            },
756            {
757                let mut a = [0u8; 20];
758                a[19] = 0x01;
759                a
760            },
761            {
762                let mut a = [0u8; 20];
763                for (i, v) in a.iter_mut().enumerate() {
764                    *v = i as u8;
765                }
766                a
767            },
768        ];
769
770        for input in &cases {
771            let encoded = nix_base32_encode(input);
772            assert_eq!(encoded.len(), 32);
773            let decoded = nix_base32_decode(&encoded).unwrap();
774            assert_eq!(&decoded, input, "roundtrip failed for {input:?}");
775        }
776    }
777
778    // ── drv path with refs ──────────────────────────────────
779
780    #[test]
781    fn drv_path_with_refs_includes_refs_in_fingerprint() {
782        let content = b"Derive(...)";
783        let no_refs = compute_drv_path_with_refs(content, "hello", &[]);
784        let with_refs = compute_drv_path_with_refs(
785            content,
786            "hello",
787            &["/nix/store/abc-dep".to_string()],
788        );
789        assert_ne!(no_refs, with_refs);
790    }
791
792    #[test]
793    fn drv_path_with_refs_order_independent() {
794        let content = b"Derive(...)";
795        let refs_a = vec![
796            "/nix/store/bbb-b".to_string(),
797            "/nix/store/aaa-a".to_string(),
798        ];
799        let refs_b = vec![
800            "/nix/store/aaa-a".to_string(),
801            "/nix/store/bbb-b".to_string(),
802        ];
803        let p1 = compute_drv_path_with_refs(content, "test", &refs_a);
804        let p2 = compute_drv_path_with_refs(content, "test", &refs_b);
805        assert_eq!(p1, p2, "ref order should not affect output");
806    }
807
808    #[test]
809    fn drv_path_with_refs_deduplicates() {
810        let content = b"Derive(...)";
811        let with_dups = vec![
812            "/nix/store/aaa-a".to_string(),
813            "/nix/store/aaa-a".to_string(),
814        ];
815        let without_dups = vec!["/nix/store/aaa-a".to_string()];
816        let p1 = compute_drv_path_with_refs(content, "test", &with_dups);
817        let p2 = compute_drv_path_with_refs(content, "test", &without_dups);
818        assert_eq!(p1, p2, "duplicate refs should be deduplicated");
819    }
820
821    // ── Property tests ──────────────────────────────────
822
823    proptest! {
824        #[test]
825        fn prop_base32_roundtrip_20_bytes(bytes in proptest::collection::vec(any::<u8>(), 20)) {
826            let arr: [u8; 20] = bytes.try_into().unwrap();
827            let encoded = nix_base32_encode(&arr);
828            prop_assert_eq!(encoded.len(), 32);
829            let decoded = nix_base32_decode(&encoded).unwrap();
830            prop_assert_eq!(decoded, arr);
831        }
832
833        #[test]
834        fn prop_base32_encode_uses_only_nix_alphabet(bytes in proptest::collection::vec(any::<u8>(), 1..=64)) {
835            let encoded = nix_base32_encode(&bytes);
836            for c in encoded.chars() {
837                prop_assert!(NIX_BASE32_CHARS.contains(&(c as u8)), "invalid char: {}", c);
838            }
839        }
840
841        #[test]
842        fn prop_compress_hash_output_length(
843            bytes in proptest::collection::vec(any::<u8>(), 1..=64),
844            target_len in 1_usize..=32
845        ) {
846            let out = compress_hash(&bytes, target_len);
847            prop_assert_eq!(out.len(), target_len);
848        }
849
850        #[test]
851        fn prop_store_path_roundtrip(digest in proptest::collection::vec(any::<u8>(), 20)) {
852            let arr: [u8; 20] = digest.try_into().unwrap();
853            let sp = StorePath { digest: arr, name: "test-pkg".to_string() };
854            let abs = sp.to_absolute_path();
855            let reparsed = StorePath::from_absolute_path(&abs).unwrap();
856            prop_assert_eq!(reparsed.digest, sp.digest);
857            prop_assert_eq!(reparsed.name, sp.name);
858        }
859
860        // Brief: round-trip property tests for nix_base32 on byte vectors
861        // of varied sizes (5, 10, 20, 32, 64).
862        // Note: nix_base32_decode is fixed at 20 bytes input, so we can only
863        // do full encode→decode roundtrips for that length.
864
865        #[test]
866        fn prop_base32_encode_length_5(bytes in proptest::collection::vec(any::<u8>(), 5)) {
867            let encoded = nix_base32_encode(&bytes);
868            // 5 bytes * 8 = 40 bits, ceil(40/5) = 8 chars
869            prop_assert_eq!(encoded.len(), 8);
870        }
871
872        #[test]
873        fn prop_base32_encode_length_10(bytes in proptest::collection::vec(any::<u8>(), 10)) {
874            let encoded = nix_base32_encode(&bytes);
875            // 10 bytes * 8 = 80 bits, ceil(80/5) = 16 chars
876            prop_assert_eq!(encoded.len(), 16);
877        }
878
879        #[test]
880        fn prop_base32_encode_length_32(bytes in proptest::collection::vec(any::<u8>(), 32)) {
881            let encoded = nix_base32_encode(&bytes);
882            // 32 bytes * 8 = 256 bits, ceil(256/5) = 52 chars
883            prop_assert_eq!(encoded.len(), 52);
884        }
885
886        #[test]
887        fn prop_base32_encode_length_64(bytes in proptest::collection::vec(any::<u8>(), 64)) {
888            let encoded = nix_base32_encode(&bytes);
889            // 64 bytes * 8 = 512 bits, ceil(512/5) = 103 chars
890            prop_assert_eq!(encoded.len(), 103);
891        }
892
893        #[test]
894        fn prop_base32_encode_uses_alphabet_only(bytes in proptest::collection::vec(any::<u8>(), 1..=128)) {
895            let encoded = nix_base32_encode(&bytes);
896            for c in encoded.chars() {
897                prop_assert!(NIX_BASE32_CHARS.contains(&(c as u8)));
898            }
899        }
900
901        // Property test: compute_drv_path is deterministic for any input.
902        #[test]
903        fn prop_drv_path_deterministic(
904            content in proptest::collection::vec(any::<u8>(), 0..200),
905            name in "[a-z][a-z0-9-]{0,30}",
906        ) {
907            let p1 = compute_drv_path(&content, &name);
908            let p2 = compute_drv_path(&content, &name);
909            prop_assert_eq!(p1, p2);
910        }
911
912        // Property test: drv path with refs is invariant under permutation.
913        #[test]
914        fn prop_drv_path_with_refs_permutation_invariant(
915            content in proptest::collection::vec(any::<u8>(), 0..50),
916            name in "[a-z]{1,10}",
917            n_refs in 0_usize..=8,
918        ) {
919            let refs: Vec<String> = (0..n_refs).map(|i| format!("/nix/store/r{i}-x")).collect();
920            let mut shuffled = refs.clone();
921            shuffled.reverse();
922            let p1 = compute_drv_path_with_refs(&content, &name, &refs);
923            let p2 = compute_drv_path_with_refs(&content, &name, &shuffled);
924            prop_assert_eq!(p1, p2);
925        }
926    }
927
928    // ── Additional StorePath edge cases ──────────────────
929
930    #[test]
931    fn from_basename_unicode_in_name_rejected_or_accepted() {
932        // The current parser only requires ASCII for hash. The name may
933        // technically contain any UTF-8 character. Document current behavior.
934        let hash = nix_base32_encode(&[0u8; 20]);
935        let basename = format!("{hash}-héllo");
936        let sp = StorePath::from_basename(&basename).unwrap();
937        assert_eq!(sp.name, "héllo");
938    }
939
940    #[test]
941    fn from_absolute_path_without_leading_slash_rejected() {
942        assert!(StorePath::from_absolute_path("nix/store/abc").is_err());
943    }
944
945    #[test]
946    fn from_absolute_path_with_extra_path_segments_rejected() {
947        // /nix/store/<hash>-<name>/extra is not a valid store path
948        let hash = nix_base32_encode(&[0u8; 20]);
949        let path = format!("/nix/store/{hash}-name/extra");
950        // The current parser accepts it because it strips the prefix and
951        // takes everything after as basename. Document current behavior.
952        let sp = StorePath::from_absolute_path(&path).unwrap();
953        assert_eq!(sp.name, "name/extra");
954    }
955
956    #[test]
957    fn store_path_hash_trait_works_in_hashset() {
958        use std::collections::HashSet;
959        let p1 = StorePath {
960            digest: [1; 20],
961            name: "x".to_string(),
962        };
963        let p2 = p1.clone();
964        let p3 = StorePath {
965            digest: [2; 20],
966            name: "x".to_string(),
967        };
968        let mut set = HashSet::new();
969        set.insert(p1);
970        set.insert(p2); // duplicate
971        set.insert(p3);
972        assert_eq!(set.len(), 2);
973    }
974
975    // ── from_str (FromStr) trait ─────────────────────────
976
977    #[test]
978    fn store_path_from_str_trait() {
979        use std::str::FromStr;
980        let path = "/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-net-hierarchical-0.1.0.1";
981        let sp: StorePath = StorePath::from_str(path).unwrap();
982        assert_eq!(sp.name, "net-hierarchical-0.1.0.1");
983    }
984
985    #[test]
986    fn store_path_parse_trait_via_str() {
987        let path = "/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-net-hierarchical-0.1.0.1";
988        let sp: StorePath = path.parse().unwrap();
989        assert_eq!(sp.name, "net-hierarchical-0.1.0.1");
990    }
991
992    // ── StorePathError variants ──────────────────────────
993
994    #[test]
995    fn store_path_error_invalid_format_includes_string() {
996        match StorePath::from_absolute_path("/tmp/foo") {
997            Err(StorePathError::Invalid(s)) => assert_eq!(s, "/tmp/foo"),
998            other => panic!("expected Invalid, got {other:?}"),
999        }
1000    }
1001
1002    #[test]
1003    fn store_path_error_empty_name_variant() {
1004        // The minimum-length check (basename.len() < HASH_LEN + 2) fires first,
1005        // so a 33-char basename ends up returning Invalid, not EmptyName.
1006        // To reach the EmptyName branch we need a basename that's long enough
1007        // (>= 34 chars) but where the chars after the dash are still empty —
1008        // which is logically impossible. The branch is reachable only via
1009        // Direct construction of from_basename in code paths the parser
1010        // can't produce, so EmptyName is effectively dead code in the public
1011        // API. Document the actual reachable error.
1012        let hash = nix_base32_encode(&[0u8; 20]);
1013        let basename = format!("{hash}-");
1014        match StorePath::from_basename(&basename) {
1015            Err(StorePathError::Invalid(_)) => {}
1016            other => panic!("expected Invalid, got {other:?}"),
1017        }
1018    }
1019
1020    #[test]
1021    fn store_path_error_invalid_hash_length() {
1022        match nix_base32_decode("abc") {
1023            Err(StorePathError::InvalidHashLength { expected, got }) => {
1024                assert_eq!(expected, 32);
1025                assert_eq!(got, 3);
1026            }
1027            other => panic!("expected InvalidHashLength, got {other:?}"),
1028        }
1029    }
1030
1031    // ── compress_hash various output sizes ──────────────
1032
1033    #[test]
1034    fn compress_hash_to_one_byte_xor_all_input() {
1035        // For output_len = 1, every input byte XORs into byte 0.
1036        let input = vec![0xFF, 0x0F, 0xF0, 0x55, 0xAA];
1037        let out = compress_hash(&input, 1);
1038        let expected = 0xFF ^ 0x0F ^ 0xF0 ^ 0x55 ^ 0xAA;
1039        assert_eq!(out[0], expected);
1040    }
1041
1042    #[test]
1043    fn compress_hash_empty_input() {
1044        let out = compress_hash(&[], 5);
1045        assert_eq!(out, vec![0u8; 5]);
1046    }
1047
1048    #[test]
1049    fn compress_hash_smaller_input_than_output() {
1050        // 3 bytes input → 5 bytes output: bytes 0..3 are XORed, 3..5 stay 0
1051        let input = vec![0xAA, 0xBB, 0xCC];
1052        let out = compress_hash(&input, 5);
1053        assert_eq!(out[0], 0xAA);
1054        assert_eq!(out[1], 0xBB);
1055        assert_eq!(out[2], 0xCC);
1056        assert_eq!(out[3], 0);
1057        assert_eq!(out[4], 0);
1058    }
1059
1060    // ── compute_output_path with named outputs ───────────
1061
1062    #[test]
1063    fn output_path_lib_format() {
1064        let path = compute_output_path("0123456789abcdef", "lib", "openssl");
1065        assert!(path.starts_with("/nix/store/"));
1066        assert!(path.ends_with("-openssl-lib"));
1067    }
1068
1069    #[test]
1070    fn output_path_default_does_not_have_out_suffix() {
1071        let path = compute_output_path("0123456789abcdef", "out", "hello");
1072        let basename = path.strip_prefix("/nix/store/").unwrap();
1073        assert!(!basename.ends_with("-hello-out"));
1074        assert!(basename.ends_with("-hello"));
1075    }
1076
1077    // ── compute_fixed_output_hash recursive sha256 ───────
1078
1079    #[test]
1080    fn fixed_output_recursive_sha256_uses_source_branch() {
1081        // Both branches are deterministic — verify recursive sha256 differs
1082        // from non-recursive sha256
1083        let r = compute_fixed_output_hash("sha256", "abc", true, "thing");
1084        let f = compute_fixed_output_hash("sha256", "abc", false, "thing");
1085        assert_ne!(r, f);
1086    }
1087
1088    #[test]
1089    fn fixed_output_recursive_md5_does_not_use_source_branch() {
1090        // Recursive but algo != sha256 → goes through "fixed:out:r:" branch
1091        let r = compute_fixed_output_hash("md5", "abc", true, "thing");
1092        let f = compute_fixed_output_hash("md5", "abc", false, "thing");
1093        // r uses "r:md5:" prefix, f uses "md5:" — different fingerprints
1094        assert_ne!(r, f);
1095    }
1096
1097    // ── compute_drv_path delegates to with_refs ─────────
1098
1099    #[test]
1100    fn compute_drv_path_equals_with_refs_empty_slice() {
1101        let p1 = compute_drv_path(b"content", "name");
1102        let p2 = compute_drv_path_with_refs(b"content", "name", &[]);
1103        assert_eq!(p1, p2);
1104    }
1105
1106    // ── DEFAULT_STORE_DIR + STORE_PATH_HASH_LEN constants ──
1107
1108    #[test]
1109    fn store_constants() {
1110        assert_eq!(DEFAULT_STORE_DIR, "/nix/store");
1111        assert_eq!(STORE_PATH_HASH_LEN, 32);
1112    }
1113}