Skip to main content

vivacity_resolver/
repository.rs

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