1use crate::constraint::Constraint;
8use crate::loader::{self, branch_alias, expand_minified_owned};
9use crate::package::{Origin, Package};
10use crate::platform::is_platform_package;
11use crate::version::{normalize, parse_stability, regex, stability_rank, DEFAULT_BRANCH_ALIAS};
12use pcre2::bytes::Regex;
13use serde_json::{Map, Value};
14use std::collections::{BTreeMap, BTreeSet};
15use std::sync::OnceLock;
16
17#[derive(Debug, thiserror::Error)]
21#[error("{0}")]
22pub struct RepoError(pub String, pub RepoErrorKind);
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum RepoErrorKind {
26 Transport,
27 Data,
28}
29
30impl RepoError {
31 pub fn data(message: impl Into<String>) -> RepoError {
32 RepoError(message.into(), RepoErrorKind::Data)
33 }
34 pub fn transport(message: impl Into<String>) -> RepoError {
35 RepoError(message.into(), RepoErrorKind::Transport)
36 }
37 pub fn is_transport(&self) -> bool {
38 self.1 == RepoErrorKind::Transport
39 }
40}
41
42#[derive(Debug, Clone)]
46pub enum Fetched {
47 NotModified,
49 NotFound,
51 Body {
52 bytes: Vec<u8>,
53 last_modified: Option<String>,
55 },
56}
57
58pub type Request = (String, Option<String>);
61
62pub trait Transport {
63 fn fetch(&self, url: &str, if_modified_since: Option<&str>) -> Result<Fetched, RepoError>;
64 fn post_form(&self, url: &str, _body: &str) -> Result<Fetched, RepoError> {
67 Err(RepoError::transport(format!(
68 "POST {url}: not supported by this transport"
69 )))
70 }
71 fn fetch_many(&self, requests: &[Request]) -> Vec<Result<Fetched, RepoError>> {
75 requests
76 .iter()
77 .map(|(u, ims)| self.fetch(u, ims.as_deref()))
78 .collect()
79 }
80}
81
82pub type HttpFetch =
84 std::sync::Arc<dyn Fn(&str, Option<&str>) -> Result<Fetched, String> + Send + Sync>;
85pub type HttpFetchMany =
87 std::sync::Arc<dyn Fn(&[Request]) -> Vec<Result<Fetched, String>> + Send + Sync>;
88
89pub type HttpPost = std::sync::Arc<dyn Fn(&str, &str) -> Result<Fetched, String> + Send + Sync>;
91pub type HttpTransports = (HttpFetch, Option<HttpFetchMany>, Option<HttpPost>);
93
94pub struct HttpTransport {
95 pub fetch: HttpFetch,
96 pub fetch_many: Option<HttpFetchMany>,
97 pub post: Option<HttpPost>,
98}
99
100impl Transport for HttpTransport {
101 fn fetch(&self, url: &str, if_modified_since: Option<&str>) -> Result<Fetched, RepoError> {
102 (self.fetch)(url, if_modified_since).map_err(RepoError::transport)
103 }
104 fn post_form(&self, url: &str, body: &str) -> Result<Fetched, RepoError> {
105 match &self.post {
106 Some(p) => p(url, body).map_err(RepoError::transport),
107 None => Err(RepoError::transport(format!(
108 "POST {url}: no transport for it"
109 ))),
110 }
111 }
112 fn fetch_many(&self, requests: &[Request]) -> Vec<Result<Fetched, RepoError>> {
113 match &self.fetch_many {
114 Some(f) => f(requests)
115 .into_iter()
116 .map(|r| r.map_err(RepoError::transport))
117 .collect(),
118 None => requests
119 .iter()
120 .map(|(u, ims)| self.fetch(u, ims.as_deref()))
121 .collect(),
122 }
123 }
124}
125
126pub struct FileTransport;
129
130impl Transport for FileTransport {
131 fn fetch(&self, url: &str, _if_modified_since: Option<&str>) -> Result<Fetched, RepoError> {
132 let path = url
133 .strip_prefix("file://")
134 .ok_or_else(|| RepoError::transport(format!("unsupported url scheme: {url}")))?;
135 std::fs::read(path)
136 .map(|bytes| Fetched::Body {
137 bytes,
138 last_modified: None,
139 })
140 .map_err(|e| {
141 RepoError::transport(format!(
142 "The \"{url}\" file could not be downloaded: Failed to open stream: {e}"
143 ))
144 })
145 }
146}
147
148pub fn is_package_acceptable(
150 acceptable: &BTreeMap<String, i32>,
151 flags: &BTreeMap<String, i32>,
152 names: &[String],
153 stability: &str,
154) -> bool {
155 for name in names {
156 if let Some(flag) = flags.get(name) {
157 if stability_rank(stability) <= *flag {
158 return true;
159 }
160 } else if acceptable.contains_key(stability) {
161 return true;
162 }
163 }
164 false
165}
166
167fn package_name_regexp(pattern: &str) -> Regex {
169 let quoted = crate::version::preg_quote(pattern).replace("\\*", ".*");
170 pcre2::bytes::RegexBuilder::new()
171 .caseless(true)
172 .build(&format!("^{quoted}$"))
173 .unwrap_or_else(|e| panic!("pattern {pattern}: {e}"))
174}
175
176#[derive(Debug, Default)]
178struct RootData {
179 lazy_providers_url: Option<String>,
180 notify_url: Option<String>,
181 has_available_package_list: bool,
182 available_packages: BTreeSet<String>,
183 available_patterns: Vec<Regex>,
184 partial_packages: Vec<(String, Vec<Value>)>,
187 source_mirrors: BTreeMap<String, Vec<Value>>,
190 dist_mirrors: Vec<Value>,
191 plain: Option<Vec<Value>>,
194 v1_protocol: bool,
197 security_advisories: Option<AdvisoryConfig>,
199 filter: Option<FilterInfo>,
201}
202
203#[derive(Debug, Clone)]
204pub struct AdvisoryConfig {
205 pub metadata: bool,
206 pub api_url: Option<String>,
207}
208
209#[derive(Debug, Clone)]
210pub struct FilterInfo {
211 pub metadata: bool,
212 pub lists: Vec<String>,
214 pub summary_url: Option<String>,
215 pub api_url: Option<String>,
216}
217
218#[derive(Debug, Clone)]
221pub struct Advisory {
222 pub package_name: String,
223 pub advisory_id: String,
224 pub affected_versions: Constraint,
225 pub complete: Option<CompleteAdvisory>,
227}
228
229#[derive(Debug, Clone, Default)]
230pub struct CompleteAdvisory {
231 pub cve: Option<String>,
232 pub severity: Option<String>,
233 pub source_remote_ids: Vec<String>,
234}
235
236pub fn advisory_from_data(package_name: &str, data: &Value) -> Option<Advisory> {
239 let affected = data.get("affectedVersions")?.as_str()?.to_owned();
240 let advisory_id = data.get("advisoryId")?.as_str()?.to_owned();
241 let constraint = match crate::constraint::parse_constraints(&affected) {
242 Ok(c) => c.constraint,
243 Err(_) => {
244 static HEAD: OnceLock<Regex> = OnceLock::new();
245 let re = regex(&HEAD, r"(^[>=<^~]*[\d.]+).*", false);
246 let head = re
247 .captures(affected.as_bytes())
248 .ok()
249 .flatten()
250 .map(|c| crate::version::group(&c, 1).to_owned())
251 .unwrap_or_default();
252 match crate::constraint::parse_constraints(&head) {
253 Ok(c) => c.constraint,
254 Err(_) => Constraint::new(crate::constraint::Op::Eq, "0.0.0-invalid-version"),
255 }
256 }
257 };
258 let complete = if data.get("title").is_some_and(|v| !v.is_null())
259 && data.get("sources").is_some_and(|v| !v.is_null())
260 && data.get("reportedAt").is_some_and(|v| !v.is_null())
261 {
262 Some(CompleteAdvisory {
263 cve: data.get("cve").and_then(Value::as_str).map(str::to_owned),
264 severity: data
265 .get("severity")
266 .and_then(Value::as_str)
267 .map(str::to_owned),
268 source_remote_ids: data
269 .get("sources")
270 .and_then(Value::as_array)
271 .map(|a| {
272 a.iter()
273 .filter_map(|s| s.get("remoteId").and_then(Value::as_str))
274 .map(str::to_owned)
275 .collect()
276 })
277 .unwrap_or_default(),
278 })
279 } else {
280 None
281 };
282 Some(Advisory {
283 package_name: package_name.to_owned(),
284 advisory_id,
285 affected_versions: constraint,
286 complete,
287 })
288}
289
290pub type AdvisoriesByName = Vec<(String, Vec<Advisory>)>;
292pub type FilterEntriesByList = Vec<(String, Vec<FilterEntry>)>;
294type FilterSummary = Vec<(String, Vec<(String, String)>)>;
296
297#[derive(Debug, Clone)]
299pub struct FilterEntry {
300 pub package_name: String,
301 pub constraint: Constraint,
302 pub list_name: String,
303 pub url: Option<String>,
304 pub reason: Option<String>,
305 pub id: Option<String>,
306 pub source: Option<String>,
307}
308
309fn build_filter_entries(
312 raw_by_list: &Value,
313 map: &[(String, Constraint)],
314 default_package: Option<&str>,
315) -> Result<FilterEntriesByList, RepoError> {
316 let mut result: FilterEntriesByList = Vec::new();
317 let Some(lists) = raw_by_list.as_object() else {
318 return Ok(result);
319 };
320 for (list_name, entries) in lists {
321 let Some(entries) = entries.as_array() else {
322 continue;
323 };
324 for data in entries {
325 let Some(obj) = data.as_object() else {
326 continue;
327 };
328 let Some(constraint) = obj.get("constraint").and_then(Value::as_str) else {
329 continue;
330 };
331 let package = match obj.get("package").and_then(Value::as_str) {
332 Some(p) => p.to_owned(),
333 None => match default_package {
334 Some(d) => d.to_owned(),
335 None => continue,
336 },
337 };
338 let parsed = crate::constraint::parse_constraints(constraint)
339 .map_err(|e| RepoError::data(e.to_string()))?
340 .constraint;
341 let Some((_, wanted)) = map.iter().find(|(n, _)| *n == package) else {
342 continue;
343 };
344 if !parsed.matches(wanted) {
345 continue;
346 }
347 let entry = FilterEntry {
348 package_name: package,
349 constraint: parsed,
350 list_name: list_name.clone(),
351 url: obj.get("url").and_then(Value::as_str).map(str::to_owned),
352 reason: obj.get("reason").and_then(Value::as_str).map(str::to_owned),
353 id: obj.get("id").and_then(Value::as_str).map(str::to_owned),
354 source: obj.get("source").and_then(Value::as_str).map(str::to_owned),
355 };
356 match result.iter_mut().find(|(l, _)| l == list_name) {
357 Some((_, v)) => v.push(entry),
358 None => result.push((list_name.clone(), vec![entry])),
359 }
360 }
361 }
362 Ok(result)
363}
364
365const RESERVED_LIST_NAMES: &[&str] = &[
368 "advisories",
369 "abandoned",
370 "package",
371 "packages",
372 "license",
373 "licence",
374 "licenses",
375 "licences",
376 "support",
377 "maintenance",
378 "security",
379 "minimum-release-age",
380];
381
382pub struct ComposerRepository {
383 pub url: String,
384 pub base_url: String,
385 pub options: Value,
388 packages_json_url: String,
389 transport: Box<dyn Transport>,
390 root: std::cell::OnceCell<RootData>,
392 fetched: std::cell::RefCell<BTreeMap<String, Option<std::rc::Rc<Value>>>>,
394 members: std::cell::OnceCell<Vec<usize>>,
397 pub cache: Option<crate::metacache::MetadataCache>,
399 degraded: std::cell::Cell<bool>,
402 pub user_filter: Option<Vec<String>>,
405 fresh_metadata: std::cell::Cell<bool>,
408 name_filter: Option<NameFilter>,
412}
413
414pub struct NameFilter {
416 regex: Regex,
417 only: bool,
418}
419
420fn php_empty(v: Option<&Value>) -> bool {
422 match v {
423 None | Some(Value::Null) | Some(Value::Bool(false)) => true,
424 Some(Value::String(s)) => s.is_empty() || s == "0",
425 Some(Value::Number(n)) => n.as_f64() == Some(0.0),
426 Some(Value::Array(a)) => a.is_empty(),
427 Some(Value::Object(o)) => o.is_empty(),
428 Some(Value::Bool(true)) => false,
429 }
430}
431
432fn url_path(url: &str) -> &str {
434 let rest = match url.find("://") {
435 Some(i) => {
436 let after = &url[i + 3..];
437 match after.find('/') {
438 Some(j) => &after[j..],
439 None => "",
440 }
441 }
442 None => url,
443 };
444 let end = rest.find(['?', '#']).unwrap_or(rest.len());
445 &rest[..end]
446}
447
448impl ComposerRepository {
449 pub fn open(url: &str, transport: Box<dyn Transport>) -> Result<ComposerRepository, RepoError> {
451 static SCHEME: OnceLock<Regex> = OnceLock::new();
452 static PACKAGIST: OnceLock<Regex> = OnceLock::new();
453 static BASE: OnceLock<Regex> = OnceLock::new();
454 let mut url = url.to_owned();
455 if !regex(&SCHEME, r"^[\w.]+\??://", false)
456 .is_match(url.as_bytes())
457 .unwrap_or(false)
458 {
459 match std::fs::canonicalize(&url) {
460 Ok(p) => url = format!("file://{}", p.to_string_lossy()),
461 Err(_) => url = format!("http://{url}"),
462 }
463 }
464 url = url.trim_end_matches('/').to_owned();
465 if let Some(rest) = url.strip_prefix("https?") {
466 url = format!("https{rest}");
467 }
468 if let Ok(Some(caps)) = regex(&PACKAGIST, r"^(?P<proto>https?)://packagist\.org/?$", true)
469 .captures(url.as_bytes())
470 {
471 url = format!("{}://repo.packagist.org", crate::version::group(&caps, 1));
472 }
473 let base_re = regex(&BASE, r"(?:/[^/\\]+\.json)?(?:[?#].*)?$", false);
474 let base_url = match base_re.find(url.as_bytes()).ok().flatten() {
475 Some(m) => url[..m.start()].trim_end_matches('/').to_owned(),
476 None => url.clone(),
477 };
478 let packages_json_url = if url_path(&url).contains(".json") {
480 url.clone()
481 } else {
482 format!("{url}/packages.json")
483 };
484 Ok(ComposerRepository {
485 url,
486 base_url,
487 options: Value::Object(Map::new()),
488 packages_json_url,
489 transport,
490 root: std::cell::OnceCell::new(),
491 fetched: std::cell::RefCell::new(BTreeMap::new()),
492 members: std::cell::OnceCell::new(),
493 cache: None,
494 degraded: std::cell::Cell::new(false),
495 user_filter: Some(Vec::new()),
496 fresh_metadata: std::cell::Cell::new(false),
497 name_filter: None,
498 })
499 }
500
501 pub fn set_name_filter(
503 &mut self,
504 only: Option<&Value>,
505 exclude: Option<&Value>,
506 ) -> Result<(), RepoError> {
507 let patterns = |v: &Value, key: &str| -> Result<Vec<String>, RepoError> {
508 v.as_array()
509 .map(|a| {
510 a.iter()
511 .filter_map(Value::as_str)
512 .map(str::to_owned)
513 .collect()
514 })
515 .ok_or_else(|| {
516 RepoError::data(format!(
517 "\"{key}\" key for repository {} should be an array",
518 self.repo_name()
519 ))
520 })
521 };
522 if only.is_some() && exclude.is_some() {
523 return Err(RepoError::data(format!(
524 "Only one of \"only\" and \"exclude\" can be specified for repository {}",
525 self.repo_name()
526 )));
527 }
528 let (list, is_only) = match (only, exclude) {
529 (Some(o), _) => (patterns(o, "only")?, true),
530 (_, Some(e)) => (patterns(e, "exclude")?, false),
531 _ => return Ok(()),
532 };
533 let parts: Vec<String> = list
534 .iter()
535 .map(|n| crate::version::preg_quote(n).replace("\\*", ".*"))
536 .collect();
537 let regex = pcre2::bytes::RegexBuilder::new()
538 .caseless(true)
539 .build(&format!("^(?:{})\\z", parts.join("|")))
540 .map_err(|e| RepoError::data(e.to_string()))?;
541 self.name_filter = Some(NameFilter {
542 regex,
543 only: is_only,
544 });
545 Ok(())
546 }
547
548 fn is_allowed(&self, name: &str) -> bool {
550 match &self.name_filter {
551 None => true,
552 Some(f) => {
553 let hit = f.regex.is_match(name.as_bytes()).unwrap_or(false);
554 if f.only {
555 hit
556 } else {
557 !hit
558 }
559 }
560 }
561 }
562
563 pub fn set_user_filter(&mut self, raw: Option<&Value>) -> Result<(), RepoError> {
565 self.user_filter = match raw {
566 Some(Value::Bool(false)) => None,
567 None | Some(Value::Null) | Some(Value::Bool(true)) => Some(Vec::new()),
568 Some(Value::Object(m)) => {
569 let mut disabled = Vec::new();
570 for (list, v) in m {
571 if list.is_empty() {
572 return Err(RepoError::data(
573 "Repository \"filter\" keys must be non-empty list-name strings.",
574 ));
575 }
576 match v {
577 Value::Bool(true) => {}
578 Value::Bool(false) => disabled.push(list.clone()),
579 other => {
580 return Err(RepoError::data(format!(
581 "Repository \"filter\" entry for \"{list}\" must be a boolean; got {other}."
582 )))
583 }
584 }
585 }
586 Some(disabled)
587 }
588 Some(_) => {
589 return Err(RepoError::data(
590 "Repository \"filter\" must be a boolean or an object mapping advertised list names to false.",
591 ))
592 }
593 };
594 Ok(())
595 }
596
597 fn root_data(&self) -> Result<&RootData, RepoError> {
599 self.root_data_max_age(None)
600 }
601
602 fn root_data_max_age(&self, max_age: Option<u64>) -> Result<&RootData, RepoError> {
606 if let Some(r) = self.root.get() {
607 return Ok(r);
608 }
609 let fresh_enough = max_age.is_some_and(|max| {
610 self.cache
611 .as_ref()
612 .and_then(|c| c.age("packages.json"))
613 .is_some_and(|age| age <= max)
614 });
615 let data: Value = if fresh_enough {
616 self.cached("packages.json")
617 .map(|(v, _)| v)
618 .ok_or_else(|| {
619 RepoError::transport(format!("{} not found", self.packages_json_url))
620 })?
621 } else {
622 self.fetch_cached(&self.packages_json_url, "packages.json")?
623 .ok_or_else(|| {
624 RepoError::transport(format!("{} not found", self.packages_json_url))
625 })?
626 };
627 let non_empty = |k: &str| !php_empty(data.get(k));
628 let mut r = RootData::default();
629 if non_empty("notify-batch") {
630 r.notify_url = data["notify-batch"]
631 .as_str()
632 .map(|s| self.canonicalize_url(s));
633 } else if non_empty("notify") {
634 r.notify_url = data["notify"].as_str().map(|s| self.canonicalize_url(s));
635 }
636 if let Some(mirrors) = data.get("mirrors").and_then(Value::as_array) {
637 for mirror in mirrors {
638 let preferred = !php_empty(mirror.get("preferred"));
639 for (key, kind) in [("git-url", "git"), ("hg-url", "hg")] {
640 if let Some(u) = mirror.get(key).filter(|u| !php_empty(Some(u))) {
641 r.source_mirrors
642 .entry(kind.to_owned())
643 .or_default()
644 .push(serde_json::json!({"url": u, "preferred": preferred}));
645 }
646 }
647 if let Some(u) = mirror.get("dist-url").and_then(Value::as_str) {
648 if !php_empty(Some(&Value::String(u.to_owned()))) {
649 r.dist_mirrors
650 .push(serde_json::json!({"url": self.canonicalize_url(u), "preferred": preferred}));
651 }
652 }
653 }
654 }
655 let mut has_providers = false;
656 let mut has_partial = false;
657 if non_empty("providers-lazy-url") {
658 r.lazy_providers_url = data["providers-lazy-url"]
659 .as_str()
660 .map(|s| self.canonicalize_url(s));
661 has_providers = true;
662 has_partial = non_empty("packages") && data["packages"].is_object();
663 }
664 if non_empty("metadata-url") {
665 r.lazy_providers_url = data["metadata-url"]
666 .as_str()
667 .map(|s| self.canonicalize_url(s));
668 has_partial = non_empty("packages") && data["packages"].is_object();
669 if non_empty("available-packages") {
670 for p in data["available-packages"].as_array().into_iter().flatten() {
671 if let Some(s) = p.as_str() {
672 r.available_packages.insert(s.to_lowercase());
673 }
674 }
675 r.has_available_package_list = true;
676 }
677 if non_empty("available-package-patterns") {
678 for p in data["available-package-patterns"]
679 .as_array()
680 .into_iter()
681 .flatten()
682 {
683 if let Some(s) = p.as_str() {
684 r.available_patterns.push(package_name_regexp(s));
685 }
686 }
687 r.has_available_package_list = true;
688 }
689 if let Some(sa) = data.get("security-advisories").and_then(Value::as_object) {
690 let api_url = sa
691 .get("api-url")
692 .and_then(Value::as_str)
693 .map(|u| self.canonicalize_url(u));
694 if api_url.is_none() && !r.has_available_package_list {
695 return Err(RepoError::data(format!(
696 "Invalid security advisory configuration on {}: If the repository does not provide a security-advisories.api-url then available-packages or available-package-patterns are required to be provided for performance reason.",
697 self.repo_name()
698 )));
699 }
700 r.security_advisories = Some(AdvisoryConfig {
701 metadata: !php_empty(sa.get("metadata")),
702 api_url,
703 });
704 }
705 if let Some(f) = data.get("filter").and_then(Value::as_object) {
706 let mut lists = Vec::new();
707 if let Some(ls) = f.get("lists").and_then(Value::as_object) {
708 for (name, cfg) in ls {
709 if cfg
710 .as_object()
711 .is_some_and(|c| !php_empty(c.get("enabled")))
712 && !RESERVED_LIST_NAMES.contains(&name.as_str())
713 && !name.starts_with("ignore")
714 {
715 lists.push(name.clone());
716 }
717 }
718 }
719 let url_of = |k: &str| {
720 f.get(k)
721 .and_then(Value::as_str)
722 .filter(|u| !u.is_empty())
723 .map(|u| self.canonicalize_url(u))
724 };
725 r.filter = Some(FilterInfo {
726 metadata: !php_empty(f.get("metadata")),
727 lists,
728 summary_url: url_of("summary-url"),
729 api_url: url_of("api-url"),
730 });
731 }
732 } else if non_empty("providers-url")
733 || non_empty("providers")
734 || non_empty("providers-includes")
735 || has_providers
736 {
737 r.v1_protocol = true;
741 }
742 if has_partial {
743 for (_, versions) in data["packages"].as_object().into_iter().flatten() {
746 let list: Vec<&Value> = match versions {
747 Value::Array(a) => a.iter().collect(),
748 Value::Object(o) => o.values().collect(),
749 _ => Vec::new(),
750 };
751 for v in list {
752 let name = v
753 .get("name")
754 .map(|n| match n {
755 Value::String(s) => s.clone(),
756 other => other.to_string(),
757 })
758 .unwrap_or_default()
759 .to_lowercase();
760 match r.partial_packages.iter_mut().find(|(n, _)| *n == name) {
761 Some(slot) => slot.1.push(v.clone()),
762 None => r.partial_packages.push((name, vec![v.clone()])),
763 }
764 }
765 }
766 } else if r.lazy_providers_url.is_none() {
767 r.plain = Some(self.load_includes(&data)?);
771 }
772 let _ = self.root.set(r);
773 Ok(self.root.get().expect("just set"))
774 }
775
776 fn load_includes(&self, data: &Value) -> Result<Vec<Value>, RepoError> {
779 let mut out = Vec::new();
780 let has_packages = data.get("packages").is_some();
781 let has_includes = data.get("includes").is_some();
782 if !has_packages && !has_includes {
783 for (_, pkg) in data.as_object().into_iter().flatten() {
784 if let Some(Value::Array(versions)) = pkg.get("versions") {
785 out.extend(versions.iter().cloned());
786 } else if let Some(Value::Object(versions)) = pkg.get("versions") {
787 out.extend(versions.values().cloned());
788 }
789 }
790 return Ok(out);
791 }
792 if let Some(packages) = data.get("packages").and_then(Value::as_object) {
793 for (_, versions) in packages {
794 match versions {
795 Value::Array(a) => out.extend(a.iter().cloned()),
796 Value::Object(o) => out.extend(o.values().cloned()),
797 _ => {}
798 }
799 }
800 }
801 if let Some(includes) = data.get("includes").and_then(Value::as_object) {
802 for (include, _) in includes {
803 let url = self.canonicalize_url(include);
804 let url = if url.contains("://") {
805 url
806 } else {
807 format!("{}/{}", self.base_url, url.trim_start_matches('/'))
808 };
809 let included = self
810 .fetch_cached(&url, include)?
811 .ok_or_else(|| RepoError::transport(format!("{url} not found")))?;
812 out.extend(self.load_includes(&included)?);
813 }
814 }
815 Ok(out)
816 }
817
818 pub fn notify_url(&self) -> Result<Option<String>, RepoError> {
819 Ok(self.root_data()?.notify_url.clone())
820 }
821
822 pub fn lazy_providers_url(&self) -> Result<Option<String>, RepoError> {
823 Ok(self.root_data()?.lazy_providers_url.clone())
824 }
825
826 fn canonicalize_url(&self, url: &str) -> String {
828 static RE: OnceLock<Regex> = OnceLock::new();
829 if let Some(rest) = url.strip_prefix('/') {
830 let re = regex(&RE, r"^[^:]++://[^/]*+", false);
831 if let Ok(Some(m)) = re.find(self.url.as_bytes()) {
832 return format!("{}/{}", &self.url[..m.end()], rest);
833 }
834 return self.url.clone();
835 }
836 url.to_owned()
837 }
838
839 fn contains(root: &RootData, name: &str) -> bool {
841 if root.available_packages.contains(name) {
842 return true;
843 }
844 root.available_patterns
845 .iter()
846 .any(|re| re.is_match(name.as_bytes()).unwrap_or(false))
847 }
848
849 fn cached(&self, cache_key: &str) -> Option<(Value, Option<String>)> {
851 let bytes = self.cache.as_ref()?.read(cache_key)?;
852 let v: Value = serde_json::from_slice(&bytes).ok()?;
853 let lm = v
854 .get("last-modified")
855 .and_then(Value::as_str)
856 .map(str::to_owned);
857 Some((v, lm))
858 }
859
860 fn settle(
865 &self,
866 url: &str,
867 cache_key: &str,
868 cached: Option<(Value, Option<String>)>,
869 result: Result<Fetched, RepoError>,
870 ) -> Result<Option<Value>, RepoError> {
871 let escaped = !cache_key.starts_with("provider-");
874 match result {
875 Ok(Fetched::NotModified) => Ok(cached.map(|(v, _)| v)),
876 Ok(Fetched::NotFound) => Ok(None),
877 Ok(Fetched::Body {
878 bytes,
879 last_modified,
880 }) => {
881 let data: Value = serde_json::from_slice(&bytes)
882 .map_err(|e| RepoError::data(format!("{url}: invalid JSON: {e}")))?;
883 if let Some(cache) = &self.cache {
884 match &last_modified {
885 Some(lm) => {
886 if let Some(encoded) =
887 crate::metacache::MetadataCache::with_last_modified(
888 &data, lm, escaped,
889 )
890 {
891 cache.write(cache_key, &encoded);
892 }
893 }
894 None => cache.write(cache_key, &bytes),
895 }
896 }
897 Ok(Some(data))
898 }
899 Err(e) => {
900 if let Some((v, Some(_))) = cached {
901 if !self.degraded.replace(true) {
902 eprintln!(
903 "Warning: {} could not be fully loaded ({}), package information was loaded from the local cache and may be out of date",
904 self.url, e.0
905 );
906 }
907 return Ok(Some(v));
908 }
909 Err(e)
910 }
911 }
912 }
913
914 fn fetch_cached(&self, url: &str, cache_key: &str) -> Result<Option<Value>, RepoError> {
916 let cached = self.cached(cache_key);
917 let ims = cached.as_ref().and_then(|(_, lm)| lm.clone());
918 let result = self.transport.fetch(url, ims.as_deref());
919 self.settle(url, cache_key, cached, result)
920 }
921
922 fn provider(
925 &self,
926 file_name: &str,
927 package_name: &str,
928 ) -> Result<Option<std::rc::Rc<Value>>, RepoError> {
929 let key = file_name.to_lowercase();
930 if let Some(v) = self.fetched.borrow().get(&key) {
931 return Ok(v.clone());
932 }
933 let Some(template) = &self.root_data()?.lazy_providers_url else {
934 return Err(RepoError::data("startCachedAsyncDownload only supports v2 protocol composer repos with a metadata-url"));
935 };
936 let url = template.replace("%package%", &key);
937 let cache_key = crate::metacache::MetadataCache::provider_key(&key);
938 let data = self.fetch_cached(&url, &cache_key)?;
939 self.fresh_metadata.set(true);
940 let value = Self::parse_provider(package_name, data);
941 self.fetched.borrow_mut().insert(key, value.clone());
942 Ok(value)
943 }
944
945 pub fn repo_name(&self) -> String {
947 format!("composer repo ({})", self.url)
948 }
949
950 pub fn has_security_advisories(&self) -> Result<bool, RepoError> {
952 Ok(self
953 .root_data_max_age(Some(600))?
954 .security_advisories
955 .as_ref()
956 .is_some_and(|c| c.metadata || c.api_url.is_some()))
957 }
958
959 pub fn get_security_advisories(
965 &self,
966 map: &[(String, Constraint)],
967 allow_partial: bool,
968 ) -> Result<(Vec<String>, AdvisoriesByName), RepoError> {
969 let root = self.root_data_max_age(Some(600))?;
970 let Some(config) = &root.security_advisories else {
971 return Ok((Vec::new(), Vec::new()));
972 };
973 let mut map: Vec<(String, Constraint)> = map
974 .iter()
975 .filter(|(n, _)| self.is_allowed(n))
976 .cloned()
977 .collect();
978 if root.has_available_package_list {
979 map.retain(|(n, _)| Self::contains(root, &n.to_lowercase()));
980 }
981 let mut advisories: AdvisoriesByName = Vec::new();
982 let mut names_found: Vec<String> = Vec::new();
983 let create = |data: &Value,
984 name: &str,
985 wanted: &Constraint|
986 -> Result<Option<Advisory>, RepoError> {
987 let Some(adv) = advisory_from_data(name, data) else {
988 return Ok(None);
989 };
990 if !allow_partial && adv.complete.is_none() {
991 return Err(RepoError::data(format!(
992 "Advisory for {name} could not be loaded as a full advisory from {}\n{data}",
993 self.repo_name()
994 )));
995 }
996 if !adv.affected_versions.matches(wanted) {
997 return Ok(None);
998 }
999 Ok(Some(adv))
1000 };
1001 if config.metadata && (allow_partial || config.api_url.is_none()) {
1002 let wanted: Vec<(String, String)> = map
1003 .iter()
1004 .map(|(n, _)| n.to_lowercase())
1005 .filter(|n| !is_platform_package(n) && n != "__root__")
1006 .map(|n| (n.clone(), n))
1007 .collect();
1008 self.prefetch(&wanted)?;
1009 let mut done: Vec<String> = Vec::new();
1010 for (name, constraint) in &map {
1011 let name = name.to_lowercase();
1012 if is_platform_package(&name) || name == "__root__" {
1013 continue;
1014 }
1015 let Some(response) = self.provider(&name, &name)? else {
1016 continue;
1017 };
1018 let Some(list) = response
1019 .get("security-advisories")
1020 .and_then(Value::as_array)
1021 else {
1022 continue;
1023 };
1024 names_found.push(name.clone());
1025 if !list.is_empty() {
1026 let mut found = Vec::new();
1027 for data in list {
1028 if let Some(a) = create(data, &name, constraint)? {
1029 found.push(a);
1030 }
1031 }
1032 advisories.push((name.clone(), found));
1033 }
1034 done.push(name);
1035 }
1036 map.retain(|(n, _)| !done.contains(&n.to_lowercase()));
1037 }
1038 if let (Some(api_url), false) = (&config.api_url, map.is_empty()) {
1039 let body: Vec<String> = map
1040 .iter()
1041 .map(|(n, _)| format!("packages%5B%5D={}", urlencode(n)))
1042 .collect();
1043 let fetched = self.transport.post_form(api_url, &body.join("&"))?;
1044 let bytes = match fetched {
1045 Fetched::Body { bytes, .. } => bytes,
1046 Fetched::NotFound => {
1047 return Err(RepoError::transport(format!(
1048 "The \"{api_url}\" file could not be downloaded (HTTP/404)"
1049 )))
1050 }
1051 Fetched::NotModified => Vec::new(),
1052 };
1053 let data: Value = serde_json::from_slice(&bytes)
1054 .map_err(|e| RepoError::data(format!("{api_url}: {e}")))?;
1055 let mut warned = false;
1056 for (name, list) in data
1057 .get("advisories")
1058 .and_then(Value::as_object)
1059 .into_iter()
1060 .flatten()
1061 {
1062 let Some((_, constraint)) = map.iter().find(|(n, _)| n == name) else {
1063 if !warned {
1064 eprintln!(
1065 "{} returned names which were not requested in response to the security-advisories API. {name} was not requested but is present in the response. Requested names were: {}",
1066 self.repo_name(),
1067 map.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", ")
1068 );
1069 warned = true;
1070 }
1071 continue;
1072 };
1073 let list = list.as_array().cloned().unwrap_or_default();
1074 if !list.is_empty() {
1075 let mut found = Vec::new();
1076 for d in &list {
1077 if let Some(a) = create(d, name, constraint)? {
1078 found.push(a);
1079 }
1080 }
1081 advisories.push((name.clone(), found));
1082 }
1083 names_found.push(name.clone());
1084 }
1085 }
1086 Ok((names_found, advisories))
1087 }
1088
1089 pub fn get_filter_lists(&self) -> Result<Vec<String>, RepoError> {
1092 let Some(disabled) = &self.user_filter else {
1093 return Ok(Vec::new());
1094 };
1095 let root = self.root_data_max_age(Some(600))?;
1096 let Some(f) = root.filter.as_ref().filter(|f| f.metadata) else {
1098 return Ok(Vec::new());
1099 };
1100 Ok(f.lists
1101 .iter()
1102 .filter(|l| !disabled.contains(l))
1103 .cloned()
1104 .collect())
1105 }
1106
1107 pub fn get_filter(
1111 &self,
1112 map: &[(String, Constraint)],
1113 configured_lists: &[String],
1114 ) -> Result<FilterEntriesByList, RepoError> {
1115 let root = self.root_data_max_age(Some(600))?;
1116 let mut map: Vec<(String, Constraint)> = map
1117 .iter()
1118 .filter(|(n, _)| self.is_allowed(n))
1119 .cloned()
1120 .collect();
1121 if root.has_available_package_list {
1122 map.retain(|(n, _)| Self::contains(root, &n.to_lowercase()));
1123 }
1124 let fresh = self.fresh_metadata.get();
1125 if let Some(f) = &root.filter {
1126 if f.api_url.is_some() && !fresh {
1127 return Err(RepoError::data(format!(
1128 "{}: a filter api-url is not supported by vivacity yet",
1129 self.repo_name()
1130 )));
1131 }
1132 if f.summary_url.is_some() && !fresh {
1133 let summary = self.load_filter_summary()?;
1134 let mut candidates: Vec<String> = Vec::new();
1135 for list in configured_lists {
1136 let Some(packages) = summary.iter().find(|(l, _)| l == list) else {
1137 continue;
1138 };
1139 for (package, constraint) in &packages.1 {
1140 let Some((_, wanted)) = map.iter().find(|(n, _)| n == package) else {
1141 continue;
1142 };
1143 if !matches!(wanted, Constraint::MatchAll)
1144 && !crate::constraint::parse_constraints(constraint)
1145 .map_err(|e| RepoError::data(e.to_string()))?
1146 .constraint
1147 .matches(wanted)
1148 {
1149 continue;
1150 }
1151 if !candidates.contains(package) {
1152 candidates.push(package.clone());
1153 }
1154 }
1155 }
1156 map.retain(|(n, _)| candidates.contains(n));
1157 }
1158 }
1159 let wanted: Vec<(String, String)> = map
1160 .iter()
1161 .map(|(n, _)| n.to_lowercase())
1162 .filter(|n| !is_platform_package(n) && n != "__root__")
1163 .map(|n| (n.clone(), n))
1164 .collect();
1165 self.prefetch(&wanted)?;
1166 let mut filter: FilterEntriesByList = Vec::new();
1167 for (name, _) in &map {
1168 let name = name.to_lowercase();
1169 if is_platform_package(&name) || name == "__root__" {
1170 continue;
1171 }
1172 let Some(response) = self.provider(&name, &name)? else {
1173 continue;
1174 };
1175 let Some(raw) = response.get("filter").filter(|v| v.is_object()) else {
1176 continue;
1177 };
1178 for (list, entries) in build_filter_entries(raw, &map, Some(&name))? {
1179 match filter.iter_mut().find(|(l, _)| *l == list) {
1180 Some((_, v)) => v.extend(entries),
1181 None => filter.push((list, entries)),
1182 }
1183 }
1184 }
1185 Ok(filter)
1186 }
1187
1188 fn load_filter_summary(&self) -> Result<FilterSummary, RepoError> {
1191 let root = self.root_data_max_age(Some(600))?;
1192 let Some(url) = root.filter.as_ref().and_then(|f| f.summary_url.clone()) else {
1193 return Ok(Vec::new());
1194 };
1195 let data = self.fetch_cached(&url, "filter-summary.json")?;
1196 let Some(filter) = data
1197 .as_ref()
1198 .and_then(|d| d.get("filter"))
1199 .and_then(Value::as_object)
1200 else {
1201 return Err(RepoError::transport(format!(
1202 "Filter summary URL {url} returned 404 for {}",
1203 self.repo_name()
1204 )));
1205 };
1206 let mut summary: FilterSummary = Vec::new();
1207 for (list, packages) in filter {
1208 let Some(packages) = packages.as_object() else {
1209 return Err(RepoError::data(format!(
1210 "Invalid filter summary received from {}: list \"{list}\" must map to an object of package => constraint",
1211 self.repo_name()
1212 )));
1213 };
1214 let mut entries = Vec::new();
1215 for (name, constraint) in packages {
1216 let Some(c) = constraint.as_str() else {
1217 return Err(RepoError::data(format!(
1218 "Invalid filter summary received from {}: list \"{list}\" entries must be strings",
1219 self.repo_name()
1220 )));
1221 };
1222 entries.push((name.to_lowercase(), c.to_owned()));
1223 }
1224 summary.push((list.clone(), entries));
1225 }
1226 Ok(summary)
1227 }
1228
1229 fn parse_provider(package_name: &str, data: Option<Value>) -> Option<std::rc::Rc<Value>> {
1230 let v = data?;
1231 let has = v
1232 .get("packages")
1233 .and_then(|p| p.get(package_name))
1234 .is_some()
1235 || v.get("security-advisories").is_some()
1236 || v.get("filter").is_some();
1237 if has {
1238 Some(std::rc::Rc::new(v))
1239 } else {
1240 None
1241 }
1242 }
1243
1244 fn prefetch(&self, names: &[(String, String)]) -> Result<(), RepoError> {
1247 let Some(template) = self.root_data()?.lazy_providers_url.clone() else {
1248 return Ok(());
1249 };
1250 let mut todo: Vec<(String, String, String)> = Vec::new();
1251 {
1252 let cache = self.fetched.borrow();
1253 for (file_name, package_name) in names {
1254 let key = file_name.to_lowercase();
1255 if cache.contains_key(&key) || todo.iter().any(|(k, _, _)| *k == key) {
1256 continue;
1257 }
1258 let url = template.replace("%package%", &key);
1259 todo.push((key, package_name.clone(), url));
1260 }
1261 }
1262 if todo.len() < 2 {
1263 return Ok(());
1264 }
1265 let cached: Vec<Option<(Value, Option<String>)>> = todo
1266 .iter()
1267 .map(|(key, _, _)| self.cached(&crate::metacache::MetadataCache::provider_key(key)))
1268 .collect();
1269 let requests: Vec<Request> = todo
1270 .iter()
1271 .zip(&cached)
1272 .map(|((_, _, url), c)| (url.clone(), c.as_ref().and_then(|(_, lm)| lm.clone())))
1273 .collect();
1274 let results = self.transport.fetch_many(&requests);
1275 self.fresh_metadata.set(true);
1276 let mut settled = Vec::with_capacity(todo.len());
1277 for (((key, package_name, url), c), result) in todo.into_iter().zip(cached).zip(results) {
1278 let cache_key = crate::metacache::MetadataCache::provider_key(&key);
1279 let data = self.settle(&url, &cache_key, c, result)?;
1280 settled.push((key, Self::parse_provider(&package_name, data)));
1281 }
1282 let mut memo = self.fetched.borrow_mut();
1283 for (key, value) in settled {
1284 memo.insert(key, value);
1285 }
1286 Ok(())
1287 }
1288
1289 fn is_version_acceptable(
1291 constraint: Option<&Constraint>,
1292 name: &str,
1293 version_data: &Map<String, Value>,
1294 acceptable: &BTreeMap<String, i32>,
1295 flags: &BTreeMap<String, i32>,
1296 ) -> bool {
1297 let mut versions: Vec<String> = Vec::new();
1298 if let Some(v) = version_data
1299 .get("version_normalized")
1300 .and_then(Value::as_str)
1301 {
1302 versions.push(v.to_owned());
1303 }
1304 if let Some(alias) = branch_alias(version_data) {
1305 versions.push(alias);
1306 }
1307 let names = vec![name.to_owned()];
1308 for v in &versions {
1309 if !is_package_acceptable(acceptable, flags, &names, parse_stability(v)) {
1310 continue;
1311 }
1312 if let Some(c) = constraint {
1313 if !c.matches_version(v) {
1314 continue;
1315 }
1316 }
1317 return true;
1318 }
1319 false
1320 }
1321
1322 #[allow(clippy::too_many_arguments)]
1327 fn what_provides_partial(
1328 &self,
1329 root: &RootData,
1330 name: &str,
1331 acceptable: &BTreeMap<String, i32>,
1332 flags: &BTreeMap<String, i32>,
1333 already_loaded: &BTreeMap<String, BTreeSet<String>>,
1334 origin: Origin,
1335 arena: &mut Vec<Package>,
1336 ) -> Result<Vec<usize>, RepoError> {
1337 let Some((_, versions)) = root.partial_packages.iter().find(|(n, _)| n == name) else {
1338 return Ok(Vec::new());
1339 };
1340 let mut to_load: Vec<(String, Value)> = Vec::new();
1341 for v in versions {
1342 let mut data = v.as_object().cloned().unwrap_or_default();
1343 let normalized_name = data
1344 .get("name")
1345 .and_then(Value::as_str)
1346 .unwrap_or("")
1347 .to_lowercase();
1348 if normalized_name != name {
1349 continue;
1350 }
1351 let uid = data
1352 .get("uid")
1353 .map(|u| match u {
1354 Value::String(s) => s.clone(),
1355 other => other.to_string(),
1356 })
1357 .unwrap_or_default();
1358 if to_load.iter().any(|(u, _)| *u == uid) {
1359 continue;
1360 }
1361 Self::fill_version_normalized(&mut data)?;
1362 let normalized = data
1363 .get("version_normalized")
1364 .and_then(Value::as_str)
1365 .unwrap_or("")
1366 .to_owned();
1367 if already_loaded
1368 .get(name)
1369 .is_some_and(|s| s.contains(&normalized))
1370 {
1371 continue;
1372 }
1373 if Self::is_version_acceptable(None, &normalized_name, &data, acceptable, flags) {
1374 to_load.push((uid, Value::Object(data)));
1375 }
1376 }
1377 let mut out = Vec::new();
1378 for (_, config) in &to_load {
1379 let config = Self::with_notification_url(config, root);
1380 let (mut package, alias) =
1381 loader::load(&config, origin, true).map_err(|e| RepoError::data(e.0))?;
1382 self.configure_package(root, &mut package);
1383 let idx = arena.len();
1384 arena.push(package);
1385 out.push(idx);
1386 if let Some((normalized, pretty)) = alias {
1387 let a = arena[idx].alias(idx, &normalized, &pretty);
1388 arena.push(a);
1389 out.push(arena.len() - 1);
1390 }
1391 }
1392 Ok(out)
1393 }
1394
1395 fn configure_package(&self, root: &RootData, p: &mut Package) {
1401 let Some(obj) = p.raw.as_object_mut() else {
1402 return;
1403 };
1404 obj.shift_remove("transport-options");
1405 if let Some(src) = &p.source {
1406 if let Some(mirrors) = root.source_mirrors.get(&src.kind) {
1407 if let Some(Value::Object(s)) = obj.get_mut("source") {
1408 s.insert("mirrors".into(), Value::Array(mirrors.clone()));
1409 }
1410 }
1411 }
1412 if let Some(Value::Object(d)) = obj.get_mut("dist") {
1413 if root.dist_mirrors.is_empty() {
1414 d.shift_remove("mirrors");
1415 } else {
1416 d.insert("mirrors".into(), Value::Array(root.dist_mirrors.clone()));
1417 }
1418 }
1419 if let Some(dist) = &p.dist {
1420 let urls = dist_urls(
1421 dist,
1422 &root.dist_mirrors,
1423 &p.name,
1424 &p.version,
1425 &p.pretty_version,
1426 );
1427 if urls.iter().any(|u| u.starts_with(&self.base_url)) {
1428 let empty = self.options.as_object().is_some_and(Map::is_empty)
1429 || self.options.as_array().is_some_and(Vec::is_empty);
1430 if !empty {
1431 obj.insert("transport-options".into(), self.options.clone());
1432 }
1433 }
1434 }
1435 }
1436
1437 fn add_notification_url(obj: &mut Map<String, Value>, root: &RootData) {
1439 if !obj.contains_key("notification-url") {
1440 obj.insert(
1441 "notification-url".into(),
1442 match &root.notify_url {
1443 Some(u) => Value::String(u.clone()),
1444 None => Value::Null,
1445 },
1446 );
1447 }
1448 }
1449
1450 fn with_notification_url(config: &Value, root: &RootData) -> Value {
1451 let mut config = config.clone();
1452 if let Some(obj) = config.as_object_mut() {
1453 Self::add_notification_url(obj, root);
1454 }
1455 config
1456 }
1457
1458 fn fill_version_normalized(data: &mut Map<String, Value>) -> Result<(), RepoError> {
1461 let pretty = data
1462 .get("version")
1463 .and_then(Value::as_str)
1464 .unwrap_or("")
1465 .to_owned();
1466 match data.get("version_normalized").and_then(Value::as_str) {
1467 None => {
1468 let n = normalize(&pretty, None).map_err(|e| RepoError::data(e.0))?;
1469 data.insert("version_normalized".into(), Value::String(n));
1470 }
1471 Some(v) if v == DEFAULT_BRANCH_ALIAS => {
1472 let n = normalize(&pretty, None).map_err(|e| RepoError::data(e.0))?;
1473 data.insert("version_normalized".into(), Value::String(n));
1474 }
1475 _ => {}
1476 }
1477 Ok(())
1478 }
1479
1480 pub fn load_packages(
1485 &self,
1486 package_name_map: &[(String, Constraint)],
1487 acceptable: &BTreeMap<String, i32>,
1488 flags: &BTreeMap<String, i32>,
1489 already_loaded: &BTreeMap<String, BTreeSet<String>>,
1490 origin: Origin,
1491 arena: &mut Vec<Package>,
1492 ) -> Result<(Vec<String>, Vec<usize>), RepoError> {
1493 let root = self.root_data()?;
1494 if root.v1_protocol {
1495 return Err(RepoError::data(format!(
1496 "{}: Composer v1 repository protocol (providers) is not supported by vivacity",
1497 self.url
1498 )));
1499 }
1500 if let Some(plain) = &root.plain {
1501 if self.members.get().is_none() {
1503 let configs: Vec<Value> = plain
1504 .iter()
1505 .map(|c| Self::with_notification_url(c, root))
1506 .collect();
1507 let ids = loader::load_packages(&configs, origin, arena, true)
1508 .map_err(|e| RepoError::data(e.0))?;
1509 for &id in &ids {
1510 let mut p = std::mem::replace(&mut arena[id], Package::new("", "", "", origin));
1511 self.configure_package(root, &mut p);
1512 arena[id] = p;
1513 }
1514 let _ = self.members.set(ids);
1515 }
1516 let members = self.members.get().expect("just set");
1517 return Ok(crate::pool::array_repository_load_packages(
1518 members,
1519 package_name_map,
1520 acceptable,
1521 flags,
1522 already_loaded,
1523 arena,
1524 ));
1525 }
1526 let mut map: Vec<(String, Constraint)> = package_name_map.to_vec();
1527 let mut packages: Vec<usize> = Vec::new();
1528 let mut names_found: Vec<String> = Vec::new();
1529
1530 if !root.partial_packages.is_empty() {
1531 let mut rest: Vec<(String, Constraint)> = Vec::new();
1532 for (name, constraint) in map {
1533 if !root.partial_packages.iter().any(|(n, _)| *n == name) {
1534 rest.push((name, constraint));
1535 continue;
1536 }
1537 let candidates = self.what_provides_partial(
1538 root,
1539 &name,
1540 acceptable,
1541 flags,
1542 already_loaded,
1543 origin,
1544 arena,
1545 )?;
1546 let mut matches: Vec<usize> = Vec::new();
1547 for &c in &candidates {
1548 if !names_found.contains(&name) {
1549 names_found.push(name.clone());
1550 }
1551 let all = matches!(constraint, Constraint::MatchAll);
1552 if all || constraint.matches_version(&arena[c].version) {
1553 if !matches.contains(&c) {
1554 matches.push(c);
1555 }
1556 if let Some(base) = arena[c].alias_of {
1557 if !matches.contains(&base) {
1558 matches.push(base);
1559 }
1560 }
1561 }
1562 }
1563 for &c in &candidates {
1564 if let Some(base) = arena[c].alias_of {
1565 if matches.contains(&base) && !matches.contains(&c) {
1566 matches.push(c);
1567 }
1568 }
1569 }
1570 packages.extend(matches);
1571 }
1572 map = rest;
1573 }
1574
1575 if root.lazy_providers_url.is_none() || map.is_empty() {
1576 return Ok((names_found, packages));
1577 }
1578 if root.has_available_package_list {
1579 map.retain(|(name, _)| Self::contains(root, &name.to_lowercase()));
1580 }
1581 let only_dev = acceptable.len() == 1 && acceptable.contains_key("dev") && flags.is_empty();
1584 let mut names: Vec<(String, Constraint)> = Vec::new();
1585 let mut dev_names: Vec<(String, Constraint)> = Vec::new();
1586 for (name, c) in &map {
1587 if is_package_acceptable(acceptable, flags, std::slice::from_ref(name), "dev") {
1588 dev_names.push((format!("{name}~dev"), c.clone()));
1589 }
1590 if !only_dev {
1591 names.push((name.clone(), c.clone()));
1592 }
1593 }
1594 names.extend(dev_names);
1595
1596 let wanted: Vec<(String, String)> = names
1597 .iter()
1598 .map(|(n, _)| n.to_lowercase())
1599 .filter(|n| {
1600 let real = n.strip_suffix("~dev").unwrap_or(n);
1601 !is_platform_package(real) && real != "__root__"
1602 })
1603 .map(|n| {
1604 let real = n.strip_suffix("~dev").unwrap_or(&n).to_owned();
1605 (n.clone(), real)
1606 })
1607 .collect();
1608 self.prefetch(&wanted)?;
1609
1610 for (name, constraint) in &names {
1611 let name = name.to_lowercase();
1612 let real_name = name.strip_suffix("~dev").unwrap_or(&name).to_owned();
1613 if is_platform_package(&real_name) || real_name == "__root__" {
1614 continue;
1615 }
1616 let Some(response) = self.provider(&name, &real_name)? else {
1617 continue;
1618 };
1619 let versions: Vec<Value> =
1620 match response.get("packages").and_then(|p| p.get(&real_name)) {
1621 Some(Value::Array(a)) => a.clone(),
1622 Some(Value::Object(o)) => o.values().cloned().collect(),
1623 _ => continue,
1624 };
1625 let versions: Vec<Value> =
1626 if response.get("minified").and_then(Value::as_str) == Some("composer/2.0") {
1627 expand_minified_owned(versions)
1628 } else {
1629 versions
1630 };
1631 if !names_found.contains(&real_name) {
1632 names_found.push(real_name.clone());
1633 }
1634 let mut to_load: Vec<Value> = Vec::new();
1635 for v in versions {
1636 let mut data = match v {
1637 Value::Object(o) => o,
1638 _ => Map::new(),
1639 };
1640 Self::fill_version_normalized(&mut data)?;
1641 let normalized = data
1642 .get("version_normalized")
1643 .and_then(Value::as_str)
1644 .unwrap_or("")
1645 .to_owned();
1646 if already_loaded
1647 .get(&real_name)
1648 .is_some_and(|s| s.contains(&normalized))
1649 {
1650 continue;
1651 }
1652 if Self::is_version_acceptable(
1653 Some(constraint),
1654 &real_name,
1655 &data,
1656 acceptable,
1657 flags,
1658 ) {
1659 Self::add_notification_url(&mut data, root);
1660 to_load.push(Value::Object(data));
1661 }
1662 }
1663 let ids = loader::load_packages(&to_load, origin, arena, true)
1664 .map_err(|e| RepoError::data(e.0))?;
1665 for &id in &ids {
1666 let base = arena[id].alias_of.unwrap_or(id);
1667 let mut p = std::mem::replace(&mut arena[base], Package::new("", "", "", origin));
1668 self.configure_package(root, &mut p);
1669 arena[base] = p;
1670 if base != id {
1671 let mut a = std::mem::replace(&mut arena[id], Package::new("", "", "", origin));
1672 self.configure_package(root, &mut a);
1673 arena[id] = a;
1674 }
1675 }
1676 packages.extend(ids);
1677 }
1678 Ok((names_found, packages))
1679 }
1680}
1681
1682pub fn locked_repository(lock: &Value, arena: &mut Vec<Package>) -> Result<Vec<usize>, RepoError> {
1685 locked_repository_with(lock, arena, true)
1686}
1687
1688pub fn locked_repository_with(
1690 lock: &Value,
1691 arena: &mut Vec<Package>,
1692 with_dev: bool,
1693) -> Result<Vec<usize>, RepoError> {
1694 let mut configs: Vec<Value> = lock
1695 .get("packages")
1696 .and_then(Value::as_array)
1697 .cloned()
1698 .unwrap_or_default();
1699 if with_dev {
1700 match lock.get("packages-dev").and_then(Value::as_array) {
1701 Some(dev) => configs.extend(dev.iter().cloned()),
1702 None => {
1703 return Err(RepoError::data(
1704 "The lock file does not contain require-dev information, run install with the --no-dev option or delete it and run composer update to generate a new lock file.",
1705 ))
1706 }
1707 }
1708 }
1709 if configs.is_empty() {
1710 return Ok(Vec::new());
1711 }
1712 let ids = loader::load_packages(&configs, Origin::Locked, arena, false)
1713 .map_err(|e| RepoError::data(e.0))?;
1714 let mut out = ids.clone();
1715 let mut by_name: BTreeMap<String, usize> = BTreeMap::new();
1718 for id in &ids {
1719 by_name.insert(arena[*id].name.clone(), *id);
1720 if let Some(base) = arena[*id].alias_of {
1721 by_name.insert(arena[base].name.clone(), base);
1722 }
1723 }
1724 for alias in lock
1725 .get("aliases")
1726 .and_then(Value::as_array)
1727 .into_iter()
1728 .flatten()
1729 {
1730 let (Some(pkg), Some(alias_normalized), Some(alias_pretty)) = (
1731 alias.get("package").and_then(Value::as_str),
1732 alias.get("alias_normalized").and_then(Value::as_str),
1733 alias.get("alias").and_then(Value::as_str),
1734 ) else {
1735 continue;
1736 };
1737 if let Some(&base) = by_name.get(pkg) {
1738 let mut a = arena[base].alias(base, alias_normalized, alias_pretty);
1739 a.root_package_alias = true;
1740 arena.push(a);
1741 out.push(arena.len() - 1);
1742 }
1743 }
1744 Ok(out)
1745}
1746
1747fn process_mirror_url(
1749 mirror_url: &str,
1750 name: &str,
1751 version: &str,
1752 reference: Option<&str>,
1753 kind: &str,
1754 pretty_version: &str,
1755) -> String {
1756 static HEX: OnceLock<Regex> = OnceLock::new();
1757 let reference = reference.map(|r| {
1758 if r.is_empty() {
1759 String::new()
1760 } else if regex(&HEX, r"^([a-f0-9]*|%reference%)$", false)
1761 .is_match(r.as_bytes())
1762 .unwrap_or(false)
1763 {
1764 r.to_owned()
1765 } else {
1766 vivacity_core::content_hash::md5_hex(r.as_bytes())
1767 }
1768 });
1769 let version = if version.contains('/') {
1770 vivacity_core::content_hash::md5_hex(version.as_bytes())
1771 } else {
1772 version.to_owned()
1773 };
1774 mirror_url
1775 .replace("%package%", name)
1776 .replace("%version%", &version)
1777 .replace("%reference%", reference.as_deref().unwrap_or(""))
1778 .replace("%type%", kind)
1779 .replace("%prettyVersion%", pretty_version)
1780}
1781
1782fn dist_urls(
1785 dist: &crate::package::SourceRef,
1786 mirrors: &[Value],
1787 name: &str,
1788 version: &str,
1789 pretty: &str,
1790) -> Vec<String> {
1791 if dist.url.is_empty() {
1792 return Vec::new();
1793 }
1794 let url = if dist.url.contains('%') {
1795 process_mirror_url(
1796 &dist.url,
1797 name,
1798 version,
1799 dist.reference.as_deref(),
1800 &dist.kind,
1801 pretty,
1802 )
1803 } else {
1804 dist.url.clone()
1805 };
1806 let mut urls = vec![url];
1807 for m in mirrors {
1808 let Some(mu) = m.get("url").and_then(Value::as_str) else {
1809 continue;
1810 };
1811 let mirror_url = process_mirror_url(
1812 mu,
1813 name,
1814 version,
1815 dist.reference.as_deref(),
1816 &dist.kind,
1817 pretty,
1818 );
1819 if !urls.contains(&mirror_url) {
1820 if m.get("preferred") == Some(&Value::Bool(true)) {
1821 urls.insert(0, mirror_url);
1822 } else {
1823 urls.push(mirror_url);
1824 }
1825 }
1826 }
1827 urls
1828}
1829
1830fn urlencode(s: &str) -> String {
1832 let mut out = String::new();
1833 for b in s.bytes() {
1834 match b {
1835 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => out.push(b as char),
1836 b' ' => out.push('+'),
1837 _ => out.push_str(&format!("%{b:02X}")),
1838 }
1839 }
1840 out
1841}
1842
1843#[cfg(test)]
1844mod cache_tests {
1845 use super::*;
1846 use std::cell::RefCell;
1847
1848 struct Scripted {
1850 responses: RefCell<Vec<Fetched>>,
1851 seen: std::rc::Rc<RefCell<Vec<Request>>>,
1852 }
1853
1854 impl Transport for Scripted {
1855 fn fetch(&self, url: &str, ims: Option<&str>) -> Result<Fetched, RepoError> {
1856 self.seen
1857 .borrow_mut()
1858 .push((url.to_owned(), ims.map(str::to_owned)));
1859 let mut responses = self.responses.borrow_mut();
1860 assert!(
1861 !responses.is_empty(),
1862 "unexpected request: {url} (seen: {:?})",
1863 self.seen.borrow()
1864 );
1865 Ok(responses.remove(0))
1866 }
1867 }
1868
1869 fn body(json: &str, lm: Option<&str>) -> Fetched {
1870 Fetched::Body {
1871 bytes: json.as_bytes().to_vec(),
1872 last_modified: lm.map(str::to_owned),
1873 }
1874 }
1875
1876 #[test]
1877 fn revalidates_from_composer_cache() {
1878 let tmp = tempfile::tempdir().expect("tmp");
1879 let cache_dir = tmp.path().join("repo");
1880 let root = r#"{"packages": [], "metadata-url": "/p2/%package%.json"}"#;
1881 let provider = r#"{"packages": {"acme/lib": [{"name": "acme/lib", "version": "1.0.0", "version_normalized": "1.0.0.0"}]}}"#;
1882
1883 let t = Scripted {
1885 responses: RefCell::new(vec![
1886 body(root, Some("Sat, 12 Sep 2026 10:00:00 GMT")),
1887 body(provider, Some("Sun, 13 Sep 2026 09:00:00 GMT")),
1888 Fetched::NotFound,
1889 ]),
1890 seen: std::rc::Rc::new(RefCell::new(Vec::new())),
1891 };
1892 let mut repo =
1893 ComposerRepository::open("https://satis.example.org", Box::new(t)).expect("open");
1894 repo.cache = Some(crate::metacache::MetadataCache::new(&cache_dir, &repo.url));
1895 let mut arena = Vec::new();
1896 let (found, ids) = repo
1897 .load_packages(
1898 &[("acme/lib".to_owned(), Constraint::MatchAll)],
1899 &[("stable".to_owned(), 0)].into_iter().collect(),
1900 &BTreeMap::new(),
1901 &BTreeMap::new(),
1902 Origin::Repository(2),
1903 &mut arena,
1904 )
1905 .expect("load");
1906 assert_eq!(found, vec!["acme/lib"]);
1907 assert_eq!(ids.len(), 1);
1908 let dir = cache_dir.join("https---satis.example.org");
1909 let cached = std::fs::read_to_string(dir.join("provider-acme~lib.json")).expect("cached");
1910 assert!(
1911 cached.ends_with(r#""last-modified":"Sun, 13 Sep 2026 09:00:00 GMT"}"#),
1912 "{cached}"
1913 );
1914 assert!(std::fs::read_to_string(dir.join("packages.json"))
1915 .expect("root cached")
1916 .contains(r#""metadata-url":"\/p2\/%package%.json""#));
1917
1918 let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
1920 let t = Scripted {
1921 responses: RefCell::new(vec![
1922 Fetched::NotModified,
1923 Fetched::NotModified,
1924 Fetched::NotFound,
1925 ]),
1926 seen: seen.clone(),
1927 };
1928 let mut repo =
1929 ComposerRepository::open("https://satis.example.org", Box::new(t)).expect("open");
1930 repo.cache = Some(crate::metacache::MetadataCache::new(&cache_dir, &repo.url));
1931 let mut arena = Vec::new();
1932 let (found, ids) = repo
1933 .load_packages(
1934 &[("acme/lib".to_owned(), Constraint::MatchAll)],
1935 &[("stable".to_owned(), 0)].into_iter().collect(),
1936 &BTreeMap::new(),
1937 &BTreeMap::new(),
1938 Origin::Repository(2),
1939 &mut arena,
1940 )
1941 .expect("load");
1942 assert_eq!(found, vec!["acme/lib"]);
1943 assert_eq!(arena[ids[0]].version, "1.0.0.0");
1944 let seen = seen.borrow();
1945 assert_eq!(seen[0].1.as_deref(), Some("Sat, 12 Sep 2026 10:00:00 GMT"));
1946 assert_eq!(seen[1].0, "https://satis.example.org/p2/acme/lib.json");
1947 assert_eq!(seen[1].1.as_deref(), Some("Sun, 13 Sep 2026 09:00:00 GMT"));
1948 }
1949}