Skip to main content

vivacity_core/
fetch.rs

1//! Dist downloads, interoperable with Composer's cache: same layout
2//! (`<cache>/files/<vendor>/<pkg>/<sha1-of-url>.zip`), both read AND fed, so
3//! a cache warmed by one serves the other. Minimal v1 auth: `github-oauth`,
4//! `http-basic`, `bearer` (project auth.json, COMPOSER_AUTH, then the
5//! COMPOSER_HOME auth.json). The lock's shasum, when present, is checked on
6//! download AND when reading back from the cache (meta-analysis F7: a shared
7//! cache is read back with suspicion).
8
9use crate::error::{Error, Result};
10use serde_json::Value;
11use sha1::{Digest, Sha1};
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Default, Clone)]
16pub struct Auth {
17    pub github_oauth: BTreeMap<String, String>,
18    pub http_basic: BTreeMap<String, (String, String)>,
19    pub bearer: BTreeMap<String, String>,
20}
21
22impl Auth {
23    /// Merges (from lowest to highest priority): the COMPOSER_HOME auth.json,
24    /// the COMPOSER_AUTH variable, the project auth.json.
25    pub fn load(project_dir: &Path) -> Auth {
26        let mut auth = Auth::default();
27        if let Some(home) = composer_home() {
28            auth.merge_json_file(&home.join("auth.json"));
29        }
30        if let Ok(env) = std::env::var("COMPOSER_AUTH") {
31            if let Ok(v) = serde_json::from_str::<Value>(&env) {
32                auth.merge_value(&v);
33            }
34        }
35        auth.merge_json_file(&project_dir.join("auth.json"));
36        auth
37    }
38
39    fn merge_json_file(&mut self, path: &Path) {
40        if let Ok(text) = std::fs::read_to_string(path) {
41            if let Ok(v) = serde_json::from_str::<Value>(&text) {
42                self.merge_value(&v);
43            }
44        }
45    }
46
47    fn merge_value(&mut self, v: &Value) {
48        if let Some(map) = v.get("github-oauth").and_then(Value::as_object) {
49            for (host, tok) in map {
50                if let Some(t) = tok.as_str() {
51                    self.github_oauth
52                        .insert(host.to_ascii_lowercase(), t.to_owned());
53                }
54            }
55        }
56        if let Some(map) = v.get("bearer").and_then(Value::as_object) {
57            for (host, tok) in map {
58                if let Some(t) = tok.as_str() {
59                    self.bearer.insert(host.to_ascii_lowercase(), t.to_owned());
60                }
61            }
62        }
63        if let Some(map) = v.get("http-basic").and_then(Value::as_object) {
64            for (host, creds) in map {
65                if let (Some(u), Some(p)) = (
66                    creds.get("username").and_then(Value::as_str),
67                    creds.get("password").and_then(Value::as_str),
68                ) {
69                    self.http_basic
70                        .insert(host.to_ascii_lowercase(), (u.to_owned(), p.to_owned()));
71                }
72            }
73        }
74    }
75
76    /// Value of the Authorization header for this host, if any.
77    pub fn authorization_for(&self, host: &str) -> Option<String> {
78        let host = host.to_ascii_lowercase();
79        // GitHub dists go through api.github.com / codeload.github.com
80        // but the token is stored under github.com.
81        if host == "github.com" || host.ends_with(".github.com") {
82            if let Some(t) = self.github_oauth.get("github.com") {
83                return Some(format!("token {t}"));
84            }
85        }
86        if let Some(t) = self.github_oauth.get(&host) {
87            return Some(format!("token {t}"));
88        }
89        if let Some(t) = self.bearer.get(&host) {
90            return Some(format!("Bearer {t}"));
91        }
92        if let Some((u, p)) = self.http_basic.get(&host) {
93            use base64::Engine as _;
94            let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{u}:{p}"));
95            return Some(format!("Basic {encoded}"));
96        }
97        None
98    }
99}
100
101/// `Factory::useXdg`: true as soon as any `XDG_*` environment variable exists.
102#[cfg(not(windows))]
103fn use_xdg() -> bool {
104    std::env::vars_os().any(|(k, _)| k.to_string_lossy().starts_with("XDG_"))
105}
106
107#[cfg(not(windows))]
108fn user_dir() -> Option<PathBuf> {
109    std::env::var("HOME")
110        .ok()
111        .map(|h| PathBuf::from(h.trim_end_matches('/')))
112}
113
114/// `Factory::getHomeDir` (docs/reference/Factory.php): COMPOSER_HOME, else
115/// on Windows `%APPDATA%/Composer`, else the first existing directory among
116/// `$XDG_CONFIG_HOME/composer` (if XDG is in use) and `~/.composer`, else
117/// the first candidate.
118pub fn composer_home() -> Option<PathBuf> {
119    if let Ok(h) = std::env::var("COMPOSER_HOME") {
120        if !h.is_empty() {
121            return Some(PathBuf::from(h));
122        }
123    }
124    #[cfg(windows)]
125    {
126        // Composer requires APPDATA on Windows (throws otherwise); here it
127        // is None, and the layers above (auth.json, config.json) cope
128        // without it.
129        std::env::var("APPDATA")
130            .ok()
131            .filter(|s| !s.is_empty())
132            .map(|a| PathBuf::from(a.trim_end_matches(['/', '\\'])).join("Composer"))
133    }
134    #[cfg(not(windows))]
135    {
136        let user = user_dir()?;
137        let mut dirs: Vec<PathBuf> = Vec::new();
138        if use_xdg() {
139            let xdg = std::env::var("XDG_CONFIG_HOME")
140                .ok()
141                .filter(|s| !s.is_empty())
142                .map(PathBuf::from)
143                .unwrap_or_else(|| user.join(".config"));
144            dirs.push(xdg.join("composer"));
145        }
146        dirs.push(user.join(".composer"));
147        dirs.iter()
148            .find(|d| d.is_dir())
149            .cloned()
150            .or_else(|| dirs.first().cloned())
151    }
152}
153
154/// `Factory::getCacheDir`: COMPOSER_CACHE_DIR; else `$COMPOSER_HOME/cache`
155/// if COMPOSER_HOME is set; Windows -> `%LOCALAPPDATA%/Composer` (else
156/// `<home>/cache`); Darwin -> `~/Library/Caches/composer`;
157/// `~/.composer/cache` if it exists; XDG -> `$XDG_CACHE_HOME/composer`;
158/// else `<home>/cache`.
159pub fn composer_cache_dir() -> PathBuf {
160    if let Ok(d) = std::env::var("COMPOSER_CACHE_DIR") {
161        if !d.is_empty() {
162            return PathBuf::from(d);
163        }
164    }
165    if let Ok(h) = std::env::var("COMPOSER_HOME") {
166        if !h.is_empty() {
167            return PathBuf::from(h).join("cache");
168        }
169    }
170    #[cfg(windows)]
171    {
172        if let Ok(l) = std::env::var("LOCALAPPDATA") {
173            if !l.is_empty() {
174                return PathBuf::from(l.trim_end_matches(['/', '\\'])).join("Composer");
175            }
176        }
177        composer_home()
178            .unwrap_or_else(|| PathBuf::from("."))
179            .join("cache")
180    }
181    #[cfg(not(windows))]
182    {
183        let user = user_dir().unwrap_or_else(|| PathBuf::from("."));
184        let home = composer_home().unwrap_or_else(|| user.join(".composer"));
185        if cfg!(target_os = "macos") {
186            return user.join("Library/Caches/composer");
187        }
188        if home == user.join(".composer") && home.join("cache").is_dir() {
189            return home.join("cache");
190        }
191        if use_xdg() {
192            let xdg = std::env::var("XDG_CACHE_HOME")
193                .ok()
194                .filter(|s| !s.is_empty())
195                .map(PathBuf::from)
196                .unwrap_or_else(|| user.join(".cache"));
197            return xdg.join("composer");
198        }
199        home.join("cache")
200    }
201}
202
203/// Cache path of a dist, identical to Composer's: sha1 of the FULL URL
204/// (deliberate in Composer: prevents cross-repository poisoning), key
205/// sanitised to `[a-z0-9._/-]`.
206pub fn dist_cache_path(cache_root: &Path, name: &str, url: &str) -> PathBuf {
207    dist_cache_path_of(cache_root, name, url, "zip")
208}
209
210/// `FileDownloader`'s cache key: `<name>/<sha1(url)>.<dist type>` — a
211/// `tar` dist is cached as `.tar`, so Composer and vivacity find each
212/// other's files.
213pub fn dist_cache_path_of(cache_root: &Path, name: &str, url: &str, dist_type: &str) -> PathBuf {
214    let mut h = Sha1::new();
215    h.update(url.as_bytes());
216    let sha: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
217    let sane_name: String = name
218        .chars()
219        .map(|c| {
220            let c = c.to_ascii_lowercase();
221            if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '/' | '-') {
222                c
223            } else {
224                '-'
225            }
226        })
227        .collect();
228    cache_root
229        .join("files")
230        .join(sane_name)
231        .join(format!("{sha}.{dist_type}"))
232}
233
234fn sha1_hex(bytes: &[u8]) -> String {
235    let mut h = Sha1::new();
236    h.update(bytes);
237    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
238}
239
240/// Response of a conditional metadata GET.
241#[derive(Debug, Clone)]
242pub enum MetadataResponse {
243    NotModified,
244    NotFound,
245    Body {
246        bytes: Vec<u8>,
247        last_modified: Option<String>,
248    },
249}
250
251pub struct Fetcher {
252    client: reqwest::Client,
253    cache_root: PathBuf,
254    auth: Auth,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum Provenance {
259    Cache,
260    Network,
261}
262
263/// Composer's retry rule, ported from `CurlDownloader` (2.10.3, lines 384-398
264/// and 465-478):
265/// - a transport error is retried only for curl errno 6 (host not resolved),
266///   7 (cannot connect), 28 (timeout), 16 / 92 (http2), or 56 / 35 with
267///   "connection reset by peer" — in reqwest's terms, no response came back and
268///   the cause is a connect, a timeout, or the body being cut;
269/// - a *status* is retried only when it is one of 423, 425, 500, 502, 503, 504,
270///   507, 510, or a 400 from `codeload.github.com`. A 404, a 401 or a 403 is
271///   therefore final, where vivacity used to try three times — slower, and it
272///   said "after 3 attempts" about a verdict that never moves;
273/// - at most three retries, with Composer's own delays:
274///   `restartJobWithDelay` sleeps not at all, then 100 ms, then 500 ms.
275const MAX_RETRIES: u32 = 3;
276
277fn status_is_retryable(status: u16, url: &str) -> bool {
278    if matches!(status, 423 | 425 | 500 | 502 | 503 | 504 | 507 | 510) {
279        return true;
280    }
281    status == 400
282        && reqwest::Url::parse(url)
283            .ok()
284            .as_ref()
285            .and_then(|u| u.host_str())
286            == Some("codeload.github.com")
287}
288
289/// `true` when the request came back without any response at all and the cause
290/// is one curl would retry.
291fn transport_is_retryable(e: &reqwest::Error) -> bool {
292    e.status().is_none() && (e.is_connect() || e.is_timeout() || e.is_request() || e.is_body())
293}
294
295/// The delay before retry number `retries` (1-based), as
296/// `CurlDownloader::restartJobWithDelay` sleeps it.
297fn retry_delay(retries: u32) -> std::time::Duration {
298    std::time::Duration::from_millis(match retries {
299        0 | 1 => 0,
300        2 => 100,
301        _ => 500,
302    })
303}
304
305/// The message, saying how many attempts there really were — claiming three
306/// when the verdict was final after one is the kind of detail that sends a
307/// reader looking for a network problem that never existed.
308fn attempts_message(retries: u32, last: &str) -> String {
309    if retries == 0 {
310        last.to_owned()
311    } else {
312        format!("failed after {} attempts: {last}", retries + 1)
313    }
314}
315
316/// How a failed attempt is reported: the message, and whether trying again can
317/// change the answer.
318struct Attempt {
319    message: String,
320    retryable: bool,
321}
322
323impl Fetcher {
324    pub fn new(cache_root: PathBuf, auth: Auth) -> Result<Fetcher> {
325        let client = reqwest::Client::builder()
326            .user_agent(format!("vivacity/{}", env!("CARGO_PKG_VERSION")))
327            .build()
328            .map_err(|e| Error::Http {
329                url: "client".to_owned(),
330                message: e.to_string(),
331            })?;
332        Ok(Fetcher {
333            client,
334            cache_root,
335            auth,
336        })
337    }
338
339    /// Bytes of the dist: cache first (shasum re-checked), network otherwise
340    /// (3 attempts, backoff), cache fed through temp+rename.
341    pub async fn dist_bytes(
342        &self,
343        name: &str,
344        url: &str,
345        expected_sha1: Option<&str>,
346        offline: bool,
347    ) -> Result<(Vec<u8>, Provenance)> {
348        self.dist_bytes_of(name, url, "zip", expected_sha1, offline)
349            .await
350    }
351
352    /// `dist_bytes` for a given dist type (the cache file's extension).
353    pub async fn dist_bytes_of(
354        &self,
355        name: &str,
356        url: &str,
357        dist_type: &str,
358        expected_sha1: Option<&str>,
359        offline: bool,
360    ) -> Result<(Vec<u8>, Provenance)> {
361        let cache_path = dist_cache_path_of(&self.cache_root, name, url, dist_type);
362        if let Ok(bytes) = std::fs::read(&cache_path) {
363            match expected_sha1 {
364                Some(exp) if sha1_hex(&bytes) != exp => {
365                    // Corrupted/poisoned cache entry: throw it away.
366                    let _ = std::fs::remove_file(&cache_path);
367                }
368                _ => return Ok((bytes, Provenance::Cache)),
369            }
370        }
371        if offline {
372            return Err(Error::Http {
373                url: url.to_owned(),
374                message: format!("missing cache for {name} in offline mode"),
375            });
376        }
377
378        // A dist that lives on this filesystem is read, not downloaded: that
379        // is what Composer does with it, and refusing it lost the whole
380        // `artifact` repository family (offline installs, private builds).
381        if let Some(path) = Self::local_dist_path(url) {
382            let bytes = std::fs::read(&path).map_err(|e| Error::Http {
383                url: url.to_owned(),
384                message: format!("{} could not be read: {e}", path.display()),
385            })?;
386            if let Some(exp) = expected_sha1 {
387                let actual = sha1_hex(&bytes);
388                if actual != exp {
389                    return Err(Error::ShasumMismatch {
390                        name: name.to_owned(),
391                        expected: exp.to_owned(),
392                        actual,
393                    });
394                }
395            }
396            self.cache_dist(&cache_path, &bytes);
397            return Ok((bytes, Provenance::Network));
398        }
399
400        let mut retries = 0u32;
401        let last_err = loop {
402            match self.try_download(url).await {
403                Ok(bytes) => {
404                    if let Some(exp) = expected_sha1 {
405                        let actual = sha1_hex(&bytes);
406                        if actual != exp {
407                            return Err(Error::ShasumMismatch {
408                                name: name.to_owned(),
409                                expected: exp.to_owned(),
410                                actual,
411                            });
412                        }
413                    }
414                    self.cache_dist(&cache_path, &bytes);
415                    return Ok((bytes, Provenance::Network));
416                }
417                Err(a) => {
418                    if !a.retryable || retries >= MAX_RETRIES {
419                        break a.message;
420                    }
421                    retries += 1;
422                    tokio::time::sleep(retry_delay(retries)).await;
423                }
424            }
425        };
426        Err(Error::Http {
427            url: url.to_owned(),
428            message: attempts_message(retries, &last_err),
429        })
430    }
431
432    /// Metadata GET (packages.json, p2 files): `Ok(None)` on 404 (unknown
433    /// package, tolerated by Composer), error otherwise; 3 attempts on
434    /// transport errors.
435    pub async fn metadata_bytes(&self, url: &str) -> Result<Option<Vec<u8>>> {
436        match self.metadata_fetch(url, None).await? {
437            MetadataResponse::Body { bytes, .. } => Ok(Some(bytes)),
438            MetadataResponse::NotFound | MetadataResponse::NotModified => Ok(None),
439        }
440    }
441
442    /// `application/x-www-form-urlencoded` POST (the security advisories
443    /// API: `packages[]=...`), 10 s timeout like Composer, a single attempt;
444    /// 404 -> `NotFound`.
445    pub async fn post_form(&self, url: &str, body: &str) -> Result<MetadataResponse> {
446        let mut req = self
447            .client
448            .post(url)
449            .header(
450                reqwest::header::CONTENT_TYPE,
451                "application/x-www-form-urlencoded",
452            )
453            .timeout(std::time::Duration::from_secs(10))
454            .body(body.to_owned());
455        if let Ok(parsed) = reqwest::Url::parse(url) {
456            if let Some(host) = parsed.host_str() {
457                if let Some(authz) = self.auth.authorization_for(host) {
458                    req = req.header(reqwest::header::AUTHORIZATION, authz);
459                }
460            }
461        }
462        let resp = req.send().await.map_err(|e| Error::Http {
463            url: url.to_owned(),
464            message: e.to_string(),
465        })?;
466        if resp.status() == reqwest::StatusCode::NOT_FOUND {
467            return Ok(MetadataResponse::NotFound);
468        }
469        let resp = resp.error_for_status().map_err(|e| Error::Http {
470            url: url.to_owned(),
471            message: e.to_string(),
472        })?;
473        let bytes = resp.bytes().await.map_err(|e| Error::Http {
474            url: url.to_owned(),
475            message: e.to_string(),
476        })?;
477        Ok(MetadataResponse::Body {
478            bytes: bytes.to_vec(),
479            last_modified: None,
480        })
481    }
482
483    /// Conditional metadata GET: `If-Modified-Since` when the cache has a
484    /// date, 304 -> `NotModified`, 404 -> `NotFound`, else the body and the
485    /// `Last-Modified` header; 3 attempts on transport errors.
486    pub async fn metadata_fetch(
487        &self,
488        url: &str,
489        if_modified_since: Option<&str>,
490    ) -> Result<MetadataResponse> {
491        let mut retries = 0u32;
492        let last_err = loop {
493            let mut req = self.client.get(url);
494            if let Ok(parsed) = reqwest::Url::parse(url) {
495                if let Some(host) = parsed.host_str() {
496                    if let Some(authz) = self.auth.authorization_for(host) {
497                        req = req.header(reqwest::header::AUTHORIZATION, authz);
498                    }
499                }
500            }
501            if let Some(ims) = if_modified_since {
502                req = req.header(reqwest::header::IF_MODIFIED_SINCE, ims);
503            }
504            let attempt = match req.send().await {
505                Ok(resp) => {
506                    if resp.status() == reqwest::StatusCode::NOT_FOUND {
507                        return Ok(MetadataResponse::NotFound);
508                    }
509                    if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
510                        return Ok(MetadataResponse::NotModified);
511                    }
512                    let last_modified = resp
513                        .headers()
514                        .get(reqwest::header::LAST_MODIFIED)
515                        .and_then(|v| v.to_str().ok())
516                        .map(str::to_owned);
517                    match resp.error_for_status() {
518                        Ok(resp) => match resp.bytes().await {
519                            Ok(b) => {
520                                return Ok(MetadataResponse::Body {
521                                    bytes: b.to_vec(),
522                                    last_modified,
523                                })
524                            }
525                            Err(e) => Attempt {
526                                retryable: transport_is_retryable(&e),
527                                message: e.to_string(),
528                            },
529                        },
530                        Err(e) => Attempt {
531                            retryable: e
532                                .status()
533                                .is_some_and(|s| status_is_retryable(s.as_u16(), url)),
534                            message: e.to_string(),
535                        },
536                    }
537                }
538                Err(e) => Attempt {
539                    retryable: transport_is_retryable(&e),
540                    message: e.to_string(),
541                },
542            };
543            if !attempt.retryable || retries >= MAX_RETRIES {
544                break attempt.message;
545            }
546            retries += 1;
547            tokio::time::sleep(retry_delay(retries)).await;
548        };
549        Err(Error::Http {
550            url: url.to_owned(),
551            message: attempts_message(retries, &last_err),
552        })
553    }
554
555    /// Writes a dist into Composer's own files cache, through a temporary
556    /// name so a killed process never leaves a half-written entry behind.
557    fn cache_dist(&self, cache_path: &Path, bytes: &[u8]) {
558        if let Some(parent) = cache_path.parent() {
559            if std::fs::create_dir_all(parent).is_ok() {
560                let tmp = cache_path.with_extension("zip.vivacity-tmp");
561                if std::fs::write(&tmp, bytes).is_ok() {
562                    let _ = std::fs::rename(&tmp, cache_path);
563                }
564            }
565        }
566    }
567
568    /// The filesystem path a dist URL names, when it names one. Composer's
569    /// `HttpDownloader::addJob` hands anything that is not `http(s)://` to
570    /// `RemoteFilesystem`, which opens it with PHP's stream layer — so a
571    /// `file://` URL and a plain path both work there, and a plain path is
572    /// exactly what an `artifact` repository writes into the lock. Nothing is
573    /// percent-decoded, as PHP's file wrapper decodes nothing either.
574    fn local_dist_path(url: &str) -> Option<PathBuf> {
575        if let Some(rest) = url.strip_prefix("file://") {
576            // The third slash belongs to the path (`file:///srv/x` is
577            // `/srv/x`) except when a drive letter follows it, the Windows
578            // form (`file:///C:/x` is `C:/x`). A host part
579            // (`file://host/share`) is left to the caller's error, as PHP
580            // leaves it to the stream wrapper.
581            let path = match rest.strip_prefix('/') {
582                Some(after) if after.chars().nth(1) == Some(':') => after,
583                _ => rest,
584            };
585            return Some(PathBuf::from(path));
586        }
587        if url.contains("://") {
588            return None;
589        }
590        Some(PathBuf::from(url))
591    }
592
593    async fn try_download(&self, url: &str) -> std::result::Result<Vec<u8>, Attempt> {
594        let fail = |e: reqwest::Error, url: &str| Attempt {
595            message: e.to_string(),
596            retryable: match e.status() {
597                Some(s) => status_is_retryable(s.as_u16(), url),
598                None => transport_is_retryable(&e),
599            },
600        };
601        let mut req = self.client.get(url);
602        if let Ok(parsed) = reqwest::Url::parse(url) {
603            if let Some(host) = parsed.host_str() {
604                if let Some(authz) = self.auth.authorization_for(host) {
605                    req = req.header(reqwest::header::AUTHORIZATION, authz);
606                }
607            }
608        }
609        let resp = req.send().await.map_err(|e| fail(e, url))?;
610        let resp = resp.error_for_status().map_err(|e| fail(e, url))?;
611        Ok(resp.bytes().await.map_err(|e| fail(e, url))?.to_vec())
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn cache_layout_matches_composer() {
621        // Checked against Composer's real cache in M0: the key is
622        // files/<name>/<sha1(url)>.zip.
623        let p = dist_cache_path(
624            Path::new("/c"),
625            "monolog/monolog",
626            "https://api.github.com/repos/Seldaek/monolog/zipball/abc",
627        );
628        // `join` separates with `\` on Windows: compare in normalized form.
629        let s = p.to_string_lossy().replace('\\', "/");
630        assert!(s.starts_with("/c/files/monolog/monolog/"));
631        assert!(s.ends_with(".zip"));
632        assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
633    }
634
635    #[test]
636    fn the_retry_rule_is_composers() {
637        // The list `CurlDownloader` retries on, and nothing else: a 404, a 401
638        // or a 403 is final, which is why a missing dist no longer takes three
639        // tries to say so.
640        for s in [423, 425, 500, 502, 503, 504, 507, 510] {
641            assert!(status_is_retryable(s, "https://repo.example/x"), "{s}");
642        }
643        for s in [400, 401, 403, 404, 405, 409, 410, 418, 451, 501, 505] {
644            assert!(!status_is_retryable(s, "https://repo.example/x"), "{s}");
645        }
646        // The one host-specific exception Composer carries.
647        assert!(status_is_retryable(
648            400,
649            "https://codeload.github.com/acme/widget/zip/abc"
650        ));
651        assert!(!status_is_retryable(400, "https://github.com/acme/widget"));
652    }
653
654    #[test]
655    fn the_retry_delays_are_composers() {
656        // `restartJobWithDelay`: nothing, then 100 ms, then 500 ms.
657        assert_eq!(retry_delay(1).as_millis(), 0);
658        assert_eq!(retry_delay(2).as_millis(), 100);
659        assert_eq!(retry_delay(3).as_millis(), 500);
660        assert_eq!(retry_delay(4).as_millis(), 500);
661        // Three retries at most, so four attempts, and the message says so
662        // rather than claiming three when the verdict was final at once.
663        assert_eq!(MAX_RETRIES, 3);
664        assert_eq!(attempts_message(0, "gone"), "gone");
665        assert_eq!(attempts_message(3, "gone"), "failed after 4 attempts: gone");
666    }
667
668    #[test]
669    fn a_dist_url_that_names_a_local_file_is_recognised() {
670        let p = |u: &str| Fetcher::local_dist_path(u).map(|p| p.to_string_lossy().into_owned());
671        // What an `artifact` repository writes into the lock: a plain path.
672        assert_eq!(
673            p("/srv/artifacts/acme-widget-1.0.0.zip"),
674            Some("/srv/artifacts/acme-widget-1.0.0.zip".to_owned())
675        );
676        // A `file://` URL, the other form Composer opens.
677        assert_eq!(
678            p("file:///srv/a%20b/x.zip"),
679            // Nothing is decoded, as PHP's file wrapper decodes nothing.
680            Some("/srv/a%20b/x.zip".to_owned())
681        );
682        // The Windows form of the same thing: the third slash is the
683        // separator before the drive letter, not part of the path.
684        assert_eq!(p("file:///C:/srv/x.zip"), Some("C:/srv/x.zip".to_owned()));
685        // Everything else stays a download.
686        assert_eq!(p("https://repo.example/x.zip"), None);
687        assert_eq!(p("http://repo.example/x.zip"), None);
688        assert_eq!(p("ssh://git@example/x.git"), None);
689    }
690
691    #[test]
692    fn auth_header_selection() {
693        let mut auth = Auth::default();
694        auth.github_oauth
695            .insert("github.com".into(), "ghtok".into());
696        auth.bearer
697            .insert("repo.example.com".into(), "beartok".into());
698        auth.http_basic
699            .insert("basic.example.com".into(), ("user".into(), "pass".into()));
700
701        assert_eq!(
702            auth.authorization_for("github.com").as_deref(),
703            Some("token ghtok")
704        );
705        assert_eq!(
706            auth.authorization_for("codeload.github.com").as_deref(),
707            Some("token ghtok"),
708            "github dists go through codeload"
709        );
710        assert_eq!(
711            auth.authorization_for("api.github.com").as_deref(),
712            Some("token ghtok")
713        );
714        assert_eq!(
715            auth.authorization_for("repo.example.com").as_deref(),
716            Some("Bearer beartok")
717        );
718        assert_eq!(
719            auth.authorization_for("basic.example.com").as_deref(),
720            Some("Basic dXNlcjpwYXNz")
721        );
722        assert_eq!(auth.authorization_for("unknown.example.com"), None);
723    }
724
725    #[test]
726    fn composer_auth_env_shape_is_parsed() {
727        let mut auth = Auth::default();
728        auth.merge_value(&serde_json::json!({
729            "github-oauth": {"github.com": "t1"},
730            "http-basic": {"h": {"username": "u", "password": "p"}},
731            "bearer": {"b": "tk"}
732        }));
733        assert_eq!(auth.github_oauth.len(), 1);
734        assert_eq!(auth.http_basic.len(), 1);
735        assert_eq!(auth.bearer.len(), 1);
736    }
737}