1use serde::{Deserialize, Serialize};
32use tatara_lisp::DeriveTataraDomain;
33
34use crate::SpecError;
35
36#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
41#[tatara(keyword = "deffetcher")]
42pub struct FetcherSpec {
43 pub name: String,
46 pub transport: FetchTransport,
48 #[serde(rename = "hashMode")]
50 pub hash_mode: FetchHashMode,
51 #[serde(rename = "outputKind")]
55 pub output_kind: FetcherOutputKind,
56 pub phases: Vec<FetcherPhase>,
60}
61
62#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum FetchTransport {
65 Http,
67 Git,
70 Tree,
73 LocalPath,
75 Mercurial,
78}
79
80#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
82pub enum FetchHashMode {
83 Flat,
86 Recursive,
90 Sri,
93}
94
95#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
97pub enum FetcherOutputKind {
98 FixedOutput,
101 ContentAddressed,
103}
104
105#[derive(Serialize, Deserialize, Debug, Clone)]
108pub struct FetcherPhase {
109 pub kind: FetcherPhaseKind,
110 #[serde(default)]
111 pub bind: Option<String>,
112 #[serde(default)]
113 pub from: Option<String>,
114}
115
116#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
118pub enum FetcherPhaseKind {
119 ValidateUrl,
122 ResolveRegistryRef,
126 FetchBytes,
128 Unpack,
131 CheckHash,
134 WriteToStore,
137 CacheLookup,
140 EmitNarHash,
143}
144
145pub struct FetchArgs {
149 pub url: String,
150 pub declared_hash: Option<String>,
151 pub name_hint: Option<String>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct FetchOutcome {
158 pub store_path: String,
159 pub nar_hash: String,
160}
161
162pub trait FetcherEnvironment {
171 fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String>;
179
180 fn hash_bytes(&self, bytes: &[u8]) -> String;
185
186 fn write_to_store(&self, name: &str, bytes: &[u8]) -> Result<String, String>;
193
194 fn cache_lookup(&self, _name: &str, _declared_hash: &str) -> Result<Option<String>, String> {
199 Ok(None)
200 }
201}
202
203pub trait HttpTransport {
214 fn get(&self, url: &str) -> Result<Vec<u8>, HttpError>;
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum HttpError {
229 BadUrl(String),
230 UnsupportedScheme(String),
231 NetworkFailure(String),
232 NotFound(String),
233 Forbidden(String),
234 BodyReadFailure(String),
235 IoError(String),
236}
237
238impl std::fmt::Display for HttpError {
239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 match self {
241 Self::BadUrl(m) => write!(f, "bad URL: {m}"),
242 Self::UnsupportedScheme(s) => write!(f, "unsupported scheme `{s}`"),
243 Self::NetworkFailure(m) => write!(f, "network: {m}"),
244 Self::NotFound(m) => write!(f, "not found: {m}"),
245 Self::Forbidden(m) => write!(f, "forbidden: {m}"),
246 Self::BodyReadFailure(m) => write!(f, "body read: {m}"),
247 Self::IoError(m) => write!(f, "io: {m}"),
248 }
249 }
250}
251
252impl std::error::Error for HttpError {}
253
254pub struct FsTransport;
257
258impl HttpTransport for FsTransport {
259 fn get(&self, url: &str) -> Result<Vec<u8>, HttpError> {
260 let parsed = url::Url::parse(url).map_err(|e| HttpError::BadUrl(e.to_string()))?;
261 if parsed.scheme() != "file" {
262 return Err(HttpError::UnsupportedScheme(parsed.scheme().to_string()));
263 }
264 let path = parsed.to_file_path()
265 .map_err(|_| HttpError::BadUrl(format!("non-file URL `{url}`")))?;
266 std::fs::read(&path).map_err(|e| match e.kind() {
267 std::io::ErrorKind::NotFound => HttpError::NotFound(path.display().to_string()),
268 std::io::ErrorKind::PermissionDenied => HttpError::Forbidden(path.display().to_string()),
269 _ => HttpError::IoError(e.to_string()),
270 })
271 }
272}
273
274#[derive(Default)]
277pub struct MockTransport {
278 pub responses: std::collections::HashMap<String, Vec<u8>>,
279}
280
281impl MockTransport {
282 pub fn with(mut self, url: &str, bytes: Vec<u8>) -> Self {
284 self.responses.insert(url.to_string(), bytes);
285 self
286 }
287}
288
289impl HttpTransport for MockTransport {
290 fn get(&self, url: &str) -> Result<Vec<u8>, HttpError> {
291 self.responses.get(url).cloned()
292 .ok_or_else(|| HttpError::NotFound(url.to_string()))
293 }
294}
295
296pub struct SchemeRouter<R: HttpTransport> {
300 pub remote: R,
301}
302
303impl<R: HttpTransport> SchemeRouter<R> {
304 pub fn new(remote: R) -> Self { Self { remote } }
305}
306
307impl<R: HttpTransport> HttpTransport for SchemeRouter<R> {
308 fn get(&self, url: &str) -> Result<Vec<u8>, HttpError> {
309 let parsed = url::Url::parse(url).map_err(|e| HttpError::BadUrl(e.to_string()))?;
310 match parsed.scheme() {
311 "file" => FsTransport.get(url),
312 "http" | "https" => self.remote.get(url),
313 other => Err(HttpError::UnsupportedScheme(other.to_string())),
314 }
315 }
316}
317
318pub fn apply<E: FetcherEnvironment>(
334 spec: &FetcherSpec,
335 args: &FetchArgs,
336 env: &E,
337) -> Result<FetchOutcome, SpecError> {
338 if spec.transport != FetchTransport::Http
341 || spec.hash_mode != FetchHashMode::Flat
342 {
343 return Err(SpecError::Interp {
344 phase: "fetcher-unimplemented".into(),
345 message: format!(
346 "fetcher `{}` (transport {:?}, hash-mode {:?}) — \
347 M3.0 supports only Http+Flat (fetchurl). M3.1+ \
348 implementations land per-transport.",
349 spec.name, spec.transport, spec.hash_mode,
350 ),
351 });
352 }
353
354 let name = args.name_hint.as_deref().unwrap_or("download");
355
356 for phase in &spec.phases {
358 match phase.kind {
359 FetcherPhaseKind::ValidateUrl => validate_url(&args.url)?,
360 FetcherPhaseKind::ResolveRegistryRef => {
361 }
363 FetcherPhaseKind::CacheLookup => {
364 if let Some(declared) = args.declared_hash.as_deref() {
365 let hit = env
366 .cache_lookup(name, declared)
367 .map_err(|e| SpecError::Interp {
368 phase: "cache-lookup".into(),
369 message: e,
370 })?;
371 if let Some(path) = hit {
372 return Ok(FetchOutcome {
373 store_path: path,
374 nar_hash: declared.to_string(),
375 });
376 }
377 }
378 }
379 FetcherPhaseKind::FetchBytes => {
380 }
384 FetcherPhaseKind::Unpack => {
385 }
387 FetcherPhaseKind::CheckHash | FetcherPhaseKind::WriteToStore
388 | FetcherPhaseKind::EmitNarHash => {
389 }
391 }
392 }
393
394 let bytes = env.fetch_bytes(&args.url).map_err(|e| SpecError::Interp {
396 phase: "fetch-bytes".into(),
397 message: format!("fetching `{}`: {e}", args.url),
398 })?;
399
400 let computed = env.hash_bytes(&bytes);
401 if let Some(declared) = args.declared_hash.as_deref() {
402 if declared != computed {
403 return Err(SpecError::Interp {
404 phase: "hash-mismatch".into(),
405 message: format!(
406 "hash mismatch for `{}`: declared {declared}, got {computed}",
407 args.url,
408 ),
409 });
410 }
411 }
412
413 let store_path = env
414 .write_to_store(name, &bytes)
415 .map_err(|e| SpecError::Interp {
416 phase: "write-to-store".into(),
417 message: format!("writing `{name}`: {e}"),
418 })?;
419
420 Ok(FetchOutcome { store_path, nar_hash: computed })
421}
422
423fn validate_url(url: &str) -> Result<(), SpecError> {
424 if url.is_empty() {
425 return Err(SpecError::Interp {
426 phase: "url-validate".into(),
427 message: "url is empty".into(),
428 });
429 }
430 let allowed = ["http://", "https://", "file://"];
431 if !allowed.iter().any(|p| url.starts_with(p)) {
432 return Err(SpecError::Interp {
433 phase: "url-validate".into(),
434 message: format!(
435 "url `{url}` uses an unsupported scheme \
436 (allowed: http://, https://, file://)",
437 ),
438 });
439 }
440 Ok(())
441}
442
443pub const CANONICAL_FETCHERS_LISP: &str = include_str!("../specs/fetchers.lisp");
446
447pub fn load_canonical() -> Result<Vec<FetcherSpec>, SpecError> {
453 crate::loader::load_all::<FetcherSpec>(CANONICAL_FETCHERS_LISP)
454}
455
456pub fn load_named(name: &str) -> Result<FetcherSpec, SpecError> {
462 load_canonical()?
463 .into_iter()
464 .find(|f| f.name == name)
465 .ok_or_else(|| SpecError::Load(format!("no (deffetcher) with :name {name:?}")))
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use std::collections::HashSet;
472
473 #[test]
474 fn canonical_fetchers_parse() {
475 let specs = load_canonical().expect("canonical fetchers must compile");
476 assert!(!specs.is_empty());
477 }
478
479 #[test]
480 fn every_cppnix_fetcher_named() {
481 let specs = load_canonical().unwrap();
482 let names: HashSet<&str> = specs.iter().map(|f| f.name.as_str()).collect();
483 for required in ["fetchurl", "fetchTarball", "fetchGit", "fetchTree", "path"] {
486 assert!(
487 names.contains(required),
488 "canonical fetcher corpus missing `{required}`",
489 );
490 }
491 }
492
493 #[test]
494 fn fetchurl_uses_http_flat() {
495 let f = load_named("fetchurl").unwrap();
496 assert_eq!(f.transport, FetchTransport::Http);
497 assert_eq!(f.hash_mode, FetchHashMode::Flat);
498 assert_eq!(f.output_kind, FetcherOutputKind::FixedOutput);
499 }
500
501 #[test]
502 fn fetchgit_uses_git_recursive() {
503 let f = load_named("fetchGit").unwrap();
504 assert_eq!(f.transport, FetchTransport::Git);
505 assert_eq!(f.hash_mode, FetchHashMode::Recursive);
506 }
507
508 #[test]
509 fn every_fetcher_has_validate_and_writetostore() {
510 let specs = load_canonical().unwrap();
511 for spec in &specs {
512 let kinds: Vec<FetcherPhaseKind> =
513 spec.phases.iter().map(|p| p.kind).collect();
514 assert!(
515 kinds.contains(&FetcherPhaseKind::ValidateUrl)
516 || spec.transport == FetchTransport::LocalPath,
517 "{}: every network fetcher must ValidateUrl",
518 spec.name,
519 );
520 assert!(
521 kinds.contains(&FetcherPhaseKind::WriteToStore),
522 "{}: missing WriteToStore",
523 spec.name,
524 );
525 }
526 }
527
528 use std::cell::RefCell;
531 use std::collections::HashMap;
532
533 struct MockEnv {
537 responses: HashMap<String, Vec<u8>>,
538 store: RefCell<HashMap<String, Vec<u8>>>,
539 }
540
541 impl MockEnv {
542 fn new() -> Self {
543 Self {
544 responses: HashMap::new(),
545 store: RefCell::new(HashMap::new()),
546 }
547 }
548 fn with_response(mut self, url: &str, body: &[u8]) -> Self {
549 self.responses.insert(url.into(), body.to_vec());
550 self
551 }
552 }
553
554 impl FetcherEnvironment for MockEnv {
555 fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
556 self.responses
557 .get(url)
558 .cloned()
559 .ok_or_else(|| format!("no canned response for {url}"))
560 }
561 fn hash_bytes(&self, bytes: &[u8]) -> String {
562 let first = bytes.first().copied().unwrap_or(0);
566 format!("sha256:test-{}-{:02x}", bytes.len(), first)
567 }
568 fn write_to_store(&self, name: &str, bytes: &[u8]) -> Result<String, String> {
569 let path = format!("/nix/store/abc-{name}");
570 self.store.borrow_mut().insert(path.clone(), bytes.to_vec());
571 Ok(path)
572 }
573 }
574
575 #[test]
576 fn fetchurl_happy_path() {
577 let spec = load_named("fetchurl").unwrap();
578 let env = MockEnv::new()
579 .with_response("https://example.com/hello.tar", b"hello\n");
580 let args = FetchArgs {
581 url: "https://example.com/hello.tar".into(),
582 declared_hash: None,
583 name_hint: Some("hello.tar".into()),
584 };
585 let outcome = apply(&spec, &args, &env).unwrap();
586 assert_eq!(outcome.store_path, "/nix/store/abc-hello.tar");
587 assert!(outcome.nar_hash.starts_with("sha256:"));
588 assert_eq!(
590 env.store.borrow().get("/nix/store/abc-hello.tar"),
591 Some(&b"hello\n".to_vec()),
592 );
593 }
594
595 #[test]
596 fn fetchurl_rejects_malformed_url() {
597 let spec = load_named("fetchurl").unwrap();
598 let env = MockEnv::new();
599 let args = FetchArgs {
600 url: "ftp://example.com/x".into(),
601 declared_hash: None,
602 name_hint: None,
603 };
604 let err = apply(&spec, &args, &env).unwrap_err();
605 match err {
606 SpecError::Interp { phase, .. } => assert_eq!(phase, "url-validate"),
607 _ => panic!("expected url-validate error"),
608 }
609 }
610
611 #[test]
612 fn fetchurl_rejects_empty_url() {
613 let spec = load_named("fetchurl").unwrap();
614 let env = MockEnv::new();
615 let args = FetchArgs {
616 url: String::new(),
617 declared_hash: None,
618 name_hint: None,
619 };
620 let err = apply(&spec, &args, &env).unwrap_err();
621 match err {
622 SpecError::Interp { phase, .. } => assert_eq!(phase, "url-validate"),
623 _ => panic!("expected url-validate"),
624 }
625 }
626
627 #[test]
628 fn fetchurl_verifies_declared_hash() {
629 let spec = load_named("fetchurl").unwrap();
630 let env = MockEnv::new()
631 .with_response("https://example.com/x", b"hello");
632 let args = FetchArgs {
635 url: "https://example.com/x".into(),
636 declared_hash: Some("sha256:fake-hash".into()),
637 name_hint: Some("x".into()),
638 };
639 let err = apply(&spec, &args, &env).unwrap_err();
640 match err {
641 SpecError::Interp { phase, message } => {
642 assert_eq!(phase, "hash-mismatch");
643 assert!(message.contains("fake-hash"));
644 }
645 _ => panic!("expected hash-mismatch"),
646 }
647 }
648
649 #[test]
650 fn fetchurl_accepts_matching_hash() {
651 let spec = load_named("fetchurl").unwrap();
652 let body = b"hello";
653 let env = MockEnv::new().with_response("https://example.com/x", body);
654 let expected = env.hash_bytes(body);
656 let args = FetchArgs {
657 url: "https://example.com/x".into(),
658 declared_hash: Some(expected.clone()),
659 name_hint: Some("x".into()),
660 };
661 let outcome = apply(&spec, &args, &env).unwrap();
662 assert_eq!(outcome.nar_hash, expected);
663 }
664
665 #[test]
666 fn cache_hit_short_circuits_fetch() {
667 struct CacheHitEnv;
668 impl FetcherEnvironment for CacheHitEnv {
669 fn fetch_bytes(&self, _: &str) -> Result<Vec<u8>, String> {
670 Err("fetch should NOT have been called on cache hit".into())
671 }
672 fn hash_bytes(&self, _: &[u8]) -> String { unreachable!() }
673 fn write_to_store(&self, _: &str, _: &[u8]) -> Result<String, String> {
674 unreachable!()
675 }
676 fn cache_lookup(&self, _: &str, h: &str) -> Result<Option<String>, String> {
677 Ok(Some(format!("/nix/store/cached-{h}")))
678 }
679 }
680 let spec = load_named("fetchurl").unwrap();
681 let args = FetchArgs {
682 url: "https://example.com/x".into(),
683 declared_hash: Some("sha256:abc".into()),
684 name_hint: Some("x".into()),
685 };
686 let outcome = apply(&spec, &args, &CacheHitEnv).unwrap();
687 assert_eq!(outcome.store_path, "/nix/store/cached-sha256:abc");
688 }
689
690 #[test]
691 fn non_fetchurl_transport_returns_typed_not_yet() {
692 let spec = load_named("fetchGit").unwrap();
694 let env = MockEnv::new();
695 let args = FetchArgs {
696 url: "https://example.com/repo.git".into(),
697 declared_hash: None,
698 name_hint: Some("repo".into()),
699 };
700 let err = apply(&spec, &args, &env).unwrap_err();
701 match err {
702 SpecError::Interp { phase, message } => {
703 assert_eq!(phase, "fetcher-unimplemented");
704 assert!(message.contains("Git"));
705 }
706 _ => panic!("expected fetcher-unimplemented"),
707 }
708 }
709}