1use 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 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 pub fn authorization_for(&self, host: &str) -> Option<String> {
78 let host = host.to_ascii_lowercase();
79 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#[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
114pub 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 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
154pub 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
203pub fn dist_cache_path(cache_root: &Path, name: &str, url: &str) -> PathBuf {
207 dist_cache_path_of(cache_root, name, url, "zip")
208}
209
210pub 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#[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
263const 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
289fn 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
295fn 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
305fn 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
316struct 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 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 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 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 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 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 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 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 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 fn local_dist_path(url: &str) -> Option<PathBuf> {
575 if let Some(rest) = url.strip_prefix("file://") {
576 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 let p = dist_cache_path(
624 Path::new("/c"),
625 "monolog/monolog",
626 "https://api.github.com/repos/Seldaek/monolog/zipball/abc",
627 );
628 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 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 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 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 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 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 assert_eq!(
678 p("file:///srv/a%20b/x.zip"),
679 Some("/srv/a%20b/x.zip".to_owned())
681 );
682 assert_eq!(p("file:///C:/srv/x.zip"), Some("C:/srv/x.zip".to_owned()));
685 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}