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 providers_api_url: Option<String>,
182 notify_url: Option<String>,
183 has_available_package_list: bool,
184 available_packages: BTreeSet<String>,
185 available_patterns: Vec<Regex>,
186 partial_packages: Vec<(String, Vec<Value>)>,
189 source_mirrors: BTreeMap<String, Vec<Value>>,
192 dist_mirrors: Vec<Value>,
193 plain: Option<Vec<Value>>,
196 v1_protocol: bool,
199 security_advisories: Option<AdvisoryConfig>,
201 filter: Option<FilterInfo>,
203}
204
205#[derive(Debug, Clone)]
206pub struct AdvisoryConfig {
207 pub metadata: bool,
208 pub api_url: Option<String>,
209}
210
211#[derive(Debug, Clone)]
212pub struct FilterInfo {
213 pub metadata: bool,
214 pub lists: Vec<String>,
216 pub summary_url: Option<String>,
217 pub api_url: Option<String>,
218}
219
220#[derive(Debug, Clone)]
223pub struct Advisory {
224 pub package_name: String,
225 pub advisory_id: String,
226 pub affected_versions: Constraint,
227 pub complete: Option<CompleteAdvisory>,
229}
230
231#[derive(Debug, Clone, Default)]
232pub struct CompleteAdvisory {
233 pub cve: Option<String>,
234 pub severity: Option<String>,
235 pub source_remote_ids: Vec<String>,
236 pub link: Option<String>,
238}
239
240pub fn advisory_from_data(package_name: &str, data: &Value) -> Option<Advisory> {
243 let affected = data.get("affectedVersions")?.as_str()?.to_owned();
244 let advisory_id = data.get("advisoryId")?.as_str()?.to_owned();
245 let constraint = match crate::constraint::parse_constraints(&affected) {
246 Ok(c) => c.constraint,
247 Err(_) => {
248 static HEAD: OnceLock<Regex> = OnceLock::new();
249 let re = regex(&HEAD, r"(^[>=<^~]*[\d.]+).*", false);
250 let head = re
251 .captures(affected.as_bytes())
252 .ok()
253 .flatten()
254 .map(|c| crate::version::group(&c, 1).to_owned())
255 .unwrap_or_default();
256 match crate::constraint::parse_constraints(&head) {
257 Ok(c) => c.constraint,
258 Err(_) => Constraint::new(crate::constraint::Op::Eq, "0.0.0-invalid-version"),
259 }
260 }
261 };
262 let complete = if data.get("title").is_some_and(|v| !v.is_null())
263 && data.get("sources").is_some_and(|v| !v.is_null())
264 && data.get("reportedAt").is_some_and(|v| !v.is_null())
265 {
266 Some(CompleteAdvisory {
267 cve: data.get("cve").and_then(Value::as_str).map(str::to_owned),
268 severity: data
269 .get("severity")
270 .and_then(Value::as_str)
271 .map(str::to_owned),
272 source_remote_ids: data
273 .get("sources")
274 .and_then(Value::as_array)
275 .map(|a| {
276 a.iter()
277 .filter_map(|s| s.get("remoteId").and_then(Value::as_str))
278 .map(str::to_owned)
279 .collect()
280 })
281 .unwrap_or_default(),
282 link: data.get("link").and_then(Value::as_str).map(str::to_owned),
283 })
284 } else {
285 None
286 };
287 Some(Advisory {
288 package_name: package_name.to_owned(),
289 advisory_id,
290 affected_versions: constraint,
291 complete,
292 })
293}
294
295pub type Providers = Vec<(String, Option<String>)>;
297
298pub type AdvisoriesByName = Vec<(String, Vec<Advisory>)>;
300pub type FilterEntriesByList = Vec<(String, Vec<FilterEntry>)>;
302type FilterSummary = Vec<(String, Vec<(String, String)>)>;
304
305#[derive(Debug, Clone)]
307pub struct FilterEntry {
308 pub package_name: String,
309 pub constraint: Constraint,
310 pub list_name: String,
311 pub url: Option<String>,
312 pub reason: Option<String>,
313 pub id: Option<String>,
314 pub source: Option<String>,
315}
316
317impl FilterEntry {
318 pub fn same_entry(&self, other: &FilterEntry) -> bool {
321 self.package_name == other.package_name
322 && self.constraint == other.constraint
323 && self.list_name == other.list_name
324 && self.url == other.url
325 && self.reason == other.reason
326 && self.id == other.id
327 && self.source == other.source
328 }
329}
330
331fn build_filter_entries(
334 raw_by_list: &Value,
335 map: &[(String, Constraint)],
336 default_package: Option<&str>,
337) -> Result<FilterEntriesByList, RepoError> {
338 let mut result: FilterEntriesByList = Vec::new();
339 let Some(lists) = raw_by_list.as_object() else {
340 return Ok(result);
341 };
342 for (list_name, entries) in lists {
343 let Some(entries) = entries.as_array() else {
344 continue;
345 };
346 for data in entries {
347 let Some(obj) = data.as_object() else {
348 continue;
349 };
350 let Some(constraint) = obj.get("constraint").and_then(Value::as_str) else {
351 continue;
352 };
353 let package = match obj.get("package").and_then(Value::as_str) {
354 Some(p) => p.to_owned(),
355 None => match default_package {
356 Some(d) => d.to_owned(),
357 None => continue,
358 },
359 };
360 let parsed = crate::constraint::parse_constraints(constraint)
361 .map_err(|e| RepoError::data(e.to_string()))?
362 .constraint;
363 let Some((_, wanted)) = map.iter().find(|(n, _)| *n == package) else {
364 continue;
365 };
366 if !parsed.matches(wanted) {
367 continue;
368 }
369 let entry = FilterEntry {
370 package_name: package,
371 constraint: parsed,
372 list_name: list_name.clone(),
373 url: obj.get("url").and_then(Value::as_str).map(str::to_owned),
374 reason: obj.get("reason").and_then(Value::as_str).map(str::to_owned),
375 id: obj.get("id").and_then(Value::as_str).map(str::to_owned),
376 source: obj.get("source").and_then(Value::as_str).map(str::to_owned),
377 };
378 match result.iter_mut().find(|(l, _)| l == list_name) {
379 Some((_, v)) => v.push(entry),
380 None => result.push((list_name.clone(), vec![entry])),
381 }
382 }
383 }
384 Ok(result)
385}
386
387const RESERVED_LIST_NAMES: &[&str] = &[
390 "advisories",
391 "abandoned",
392 "package",
393 "packages",
394 "license",
395 "licence",
396 "licenses",
397 "licences",
398 "support",
399 "maintenance",
400 "security",
401 "minimum-release-age",
402];
403
404pub struct ComposerRepository {
405 pub url: String,
406 pub base_url: String,
407 pub options: Value,
410 packages_json_url: String,
411 transport: Box<dyn Transport>,
412 root: std::cell::OnceCell<RootData>,
414 fetched: std::cell::RefCell<BTreeMap<String, Option<std::rc::Rc<Value>>>>,
416 members: std::cell::OnceCell<Vec<usize>>,
419 pub cache: Option<crate::metacache::MetadataCache>,
421 degraded: std::cell::Cell<bool>,
424 pub user_filter: Option<Vec<String>>,
427 fresh_metadata: std::cell::Cell<bool>,
430 name_filter: Option<NameFilter>,
434}
435
436pub struct NameFilter {
438 regex: Regex,
439 only: bool,
440}
441
442fn php_empty(v: Option<&Value>) -> bool {
444 match v {
445 None | Some(Value::Null) | Some(Value::Bool(false)) => true,
446 Some(Value::String(s)) => s.is_empty() || s == "0",
447 Some(Value::Number(n)) => n.as_f64() == Some(0.0),
448 Some(Value::Array(a)) => a.is_empty(),
449 Some(Value::Object(o)) => o.is_empty(),
450 Some(Value::Bool(true)) => false,
451 }
452}
453
454fn url_path(url: &str) -> &str {
456 let rest = match url.find("://") {
457 Some(i) => {
458 let after = &url[i + 3..];
459 match after.find('/') {
460 Some(j) => &after[j..],
461 None => "",
462 }
463 }
464 None => url,
465 };
466 let end = rest.find(['?', '#']).unwrap_or(rest.len());
467 &rest[..end]
468}
469
470impl ComposerRepository {
471 pub fn open(url: &str, transport: Box<dyn Transport>) -> Result<ComposerRepository, RepoError> {
473 static SCHEME: OnceLock<Regex> = OnceLock::new();
474 static PACKAGIST: OnceLock<Regex> = OnceLock::new();
475 static BASE: OnceLock<Regex> = OnceLock::new();
476 let mut url = url.to_owned();
477 if !regex(&SCHEME, r"^[\w.]+\??://", false)
478 .is_match(url.as_bytes())
479 .unwrap_or(false)
480 {
481 match std::fs::canonicalize(&url) {
482 Ok(p) => url = format!("file://{}", p.to_string_lossy()),
483 Err(_) => url = format!("http://{url}"),
484 }
485 }
486 url = url.trim_end_matches('/').to_owned();
487 if let Some(rest) = url.strip_prefix("https?") {
488 url = format!("https{rest}");
489 }
490 if let Ok(Some(caps)) = regex(&PACKAGIST, r"^(?P<proto>https?)://packagist\.org/?$", true)
491 .captures(url.as_bytes())
492 {
493 url = format!("{}://repo.packagist.org", crate::version::group(&caps, 1));
494 }
495 let base_re = regex(&BASE, r"(?:/[^/\\]+\.json)?(?:[?#].*)?$", false);
496 let base_url = match base_re.find(url.as_bytes()).ok().flatten() {
497 Some(m) => url[..m.start()].trim_end_matches('/').to_owned(),
498 None => url.clone(),
499 };
500 let packages_json_url = if url_path(&url).contains(".json") {
502 url.clone()
503 } else {
504 format!("{url}/packages.json")
505 };
506 Ok(ComposerRepository {
507 url,
508 base_url,
509 options: Value::Object(Map::new()),
510 packages_json_url,
511 transport,
512 root: std::cell::OnceCell::new(),
513 fetched: std::cell::RefCell::new(BTreeMap::new()),
514 members: std::cell::OnceCell::new(),
515 cache: None,
516 degraded: std::cell::Cell::new(false),
517 user_filter: Some(Vec::new()),
518 fresh_metadata: std::cell::Cell::new(false),
519 name_filter: None,
520 })
521 }
522
523 pub fn set_name_filter(
525 &mut self,
526 only: Option<&Value>,
527 exclude: Option<&Value>,
528 ) -> Result<(), RepoError> {
529 let patterns = |v: &Value, key: &str| -> Result<Vec<String>, RepoError> {
530 v.as_array()
531 .map(|a| {
532 a.iter()
533 .filter_map(Value::as_str)
534 .map(str::to_owned)
535 .collect()
536 })
537 .ok_or_else(|| {
538 RepoError::data(format!(
539 "\"{key}\" key for repository {} should be an array",
540 self.repo_name()
541 ))
542 })
543 };
544 if only.is_some() && exclude.is_some() {
545 return Err(RepoError::data(format!(
546 "Only one of \"only\" and \"exclude\" can be specified for repository {}",
547 self.repo_name()
548 )));
549 }
550 let (list, is_only) = match (only, exclude) {
551 (Some(o), _) => (patterns(o, "only")?, true),
552 (_, Some(e)) => (patterns(e, "exclude")?, false),
553 _ => return Ok(()),
554 };
555 let parts: Vec<String> = list
556 .iter()
557 .map(|n| crate::version::preg_quote(n).replace("\\*", ".*"))
558 .collect();
559 let regex = pcre2::bytes::RegexBuilder::new()
560 .caseless(true)
561 .build(&format!("^(?:{})\\z", parts.join("|")))
562 .map_err(|e| RepoError::data(e.to_string()))?;
563 self.name_filter = Some(NameFilter {
564 regex,
565 only: is_only,
566 });
567 Ok(())
568 }
569
570 fn is_allowed(&self, name: &str) -> bool {
572 match &self.name_filter {
573 None => true,
574 Some(f) => {
575 let hit = f.regex.is_match(name.as_bytes()).unwrap_or(false);
576 if f.only {
577 hit
578 } else {
579 !hit
580 }
581 }
582 }
583 }
584
585 pub fn set_user_filter(&mut self, raw: Option<&Value>) -> Result<(), RepoError> {
587 self.user_filter = match raw {
588 Some(Value::Bool(false)) => None,
589 None | Some(Value::Null) | Some(Value::Bool(true)) => Some(Vec::new()),
590 Some(Value::Object(m)) => {
591 let mut disabled = Vec::new();
592 for (list, v) in m {
593 if list.is_empty() {
594 return Err(RepoError::data(
595 "Repository \"filter\" keys must be non-empty list-name strings.",
596 ));
597 }
598 match v {
599 Value::Bool(true) => {}
600 Value::Bool(false) => disabled.push(list.clone()),
601 other => {
602 return Err(RepoError::data(format!(
603 "Repository \"filter\" entry for \"{list}\" must be a boolean; got {other}."
604 )))
605 }
606 }
607 }
608 Some(disabled)
609 }
610 Some(_) => {
611 return Err(RepoError::data(
612 "Repository \"filter\" must be a boolean or an object mapping advertised list names to false.",
613 ))
614 }
615 };
616 Ok(())
617 }
618
619 fn root_data(&self) -> Result<&RootData, RepoError> {
621 self.root_data_max_age(None)
622 }
623
624 fn root_data_max_age(&self, max_age: Option<u64>) -> Result<&RootData, RepoError> {
628 if let Some(r) = self.root.get() {
629 return Ok(r);
630 }
631 let fresh_enough = max_age.is_some_and(|max| {
632 self.cache
633 .as_ref()
634 .and_then(|c| c.age("packages.json"))
635 .is_some_and(|age| age <= max)
636 });
637 let data: Value = if fresh_enough {
638 self.cached("packages.json")
639 .map(|(v, _)| v)
640 .ok_or_else(|| {
641 RepoError::transport(format!("{} not found", self.packages_json_url))
642 })?
643 } else {
644 self.fetch_cached(&self.packages_json_url, "packages.json")?
645 .ok_or_else(|| {
646 RepoError::transport(format!("{} not found", self.packages_json_url))
647 })?
648 };
649 let non_empty = |k: &str| !php_empty(data.get(k));
650 let mut r = RootData::default();
651 if non_empty("notify-batch") {
652 r.notify_url = data["notify-batch"]
653 .as_str()
654 .map(|s| self.canonicalize_url(s));
655 } else if non_empty("notify") {
656 r.notify_url = data["notify"].as_str().map(|s| self.canonicalize_url(s));
657 }
658 if let Some(mirrors) = data.get("mirrors").and_then(Value::as_array) {
659 for mirror in mirrors {
660 let preferred = !php_empty(mirror.get("preferred"));
661 for (key, kind) in [("git-url", "git"), ("hg-url", "hg")] {
662 if let Some(u) = mirror.get(key).filter(|u| !php_empty(Some(u))) {
663 r.source_mirrors
664 .entry(kind.to_owned())
665 .or_default()
666 .push(serde_json::json!({"url": u, "preferred": preferred}));
667 }
668 }
669 if let Some(u) = mirror.get("dist-url").and_then(Value::as_str) {
670 if !php_empty(Some(&Value::String(u.to_owned()))) {
671 r.dist_mirrors
672 .push(serde_json::json!({"url": self.canonicalize_url(u), "preferred": preferred}));
673 }
674 }
675 }
676 }
677 if non_empty("providers-api") {
678 r.providers_api_url = data["providers-api"]
679 .as_str()
680 .map(|s| self.canonicalize_url(s));
681 }
682 let mut has_providers = false;
683 let mut has_partial = false;
684 if non_empty("providers-lazy-url") {
685 r.lazy_providers_url = data["providers-lazy-url"]
686 .as_str()
687 .map(|s| self.canonicalize_url(s));
688 has_providers = true;
689 has_partial = non_empty("packages") && data["packages"].is_object();
690 }
691 if non_empty("metadata-url") {
692 r.lazy_providers_url = data["metadata-url"]
693 .as_str()
694 .map(|s| self.canonicalize_url(s));
695 has_partial = non_empty("packages") && data["packages"].is_object();
696 if non_empty("available-packages") {
697 for p in data["available-packages"].as_array().into_iter().flatten() {
698 if let Some(s) = p.as_str() {
699 r.available_packages.insert(s.to_lowercase());
700 }
701 }
702 r.has_available_package_list = true;
703 }
704 if non_empty("available-package-patterns") {
705 for p in data["available-package-patterns"]
706 .as_array()
707 .into_iter()
708 .flatten()
709 {
710 if let Some(s) = p.as_str() {
711 r.available_patterns.push(package_name_regexp(s));
712 }
713 }
714 r.has_available_package_list = true;
715 }
716 if let Some(sa) = data.get("security-advisories").and_then(Value::as_object) {
717 let api_url = sa
718 .get("api-url")
719 .and_then(Value::as_str)
720 .map(|u| self.canonicalize_url(u));
721 if api_url.is_none() && !r.has_available_package_list {
722 return Err(RepoError::data(format!(
723 "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.",
724 self.repo_name()
725 )));
726 }
727 r.security_advisories = Some(AdvisoryConfig {
728 metadata: !php_empty(sa.get("metadata")),
729 api_url,
730 });
731 }
732 if let Some(f) = data.get("filter").and_then(Value::as_object) {
733 let mut lists = Vec::new();
734 if let Some(ls) = f.get("lists").and_then(Value::as_object) {
735 for (name, cfg) in ls {
736 if cfg
737 .as_object()
738 .is_some_and(|c| !php_empty(c.get("enabled")))
739 && !RESERVED_LIST_NAMES.contains(&name.as_str())
740 && !name.starts_with("ignore")
741 {
742 lists.push(name.clone());
743 }
744 }
745 }
746 let url_of = |k: &str| {
747 f.get(k)
748 .and_then(Value::as_str)
749 .filter(|u| !u.is_empty())
750 .map(|u| self.canonicalize_url(u))
751 };
752 r.filter = Some(FilterInfo {
753 metadata: !php_empty(f.get("metadata")),
754 lists,
755 summary_url: url_of("summary-url"),
756 api_url: url_of("api-url"),
757 });
758 }
759 } else if non_empty("providers-url")
760 || non_empty("providers")
761 || non_empty("providers-includes")
762 || has_providers
763 {
764 r.v1_protocol = true;
768 }
769 if has_partial {
770 for (_, versions) in data["packages"].as_object().into_iter().flatten() {
773 let list: Vec<&Value> = match versions {
774 Value::Array(a) => a.iter().collect(),
775 Value::Object(o) => o.values().collect(),
776 _ => Vec::new(),
777 };
778 for v in list {
779 let name = v
780 .get("name")
781 .map(|n| match n {
782 Value::String(s) => s.clone(),
783 other => other.to_string(),
784 })
785 .unwrap_or_default()
786 .to_lowercase();
787 match r.partial_packages.iter_mut().find(|(n, _)| *n == name) {
788 Some(slot) => slot.1.push(v.clone()),
789 None => r.partial_packages.push((name, vec![v.clone()])),
790 }
791 }
792 }
793 } else if r.lazy_providers_url.is_none() {
794 r.plain = Some(self.load_includes(&data)?);
798 }
799 let _ = self.root.set(r);
800 Ok(self.root.get().expect("just set"))
801 }
802
803 fn load_includes(&self, data: &Value) -> Result<Vec<Value>, RepoError> {
806 let mut out = Vec::new();
807 let has_packages = data.get("packages").is_some();
808 let has_includes = data.get("includes").is_some();
809 if !has_packages && !has_includes {
810 for (_, pkg) in data.as_object().into_iter().flatten() {
811 if let Some(Value::Array(versions)) = pkg.get("versions") {
812 out.extend(versions.iter().cloned());
813 } else if let Some(Value::Object(versions)) = pkg.get("versions") {
814 out.extend(versions.values().cloned());
815 }
816 }
817 return Ok(out);
818 }
819 if let Some(packages) = data.get("packages").and_then(Value::as_object) {
820 for (_, versions) in packages {
821 match versions {
822 Value::Array(a) => out.extend(a.iter().cloned()),
823 Value::Object(o) => out.extend(o.values().cloned()),
824 _ => {}
825 }
826 }
827 }
828 if let Some(includes) = data.get("includes").and_then(Value::as_object) {
829 for (include, _) in includes {
830 let url = self.canonicalize_url(include);
831 let url = if url.contains("://") {
832 url
833 } else {
834 format!("{}/{}", self.base_url, url.trim_start_matches('/'))
835 };
836 let included = self
837 .fetch_cached(&url, include)?
838 .ok_or_else(|| RepoError::transport(format!("{url} not found")))?;
839 out.extend(self.load_includes(&included)?);
840 }
841 }
842 Ok(out)
843 }
844
845 pub fn notify_url(&self) -> Result<Option<String>, RepoError> {
846 Ok(self.root_data()?.notify_url.clone())
847 }
848
849 pub fn lazy_providers_url(&self) -> Result<Option<String>, RepoError> {
850 Ok(self.root_data()?.lazy_providers_url.clone())
851 }
852
853 fn canonicalize_url(&self, url: &str) -> String {
855 static RE: OnceLock<Regex> = OnceLock::new();
856 if let Some(rest) = url.strip_prefix('/') {
857 let re = regex(&RE, r"^[^:]++://[^/]*+", false);
858 if let Ok(Some(m)) = re.find(self.url.as_bytes()) {
859 return format!("{}/{}", &self.url[..m.end()], rest);
860 }
861 return self.url.clone();
862 }
863 url.to_owned()
864 }
865
866 fn contains(root: &RootData, name: &str) -> bool {
868 if root.available_packages.contains(name) {
869 return true;
870 }
871 root.available_patterns
872 .iter()
873 .any(|re| re.is_match(name.as_bytes()).unwrap_or(false))
874 }
875
876 fn cached(&self, cache_key: &str) -> Option<(Value, Option<String>)> {
878 let bytes = self.cache.as_ref()?.read(cache_key)?;
879 let v: Value = serde_json::from_slice(&bytes).ok()?;
880 let lm = v
881 .get("last-modified")
882 .and_then(Value::as_str)
883 .map(str::to_owned);
884 Some((v, lm))
885 }
886
887 fn settle(
892 &self,
893 url: &str,
894 cache_key: &str,
895 cached: Option<(Value, Option<String>)>,
896 result: Result<Fetched, RepoError>,
897 ) -> Result<Option<Value>, RepoError> {
898 let escaped = !cache_key.starts_with("provider-");
901 match result {
902 Ok(Fetched::NotModified) => Ok(cached.map(|(v, _)| v)),
903 Ok(Fetched::NotFound) => Ok(None),
904 Ok(Fetched::Body {
905 bytes,
906 last_modified,
907 }) => {
908 let data: Value = serde_json::from_slice(&bytes)
909 .map_err(|e| RepoError::data(format!("{url}: invalid JSON: {e}")))?;
910 if let Some(cache) = &self.cache {
911 match &last_modified {
912 Some(lm) => {
913 if let Some(encoded) =
914 crate::metacache::MetadataCache::with_last_modified(
915 &data, lm, escaped,
916 )
917 {
918 cache.write(cache_key, &encoded);
919 }
920 }
921 None => cache.write(cache_key, &bytes),
922 }
923 }
924 Ok(Some(data))
925 }
926 Err(e) => {
927 if let Some((v, Some(_))) = cached {
928 if !self.degraded.replace(true) {
929 eprintln!(
930 "Warning: {} could not be fully loaded ({}), package information was loaded from the local cache and may be out of date",
931 self.url, e.0
932 );
933 }
934 return Ok(Some(v));
935 }
936 Err(e)
937 }
938 }
939 }
940
941 fn fetch_cached(&self, url: &str, cache_key: &str) -> Result<Option<Value>, RepoError> {
943 let cached = self.cached(cache_key);
944 let ims = cached.as_ref().and_then(|(_, lm)| lm.clone());
945 let result = self.transport.fetch(url, ims.as_deref());
946 self.settle(url, cache_key, cached, result)
947 }
948
949 fn provider(
952 &self,
953 file_name: &str,
954 package_name: &str,
955 ) -> Result<Option<std::rc::Rc<Value>>, RepoError> {
956 let key = file_name.to_lowercase();
957 if let Some(v) = self.fetched.borrow().get(&key) {
958 return Ok(v.clone());
959 }
960 let Some(template) = &self.root_data()?.lazy_providers_url else {
961 return Err(RepoError::data("startCachedAsyncDownload only supports v2 protocol composer repos with a metadata-url"));
962 };
963 let url = template.replace("%package%", &key);
964 let cache_key = crate::metacache::MetadataCache::provider_key(&key);
965 let data = self.fetch_cached(&url, &cache_key)?;
966 self.fresh_metadata.set(true);
967 let value = Self::parse_provider(package_name, data);
968 self.fetched.borrow_mut().insert(key, value.clone());
969 Ok(value)
970 }
971
972 pub fn repo_name(&self) -> String {
974 format!("composer repo ({})", self.url)
975 }
976
977 pub fn providers_api(&self, package_name: &str) -> Result<Option<Providers>, RepoError> {
982 let Some(template) = self.root_data()?.providers_api_url.clone() else {
983 return Ok(None);
984 };
985 let url = template.replace("%package%", package_name);
986 let body = match self.transport.fetch(&url, None)? {
987 Fetched::Body { bytes, .. } => bytes,
988 Fetched::NotFound | Fetched::NotModified => return Ok(Some(Vec::new())),
989 };
990 let data: Value =
991 serde_json::from_slice(&body).map_err(|e| RepoError::data(format!("{url}: {e}")))?;
992 let mut out: Vec<(String, Option<String>)> = Vec::new();
993 for p in data
994 .get("providers")
995 .and_then(Value::as_array)
996 .into_iter()
997 .flatten()
998 {
999 let Some(name) = p.get("name").and_then(Value::as_str) else {
1000 continue;
1001 };
1002 let description = p
1003 .get("description")
1004 .and_then(Value::as_str)
1005 .map(str::to_owned);
1006 match out.iter_mut().find(|(n, _)| n == name) {
1007 Some(slot) => slot.1 = description,
1008 None => out.push((name.to_owned(), description)),
1009 }
1010 }
1011 Ok(Some(out))
1012 }
1013
1014 pub fn provider_candidates(
1019 &self,
1020 origin: Origin,
1021 arena: &mut Vec<Package>,
1022 ) -> Result<Vec<usize>, RepoError> {
1023 let root = self.root_data()?;
1024 let all: BTreeMap<String, i32> = ["stable", "RC", "beta", "alpha", "dev"]
1025 .iter()
1026 .map(|s| (s.to_string(), crate::version::stability_rank(s)))
1027 .collect();
1028 let flags = BTreeMap::new();
1029 let already = BTreeMap::new();
1030 let mut out: Vec<usize> = Vec::new();
1031 let names: Vec<String> = root
1032 .partial_packages
1033 .iter()
1034 .map(|(n, _)| n.clone())
1035 .collect();
1036 for name in names {
1037 out.extend(
1038 self.what_provides_partial(root, &name, &all, &flags, &already, origin, arena)?,
1039 );
1040 }
1041 if let Some(plain) = &root.plain {
1042 if self.members.get().is_none() {
1043 let configs: Vec<Value> = plain
1044 .iter()
1045 .map(|c| Self::with_notification_url(c, root))
1046 .collect();
1047 let ids = loader::load_packages(&configs, origin, arena, true)
1048 .map_err(|e| RepoError::data(e.0))?;
1049 for &id in &ids {
1050 let mut p = std::mem::replace(&mut arena[id], Package::new("", "", "", origin));
1051 self.configure_package(root, &mut p);
1052 arena[id] = p;
1053 }
1054 let _ = self.members.set(ids);
1055 }
1056 if let Some(members) = self.members.get() {
1057 out.extend(members.iter().copied());
1058 }
1059 }
1060 Ok(out)
1061 }
1062
1063 pub fn has_security_advisories(&self) -> Result<bool, RepoError> {
1065 Ok(self
1066 .root_data_max_age(Some(600))?
1067 .security_advisories
1068 .as_ref()
1069 .is_some_and(|c| c.metadata || c.api_url.is_some()))
1070 }
1071
1072 pub fn get_security_advisories(
1078 &self,
1079 map: &[(String, Constraint)],
1080 allow_partial: bool,
1081 ) -> Result<(Vec<String>, AdvisoriesByName), RepoError> {
1082 let root = self.root_data_max_age(Some(600))?;
1083 let Some(config) = &root.security_advisories else {
1084 return Ok((Vec::new(), Vec::new()));
1085 };
1086 let mut map: Vec<(String, Constraint)> = map
1087 .iter()
1088 .filter(|(n, _)| self.is_allowed(n))
1089 .cloned()
1090 .collect();
1091 if root.has_available_package_list {
1092 map.retain(|(n, _)| Self::contains(root, &n.to_lowercase()));
1093 }
1094 let mut advisories: AdvisoriesByName = Vec::new();
1095 let mut names_found: Vec<String> = Vec::new();
1096 let create = |data: &Value,
1097 name: &str,
1098 wanted: &Constraint|
1099 -> Result<Option<Advisory>, RepoError> {
1100 let Some(adv) = advisory_from_data(name, data) else {
1101 return Ok(None);
1102 };
1103 if !allow_partial && adv.complete.is_none() {
1104 return Err(RepoError::data(format!(
1105 "Advisory for {name} could not be loaded as a full advisory from {}\n{data}",
1106 self.repo_name()
1107 )));
1108 }
1109 if !adv.affected_versions.matches(wanted) {
1110 return Ok(None);
1111 }
1112 Ok(Some(adv))
1113 };
1114 if config.metadata && (allow_partial || config.api_url.is_none()) {
1115 let wanted: Vec<(String, String)> = map
1116 .iter()
1117 .map(|(n, _)| n.to_lowercase())
1118 .filter(|n| !is_platform_package(n) && n != "__root__")
1119 .map(|n| (n.clone(), n))
1120 .collect();
1121 self.prefetch(&wanted)?;
1122 let mut done: Vec<String> = Vec::new();
1123 for (name, constraint) in &map {
1124 let name = name.to_lowercase();
1125 if is_platform_package(&name) || name == "__root__" {
1126 continue;
1127 }
1128 let Some(response) = self.provider(&name, &name)? else {
1129 continue;
1130 };
1131 let Some(list) = response
1132 .get("security-advisories")
1133 .and_then(Value::as_array)
1134 else {
1135 continue;
1136 };
1137 names_found.push(name.clone());
1138 if !list.is_empty() {
1139 let mut found = Vec::new();
1140 for data in list {
1141 if let Some(a) = create(data, &name, constraint)? {
1142 found.push(a);
1143 }
1144 }
1145 advisories.push((name.clone(), found));
1146 }
1147 done.push(name);
1148 }
1149 map.retain(|(n, _)| !done.contains(&n.to_lowercase()));
1150 }
1151 if let (Some(api_url), false) = (&config.api_url, map.is_empty()) {
1152 let body: Vec<String> = map
1153 .iter()
1154 .map(|(n, _)| format!("packages%5B%5D={}", urlencode(n)))
1155 .collect();
1156 let fetched = self.transport.post_form(api_url, &body.join("&"))?;
1157 let bytes = match fetched {
1158 Fetched::Body { bytes, .. } => bytes,
1159 Fetched::NotFound => {
1160 return Err(RepoError::transport(format!(
1161 "The \"{api_url}\" file could not be downloaded (HTTP/404)"
1162 )))
1163 }
1164 Fetched::NotModified => Vec::new(),
1165 };
1166 let data: Value = serde_json::from_slice(&bytes)
1167 .map_err(|e| RepoError::data(format!("{api_url}: {e}")))?;
1168 let mut warned = false;
1169 for (name, list) in data
1170 .get("advisories")
1171 .and_then(Value::as_object)
1172 .into_iter()
1173 .flatten()
1174 {
1175 let Some((_, constraint)) = map.iter().find(|(n, _)| n == name) else {
1176 if !warned {
1177 eprintln!(
1178 "{} 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: {}",
1179 self.repo_name(),
1180 map.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", ")
1181 );
1182 warned = true;
1183 }
1184 continue;
1185 };
1186 let list = list.as_array().cloned().unwrap_or_default();
1187 if !list.is_empty() {
1188 let mut found = Vec::new();
1189 for d in &list {
1190 if let Some(a) = create(d, name, constraint)? {
1191 found.push(a);
1192 }
1193 }
1194 advisories.push((name.clone(), found));
1195 }
1196 names_found.push(name.clone());
1197 }
1198 }
1199 Ok((names_found, advisories))
1200 }
1201
1202 pub fn get_filter_lists(&self) -> Result<Vec<String>, RepoError> {
1205 let Some(disabled) = &self.user_filter else {
1206 return Ok(Vec::new());
1207 };
1208 let root = self.root_data_max_age(Some(600))?;
1209 let Some(f) = root.filter.as_ref().filter(|f| f.metadata) else {
1211 return Ok(Vec::new());
1212 };
1213 Ok(f.lists
1214 .iter()
1215 .filter(|l| !disabled.contains(l))
1216 .cloned()
1217 .collect())
1218 }
1219
1220 pub fn get_filter(
1224 &self,
1225 map: &[(String, Constraint)],
1226 configured_lists: &[String],
1227 ) -> Result<FilterEntriesByList, RepoError> {
1228 let root = self.root_data_max_age(Some(600))?;
1229 let mut map: Vec<(String, Constraint)> = map
1230 .iter()
1231 .filter(|(n, _)| self.is_allowed(n))
1232 .cloned()
1233 .collect();
1234 if root.has_available_package_list {
1235 map.retain(|(n, _)| Self::contains(root, &n.to_lowercase()));
1236 }
1237 let fresh = self.fresh_metadata.get();
1238 if let Some(f) = &root.filter {
1239 if f.api_url.is_some() && !fresh {
1240 return Err(RepoError::data(format!(
1241 "{}: a filter api-url is not supported by vivacity yet",
1242 self.repo_name()
1243 )));
1244 }
1245 if f.summary_url.is_some() && !fresh {
1246 let summary = self.load_filter_summary()?;
1247 let mut candidates: Vec<String> = Vec::new();
1248 for list in configured_lists {
1249 let Some(packages) = summary.iter().find(|(l, _)| l == list) else {
1250 continue;
1251 };
1252 for (package, constraint) in &packages.1 {
1253 let Some((_, wanted)) = map.iter().find(|(n, _)| n == package) else {
1254 continue;
1255 };
1256 if !matches!(wanted, Constraint::MatchAll)
1257 && !crate::constraint::parse_constraints(constraint)
1258 .map_err(|e| RepoError::data(e.to_string()))?
1259 .constraint
1260 .matches(wanted)
1261 {
1262 continue;
1263 }
1264 if !candidates.contains(package) {
1265 candidates.push(package.clone());
1266 }
1267 }
1268 }
1269 map.retain(|(n, _)| candidates.contains(n));
1270 }
1271 }
1272 let wanted: Vec<(String, String)> = map
1273 .iter()
1274 .map(|(n, _)| n.to_lowercase())
1275 .filter(|n| !is_platform_package(n) && n != "__root__")
1276 .map(|n| (n.clone(), n))
1277 .collect();
1278 self.prefetch(&wanted)?;
1279 let mut filter: FilterEntriesByList = Vec::new();
1280 for (name, _) in &map {
1281 let name = name.to_lowercase();
1282 if is_platform_package(&name) || name == "__root__" {
1283 continue;
1284 }
1285 let Some(response) = self.provider(&name, &name)? else {
1286 continue;
1287 };
1288 let Some(raw) = response.get("filter").filter(|v| v.is_object()) else {
1289 continue;
1290 };
1291 for (list, entries) in build_filter_entries(raw, &map, Some(&name))? {
1292 match filter.iter_mut().find(|(l, _)| *l == list) {
1293 Some((_, v)) => v.extend(entries),
1294 None => filter.push((list, entries)),
1295 }
1296 }
1297 }
1298 Ok(filter)
1299 }
1300
1301 fn load_filter_summary(&self) -> Result<FilterSummary, RepoError> {
1304 let root = self.root_data_max_age(Some(600))?;
1305 let Some(url) = root.filter.as_ref().and_then(|f| f.summary_url.clone()) else {
1306 return Ok(Vec::new());
1307 };
1308 let data = self.fetch_cached(&url, "filter-summary.json")?;
1309 let Some(filter) = data
1310 .as_ref()
1311 .and_then(|d| d.get("filter"))
1312 .and_then(Value::as_object)
1313 else {
1314 return Err(RepoError::transport(format!(
1315 "Filter summary URL {url} returned 404 for {}",
1316 self.repo_name()
1317 )));
1318 };
1319 let mut summary: FilterSummary = Vec::new();
1320 for (list, packages) in filter {
1321 let Some(packages) = packages.as_object() else {
1322 return Err(RepoError::data(format!(
1323 "Invalid filter summary received from {}: list \"{list}\" must map to an object of package => constraint",
1324 self.repo_name()
1325 )));
1326 };
1327 let mut entries = Vec::new();
1328 for (name, constraint) in packages {
1329 let Some(c) = constraint.as_str() else {
1330 return Err(RepoError::data(format!(
1331 "Invalid filter summary received from {}: list \"{list}\" entries must be strings",
1332 self.repo_name()
1333 )));
1334 };
1335 entries.push((name.to_lowercase(), c.to_owned()));
1336 }
1337 summary.push((list.clone(), entries));
1338 }
1339 Ok(summary)
1340 }
1341
1342 fn parse_provider(package_name: &str, data: Option<Value>) -> Option<std::rc::Rc<Value>> {
1343 let v = data?;
1344 let has = v
1345 .get("packages")
1346 .and_then(|p| p.get(package_name))
1347 .is_some()
1348 || v.get("security-advisories").is_some()
1349 || v.get("filter").is_some();
1350 if has {
1351 Some(std::rc::Rc::new(v))
1352 } else {
1353 None
1354 }
1355 }
1356
1357 fn prefetch(&self, names: &[(String, String)]) -> Result<(), RepoError> {
1360 let Some(template) = self.root_data()?.lazy_providers_url.clone() else {
1361 return Ok(());
1362 };
1363 let mut todo: Vec<(String, String, String)> = Vec::new();
1364 {
1365 let cache = self.fetched.borrow();
1366 for (file_name, package_name) in names {
1367 let key = file_name.to_lowercase();
1368 if cache.contains_key(&key) || todo.iter().any(|(k, _, _)| *k == key) {
1369 continue;
1370 }
1371 let url = template.replace("%package%", &key);
1372 todo.push((key, package_name.clone(), url));
1373 }
1374 }
1375 if todo.len() < 2 {
1376 return Ok(());
1377 }
1378 let cached: Vec<Option<(Value, Option<String>)>> = todo
1379 .iter()
1380 .map(|(key, _, _)| self.cached(&crate::metacache::MetadataCache::provider_key(key)))
1381 .collect();
1382 let requests: Vec<Request> = todo
1383 .iter()
1384 .zip(&cached)
1385 .map(|((_, _, url), c)| (url.clone(), c.as_ref().and_then(|(_, lm)| lm.clone())))
1386 .collect();
1387 let results = self.transport.fetch_many(&requests);
1388 self.fresh_metadata.set(true);
1389 let mut settled = Vec::with_capacity(todo.len());
1390 for (((key, package_name, url), c), result) in todo.into_iter().zip(cached).zip(results) {
1391 let cache_key = crate::metacache::MetadataCache::provider_key(&key);
1392 let data = self.settle(&url, &cache_key, c, result)?;
1393 settled.push((key, Self::parse_provider(&package_name, data)));
1394 }
1395 let mut memo = self.fetched.borrow_mut();
1396 for (key, value) in settled {
1397 memo.insert(key, value);
1398 }
1399 Ok(())
1400 }
1401
1402 fn is_version_acceptable(
1404 constraint: Option<&Constraint>,
1405 name: &str,
1406 version_data: &Map<String, Value>,
1407 acceptable: &BTreeMap<String, i32>,
1408 flags: &BTreeMap<String, i32>,
1409 ) -> bool {
1410 let mut versions: Vec<String> = Vec::new();
1411 if let Some(v) = version_data
1412 .get("version_normalized")
1413 .and_then(Value::as_str)
1414 {
1415 versions.push(v.to_owned());
1416 }
1417 if let Some(alias) = branch_alias(version_data) {
1418 versions.push(alias);
1419 }
1420 let names = vec![name.to_owned()];
1421 for v in &versions {
1422 if !is_package_acceptable(acceptable, flags, &names, parse_stability(v)) {
1423 continue;
1424 }
1425 if let Some(c) = constraint {
1426 if !c.matches_version(v) {
1427 continue;
1428 }
1429 }
1430 return true;
1431 }
1432 false
1433 }
1434
1435 #[allow(clippy::too_many_arguments)]
1440 fn what_provides_partial(
1441 &self,
1442 root: &RootData,
1443 name: &str,
1444 acceptable: &BTreeMap<String, i32>,
1445 flags: &BTreeMap<String, i32>,
1446 already_loaded: &BTreeMap<String, BTreeSet<String>>,
1447 origin: Origin,
1448 arena: &mut Vec<Package>,
1449 ) -> Result<Vec<usize>, RepoError> {
1450 let Some((_, versions)) = root.partial_packages.iter().find(|(n, _)| n == name) else {
1451 return Ok(Vec::new());
1452 };
1453 let mut to_load: Vec<(String, Value)> = Vec::new();
1454 for v in versions {
1455 let mut data = v.as_object().cloned().unwrap_or_default();
1456 let normalized_name = data
1457 .get("name")
1458 .and_then(Value::as_str)
1459 .unwrap_or("")
1460 .to_lowercase();
1461 if normalized_name != name {
1462 continue;
1463 }
1464 let uid = data
1465 .get("uid")
1466 .map(|u| match u {
1467 Value::String(s) => s.clone(),
1468 other => other.to_string(),
1469 })
1470 .unwrap_or_default();
1471 if to_load.iter().any(|(u, _)| *u == uid) {
1472 continue;
1473 }
1474 Self::fill_version_normalized(&mut data)?;
1475 let normalized = data
1476 .get("version_normalized")
1477 .and_then(Value::as_str)
1478 .unwrap_or("")
1479 .to_owned();
1480 if already_loaded
1481 .get(name)
1482 .is_some_and(|s| s.contains(&normalized))
1483 {
1484 continue;
1485 }
1486 if Self::is_version_acceptable(None, &normalized_name, &data, acceptable, flags) {
1487 to_load.push((uid, Value::Object(data)));
1488 }
1489 }
1490 let mut out = Vec::new();
1491 for (_, config) in &to_load {
1492 let config = Self::with_notification_url(config, root);
1493 let (mut package, alias) =
1494 loader::load(&config, origin, true).map_err(|e| RepoError::data(e.0))?;
1495 self.configure_package(root, &mut package);
1496 let idx = arena.len();
1497 arena.push(package);
1498 out.push(idx);
1499 if let Some((normalized, pretty)) = alias {
1500 let a = arena[idx].alias(idx, &normalized, &pretty);
1501 arena.push(a);
1502 out.push(arena.len() - 1);
1503 }
1504 }
1505 Ok(out)
1506 }
1507
1508 fn configure_package(&self, root: &RootData, p: &mut Package) {
1514 let Some(obj) = p.raw.as_object_mut() else {
1515 return;
1516 };
1517 obj.shift_remove("transport-options");
1518 if let Some(src) = &p.source {
1519 if let Some(mirrors) = root.source_mirrors.get(&src.kind) {
1520 if let Some(Value::Object(s)) = obj.get_mut("source") {
1521 s.insert("mirrors".into(), Value::Array(mirrors.clone()));
1522 }
1523 }
1524 }
1525 if let Some(Value::Object(d)) = obj.get_mut("dist") {
1526 if root.dist_mirrors.is_empty() {
1527 d.shift_remove("mirrors");
1528 } else {
1529 d.insert("mirrors".into(), Value::Array(root.dist_mirrors.clone()));
1530 }
1531 }
1532 if let Some(dist) = &p.dist {
1533 let urls = dist_urls(
1534 dist,
1535 &root.dist_mirrors,
1536 &p.name,
1537 &p.version,
1538 &p.pretty_version,
1539 );
1540 if urls.iter().any(|u| u.starts_with(&self.base_url)) {
1541 let empty = self.options.as_object().is_some_and(Map::is_empty)
1542 || self.options.as_array().is_some_and(Vec::is_empty);
1543 if !empty {
1544 obj.insert("transport-options".into(), self.options.clone());
1545 }
1546 }
1547 }
1548 }
1549
1550 fn add_notification_url(obj: &mut Map<String, Value>, root: &RootData) {
1552 if !obj.contains_key("notification-url") {
1553 obj.insert(
1554 "notification-url".into(),
1555 match &root.notify_url {
1556 Some(u) => Value::String(u.clone()),
1557 None => Value::Null,
1558 },
1559 );
1560 }
1561 }
1562
1563 fn with_notification_url(config: &Value, root: &RootData) -> Value {
1564 let mut config = config.clone();
1565 if let Some(obj) = config.as_object_mut() {
1566 Self::add_notification_url(obj, root);
1567 }
1568 config
1569 }
1570
1571 fn fill_version_normalized(data: &mut Map<String, Value>) -> Result<(), RepoError> {
1574 let pretty = data
1575 .get("version")
1576 .and_then(Value::as_str)
1577 .unwrap_or("")
1578 .to_owned();
1579 match data.get("version_normalized").and_then(Value::as_str) {
1580 None => {
1581 let n = normalize(&pretty, None).map_err(|e| RepoError::data(e.0))?;
1582 data.insert("version_normalized".into(), Value::String(n));
1583 }
1584 Some(v) if v == DEFAULT_BRANCH_ALIAS => {
1585 let n = normalize(&pretty, None).map_err(|e| RepoError::data(e.0))?;
1586 data.insert("version_normalized".into(), Value::String(n));
1587 }
1588 _ => {}
1589 }
1590 Ok(())
1591 }
1592
1593 pub fn load_packages(
1598 &self,
1599 package_name_map: &[(String, Constraint)],
1600 acceptable: &BTreeMap<String, i32>,
1601 flags: &BTreeMap<String, i32>,
1602 already_loaded: &BTreeMap<String, BTreeSet<String>>,
1603 origin: Origin,
1604 arena: &mut Vec<Package>,
1605 ) -> Result<(Vec<String>, Vec<usize>), RepoError> {
1606 let root = self.root_data()?;
1607 if root.v1_protocol {
1608 return Err(RepoError::data(format!(
1609 "{}: Composer v1 repository protocol (providers) is not supported by vivacity",
1610 self.url
1611 )));
1612 }
1613 if let Some(plain) = &root.plain {
1614 if self.members.get().is_none() {
1616 let configs: Vec<Value> = plain
1617 .iter()
1618 .map(|c| Self::with_notification_url(c, root))
1619 .collect();
1620 let ids = loader::load_packages(&configs, origin, arena, true)
1621 .map_err(|e| RepoError::data(e.0))?;
1622 for &id in &ids {
1623 let mut p = std::mem::replace(&mut arena[id], Package::new("", "", "", origin));
1624 self.configure_package(root, &mut p);
1625 arena[id] = p;
1626 }
1627 let _ = self.members.set(ids);
1628 }
1629 let members = self.members.get().expect("just set");
1630 return Ok(crate::pool::array_repository_load_packages(
1631 members,
1632 package_name_map,
1633 acceptable,
1634 flags,
1635 already_loaded,
1636 arena,
1637 ));
1638 }
1639 let mut map: Vec<(String, Constraint)> = package_name_map.to_vec();
1640 let mut packages: Vec<usize> = Vec::new();
1641 let mut names_found: Vec<String> = Vec::new();
1642
1643 if !root.partial_packages.is_empty() {
1644 let mut rest: Vec<(String, Constraint)> = Vec::new();
1645 for (name, constraint) in map {
1646 if !root.partial_packages.iter().any(|(n, _)| *n == name) {
1647 rest.push((name, constraint));
1648 continue;
1649 }
1650 let candidates = self.what_provides_partial(
1651 root,
1652 &name,
1653 acceptable,
1654 flags,
1655 already_loaded,
1656 origin,
1657 arena,
1658 )?;
1659 let mut matches: Vec<usize> = Vec::new();
1660 for &c in &candidates {
1661 if !names_found.contains(&name) {
1662 names_found.push(name.clone());
1663 }
1664 let all = matches!(constraint, Constraint::MatchAll);
1665 if all || constraint.matches_version(&arena[c].version) {
1666 if !matches.contains(&c) {
1667 matches.push(c);
1668 }
1669 if let Some(base) = arena[c].alias_of {
1670 if !matches.contains(&base) {
1671 matches.push(base);
1672 }
1673 }
1674 }
1675 }
1676 for &c in &candidates {
1677 if let Some(base) = arena[c].alias_of {
1678 if matches.contains(&base) && !matches.contains(&c) {
1679 matches.push(c);
1680 }
1681 }
1682 }
1683 packages.extend(matches);
1684 }
1685 map = rest;
1686 }
1687
1688 if root.lazy_providers_url.is_none() || map.is_empty() {
1689 return Ok((names_found, packages));
1690 }
1691 if root.has_available_package_list {
1692 map.retain(|(name, _)| Self::contains(root, &name.to_lowercase()));
1693 }
1694 let only_dev = acceptable.len() == 1 && acceptable.contains_key("dev") && flags.is_empty();
1697 let mut names: Vec<(String, Constraint)> = Vec::new();
1698 let mut dev_names: Vec<(String, Constraint)> = Vec::new();
1699 for (name, c) in &map {
1700 if is_package_acceptable(acceptable, flags, std::slice::from_ref(name), "dev") {
1701 dev_names.push((format!("{name}~dev"), c.clone()));
1702 }
1703 if !only_dev {
1704 names.push((name.clone(), c.clone()));
1705 }
1706 }
1707 names.extend(dev_names);
1708
1709 let wanted: Vec<(String, String)> = names
1710 .iter()
1711 .map(|(n, _)| n.to_lowercase())
1712 .filter(|n| {
1713 let real = n.strip_suffix("~dev").unwrap_or(n);
1714 !is_platform_package(real) && real != "__root__"
1715 })
1716 .map(|n| {
1717 let real = n.strip_suffix("~dev").unwrap_or(&n).to_owned();
1718 (n.clone(), real)
1719 })
1720 .collect();
1721 self.prefetch(&wanted)?;
1722
1723 for (name, constraint) in &names {
1724 let name = name.to_lowercase();
1725 let real_name = name.strip_suffix("~dev").unwrap_or(&name).to_owned();
1726 if is_platform_package(&real_name) || real_name == "__root__" {
1727 continue;
1728 }
1729 let Some(response) = self.provider(&name, &real_name)? else {
1730 continue;
1731 };
1732 let versions: Vec<Value> =
1733 match response.get("packages").and_then(|p| p.get(&real_name)) {
1734 Some(Value::Array(a)) => a.clone(),
1735 Some(Value::Object(o)) => o.values().cloned().collect(),
1736 _ => continue,
1737 };
1738 let versions: Vec<Value> =
1739 if response.get("minified").and_then(Value::as_str) == Some("composer/2.0") {
1740 expand_minified_owned(versions)
1741 } else {
1742 versions
1743 };
1744 if !names_found.contains(&real_name) {
1745 names_found.push(real_name.clone());
1746 }
1747 let mut to_load: Vec<Value> = Vec::new();
1748 for v in versions {
1749 let mut data = match v {
1750 Value::Object(o) => o,
1751 _ => Map::new(),
1752 };
1753 Self::fill_version_normalized(&mut data)?;
1754 let normalized = data
1755 .get("version_normalized")
1756 .and_then(Value::as_str)
1757 .unwrap_or("")
1758 .to_owned();
1759 if already_loaded
1760 .get(&real_name)
1761 .is_some_and(|s| s.contains(&normalized))
1762 {
1763 continue;
1764 }
1765 if Self::is_version_acceptable(
1766 Some(constraint),
1767 &real_name,
1768 &data,
1769 acceptable,
1770 flags,
1771 ) {
1772 Self::add_notification_url(&mut data, root);
1773 to_load.push(Value::Object(data));
1774 }
1775 }
1776 let ids = loader::load_packages(&to_load, origin, arena, true)
1777 .map_err(|e| RepoError::data(e.0))?;
1778 for &id in &ids {
1779 let base = arena[id].alias_of.unwrap_or(id);
1780 let mut p = std::mem::replace(&mut arena[base], Package::new("", "", "", origin));
1781 self.configure_package(root, &mut p);
1782 arena[base] = p;
1783 if base != id {
1784 let mut a = std::mem::replace(&mut arena[id], Package::new("", "", "", origin));
1785 self.configure_package(root, &mut a);
1786 arena[id] = a;
1787 }
1788 }
1789 packages.extend(ids);
1790 }
1791 Ok((names_found, packages))
1792 }
1793}
1794
1795pub fn locked_repository(lock: &Value, arena: &mut Vec<Package>) -> Result<Vec<usize>, RepoError> {
1798 locked_repository_with(lock, arena, true)
1799}
1800
1801pub fn locked_repository_with(
1803 lock: &Value,
1804 arena: &mut Vec<Package>,
1805 with_dev: bool,
1806) -> Result<Vec<usize>, RepoError> {
1807 let mut configs: Vec<Value> = lock
1808 .get("packages")
1809 .and_then(Value::as_array)
1810 .cloned()
1811 .unwrap_or_default();
1812 if with_dev {
1813 match lock.get("packages-dev").and_then(Value::as_array) {
1814 Some(dev) => configs.extend(dev.iter().cloned()),
1815 None => {
1816 return Err(RepoError::data(
1817 "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.",
1818 ))
1819 }
1820 }
1821 }
1822 if configs.is_empty() {
1823 return Ok(Vec::new());
1824 }
1825 let ids = loader::load_packages(&configs, Origin::Locked, arena, false)
1826 .map_err(|e| RepoError::data(e.0))?;
1827 let mut out = ids.clone();
1828 let mut by_name: BTreeMap<String, usize> = BTreeMap::new();
1831 for id in &ids {
1832 by_name.insert(arena[*id].name.clone(), *id);
1833 if let Some(base) = arena[*id].alias_of {
1834 by_name.insert(arena[base].name.clone(), base);
1835 }
1836 }
1837 for alias in lock
1838 .get("aliases")
1839 .and_then(Value::as_array)
1840 .into_iter()
1841 .flatten()
1842 {
1843 let (Some(pkg), Some(alias_normalized), Some(alias_pretty)) = (
1844 alias.get("package").and_then(Value::as_str),
1845 alias.get("alias_normalized").and_then(Value::as_str),
1846 alias.get("alias").and_then(Value::as_str),
1847 ) else {
1848 continue;
1849 };
1850 if let Some(&base) = by_name.get(pkg) {
1851 let mut a = arena[base].alias(base, alias_normalized, alias_pretty);
1852 a.root_package_alias = true;
1853 arena.push(a);
1854 out.push(arena.len() - 1);
1855 }
1856 }
1857 Ok(out)
1858}
1859
1860fn process_mirror_url(
1862 mirror_url: &str,
1863 name: &str,
1864 version: &str,
1865 reference: Option<&str>,
1866 kind: &str,
1867 pretty_version: &str,
1868) -> String {
1869 static HEX: OnceLock<Regex> = OnceLock::new();
1870 let reference = reference.map(|r| {
1871 if r.is_empty() {
1872 String::new()
1873 } else if regex(&HEX, r"^([a-f0-9]*|%reference%)$", false)
1874 .is_match(r.as_bytes())
1875 .unwrap_or(false)
1876 {
1877 r.to_owned()
1878 } else {
1879 vivacity_core::content_hash::md5_hex(r.as_bytes())
1880 }
1881 });
1882 let version = if version.contains('/') {
1883 vivacity_core::content_hash::md5_hex(version.as_bytes())
1884 } else {
1885 version.to_owned()
1886 };
1887 mirror_url
1888 .replace("%package%", name)
1889 .replace("%version%", &version)
1890 .replace("%reference%", reference.as_deref().unwrap_or(""))
1891 .replace("%type%", kind)
1892 .replace("%prettyVersion%", pretty_version)
1893}
1894
1895fn dist_urls(
1898 dist: &crate::package::SourceRef,
1899 mirrors: &[Value],
1900 name: &str,
1901 version: &str,
1902 pretty: &str,
1903) -> Vec<String> {
1904 if dist.url.is_empty() {
1905 return Vec::new();
1906 }
1907 let url = if dist.url.contains('%') {
1908 process_mirror_url(
1909 &dist.url,
1910 name,
1911 version,
1912 dist.reference.as_deref(),
1913 &dist.kind,
1914 pretty,
1915 )
1916 } else {
1917 dist.url.clone()
1918 };
1919 let mut urls = vec![url];
1920 for m in mirrors {
1921 let Some(mu) = m.get("url").and_then(Value::as_str) else {
1922 continue;
1923 };
1924 let mirror_url = process_mirror_url(
1925 mu,
1926 name,
1927 version,
1928 dist.reference.as_deref(),
1929 &dist.kind,
1930 pretty,
1931 );
1932 if !urls.contains(&mirror_url) {
1933 if m.get("preferred") == Some(&Value::Bool(true)) {
1934 urls.insert(0, mirror_url);
1935 } else {
1936 urls.push(mirror_url);
1937 }
1938 }
1939 }
1940 urls
1941}
1942
1943fn urlencode(s: &str) -> String {
1945 let mut out = String::new();
1946 for b in s.bytes() {
1947 match b {
1948 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => out.push(b as char),
1949 b' ' => out.push('+'),
1950 _ => out.push_str(&format!("%{b:02X}")),
1951 }
1952 }
1953 out
1954}
1955
1956#[cfg(test)]
1957mod cache_tests {
1958 use super::*;
1959 use std::cell::RefCell;
1960
1961 struct Scripted {
1963 responses: RefCell<Vec<Fetched>>,
1964 seen: std::rc::Rc<RefCell<Vec<Request>>>,
1965 }
1966
1967 impl Transport for Scripted {
1968 fn fetch(&self, url: &str, ims: Option<&str>) -> Result<Fetched, RepoError> {
1969 self.seen
1970 .borrow_mut()
1971 .push((url.to_owned(), ims.map(str::to_owned)));
1972 let mut responses = self.responses.borrow_mut();
1973 assert!(
1974 !responses.is_empty(),
1975 "unexpected request: {url} (seen: {:?})",
1976 self.seen.borrow()
1977 );
1978 Ok(responses.remove(0))
1979 }
1980 }
1981
1982 fn body(json: &str, lm: Option<&str>) -> Fetched {
1983 Fetched::Body {
1984 bytes: json.as_bytes().to_vec(),
1985 last_modified: lm.map(str::to_owned),
1986 }
1987 }
1988
1989 #[test]
1990 fn revalidates_from_composer_cache() {
1991 let tmp = tempfile::tempdir().expect("tmp");
1992 let cache_dir = tmp.path().join("repo");
1993 let root = r#"{"packages": [], "metadata-url": "/p2/%package%.json"}"#;
1994 let provider = r#"{"packages": {"acme/lib": [{"name": "acme/lib", "version": "1.0.0", "version_normalized": "1.0.0.0"}]}}"#;
1995
1996 let t = Scripted {
1998 responses: RefCell::new(vec![
1999 body(root, Some("Sat, 12 Sep 2026 10:00:00 GMT")),
2000 body(provider, Some("Sun, 13 Sep 2026 09:00:00 GMT")),
2001 Fetched::NotFound,
2002 ]),
2003 seen: std::rc::Rc::new(RefCell::new(Vec::new())),
2004 };
2005 let mut repo =
2006 ComposerRepository::open("https://satis.example.org", Box::new(t)).expect("open");
2007 repo.cache = Some(crate::metacache::MetadataCache::new(&cache_dir, &repo.url));
2008 let mut arena = Vec::new();
2009 let (found, ids) = repo
2010 .load_packages(
2011 &[("acme/lib".to_owned(), Constraint::MatchAll)],
2012 &[("stable".to_owned(), 0)].into_iter().collect(),
2013 &BTreeMap::new(),
2014 &BTreeMap::new(),
2015 Origin::Repository(2),
2016 &mut arena,
2017 )
2018 .expect("load");
2019 assert_eq!(found, vec!["acme/lib"]);
2020 assert_eq!(ids.len(), 1);
2021 let dir = cache_dir.join("https---satis.example.org");
2022 let cached = std::fs::read_to_string(dir.join("provider-acme~lib.json")).expect("cached");
2023 assert!(
2024 cached.ends_with(r#""last-modified":"Sun, 13 Sep 2026 09:00:00 GMT"}"#),
2025 "{cached}"
2026 );
2027 assert!(std::fs::read_to_string(dir.join("packages.json"))
2028 .expect("root cached")
2029 .contains(r#""metadata-url":"\/p2\/%package%.json""#));
2030
2031 let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
2033 let t = Scripted {
2034 responses: RefCell::new(vec![
2035 Fetched::NotModified,
2036 Fetched::NotModified,
2037 Fetched::NotFound,
2038 ]),
2039 seen: seen.clone(),
2040 };
2041 let mut repo =
2042 ComposerRepository::open("https://satis.example.org", Box::new(t)).expect("open");
2043 repo.cache = Some(crate::metacache::MetadataCache::new(&cache_dir, &repo.url));
2044 let mut arena = Vec::new();
2045 let (found, ids) = repo
2046 .load_packages(
2047 &[("acme/lib".to_owned(), Constraint::MatchAll)],
2048 &[("stable".to_owned(), 0)].into_iter().collect(),
2049 &BTreeMap::new(),
2050 &BTreeMap::new(),
2051 Origin::Repository(2),
2052 &mut arena,
2053 )
2054 .expect("load");
2055 assert_eq!(found, vec!["acme/lib"]);
2056 assert_eq!(arena[ids[0]].version, "1.0.0.0");
2057 let seen = seen.borrow();
2058 assert_eq!(seen[0].1.as_deref(), Some("Sat, 12 Sep 2026 10:00:00 GMT"));
2059 assert_eq!(seen[1].0, "https://satis.example.org/p2/acme/lib.json");
2060 assert_eq!(seen[1].1.as_deref(), Some("Sun, 13 Sep 2026 09:00:00 GMT"));
2061 }
2062}