Skip to main content

ridl_core/
fetch.rs

1//! Remote import fetch and materialization (ADR-0002
2//! §5, §7; ADR-0004 §9).
3//!
4//! [`fetch`] downloads one artifact over HTTP with `ureq` (synchronous, minimal
5//! — ADR-0004 §9). [`materialize_imports`] drives the whole resolution: for
6//! every remote import URL it reuses a cache hit when the lockfile-pinned hash
7//! is already unpacked, fetches and caches otherwise, and regenerates the
8//! lockfile from the hashes it resolved. Under [`Frozen::Yes`] it never fetches
9//! and fails on any import that is missing from the lockfile or absent from the
10//! cache (ADR-0002 §7).
11//!
12//! The fetched artifact is an uncompressed tar archive of one package directory
13//! (ADR-0007 decision 12, provisional until the registry spec is written);
14//! [`Cache::store`] unpacks it.
15//!
16//! This module sits behind the `fetch` feature: it pulls `ureq` for the network
17//! and drives the `fetch`-gated [`Cache`].
18
19use std::collections::BTreeMap;
20use std::path::PathBuf;
21use std::time::Duration;
22
23use rowan::TextRange;
24
25use crate::cache::Cache;
26use crate::diag::{DiagCode, Diagnostic, FileId, Severity, Span};
27use crate::lock::{LockEntry, Lockfile};
28
29/// A remote fetch failure: the human-readable reason. It carries no source span
30/// because it concerns a URL, not a byte range.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct FetchError {
33    pub message: String,
34}
35
36/// The bounded end-to-end timeout applied to every fetch. `ureq`'s default
37/// agent leaves connect, receive-headers, and receive-body unbounded, so a
38/// server that accepts the connection then stalls — or trickles bytes slowly
39/// under the 10 MB cap — would hang the build forever. This caps the whole
40/// call.
41const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
42
43/// Downloads the artifact at `url` and returns its bytes.
44///
45/// The entire call — connect, send, and receive — is bounded by
46/// `FETCH_TIMEOUT` (30 seconds): a stalled or trickling server is a
47/// [`FetchError`], never a hang. A non-2xx HTTP status, a transport failure, or
48/// a body over the 10 MB read limit is also a [`FetchError`]. Callers validate
49/// the URL first (see `is_fetchable_url`); `ureq` will still reject a
50/// malformed URL here.
51pub fn fetch(url: &str) -> Result<Vec<u8>, FetchError> {
52    fetch_with_timeout(url, FETCH_TIMEOUT)
53}
54
55/// [`fetch`] with an explicit `timeout`, so a test can drive the timeout path
56/// with a short bound instead of the production 30 seconds. Builds a one-off
57/// agent whose global timeout caps the whole call.
58fn fetch_with_timeout(url: &str, timeout: Duration) -> Result<Vec<u8>, FetchError> {
59    let agent: ureq::Agent = ureq::Agent::config_builder()
60        .timeout_global(Some(timeout))
61        .build()
62        .into();
63    let mut response = agent.get(url).call().map_err(|err| FetchError {
64        message: err.to_string(),
65    })?;
66    response.body_mut().read_to_vec().map_err(|err| FetchError {
67        message: err.to_string(),
68    })
69}
70
71/// Whether `url` is a value this fetch layer will attempt: an `http(s)` URL with
72/// a non-empty host and no whitespace or control characters. The manifest
73/// records every `[imports]` value verbatim, including ones that failed
74/// `MANI-007`, so a recorded URL is re-validated here before it reaches `ureq`.
75/// Full RFC 3986 and version-suffix validation is deferred to the registry spec.
76/// This only rejects values that plainly cannot be fetched.
77fn is_fetchable_url(url: &str) -> bool {
78    if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
79        return false;
80    }
81    let Some(rest) = url
82        .strip_prefix("https://")
83        .or_else(|| url.strip_prefix("http://"))
84    else {
85        return false;
86    };
87    let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
88    !rest[..host_end].is_empty()
89}
90
91/// Whether a materialize run verifies against the lockfile strictly and never
92/// fetches ([`Frozen::Yes`], `ridlc --frozen`) or fetches and regenerates the
93/// lockfile as needed ([`Frozen::No`]).
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum Frozen {
96    Yes,
97    No,
98}
99
100impl From<bool> for Frozen {
101    fn from(frozen: bool) -> Self {
102        if frozen { Frozen::Yes } else { Frozen::No }
103    }
104}
105
106/// Materializes every remote import into the cache and returns the resolved
107/// `(url, unpacked-package-directory)` pairs, the regenerated [`Lockfile`], and
108/// any diagnostics.
109///
110/// For each URL, in sorted order:
111///
112/// - If the lockfile pins a hash and that hash is already unpacked in the
113///   cache, the cache hit is used and no fetch happens — the regenerated
114///   lockfile keeps the pin.
115/// - Otherwise, under [`Frozen::No`], the artifact is fetched and cached. If the
116///   lockfile pinned a different hash, the fetch is a `MANI-102` mismatch and
117///   the import is dropped; otherwise the newly computed hash is pinned.
118/// - Under [`Frozen::Yes`] nothing is ever fetched: a URL with no lockfile entry
119///   is `MANI-103`, and a pinned URL that is not in the cache is `MANI-104`.
120///
121/// A URL that is not a fetchable `http(s)` URL is a `MANI-101` fetch failure.
122/// Every diagnostic is detached ([`FileId::DETACHED`]): it names the URL rather
123/// than a source span, so the caller renders it as a bare coded message.
124pub fn materialize_imports(
125    imports: &BTreeMap<String, String>,
126    lock: Option<&Lockfile>,
127    cache: &Cache,
128    frozen: Frozen,
129) -> (Vec<(String, PathBuf)>, Lockfile, Vec<Diagnostic>) {
130    let mut resolved = Vec::new();
131    let mut regenerated = Lockfile::default();
132    let mut diagnostics = Vec::new();
133
134    // Iterate by URL so the run is deterministic even when two logical names
135    // alias the same URL; a URL is materialized once.
136    let mut urls: Vec<&String> = imports.values().collect();
137    urls.sort();
138    urls.dedup();
139
140    for url in urls {
141        if !is_fetchable_url(url) {
142            diagnostics.push(detached(
143                DiagCode::MANI_101,
144                format!("cannot fetch `{url}`: not a fetchable `http(s)` URL"),
145            ));
146            continue;
147        }
148
149        let pinned = lock
150            .and_then(|lock| lock.entries.get(url))
151            .map(|entry| entry.sha256.clone());
152
153        // A cache hit against the pinned hash skips the fetch entirely — the
154        // one path that must never touch the network (proven by the request
155        // counter in the tests).
156        if let Some(sha) = &pinned
157            && let Some(path) = cache.lookup(url, sha)
158        {
159            regenerated.entries.insert(
160                url.clone(),
161                LockEntry {
162                    sha256: sha.clone(),
163                },
164            );
165            resolved.push((url.clone(), path));
166            continue;
167        }
168
169        match frozen {
170            Frozen::Yes => match pinned {
171                None => diagnostics.push(detached(
172                    DiagCode::MANI_103,
173                    format!("`--frozen`: no lockfile entry for `{url}`"),
174                )),
175                Some(_) => diagnostics.push(detached(
176                    DiagCode::MANI_104,
177                    format!("`--frozen`: `{url}` is pinned but not in the cache"),
178                )),
179            },
180            Frozen::No => {
181                let bytes = match fetch(url) {
182                    Ok(bytes) => bytes,
183                    Err(err) => {
184                        diagnostics.push(detached(
185                            DiagCode::MANI_101,
186                            format!("failed to fetch `{url}`: {}", err.message),
187                        ));
188                        continue;
189                    }
190                };
191                // Store before verifying against the pin: the cache is
192                // content-addressed, so a mismatched artifact lands at its own
193                // hash, never in the pinned entry's directory — it can never be
194                // served as the pin. The mismatch below then drops it.
195                let (sha, path) = match cache.store(url, &bytes) {
196                    Ok(stored) => stored,
197                    Err(err) => {
198                        diagnostics.push(detached(
199                            DiagCode::MANI_101,
200                            format!("failed to cache `{url}`: {err}"),
201                        ));
202                        continue;
203                    }
204                };
205                if let Some(expected) = &pinned
206                    && expected != &sha
207                {
208                    diagnostics.push(detached(
209                        DiagCode::MANI_102,
210                        format!(
211                            "hash mismatch for `{url}`: the lockfile pins `{expected}` but the fetched content hashes to `{sha}`"
212                        ),
213                    ));
214                    continue;
215                }
216                regenerated
217                    .entries
218                    .insert(url.clone(), LockEntry { sha256: sha });
219                resolved.push((url.clone(), path));
220            }
221        }
222    }
223
224    (resolved, regenerated, diagnostics)
225}
226
227/// Builds a detached error [`Diagnostic`] for a URL-scoped problem: no source
228/// span, since a fetch or lockfile problem has no meaningful byte range.
229fn detached(code: DiagCode, message: String) -> Diagnostic {
230    Diagnostic {
231        code,
232        severity: Severity::Error,
233        message,
234        primary: Span {
235            file: FileId::DETACHED,
236            range: TextRange::default(),
237        },
238        labels: Vec::new(),
239        fixits: Vec::new(),
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use std::io::{BufRead as _, BufReader, Write as _};
246    use std::net::{TcpListener, TcpStream};
247    use std::path::Path;
248    use std::sync::Arc;
249    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
250    use std::thread::JoinHandle;
251
252    use super::*;
253
254    /// A unique directory under the system temp dir, removed on drop.
255    struct TempDir(PathBuf);
256
257    impl TempDir {
258        fn new(label: &str) -> Self {
259            static COUNTER: AtomicUsize = AtomicUsize::new(0);
260            let mut path = std::env::temp_dir();
261            path.push(format!(
262                "ridl-core-fetch-{label}-{}-{}",
263                std::process::id(),
264                COUNTER.fetch_add(1, Ordering::SeqCst),
265            ));
266            std::fs::create_dir_all(&path).expect("create the temp dir");
267            Self(path)
268        }
269
270        fn path(&self) -> &Path {
271            &self.0
272        }
273    }
274
275    impl Drop for TempDir {
276        fn drop(&mut self) {
277            let _ = std::fs::remove_dir_all(&self.0);
278        }
279    }
280
281    /// Builds an uncompressed tar archive holding one file — the provisional
282    /// fetch artifact (ADR-0007 decision 12).
283    fn make_tar(name: &str, contents: &[u8]) -> Vec<u8> {
284        let mut builder = tar::Builder::new(Vec::new());
285        let mut header = tar::Header::new_gnu();
286        header.set_size(contents.len() as u64);
287        header.set_mode(0o644);
288        header.set_cksum();
289        builder
290            .append_data(&mut header, name, contents)
291            .expect("append the file to the tar");
292        builder.into_inner().expect("finish the tar")
293    }
294
295    /// A local HTTP stub over `std::net::TcpListener`, serving a fixed body for
296    /// every request and counting the requests it receives — no real network is
297    /// ever used. The request counter is what proves a cache hit skips the
298    /// fetch.
299    struct Stub {
300        url: String,
301        addr: std::net::SocketAddr,
302        hits: Arc<AtomicUsize>,
303        shutdown: Arc<AtomicBool>,
304        handle: Option<JoinHandle<()>>,
305    }
306
307    impl Stub {
308        /// Starts a stub serving `body` for every GET.
309        fn serving(body: Vec<u8>) -> Stub {
310            let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stub");
311            let addr = listener.local_addr().expect("stub address");
312            let hits = Arc::new(AtomicUsize::new(0));
313            let shutdown = Arc::new(AtomicBool::new(false));
314
315            let handle = {
316                let hits = hits.clone();
317                let shutdown = shutdown.clone();
318                std::thread::spawn(move || {
319                    for incoming in listener.incoming() {
320                        if shutdown.load(Ordering::SeqCst) {
321                            break;
322                        }
323                        let stream = match incoming {
324                            Ok(stream) => stream,
325                            Err(_) => break,
326                        };
327                        // The shutdown wake-up connection (see `Drop`) arrives
328                        // with the flag already set: skip it, do not count it.
329                        if shutdown.load(Ordering::SeqCst) {
330                            break;
331                        }
332                        serve_one(stream, &body, &hits);
333                    }
334                })
335            };
336
337            Stub {
338                url: format!("http://{addr}/package.tar"),
339                addr,
340                hits,
341                shutdown,
342                handle: Some(handle),
343            }
344        }
345
346        fn url(&self) -> &str {
347            &self.url
348        }
349
350        fn hits(&self) -> usize {
351            self.hits.load(Ordering::SeqCst)
352        }
353
354        fn reset_hits(&self) {
355            self.hits.store(0, Ordering::SeqCst);
356        }
357    }
358
359    impl Drop for Stub {
360        fn drop(&mut self) {
361            // Set the flag first, then make a throwaway connection to unblock
362            // the accept loop so the thread can observe the flag and exit.
363            self.shutdown.store(true, Ordering::SeqCst);
364            let _ = TcpStream::connect(self.addr);
365            if let Some(handle) = self.handle.take() {
366                let _ = handle.join();
367            }
368        }
369    }
370
371    /// Reads and discards one HTTP request, counts it, and writes the fixed
372    /// body back with a `Content-Length` and `Connection: close` so `ureq`
373    /// does not pool the connection (one request per fetch).
374    fn serve_one(mut stream: TcpStream, body: &[u8], hits: &AtomicUsize) {
375        let mut reader = BufReader::new(stream.try_clone().expect("clone the stub stream"));
376        let mut line = String::new();
377        loop {
378            line.clear();
379            match reader.read_line(&mut line) {
380                Ok(0) => return, // client closed without a request; do not count
381                Ok(_) if line == "\r\n" || line == "\n" => break,
382                Ok(_) => {}
383                Err(_) => return,
384            }
385        }
386        hits.fetch_add(1, Ordering::SeqCst);
387        let header = format!(
388            "HTTP/1.1 200 OK\r\nContent-Type: application/x-tar\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
389            body.len(),
390        );
391        let _ = stream.write_all(header.as_bytes());
392        let _ = stream.write_all(body);
393        let _ = stream.flush();
394    }
395
396    /// A stub that accepts connections and never responds — it holds every
397    /// accepted stream open so the client waits for a response that never
398    /// comes. It proves the fetch timeout fires instead of hanging.
399    struct StallingStub {
400        addr: std::net::SocketAddr,
401        shutdown: Arc<AtomicBool>,
402        handle: Option<JoinHandle<()>>,
403    }
404
405    impl StallingStub {
406        fn start() -> StallingStub {
407            let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stalling stub");
408            let addr = listener.local_addr().expect("stub address");
409            let shutdown = Arc::new(AtomicBool::new(false));
410            let handle = {
411                let shutdown = shutdown.clone();
412                std::thread::spawn(move || {
413                    let mut held = Vec::new();
414                    for incoming in listener.incoming() {
415                        if shutdown.load(Ordering::SeqCst) {
416                            break;
417                        }
418                        match incoming {
419                            // Hold the stream open, never write a response.
420                            Ok(stream) => held.push(stream),
421                            Err(_) => break,
422                        }
423                    }
424                    drop(held);
425                })
426            };
427            StallingStub {
428                addr,
429                shutdown,
430                handle: Some(handle),
431            }
432        }
433
434        fn url(&self) -> String {
435            format!("http://{}/package.tar", self.addr)
436        }
437    }
438
439    impl Drop for StallingStub {
440        fn drop(&mut self) {
441            self.shutdown.store(true, Ordering::SeqCst);
442            let _ = TcpStream::connect(self.addr);
443            if let Some(handle) = self.handle.take() {
444                let _ = handle.join();
445            }
446        }
447    }
448
449    fn cache(dir: &TempDir) -> Cache {
450        Cache {
451            root: dir.path().join("cache"),
452        }
453    }
454
455    fn imports_of(url: &str) -> BTreeMap<String, String> {
456        BTreeMap::from([("remote.pkg".to_string(), url.to_string())])
457    }
458
459    /// `fetch` returns the served body over the local stub — one request.
460    #[test]
461    fn fetch_returns_the_served_bytes() {
462        let body = make_tar("a.typl", b"package remote.pkg\n");
463        let stub = Stub::serving(body.clone());
464        let got = fetch(stub.url()).expect("the fetch succeeds");
465        assert_eq!(got, body, "the fetched bytes are the served body");
466        assert_eq!(stub.hits(), 1, "exactly one request reached the stub");
467    }
468
469    /// A server that accepts the connection then never responds must time out
470    /// rather than hang: the bounded global timeout turns the stall into a
471    /// `FetchError` promptly. Driven with a short timeout so the test is fast.
472    #[test]
473    fn fetch_times_out_on_a_stalled_server() {
474        let stub = StallingStub::start();
475        let start = std::time::Instant::now();
476        let result = fetch_with_timeout(&stub.url(), Duration::from_millis(300));
477        assert!(result.is_err(), "a stalled server must time out, not hang");
478        assert!(
479            start.elapsed() < Duration::from_secs(5),
480            "the timeout must fire promptly (no hang), took {:?}",
481            start.elapsed(),
482        );
483    }
484
485    /// `fetch` against a refused address is a `FetchError`, the MANI-101 path.
486    #[test]
487    fn fetch_of_a_dead_address_errors() {
488        // Bind then drop to obtain an address nothing is listening on.
489        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
490        let addr = listener.local_addr().expect("addr");
491        drop(listener);
492        let url = format!("http://{addr}/package.tar");
493        assert!(fetch(&url).is_err(), "a refused connection is a FetchError");
494    }
495
496    /// End to end: the stub serves a tar with one `.typl` file, and
497    /// `materialize_imports` lands it unpacked in the cache, returns the
498    /// `(url, dir)` pair, and pins the content hash in the regenerated
499    /// lockfile.
500    #[test]
501    fn materialize_unpacks_a_remote_package_into_the_cache() {
502        let source = "package remote.pkg\ntype Speed: km/h\n";
503        let stub = Stub::serving(make_tar("remote.typl", source.as_bytes()));
504        let dir = TempDir::new("materialize");
505        let cache = cache(&dir);
506        let imports = imports_of(stub.url());
507
508        let (resolved, lock, diags) = materialize_imports(&imports, None, &cache, Frozen::No);
509        assert!(diags.is_empty(), "a clean fetch, no diagnostics: {diags:?}");
510        assert_eq!(stub.hits(), 1, "the artifact is fetched exactly once");
511
512        assert_eq!(resolved.len(), 1);
513        let (resolved_url, path) = &resolved[0];
514        assert_eq!(resolved_url, stub.url());
515        let unpacked = std::fs::read_to_string(path.join("remote.typl"))
516            .expect("the .typl file is unpacked into the cache");
517        assert_eq!(unpacked, source);
518
519        let entry = lock.entries.get(stub.url()).expect("the URL is pinned");
520        assert_eq!(entry.sha256.len(), 64, "the pin is a SHA-256 hex string");
521    }
522
523    /// A cache hit against the lockfile-pinned hash skips the fetch — proven by
524    /// the request counter reading zero on the second materialize.
525    #[test]
526    fn a_cache_hit_skips_the_fetch() {
527        let stub = Stub::serving(make_tar("remote.typl", b"package remote.pkg\n"));
528        let dir = TempDir::new("cache-hit");
529        let cache = cache(&dir);
530        let imports = imports_of(stub.url());
531
532        // First run fetches once and produces the lockfile.
533        let (_, lock, diags) = materialize_imports(&imports, None, &cache, Frozen::No);
534        assert!(diags.is_empty());
535        assert_eq!(stub.hits(), 1, "the first run fetches once");
536
537        // Second run, with the lockfile from the first and the same cache: the
538        // pinned hash is already unpacked, so no request is made.
539        stub.reset_hits();
540        let (resolved, lock2, diags2) =
541            materialize_imports(&imports, Some(&lock), &cache, Frozen::No);
542        assert!(diags2.is_empty());
543        assert_eq!(stub.hits(), 0, "a cache hit makes no request");
544        assert_eq!(
545            resolved.len(),
546            1,
547            "the package still resolves from the cache"
548        );
549        assert_eq!(lock2, lock, "the regenerated lockfile keeps the same pin");
550    }
551
552    /// Fetched content whose hash differs from the lockfile pin is a MANI-102
553    /// mismatch, and the import is dropped.
554    #[test]
555    fn a_hash_mismatch_is_mani_102() {
556        let stub = Stub::serving(make_tar("remote.typl", b"package remote.pkg\n"));
557        let dir = TempDir::new("mismatch");
558        let cache = cache(&dir); // empty: the pinned hash is not cached, so we fetch
559        let imports = imports_of(stub.url());
560
561        // Pin a hash the fetched content will not match.
562        let mut lock = Lockfile::default();
563        lock.entries.insert(
564            stub.url().to_string(),
565            LockEntry {
566                sha256: "0".repeat(64),
567            },
568        );
569
570        let (resolved, regenerated, diags) =
571            materialize_imports(&imports, Some(&lock), &cache, Frozen::No);
572        assert_eq!(stub.hits(), 1, "the mismatch is found only after fetching");
573        assert_eq!(codes(&diags), vec!["MANI-102"]);
574        assert!(resolved.is_empty(), "a mismatched import does not resolve");
575        assert!(
576            regenerated.entries.is_empty(),
577            "a mismatched hash is never pinned",
578        );
579    }
580
581    /// Frozen behaviour 1 — success: the pinned hash is already cached, so the
582    /// import resolves with no fetch and no diagnostic.
583    #[test]
584    fn frozen_success_uses_the_cache_without_fetching() {
585        let stub = Stub::serving(make_tar("remote.typl", b"package remote.pkg\n"));
586        let dir = TempDir::new("frozen-ok");
587        let cache = cache(&dir);
588        let imports = imports_of(stub.url());
589
590        // Populate the cache and the lockfile with a non-frozen run.
591        let (_, lock, _) = materialize_imports(&imports, None, &cache, Frozen::No);
592        stub.reset_hits();
593
594        let (resolved, _, diags) = materialize_imports(&imports, Some(&lock), &cache, Frozen::Yes);
595        assert!(
596            diags.is_empty(),
597            "a cached pin resolves cleanly under --frozen"
598        );
599        assert_eq!(stub.hits(), 0, "--frozen never fetches");
600        assert_eq!(resolved.len(), 1);
601    }
602
603    /// Frozen behaviour 2 — a URL with no lockfile entry is MANI-103, with no
604    /// fetch.
605    #[test]
606    fn frozen_missing_lockfile_entry_is_mani_103() {
607        let stub = Stub::serving(make_tar("remote.typl", b"package remote.pkg\n"));
608        let dir = TempDir::new("frozen-103");
609        let cache = cache(&dir);
610        let imports = imports_of(stub.url());
611
612        let (resolved, _, diags) = materialize_imports(&imports, None, &cache, Frozen::Yes);
613        assert_eq!(codes(&diags), vec!["MANI-103"]);
614        assert_eq!(stub.hits(), 0, "--frozen never fetches");
615        assert!(resolved.is_empty());
616    }
617
618    /// Frozen behaviour 3 — a pinned URL that is not in the cache is MANI-104,
619    /// with no fetch.
620    #[test]
621    fn frozen_pinned_but_uncached_is_mani_104() {
622        let stub = Stub::serving(make_tar("remote.typl", b"package remote.pkg\n"));
623        let dir = TempDir::new("frozen-104");
624        let cache = cache(&dir); // empty cache
625        let imports = imports_of(stub.url());
626
627        let mut lock = Lockfile::default();
628        lock.entries.insert(
629            stub.url().to_string(),
630            LockEntry {
631                sha256: "a".repeat(64),
632            },
633        );
634
635        let (resolved, _, diags) = materialize_imports(&imports, Some(&lock), &cache, Frozen::Yes);
636        assert_eq!(codes(&diags), vec!["MANI-104"]);
637        assert_eq!(stub.hits(), 0, "--frozen never fetches");
638        assert!(resolved.is_empty());
639    }
640
641    /// A recorded import URL that is not a fetchable `http(s)` URL (it may have
642    /// failed MANI-007 yet still be recorded verbatim) is a MANI-101 without
643    /// touching the network.
644    #[test]
645    fn an_invalid_url_is_mani_101_without_fetching() {
646        let dir = TempDir::new("bad-url");
647        let cache = cache(&dir);
648        let imports = BTreeMap::from([("bad.dep".to_string(), "not-a-url".to_string())]);
649
650        let (resolved, _, diags) = materialize_imports(&imports, None, &cache, Frozen::No);
651        assert_eq!(codes(&diags), vec!["MANI-101"]);
652        assert!(resolved.is_empty());
653    }
654
655    fn codes(diags: &[Diagnostic]) -> Vec<&str> {
656        diags.iter().map(|d| d.code.as_str()).collect()
657    }
658}