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 let mut h = Sha1::new();
208 h.update(url.as_bytes());
209 let sha: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
210 let sane_name: String = name
211 .chars()
212 .map(|c| {
213 let c = c.to_ascii_lowercase();
214 if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '/' | '-') {
215 c
216 } else {
217 '-'
218 }
219 })
220 .collect();
221 cache_root
222 .join("files")
223 .join(sane_name)
224 .join(format!("{sha}.zip"))
225}
226
227fn sha1_hex(bytes: &[u8]) -> String {
228 let mut h = Sha1::new();
229 h.update(bytes);
230 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
231}
232
233#[derive(Debug, Clone)]
235pub enum MetadataResponse {
236 NotModified,
237 NotFound,
238 Body {
239 bytes: Vec<u8>,
240 last_modified: Option<String>,
241 },
242}
243
244pub struct Fetcher {
245 client: reqwest::Client,
246 cache_root: PathBuf,
247 auth: Auth,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum Provenance {
252 Cache,
253 Network,
254}
255
256impl Fetcher {
257 pub fn new(cache_root: PathBuf, auth: Auth) -> Result<Fetcher> {
258 let client = reqwest::Client::builder()
259 .user_agent(format!("vivacity/{}", env!("CARGO_PKG_VERSION")))
260 .build()
261 .map_err(|e| Error::Http {
262 url: "client".to_owned(),
263 message: e.to_string(),
264 })?;
265 Ok(Fetcher {
266 client,
267 cache_root,
268 auth,
269 })
270 }
271
272 pub async fn dist_bytes(
275 &self,
276 name: &str,
277 url: &str,
278 expected_sha1: Option<&str>,
279 offline: bool,
280 ) -> Result<(Vec<u8>, Provenance)> {
281 let cache_path = dist_cache_path(&self.cache_root, name, url);
282 if let Ok(bytes) = std::fs::read(&cache_path) {
283 match expected_sha1 {
284 Some(exp) if sha1_hex(&bytes) != exp => {
285 let _ = std::fs::remove_file(&cache_path);
287 }
288 _ => return Ok((bytes, Provenance::Cache)),
289 }
290 }
291 if offline {
292 return Err(Error::Http {
293 url: url.to_owned(),
294 message: format!("missing cache for {name} in offline mode"),
295 });
296 }
297
298 let mut last_err = String::new();
299 for attempt in 0..3u32 {
300 if attempt > 0 {
301 tokio::time::sleep(std::time::Duration::from_millis(250 * (1 << attempt))).await;
302 }
303 match self.try_download(url).await {
304 Ok(bytes) => {
305 if let Some(exp) = expected_sha1 {
306 let actual = sha1_hex(&bytes);
307 if actual != exp {
308 return Err(Error::ShasumMismatch {
309 name: name.to_owned(),
310 expected: exp.to_owned(),
311 actual,
312 });
313 }
314 }
315 if let Some(parent) = cache_path.parent() {
316 if std::fs::create_dir_all(parent).is_ok() {
317 let tmp = cache_path.with_extension("zip.vivacity-tmp");
318 if std::fs::write(&tmp, &bytes).is_ok() {
319 let _ = std::fs::rename(&tmp, &cache_path);
320 }
321 }
322 }
323 return Ok((bytes, Provenance::Network));
324 }
325 Err(e) => last_err = e,
326 }
327 }
328 Err(Error::Http {
329 url: url.to_owned(),
330 message: format!("failed after 3 attempts: {last_err}"),
331 })
332 }
333
334 pub async fn metadata_bytes(&self, url: &str) -> Result<Option<Vec<u8>>> {
338 match self.metadata_fetch(url, None).await? {
339 MetadataResponse::Body { bytes, .. } => Ok(Some(bytes)),
340 MetadataResponse::NotFound | MetadataResponse::NotModified => Ok(None),
341 }
342 }
343
344 pub async fn post_form(&self, url: &str, body: &str) -> Result<MetadataResponse> {
348 let mut req = self
349 .client
350 .post(url)
351 .header(
352 reqwest::header::CONTENT_TYPE,
353 "application/x-www-form-urlencoded",
354 )
355 .timeout(std::time::Duration::from_secs(10))
356 .body(body.to_owned());
357 if let Ok(parsed) = reqwest::Url::parse(url) {
358 if let Some(host) = parsed.host_str() {
359 if let Some(authz) = self.auth.authorization_for(host) {
360 req = req.header(reqwest::header::AUTHORIZATION, authz);
361 }
362 }
363 }
364 let resp = req.send().await.map_err(|e| Error::Http {
365 url: url.to_owned(),
366 message: e.to_string(),
367 })?;
368 if resp.status() == reqwest::StatusCode::NOT_FOUND {
369 return Ok(MetadataResponse::NotFound);
370 }
371 let resp = resp.error_for_status().map_err(|e| Error::Http {
372 url: url.to_owned(),
373 message: e.to_string(),
374 })?;
375 let bytes = resp.bytes().await.map_err(|e| Error::Http {
376 url: url.to_owned(),
377 message: e.to_string(),
378 })?;
379 Ok(MetadataResponse::Body {
380 bytes: bytes.to_vec(),
381 last_modified: None,
382 })
383 }
384
385 pub async fn metadata_fetch(
389 &self,
390 url: &str,
391 if_modified_since: Option<&str>,
392 ) -> Result<MetadataResponse> {
393 let mut last_err = String::new();
394 for attempt in 0..3u32 {
395 if attempt > 0 {
396 tokio::time::sleep(std::time::Duration::from_millis(250 * (1 << attempt))).await;
397 }
398 let mut req = self.client.get(url);
399 if let Ok(parsed) = reqwest::Url::parse(url) {
400 if let Some(host) = parsed.host_str() {
401 if let Some(authz) = self.auth.authorization_for(host) {
402 req = req.header(reqwest::header::AUTHORIZATION, authz);
403 }
404 }
405 }
406 if let Some(ims) = if_modified_since {
407 req = req.header(reqwest::header::IF_MODIFIED_SINCE, ims);
408 }
409 match req.send().await {
410 Ok(resp) => {
411 if resp.status() == reqwest::StatusCode::NOT_FOUND {
412 return Ok(MetadataResponse::NotFound);
413 }
414 if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
415 return Ok(MetadataResponse::NotModified);
416 }
417 let last_modified = resp
418 .headers()
419 .get(reqwest::header::LAST_MODIFIED)
420 .and_then(|v| v.to_str().ok())
421 .map(str::to_owned);
422 match resp.error_for_status() {
423 Ok(resp) => match resp.bytes().await {
424 Ok(b) => {
425 return Ok(MetadataResponse::Body {
426 bytes: b.to_vec(),
427 last_modified,
428 })
429 }
430 Err(e) => last_err = e.to_string(),
431 },
432 Err(e) => {
433 if e.status().is_some_and(|s| s.is_client_error()) {
435 return Err(Error::Http {
436 url: url.to_owned(),
437 message: e.to_string(),
438 });
439 }
440 last_err = e.to_string();
441 }
442 }
443 }
444 Err(e) => last_err = e.to_string(),
445 }
446 }
447 Err(Error::Http {
448 url: url.to_owned(),
449 message: format!("failed after 3 attempts: {last_err}"),
450 })
451 }
452
453 async fn try_download(&self, url: &str) -> std::result::Result<Vec<u8>, String> {
454 let mut req = self.client.get(url);
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| e.to_string())?;
463 let resp = resp.error_for_status().map_err(|e| e.to_string())?;
464 Ok(resp.bytes().await.map_err(|e| e.to_string())?.to_vec())
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn cache_layout_matches_composer() {
474 let p = dist_cache_path(
477 Path::new("/c"),
478 "monolog/monolog",
479 "https://api.github.com/repos/Seldaek/monolog/zipball/abc",
480 );
481 let s = p.to_string_lossy().replace('\\', "/");
483 assert!(s.starts_with("/c/files/monolog/monolog/"));
484 assert!(s.ends_with(".zip"));
485 assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
486 }
487
488 #[test]
489 fn auth_header_selection() {
490 let mut auth = Auth::default();
491 auth.github_oauth
492 .insert("github.com".into(), "ghtok".into());
493 auth.bearer
494 .insert("repo.example.com".into(), "beartok".into());
495 auth.http_basic
496 .insert("basic.example.com".into(), ("user".into(), "pass".into()));
497
498 assert_eq!(
499 auth.authorization_for("github.com").as_deref(),
500 Some("token ghtok")
501 );
502 assert_eq!(
503 auth.authorization_for("codeload.github.com").as_deref(),
504 Some("token ghtok"),
505 "github dists go through codeload"
506 );
507 assert_eq!(
508 auth.authorization_for("api.github.com").as_deref(),
509 Some("token ghtok")
510 );
511 assert_eq!(
512 auth.authorization_for("repo.example.com").as_deref(),
513 Some("Bearer beartok")
514 );
515 assert_eq!(
516 auth.authorization_for("basic.example.com").as_deref(),
517 Some("Basic dXNlcjpwYXNz")
518 );
519 assert_eq!(auth.authorization_for("unknown.example.com"), None);
520 }
521
522 #[test]
523 fn composer_auth_env_shape_is_parsed() {
524 let mut auth = Auth::default();
525 auth.merge_value(&serde_json::json!({
526 "github-oauth": {"github.com": "t1"},
527 "http-basic": {"h": {"username": "u", "password": "p"}},
528 "bearer": {"b": "tk"}
529 }));
530 assert_eq!(auth.github_oauth.len(), 1);
531 assert_eq!(auth.http_basic.len(), 1);
532 assert_eq!(auth.bearer.len(), 1);
533 }
534}