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
263impl Fetcher {
264 pub fn new(cache_root: PathBuf, auth: Auth) -> Result<Fetcher> {
265 let client = reqwest::Client::builder()
266 .user_agent(format!("vivacity/{}", env!("CARGO_PKG_VERSION")))
267 .build()
268 .map_err(|e| Error::Http {
269 url: "client".to_owned(),
270 message: e.to_string(),
271 })?;
272 Ok(Fetcher {
273 client,
274 cache_root,
275 auth,
276 })
277 }
278
279 pub async fn dist_bytes(
282 &self,
283 name: &str,
284 url: &str,
285 expected_sha1: Option<&str>,
286 offline: bool,
287 ) -> Result<(Vec<u8>, Provenance)> {
288 self.dist_bytes_of(name, url, "zip", expected_sha1, offline)
289 .await
290 }
291
292 pub async fn dist_bytes_of(
294 &self,
295 name: &str,
296 url: &str,
297 dist_type: &str,
298 expected_sha1: Option<&str>,
299 offline: bool,
300 ) -> Result<(Vec<u8>, Provenance)> {
301 let cache_path = dist_cache_path_of(&self.cache_root, name, url, dist_type);
302 if let Ok(bytes) = std::fs::read(&cache_path) {
303 match expected_sha1 {
304 Some(exp) if sha1_hex(&bytes) != exp => {
305 let _ = std::fs::remove_file(&cache_path);
307 }
308 _ => return Ok((bytes, Provenance::Cache)),
309 }
310 }
311 if offline {
312 return Err(Error::Http {
313 url: url.to_owned(),
314 message: format!("missing cache for {name} in offline mode"),
315 });
316 }
317
318 let mut last_err = String::new();
319 for attempt in 0..3u32 {
320 if attempt > 0 {
321 tokio::time::sleep(std::time::Duration::from_millis(250 * (1 << attempt))).await;
322 }
323 match self.try_download(url).await {
324 Ok(bytes) => {
325 if let Some(exp) = expected_sha1 {
326 let actual = sha1_hex(&bytes);
327 if actual != exp {
328 return Err(Error::ShasumMismatch {
329 name: name.to_owned(),
330 expected: exp.to_owned(),
331 actual,
332 });
333 }
334 }
335 if let Some(parent) = cache_path.parent() {
336 if std::fs::create_dir_all(parent).is_ok() {
337 let tmp = cache_path.with_extension("zip.vivacity-tmp");
338 if std::fs::write(&tmp, &bytes).is_ok() {
339 let _ = std::fs::rename(&tmp, &cache_path);
340 }
341 }
342 }
343 return Ok((bytes, Provenance::Network));
344 }
345 Err(e) => last_err = e,
346 }
347 }
348 Err(Error::Http {
349 url: url.to_owned(),
350 message: format!("failed after 3 attempts: {last_err}"),
351 })
352 }
353
354 pub async fn metadata_bytes(&self, url: &str) -> Result<Option<Vec<u8>>> {
358 match self.metadata_fetch(url, None).await? {
359 MetadataResponse::Body { bytes, .. } => Ok(Some(bytes)),
360 MetadataResponse::NotFound | MetadataResponse::NotModified => Ok(None),
361 }
362 }
363
364 pub async fn post_form(&self, url: &str, body: &str) -> Result<MetadataResponse> {
368 let mut req = self
369 .client
370 .post(url)
371 .header(
372 reqwest::header::CONTENT_TYPE,
373 "application/x-www-form-urlencoded",
374 )
375 .timeout(std::time::Duration::from_secs(10))
376 .body(body.to_owned());
377 if let Ok(parsed) = reqwest::Url::parse(url) {
378 if let Some(host) = parsed.host_str() {
379 if let Some(authz) = self.auth.authorization_for(host) {
380 req = req.header(reqwest::header::AUTHORIZATION, authz);
381 }
382 }
383 }
384 let resp = req.send().await.map_err(|e| Error::Http {
385 url: url.to_owned(),
386 message: e.to_string(),
387 })?;
388 if resp.status() == reqwest::StatusCode::NOT_FOUND {
389 return Ok(MetadataResponse::NotFound);
390 }
391 let resp = resp.error_for_status().map_err(|e| Error::Http {
392 url: url.to_owned(),
393 message: e.to_string(),
394 })?;
395 let bytes = resp.bytes().await.map_err(|e| Error::Http {
396 url: url.to_owned(),
397 message: e.to_string(),
398 })?;
399 Ok(MetadataResponse::Body {
400 bytes: bytes.to_vec(),
401 last_modified: None,
402 })
403 }
404
405 pub async fn metadata_fetch(
409 &self,
410 url: &str,
411 if_modified_since: Option<&str>,
412 ) -> Result<MetadataResponse> {
413 let mut last_err = String::new();
414 for attempt in 0..3u32 {
415 if attempt > 0 {
416 tokio::time::sleep(std::time::Duration::from_millis(250 * (1 << attempt))).await;
417 }
418 let mut req = self.client.get(url);
419 if let Ok(parsed) = reqwest::Url::parse(url) {
420 if let Some(host) = parsed.host_str() {
421 if let Some(authz) = self.auth.authorization_for(host) {
422 req = req.header(reqwest::header::AUTHORIZATION, authz);
423 }
424 }
425 }
426 if let Some(ims) = if_modified_since {
427 req = req.header(reqwest::header::IF_MODIFIED_SINCE, ims);
428 }
429 match req.send().await {
430 Ok(resp) => {
431 if resp.status() == reqwest::StatusCode::NOT_FOUND {
432 return Ok(MetadataResponse::NotFound);
433 }
434 if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
435 return Ok(MetadataResponse::NotModified);
436 }
437 let last_modified = resp
438 .headers()
439 .get(reqwest::header::LAST_MODIFIED)
440 .and_then(|v| v.to_str().ok())
441 .map(str::to_owned);
442 match resp.error_for_status() {
443 Ok(resp) => match resp.bytes().await {
444 Ok(b) => {
445 return Ok(MetadataResponse::Body {
446 bytes: b.to_vec(),
447 last_modified,
448 })
449 }
450 Err(e) => last_err = e.to_string(),
451 },
452 Err(e) => {
453 if e.status().is_some_and(|s| s.is_client_error()) {
455 return Err(Error::Http {
456 url: url.to_owned(),
457 message: e.to_string(),
458 });
459 }
460 last_err = e.to_string();
461 }
462 }
463 }
464 Err(e) => last_err = e.to_string(),
465 }
466 }
467 Err(Error::Http {
468 url: url.to_owned(),
469 message: format!("failed after 3 attempts: {last_err}"),
470 })
471 }
472
473 async fn try_download(&self, url: &str) -> std::result::Result<Vec<u8>, String> {
474 let mut req = self.client.get(url);
475 if let Ok(parsed) = reqwest::Url::parse(url) {
476 if let Some(host) = parsed.host_str() {
477 if let Some(authz) = self.auth.authorization_for(host) {
478 req = req.header(reqwest::header::AUTHORIZATION, authz);
479 }
480 }
481 }
482 let resp = req.send().await.map_err(|e| e.to_string())?;
483 let resp = resp.error_for_status().map_err(|e| e.to_string())?;
484 Ok(resp.bytes().await.map_err(|e| e.to_string())?.to_vec())
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
493 fn cache_layout_matches_composer() {
494 let p = dist_cache_path(
497 Path::new("/c"),
498 "monolog/monolog",
499 "https://api.github.com/repos/Seldaek/monolog/zipball/abc",
500 );
501 let s = p.to_string_lossy().replace('\\', "/");
503 assert!(s.starts_with("/c/files/monolog/monolog/"));
504 assert!(s.ends_with(".zip"));
505 assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
506 }
507
508 #[test]
509 fn auth_header_selection() {
510 let mut auth = Auth::default();
511 auth.github_oauth
512 .insert("github.com".into(), "ghtok".into());
513 auth.bearer
514 .insert("repo.example.com".into(), "beartok".into());
515 auth.http_basic
516 .insert("basic.example.com".into(), ("user".into(), "pass".into()));
517
518 assert_eq!(
519 auth.authorization_for("github.com").as_deref(),
520 Some("token ghtok")
521 );
522 assert_eq!(
523 auth.authorization_for("codeload.github.com").as_deref(),
524 Some("token ghtok"),
525 "github dists go through codeload"
526 );
527 assert_eq!(
528 auth.authorization_for("api.github.com").as_deref(),
529 Some("token ghtok")
530 );
531 assert_eq!(
532 auth.authorization_for("repo.example.com").as_deref(),
533 Some("Bearer beartok")
534 );
535 assert_eq!(
536 auth.authorization_for("basic.example.com").as_deref(),
537 Some("Basic dXNlcjpwYXNz")
538 );
539 assert_eq!(auth.authorization_for("unknown.example.com"), None);
540 }
541
542 #[test]
543 fn composer_auth_env_shape_is_parsed() {
544 let mut auth = Auth::default();
545 auth.merge_value(&serde_json::json!({
546 "github-oauth": {"github.com": "t1"},
547 "http-basic": {"h": {"username": "u", "password": "p"}},
548 "bearer": {"b": "tk"}
549 }));
550 assert_eq!(auth.github_oauth.len(), 1);
551 assert_eq!(auth.http_basic.len(), 1);
552 assert_eq!(auth.bearer.len(), 1);
553 }
554}