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