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