1use 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#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct FetchError {
33 pub message: String,
34}
35
36const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
42
43pub fn fetch(url: &str) -> Result<Vec<u8>, FetchError> {
52 fetch_with_timeout(url, FETCH_TIMEOUT)
53}
54
55fn 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
71fn 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#[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
106pub 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 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 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 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
227fn 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 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 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 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 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 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 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 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, 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 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 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 #[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 #[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 #[test]
487 fn fetch_of_a_dead_address_errors() {
488 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 #[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 #[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 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 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 #[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); let imports = imports_of(stub.url());
560
561 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 #[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 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 #[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 #[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); 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 #[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}