Skip to main content

sui_compat/
content_address.rs

1//! Content-addressed store path types.
2//!
3//! Nix supports several content-addressing methods for store paths.
4
5use crate::hash::{hex, HashAlgorithm, NixHash};
6use crate::store_path::{compress_hash, StorePath, StorePathError};
7use sha2::{Digest, Sha256};
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11pub enum ContentAddressError {
12    #[error("invalid content address format: {0}")]
13    InvalidFormat(String),
14    #[error("store path error: {0}")]
15    StorePath(#[from] StorePathError),
16}
17
18/// Content-addressing method.
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum ContentAddressMethod {
22    /// Text content (for derivation files and string-to-store).
23    /// Format: `text:<algo>:<hash>`
24    Text,
25    /// Flat file hashing (no NAR wrapping).
26    /// Format: `fixed:out:<algo>:<hash>`
27    Flat,
28    /// Recursive NAR hashing.
29    /// Format: `fixed:out:r:<algo>:<hash>`
30    Recursive,
31}
32
33impl std::fmt::Display for ContentAddressMethod {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::Text => f.write_str("text"),
37            Self::Flat => f.write_str("flat"),
38            Self::Recursive => f.write_str("recursive"),
39        }
40    }
41}
42
43impl std::str::FromStr for ContentAddressMethod {
44    type Err = ContentAddressError;
45
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        match s {
48            "text" => Ok(Self::Text),
49            "flat" => Ok(Self::Flat),
50            "recursive" => Ok(Self::Recursive),
51            _ => Err(ContentAddressError::InvalidFormat(s.to_string())),
52        }
53    }
54}
55
56/// A content address assertion.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ContentAddress {
59    pub method: ContentAddressMethod,
60    pub hash: NixHash,
61}
62
63impl ContentAddress {
64    /// Parse from the string format used in NarInfo CA field.
65    pub fn parse(s: &str) -> Result<Self, ContentAddressError> {
66        if let Some(rest) = s.strip_prefix("text:") {
67            let hash = parse_hash_with_algo(rest)?;
68            Ok(Self {
69                method: ContentAddressMethod::Text,
70                hash,
71            })
72        } else if let Some(rest) = s.strip_prefix("fixed:out:r:") {
73            let hash = parse_hash_with_algo(rest)?;
74            Ok(Self {
75                method: ContentAddressMethod::Recursive,
76                hash,
77            })
78        } else if let Some(rest) = s.strip_prefix("fixed:out:") {
79            let hash = parse_hash_with_algo(rest)?;
80            Ok(Self {
81                method: ContentAddressMethod::Flat,
82                hash,
83            })
84        } else {
85            Err(ContentAddressError::InvalidFormat(s.to_string()))
86        }
87    }
88
89    /// Serialize to the string format.
90    #[must_use]
91    pub fn to_nix_string(&self) -> String {
92        let prefix = match self.method {
93            ContentAddressMethod::Text => "text:",
94            ContentAddressMethod::Flat => "fixed:out:",
95            ContentAddressMethod::Recursive => "fixed:out:r:",
96        };
97        format!("{}{}", prefix, self.hash.to_nix_string())
98    }
99}
100
101impl std::fmt::Display for ContentAddress {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.write_str(&self.to_nix_string())
104    }
105}
106
107impl std::str::FromStr for ContentAddress {
108    type Err = ContentAddressError;
109
110    fn from_str(s: &str) -> Result<Self, Self::Err> {
111        Self::parse(s)
112    }
113}
114
115/// Compute a store path for text content (like `builtins.toFile`).
116///
117/// The fingerprint is: `text:<sha256hash>:<references...>:/nix/store:<name>`
118pub fn compute_text_store_path(
119    name: &str,
120    contents: &[u8],
121    references: &[String],
122) -> Result<StorePath, StorePathError> {
123    let content_hash = Sha256::digest(contents);
124
125    let mut fingerprint = String::from("text:sha256:");
126    fingerprint.push_str(&hex::encode(&content_hash));
127    for r in references {
128        fingerprint.push(':');
129        fingerprint.push_str(r);
130    }
131    fingerprint.push_str(":/nix/store:");
132    fingerprint.push_str(name);
133
134    let path_hash = compress_hash(&Sha256::digest(fingerprint.as_bytes()), 20);
135    let digest: [u8; 20] = path_hash.try_into().map_err(|_| StorePathError::InvalidHashLength {
136        expected: 20,
137        got: 0, // compress_hash guarantees length, so this branch is unreachable
138    })?;
139
140    Ok(StorePath {
141        digest,
142        name: name.to_string(),
143    })
144}
145
146/// Parse `<algo>:<hex-hash>` format.
147fn parse_hash_with_algo(s: &str) -> Result<NixHash, ContentAddressError> {
148    let (algo_str, hash_hex) = s
149        .split_once(':')
150        .ok_or_else(|| ContentAddressError::InvalidFormat(s.to_string()))?;
151
152    let algorithm = HashAlgorithm::from_nix_str(algo_str)
153        .map_err(|e| ContentAddressError::InvalidFormat(e.to_string()))?;
154
155    let digest = hex::decode(hash_hex)
156        .map_err(|_| ContentAddressError::InvalidFormat(format!("invalid hex: {hash_hex}")))?;
157
158    Ok(NixHash::new(algorithm, digest))
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn parse_text_ca() {
167        let ca = ContentAddress::parse("text:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855").unwrap();
168        assert_eq!(ca.method, ContentAddressMethod::Text);
169        assert_eq!(ca.hash.algorithm, HashAlgorithm::Sha256);
170    }
171
172    #[test]
173    fn parse_fixed_flat() {
174        let ca = ContentAddress::parse("fixed:out:sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789").unwrap();
175        assert_eq!(ca.method, ContentAddressMethod::Flat);
176    }
177
178    #[test]
179    fn parse_fixed_recursive() {
180        let ca = ContentAddress::parse("fixed:out:r:sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789").unwrap();
181        assert_eq!(ca.method, ContentAddressMethod::Recursive);
182    }
183
184    #[test]
185    fn roundtrip_ca() {
186        let ca = ContentAddress {
187            method: ContentAddressMethod::Recursive,
188            hash: NixHash::new(HashAlgorithm::Sha256, vec![0xab; 32]),
189        };
190        let s = ca.to_nix_string();
191        let parsed = ContentAddress::parse(&s).unwrap();
192        assert_eq!(parsed, ca);
193    }
194
195    #[test]
196    fn text_store_path_deterministic() {
197        let path1 = compute_text_store_path("test.txt", b"hello", &[]).unwrap();
198        let path2 = compute_text_store_path("test.txt", b"hello", &[]).unwrap();
199        assert_eq!(path1, path2);
200
201        // Different content produces different path
202        let path3 = compute_text_store_path("test.txt", b"world", &[]).unwrap();
203        assert_ne!(path1.digest, path3.digest);
204    }
205
206    #[test]
207    fn text_store_path_format() {
208        let path = compute_text_store_path("hello.txt", b"Hello, World!", &[]).unwrap();
209        let abs = path.to_absolute_path();
210        assert!(abs.starts_with("/nix/store/"));
211        assert!(abs.ends_with("-hello.txt"));
212        // Hash portion should be 32 chars
213        let basename = abs.strip_prefix("/nix/store/").unwrap();
214        let hash_part = &basename[..32];
215        assert_eq!(hash_part.len(), 32);
216    }
217
218    #[test]
219    fn compress_hash_xor_fold() {
220        let hash = vec![0xff; 32];
221        let compressed = compress_hash(&hash, 20);
222        assert_eq!(compressed.len(), 20);
223        // 32 bytes XOR-folded to 20: first 12 bytes get XOR'd with bytes 20-31
224        // 0xff ^ 0xff = 0 for those 12, rest stays 0xff
225        for &b in &compressed[..12] {
226            assert_eq!(b, 0);
227        }
228        for &b in &compressed[12..] {
229            assert_eq!(b, 0xff);
230        }
231    }
232
233    #[test]
234    fn invalid_format() {
235        assert!(ContentAddress::parse("garbage").is_err());
236        assert!(ContentAddress::parse("text:").is_err());
237        assert!(ContentAddress::parse("fixed:out:badformat").is_err());
238    }
239
240    #[test]
241    fn all_three_ca_method_types_roundtrip() {
242        let methods = [
243            (ContentAddressMethod::Text, "text:"),
244            (ContentAddressMethod::Flat, "fixed:out:"),
245            (ContentAddressMethod::Recursive, "fixed:out:r:"),
246        ];
247        for (method, expected_prefix) in methods {
248            let ca = ContentAddress {
249                method: method.clone(),
250                hash: NixHash::new(HashAlgorithm::Sha256, vec![0xcd; 32]),
251            };
252            let s = ca.to_nix_string();
253            assert!(s.starts_with(expected_prefix), "failed for {method:?}: {s}");
254            let parsed = ContentAddress::parse(&s).unwrap();
255            assert_eq!(parsed, ca);
256        }
257    }
258
259    #[test]
260    fn invalid_prefix_error() {
261        match ContentAddress::parse("nope:sha256:abc") {
262            Err(ContentAddressError::InvalidFormat(s)) => {
263                assert_eq!(s, "nope:sha256:abc");
264            }
265            other => panic!("expected InvalidFormat, got {other:?}"),
266        }
267
268        // "fixed:" without "out:" is invalid
269        match ContentAddress::parse("fixed:sha256:abc") {
270            Err(ContentAddressError::InvalidFormat(_)) => {}
271            other => panic!("expected InvalidFormat, got {other:?}"),
272        }
273    }
274
275    #[test]
276    fn hash_with_all_algorithms() {
277        let algos = [
278            (HashAlgorithm::Sha256, 32),
279            (HashAlgorithm::Sha512, 64),
280            (HashAlgorithm::Sha1, 20),
281            (HashAlgorithm::Md5, 16),
282        ];
283        for (algo, digest_len) in algos {
284            let ca = ContentAddress {
285                method: ContentAddressMethod::Recursive,
286                hash: NixHash::new(algo, vec![0x42; digest_len]),
287            };
288            let s = ca.to_nix_string();
289            let parsed = ContentAddress::parse(&s).unwrap();
290            assert_eq!(parsed.hash.algorithm, algo);
291            assert_eq!(parsed.hash.digest.len(), digest_len);
292            assert_eq!(parsed, ca);
293        }
294    }
295
296    #[test]
297    fn text_store_path_with_references() {
298        let refs = vec![
299            "/nix/store/aaa-glibc-2.37".to_string(),
300            "/nix/store/bbb-bash-5.2".to_string(),
301        ];
302        let path = compute_text_store_path("test.txt", b"hello", &refs).unwrap();
303        let abs = path.to_absolute_path();
304        assert!(abs.starts_with("/nix/store/"));
305        assert!(abs.ends_with("-test.txt"));
306
307        // Different references should produce a different path
308        let path_no_refs = compute_text_store_path("test.txt", b"hello", &[]).unwrap();
309        assert_ne!(path.digest, path_no_refs.digest);
310    }
311
312    // ── compute_text_store_path → StorePath can be used with Store ─
313
314    #[test]
315    fn text_store_path_roundtrips_through_absolute_path() {
316        let sp = compute_text_store_path("my-config.txt", b"config data", &[]).unwrap();
317        let abs = sp.to_absolute_path();
318
319        // Parse it back — verifies the StorePath is valid
320        let reparsed = StorePath::from_absolute_path(&abs).unwrap();
321        assert_eq!(reparsed.name, "my-config.txt");
322        assert_eq!(reparsed.digest, sp.digest);
323        assert_eq!(reparsed.to_absolute_path(), abs);
324    }
325
326    #[test]
327    fn text_store_path_basename_roundtrip() {
328        let sp = compute_text_store_path("script.sh", b"#!/bin/sh\necho hi", &[]).unwrap();
329        let basename = sp.to_basename();
330
331        let reparsed = StorePath::from_basename(&basename).unwrap();
332        assert_eq!(reparsed, sp);
333    }
334
335    // ── ContentAddress parse → roundtrip through NarInfo ────
336
337    #[test]
338    fn content_address_roundtrip_through_narinfo() {
339        use crate::narinfo::NarInfo;
340
341        let ca_str = "fixed:out:r:sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
342        let ca = ContentAddress::parse(ca_str).unwrap();
343
344        // Put CA into a NarInfo, serialize, reparse, extract CA
345        let narinfo = NarInfo {
346            store_path: "/nix/store/abc-test".to_string(),
347            url: "nar/test.nar".to_string(),
348            compression: "none".to_string(),
349            file_hash: "sha256:000".to_string(),
350            file_size: 100,
351            nar_hash: "sha256:111".to_string(),
352            nar_size: 200,
353            references: vec![],
354            deriver: None,
355            signatures: vec![],
356            ca: Some(ca.to_nix_string()),
357        };
358
359        let serialized = narinfo.serialize();
360        let reparsed = NarInfo::parse(&serialized).unwrap();
361        let ca_reparsed = ContentAddress::parse(reparsed.ca.as_ref().unwrap()).unwrap();
362
363        assert_eq!(ca_reparsed.method, ca.method);
364        assert_eq!(ca_reparsed.hash.algorithm, ca.hash.algorithm);
365        assert_eq!(ca_reparsed.hash.digest, ca.hash.digest);
366    }
367
368    #[test]
369    fn compute_text_store_path_different_names_differ() {
370        let p1 = compute_text_store_path("a.txt", b"same", &[]).unwrap();
371        let p2 = compute_text_store_path("b.txt", b"same", &[]).unwrap();
372        assert_ne!(p1.digest, p2.digest);
373        assert_ne!(p1.name, p2.name);
374    }
375
376    #[test]
377    fn compute_text_store_path_empty_content() {
378        let sp = compute_text_store_path("empty", b"", &[]).unwrap();
379        let abs = sp.to_absolute_path();
380        assert!(abs.starts_with("/nix/store/"));
381        assert!(abs.ends_with("-empty"));
382    }
383
384    #[test]
385    fn parse_content_address_missing_hash() {
386        assert!(ContentAddress::parse("text:sha256:").is_ok());
387        assert!(ContentAddress::parse("text:sha256").is_err());
388    }
389
390    #[test]
391    fn content_address_method_display() {
392        let text_ca = ContentAddress {
393            method: ContentAddressMethod::Text,
394            hash: NixHash::new(HashAlgorithm::Sha256, vec![0; 32]),
395        };
396        let s = text_ca.to_nix_string();
397        assert!(s.starts_with("text:sha256:"));
398    }
399
400    #[test]
401    fn text_content_address_roundtrip_through_narinfo() {
402        use crate::narinfo::NarInfo;
403
404        let ca_str = "text:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
405        let ca = ContentAddress::parse(ca_str).unwrap();
406        assert_eq!(ca.method, ContentAddressMethod::Text);
407
408        let narinfo = NarInfo {
409            store_path: "/nix/store/empty-text".to_string(),
410            url: "nar/empty.nar".to_string(),
411            compression: "none".to_string(),
412            file_hash: "sha256:000".to_string(),
413            file_size: 0,
414            nar_hash: "sha256:000".to_string(),
415            nar_size: 0,
416            references: vec![],
417            deriver: None,
418            signatures: vec![],
419            ca: Some(ca.to_nix_string()),
420        };
421
422        let serialized = narinfo.serialize();
423        let reparsed = NarInfo::parse(&serialized).unwrap();
424        let ca_reparsed = ContentAddress::parse(reparsed.ca.as_ref().unwrap()).unwrap();
425        assert_eq!(ca_reparsed, ca);
426    }
427
428    #[test]
429    fn flat_content_address_roundtrip_through_narinfo() {
430        use crate::narinfo::NarInfo;
431
432        let ca = ContentAddress {
433            method: ContentAddressMethod::Flat,
434            hash: NixHash::new(HashAlgorithm::Sha256, vec![0x42; 32]),
435        };
436
437        let narinfo = NarInfo {
438            store_path: "/nix/store/flat-file".to_string(),
439            url: "nar/flat.nar".to_string(),
440            compression: "none".to_string(),
441            file_hash: "sha256:000".to_string(),
442            file_size: 100,
443            nar_hash: "sha256:000".to_string(),
444            nar_size: 100,
445            references: vec![],
446            deriver: None,
447            signatures: vec![],
448            ca: Some(ca.to_nix_string()),
449        };
450
451        let serialized = narinfo.serialize();
452        let reparsed = NarInfo::parse(&serialized).unwrap();
453        let ca_reparsed = ContentAddress::parse(reparsed.ca.as_ref().unwrap()).unwrap();
454        assert_eq!(ca_reparsed, ca);
455    }
456
457    // ── ContentAddressMethod Display + FromStr ───────────
458
459    #[test]
460    fn ca_method_display_strings() {
461        assert_eq!(format!("{}", ContentAddressMethod::Text), "text");
462        assert_eq!(format!("{}", ContentAddressMethod::Flat), "flat");
463        assert_eq!(format!("{}", ContentAddressMethod::Recursive), "recursive");
464    }
465
466    #[test]
467    fn ca_method_from_str_known_values() {
468        use std::str::FromStr;
469        assert_eq!(
470            ContentAddressMethod::from_str("text").unwrap(),
471            ContentAddressMethod::Text,
472        );
473        assert_eq!(
474            ContentAddressMethod::from_str("flat").unwrap(),
475            ContentAddressMethod::Flat,
476        );
477        assert_eq!(
478            ContentAddressMethod::from_str("recursive").unwrap(),
479            ContentAddressMethod::Recursive,
480        );
481    }
482
483    #[test]
484    fn ca_method_from_str_unknown_returns_error() {
485        use std::str::FromStr;
486        match ContentAddressMethod::from_str("nope") {
487            Err(ContentAddressError::InvalidFormat(s)) => assert_eq!(s, "nope"),
488            other => panic!("expected InvalidFormat, got {other:?}"),
489        }
490        assert!(ContentAddressMethod::from_str("").is_err());
491        assert!(ContentAddressMethod::from_str("Text").is_err()); // case-sensitive
492    }
493
494    // ── ContentAddress Display + FromStr ─────────────────
495
496    #[test]
497    fn ca_display_matches_to_nix_string() {
498        let ca = ContentAddress {
499            method: ContentAddressMethod::Flat,
500            hash: NixHash::new(HashAlgorithm::Sha256, vec![0xab; 32]),
501        };
502        let displayed = format!("{ca}");
503        assert_eq!(displayed, ca.to_nix_string());
504    }
505
506    #[test]
507    fn ca_from_str_matches_parse() {
508        use std::str::FromStr;
509        let s = "fixed:out:r:sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
510        let ca = ContentAddress::from_str(s).unwrap();
511        assert_eq!(ca.method, ContentAddressMethod::Recursive);
512    }
513
514    // ── parse_hash_with_algo error paths ─────────────────
515
516    #[test]
517    fn parse_text_unknown_algorithm() {
518        let result = ContentAddress::parse("text:blake3:abc");
519        assert!(matches!(result, Err(ContentAddressError::InvalidFormat(_))));
520    }
521
522    #[test]
523    fn parse_flat_invalid_hex() {
524        let result = ContentAddress::parse("fixed:out:sha256:zzzz");
525        assert!(matches!(result, Err(ContentAddressError::InvalidFormat(_))));
526    }
527
528    #[test]
529    fn parse_recursive_invalid_hex() {
530        let result = ContentAddress::parse("fixed:out:r:sha256:zzzz");
531        assert!(matches!(result, Err(ContentAddressError::InvalidFormat(_))));
532    }
533
534    #[test]
535    fn parse_text_no_colon_in_hash_payload() {
536        // After "text:", the rest must contain a colon for "<algo>:<hex>"
537        let result = ContentAddress::parse("text:noColon");
538        assert!(result.is_err());
539    }
540
541    #[test]
542    fn parse_with_uppercase_hex_decodes() {
543        // The hex decoder accepts uppercase
544        let s = "fixed:out:sha256:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789";
545        let ca = ContentAddress::parse(s).unwrap();
546        assert_eq!(ca.hash.digest.len(), 32);
547    }
548
549    // ── compute_text_store_path with all hash algos ─────
550
551    #[test]
552    fn compute_text_store_path_with_long_content() {
553        let content: Vec<u8> = (0..10_000).map(|i| (i % 256) as u8).collect();
554        let path = compute_text_store_path("big.bin", &content, &[]).unwrap();
555        let abs = path.to_absolute_path();
556        assert!(abs.starts_with("/nix/store/"));
557        assert!(abs.ends_with("-big.bin"));
558    }
559
560    #[test]
561    fn compute_text_store_path_many_references() {
562        let refs: Vec<String> = (0..20)
563            .map(|i| format!("/nix/store/dep-{i:02}"))
564            .collect();
565        let path = compute_text_store_path("test", b"hello", &refs).unwrap();
566        assert!(!path.digest.iter().all(|&b| b == 0));
567    }
568
569    #[test]
570    fn compute_text_store_path_reference_order_matters() {
571        let r1 = vec!["/nix/store/aaa".to_string(), "/nix/store/bbb".to_string()];
572        let r2 = vec!["/nix/store/bbb".to_string(), "/nix/store/aaa".to_string()];
573        let p1 = compute_text_store_path("x", b"data", &r1).unwrap();
574        let p2 = compute_text_store_path("x", b"data", &r2).unwrap();
575        // The current implementation does not sort references, so order matters.
576        // Document the current behavior so any future change is intentional.
577        assert_ne!(p1.digest, p2.digest);
578    }
579
580    // ── ContentAddressMethod equality + clone ────────────
581
582    #[test]
583    fn ca_method_equality_and_clone() {
584        let m1 = ContentAddressMethod::Text;
585        let m2 = m1.clone();
586        assert_eq!(m1, m2);
587        assert_ne!(m1, ContentAddressMethod::Flat);
588        assert_ne!(m1, ContentAddressMethod::Recursive);
589    }
590
591    // ── Empty payload edge cases ─────────────────────────
592
593    #[test]
594    fn parse_empty_input_returns_error() {
595        assert!(ContentAddress::parse("").is_err());
596    }
597
598    #[test]
599    fn parse_only_prefix_returns_error() {
600        assert!(ContentAddress::parse("text").is_err());
601        assert!(ContentAddress::parse("fixed").is_err());
602        assert!(ContentAddress::parse("fixed:out").is_err());
603    }
604
605    // ── ContentAddressError From StorePathError ──────────
606
607    #[test]
608    fn ca_error_from_store_path_error() {
609        let spe = StorePathError::EmptyName;
610        let cae: ContentAddressError = spe.into();
611        // Just check it's the right variant (StorePath wraps it)
612        assert!(matches!(cae, ContentAddressError::StorePath(_)));
613    }
614}