Skip to main content

sui_spec/
fetcher.rs

1//! Typed border for nix's ingest layer — `builtins.fetchurl`,
2//! `fetchTarball`, `fetchGit`, `fetchTree`, `path`.
3//!
4//! Every fetcher takes a `(url, hash)` input and produces a store
5//! path.  The path is computed deterministically from the hash (it's
6//! always a fixed-output derivation, see [`crate::derivation`]).
7//! The difference between fetchers is the *transport* (HTTP, git
8//! protocol, local fs) and the *hash mode* (Flat for single files,
9//! Recursive for trees).
10//!
11//! Per the constructive substrate engineering pattern, the contract
12//! lives here as a typed Rust border + a Lisp spec.  sui-eval's
13//! `fetchers` builtin module today implements each in Rust; M3 work
14//! lifts the implementations to consume this spec so the Rust side
15//! is generated from the authored algorithm rather than handwritten.
16//!
17//! ## Authoring surface
18//!
19//! ```lisp
20//! (deffetcher
21//!   :name        "fetchurl"
22//!   :transport   Http
23//!   :hash-mode   Flat
24//!   :output-kind FixedOutput
25//!   :phases ((:kind ValidateUrl)
26//!            (:kind FetchBytes :bind "bytes")
27//!            (:kind CheckHash :from "bytes")
28//!            (:kind WriteToStore :from "bytes")))
29//! ```
30
31use serde::{Deserialize, Serialize};
32use tatara_lisp::DeriveTataraDomain;
33
34use crate::SpecError;
35
36// ── Typed border ───────────────────────────────────────────────────
37
38/// One fetcher authored as `(deffetcher …)`.  Variants by transport
39/// + hash mode cover every cppnix builtin in the ingest layer.
40#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
41#[tatara(keyword = "deffetcher")]
42pub struct FetcherSpec {
43    /// `"fetchurl"`, `"fetchTarball"`, `"fetchGit"`, `"fetchTree"`,
44    /// `"path"`.
45    pub name: String,
46    /// Network / filesystem transport.
47    pub transport: FetchTransport,
48    /// Hash computation mode for the output.
49    #[serde(rename = "hashMode")]
50    pub hash_mode: FetchHashMode,
51    /// Which derivation variant the fetcher produces.  All known
52    /// nix fetchers are fixed-output — but CA-derivations may
53    /// eventually let some be ContentAddressed.
54    #[serde(rename = "outputKind")]
55    pub output_kind: FetcherOutputKind,
56    /// Phase pipeline.  Each fetcher runs phases left-to-right; the
57    /// transport phase decides HOW to fetch, the hash phase decides
58    /// the result's identity.
59    pub phases: Vec<FetcherPhase>,
60}
61
62/// Where the fetcher reads bytes from.
63#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum FetchTransport {
65    /// `fetchurl` / `fetchTarball` — plain HTTP/HTTPS GET.
66    Http,
67    /// `fetchGit` — git clone + checkout.  Uses sui-eval's gix
68    /// integration today.
69    Git,
70    /// `fetchTree` — polymorphic dispatch by URL scheme; resolves
71    /// to one of Http/Git/Mercurial/Path internally.
72    Tree,
73    /// `builtins.path` — local filesystem copy + hash.
74    LocalPath,
75    /// `fetchMercurial` — hg-protocol clone.  Present in cppnix
76    /// behind experimental flag.
77    Mercurial,
78}
79
80/// How the fetcher computes the result's content hash.
81#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
82pub enum FetchHashMode {
83    /// Hash the bytes directly.  Used for single files (e.g. a
84    /// tarball downloaded by fetchurl with `unpack = false`).
85    Flat,
86    /// NAR-hash the unpacked tree.  Used for fetchTarball with
87    /// `unpack = true`, fetchGit, fetchTree, and builtins.path
88    /// on directories.
89    Recursive,
90    /// SRI hash format passthrough (sha256-base64=).  Modern
91    /// surface; supersedes Flat/Recursive for many call sites.
92    Sri,
93}
94
95/// The cppnix derivation variant the fetcher emits.
96#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
97pub enum FetcherOutputKind {
98    /// `outputHash` + `outputHashMode` set on the derivation; path
99    /// stable across reruns.  All known fetchers today.
100    FixedOutput,
101    /// Path computed from realised content (M4 — CA-drv).
102    ContentAddressed,
103}
104
105/// One phase in a fetcher pipeline.  Flat-kwarg shape matches the
106/// other spec domains.
107#[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/// Closed set of fetcher phases.
117#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
118pub enum FetcherPhaseKind {
119    /// Reject malformed URLs / disallowed schemes.  Run before any
120    /// network call.
121    ValidateUrl,
122    /// Resolve a flake-input reference (`github:owner/repo/ref`)
123    /// into a concrete URL + commit pinned by the registry.
124    /// Skipped for direct-URL fetchers.
125    ResolveRegistryRef,
126    /// Fetch raw bytes from the transport.  Binds to `:bind`.
127    FetchBytes,
128    /// Unpack a tarball / git-bundle into a tree.  Skipped for
129    /// flat fetchers.
130    Unpack,
131    /// Compute the content hash of `:from` and verify against the
132    /// declared hash.  Mismatch is fatal.
133    CheckHash,
134    /// Write the fetched content into the store at the computed
135    /// FOD path.  Binds the store path.
136    WriteToStore,
137    /// Cache the fetched bytes (or their hash) in the eval cache
138    /// keyed by URL.  Enables fast re-eval without re-fetching.
139    CacheLookup,
140    /// Emit an `<input>.narHash` style attribute that downstream
141    /// flake-eval consumes.
142    EmitNarHash,
143}
144
145// ── Spec interpreter (M3.0 minimal — fetchurl path) ───────────────
146
147/// Inputs to a fetcher run.
148pub struct FetchArgs {
149    pub url: String,
150    pub declared_hash: Option<String>,
151    pub name_hint: Option<String>,
152}
153
154/// Result of a fetcher run — the store path the fetched content
155/// landed at, plus its content hash.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct FetchOutcome {
158    pub store_path: String,
159    pub nar_hash: String,
160}
161
162/// Abstract IO environment for the fetcher.  Tests pass a mock
163/// implementation that returns canned HTTP responses + a virtual
164/// store; production uses a real HTTP client + filesystem.
165///
166/// Per the prime directive: trait-driven IO means the interpreter
167/// is pure-logic and trivially testable.  When sui-eval consumes
168/// this layer it ships a `FetcherEnvironment` impl that wraps
169/// `ureq` (HTTP) + `sui_store::LocalStore` (the store).
170pub trait FetcherEnvironment {
171    /// Fetch bytes from a URL.  Returns the raw response body on
172    /// success.
173    ///
174    /// # Errors
175    ///
176    /// Implementations return their own error which the fetcher
177    /// converts to `SpecError::Interp { phase: "fetch-bytes" }`.
178    fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String>;
179
180    /// Compute the SHA-256 of bytes, encoded as
181    /// `sha256:<lowercase-hex>` for compatibility with the declared
182    /// hash format.  Implementations may use sha2 or the hardware
183    /// path; the spec only requires byte-exact equivalence.
184    fn hash_bytes(&self, bytes: &[u8]) -> String;
185
186    /// Persist bytes to the store at the FOD-derived path for the
187    /// given name.  Returns the full `/nix/store/...` path.
188    ///
189    /// # Errors
190    ///
191    /// As above.
192    fn write_to_store(&self, name: &str, bytes: &[u8]) -> Result<String, String>;
193
194    /// Optional cache lookup — if the bytes are already in the
195    /// store under this hash, skip fetching.  Returns
196    /// `Ok(Some(store_path))` on hit, `Ok(None)` on miss.  Default
197    /// impl always misses, which is correct but suboptimal.
198    fn cache_lookup(&self, _name: &str, _declared_hash: &str) -> Result<Option<String>, String> {
199        Ok(None)
200    }
201}
202
203// ── HttpTransport — typed blocking-HTTP boundary ────────────────────
204//
205// Lifted from `sui/src/main.rs` after three call sites consumed
206// the same inline `http_get` helper.  The trait separates the
207// IO boundary from the dispatch logic so tests can substitute a
208// `MockTransport` while production wires the `UreqTransport`.
209
210/// Typed blocking-HTTP boundary.  Consumers parse / hash the
211/// returned bytes themselves; the transport handles only the
212/// network round-trip.
213pub trait HttpTransport {
214    /// Fetch the bytes at `url`.  Returns the full body on
215    /// success, a typed error otherwise.
216    ///
217    /// # Errors
218    ///
219    /// Implementations return their own error category; the
220    /// fetcher wraps with `SpecError::Interp { phase: "http-get" }`
221    /// when adapting.
222    fn get(&self, url: &str) -> Result<Vec<u8>, HttpError>;
223}
224
225/// Typed transport error.  Constructors at the impl boundary
226/// classify the failure so callers can branch on category.
227#[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
254/// Filesystem-backed transport — resolves `file://` URLs.
255/// Always available; no dependency on a network stack.
256pub 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/// Mock transport for tests — yields canned responses keyed by URL.
275/// Lookup misses produce `NotFound`.
276#[derive(Default)]
277pub struct MockTransport {
278    pub responses: std::collections::HashMap<String, Vec<u8>>,
279}
280
281impl MockTransport {
282    /// Add a canned response for `url`.
283    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
296/// A dispatcher that picks the right transport based on the
297/// URL scheme — `file://` → [`FsTransport`], everything else
298/// → the provided remote transport.
299pub 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
318/// Apply a fetcher spec.  M3.0 implementation: supports the
319/// fetchurl transport (Http + Flat hash mode).  Other transports
320/// return a typed `not-yet-implemented` error.
321///
322/// # Errors
323///
324/// - `SpecError::Interp { phase: "url-validate" }` for malformed URLs.
325/// - `SpecError::Interp { phase: "fetch-bytes" }` if the environment
326///   couldn't fetch.
327/// - `SpecError::Interp { phase: "hash-mismatch" }` if the declared
328///   hash doesn't match the fetched content's hash.
329/// - `SpecError::Interp { phase: "write-to-store" }` on store
330///   failure.
331/// - `SpecError::Interp { phase: "fetcher-unimplemented" }` for
332///   non-fetchurl transports (M3.1+).
333pub fn apply<E: FetcherEnvironment>(
334    spec: &FetcherSpec,
335    args: &FetchArgs,
336    env: &E,
337) -> Result<FetchOutcome, SpecError> {
338    // M3.0 only handles Http + Flat (i.e., fetchurl).  Others
339    // surface as typed "not yet" errors.
340    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    // Drive the authored phase pipeline.
357    for phase in &spec.phases {
358        match phase.kind {
359            FetcherPhaseKind::ValidateUrl => validate_url(&args.url)?,
360            FetcherPhaseKind::ResolveRegistryRef => {
361                // fetchurl doesn't take a registry ref — no-op.
362            }
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                // Handled by the FetchBytes → CheckHash → WriteToStore
381                // chain below; we run them in one block because they
382                // share the in-memory body buffer.
383            }
384            FetcherPhaseKind::Unpack => {
385                // Flat-hash fetchers don't unpack; no-op.
386            }
387            FetcherPhaseKind::CheckHash | FetcherPhaseKind::WriteToStore
388            | FetcherPhaseKind::EmitNarHash => {
389                // See block below.
390            }
391        }
392    }
393
394    // Drive the core fetch → hash → store chain.
395    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
443// ── Canonical spec ─────────────────────────────────────────────────
444
445pub const CANONICAL_FETCHERS_LISP: &str = include_str!("../specs/fetchers.lisp");
446
447/// Compile every authored fetcher spec.
448///
449/// # Errors
450///
451/// Returns an error if the Lisp source fails to parse.
452pub fn load_canonical() -> Result<Vec<FetcherSpec>, SpecError> {
453    crate::loader::load_all::<FetcherSpec>(CANONICAL_FETCHERS_LISP)
454}
455
456/// Return the fetcher whose `name` matches.
457///
458/// # Errors
459///
460/// Returns an error if the spec fails to parse or `name` is missing.
461pub 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        // The five cppnix builtins.* fetchers.  If any is missing,
484        // the ingest layer is incomplete.
485        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    // ── M3.0 fetcher interpreter tests ─────────────────────────
529
530    use std::cell::RefCell;
531    use std::collections::HashMap;
532
533    /// Mock environment for fetcher tests.  Backed by HashMaps —
534    /// pre-load with URL→bytes pairs, then assert on what the
535    /// fetcher recorded.
536    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            // Deterministic stand-in — just length-prefixed hex of
563            // the first byte.  Real env uses sha2.  Good enough to
564            // prove the fetcher routes correctly.
565            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        // The store recorded our bytes.
589        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        // The mock env's hash_bytes returns "sha256:test-5-68" for "hello".
633        // Test with a deliberately wrong declared hash.
634        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        // Pre-compute the expected hash from the mock.
655        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        // fetchGit uses Git transport — M3.0 doesn't implement.
693        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}