1use 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#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum ContentAddressMethod {
22 Text,
25 Flat,
28 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#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ContentAddress {
59 pub method: ContentAddressMethod,
60 pub hash: NixHash,
61}
62
63impl ContentAddress {
64 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 #[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
115pub 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, })?;
139
140 Ok(StorePath {
141 digest,
142 name: name.to_string(),
143 })
144}
145
146fn 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 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 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 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 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 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 #[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 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 #[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 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 #[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()); }
493
494 #[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 #[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 let result = ContentAddress::parse("text:noColon");
538 assert!(result.is_err());
539 }
540
541 #[test]
542 fn parse_with_uppercase_hex_decodes() {
543 let s = "fixed:out:sha256:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789";
545 let ca = ContentAddress::parse(s).unwrap();
546 assert_eq!(ca.hash.digest.len(), 32);
547 }
548
549 #[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 assert_ne!(p1.digest, p2.digest);
578 }
579
580 #[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 #[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 #[test]
608 fn ca_error_from_store_path_error() {
609 let spe = StorePathError::EmptyName;
610 let cae: ContentAddressError = spe.into();
611 assert!(matches!(cae, ContentAddressError::StorePath(_)));
613 }
614}