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    /// Something Composer reads that vivacity does not (a `path` package
29    /// versioned by hg, fossil or svn): the command goes to Composer.
30    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
48/// The message of a request the network refused because it is disabled
49/// (`HttpDownloader` under `COMPOSER_DISABLE_NETWORK`, status 499; vivacity's
50/// `--offline`), followed by the URL. `asyncFetchFile` answers it like a 404.
51pub const NETWORK_DISABLED: &str = "Network disabled, request canceled: ";
52
53/// Fetching a URL: `Ok(None)` = 404 (unknown package, tolerated by
54/// Composer over HTTP).
55/// Result of a conditional fetch (`If-Modified-Since`).
56#[derive(Debug, Clone)]
57pub enum Fetched {
58    /// 304: the cache is good.
59    NotModified,
60    /// 404: unknown package (tolerated by Composer over HTTP).
61    NotFound,
62    Body {
63        bytes: Vec<u8>,
64        /// `Last-Modified` header of the response.
65        last_modified: Option<String>,
66    },
67}
68
69/// A request: URL and optional `If-Modified-Since` (the `last-modified`
70/// value of the cached file).
71pub type Request = (String, Option<String>);
72
73pub trait Transport {
74    fn fetch(&self, url: &str, if_modified_since: Option<&str>) -> Result<Fetched, RepoError>;
75    /// POST `application/x-www-form-urlencoded` (Packagist's security
76    /// advisories API); the body is already encoded. Rejected by default.
77    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    /// Several requests at once (a `loadAsyncPackages` batch, which
83    /// Composer downloads in parallel); results in order. Sequential by
84    /// default.
85    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
93/// Network fetch provided by the caller (`https://`), `Ok(None)` on 404.
94pub type HttpFetch =
95    std::sync::Arc<dyn Fn(&str, Option<&str>) -> Result<Fetched, String> + Send + Sync>;
96/// Batch variant: all requests in parallel, results in order.
97pub type HttpFetchMany =
98    std::sync::Arc<dyn Fn(&[Request]) -> Vec<Result<Fetched, String>> + Send + Sync>;
99
100/// `ComposerRepository::ADVISORY_API_BATCH_SIZE` (Composer 2.11): names per
101/// security-advisories request.
102pub const ADVISORY_API_BATCH_SIZE: usize = 500;
103
104/// POST of an encoded form; `Ok(None)` on 404.
105pub type HttpPost = std::sync::Arc<dyn Fn(&str, &str) -> Result<Fetched, String> + Send + Sync>;
106/// A caller's three network closures: conditional GET, batch, POST.
107pub 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
141/// `file://`: a missing file is fatal, as in Composer; no `Last-Modified`,
142/// hence never a 304.
143pub 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
163/// `StabilityFilter::isPackageAcceptable`.
164pub 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
182/// `BasePackage::packageNameToRegexp`.
183fn 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/// `loadRootServerFile`: what packages.json provides.
192#[derive(Debug, Default)]
193struct RootData {
194    lazy_providers_url: Option<String>,
195    /// `providers-api` (`%package%` template): who provides a name.
196    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    /// `partialPackagesByName`: inline packages of packages.json, by name
202    /// (order of appearance).
203    partial_packages: Vec<(String, Vec<Value>)>,
204    /// `mirrors` of packages.json: `sourceMirrors[type]` and `distMirrors`
205    /// (`[{url, preferred}]`).
206    source_mirrors: BTreeMap<String, Vec<Value>>,
207    dist_mirrors: Vec<Value>,
208    /// Repository without `metadata-url` or providers: all the metadata
209    /// (`packages` + `includes`), in `loadIncludes` order.
210    plain: Option<Vec<Value>>,
211    /// Repository using the v1 protocol (`providers-url`...): rejected for
212    /// resolution.
213    v1_protocol: bool,
214    /// `security-advisories` of packages.json: `metadata`, `api-url`.
215    security_advisories: Option<AdvisoryConfig>,
216    /// `filter` of packages.json (`ComposerRepositoryFilterInformation`).
217    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    /// Advertised and enabled lists, reserved names excluded.
230    pub lists: Vec<String>,
231    pub summary_url: Option<String>,
232    pub api_url: Option<String>,
233}
234
235/// A security advisory as Composer loads it: partial (`advisoryId`,
236/// `affectedVersions`) or complete (with `title`, `sources`, `reportedAt`).
237#[derive(Debug, Clone)]
238pub struct Advisory {
239    pub package_name: String,
240    pub advisory_id: String,
241    pub affected_versions: Constraint,
242    /// `SecurityAdvisory`: cve, severity, `remoteId` of the sources.
243    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    /// `SecurityAdvisory::$link` (the messages print it as a hyperlink).
252    pub link: Option<String>,
253}
254
255/// `PartialSecurityAdvisory::create`: constraint parsed with its two
256/// fallbacks, complete if `title`, `sources` and `reportedAt` are present.
257pub 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
310/// `getProviders` entries: `(name, description)`.
311pub type Providers = Vec<(String, Option<String>)>;
312
313/// Advisories by package name (`[name => [advisory...]]`).
314pub type AdvisoriesByName = Vec<(String, Vec<Advisory>)>;
315/// List entries by list name.
316pub type FilterEntriesByList = Vec<(String, Vec<FilterEntry>)>;
317/// List summary: list -> (name, constraint).
318type FilterSummary = Vec<(String, Vec<(String, String)>)>;
319
320/// `FilterListEntry`: a version flagged by a list.
321#[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    /// The same `FilterListEntry` object (one entry covers every version
334    /// its constraint matches): identity by content.
335    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
346/// `FilterListEntryBuilder::build`: entries per list, restricted to the
347/// requested names and the versions that concern them.
348fn 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
402/// `PolicyConfig::RESERVED_NAMES` + `FUTURE_RESERVED_NAMES`: list names a
403/// repository cannot advertise; the `ignore` prefix is reserved too.
404const 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    /// `options` of the repository definition (transport-options of the
423    /// packages whose dist URL is under `base_url`).
424    pub options: Value,
425    packages_json_url: String,
426    transport: Box<dyn Transport>,
427    /// Loaded on the first `loadPackages`, as in Composer.
428    root: std::cell::OnceCell<RootData>,
429    /// `provider-<name>.json` files already read (in-memory cache of the run).
430    fetched: std::cell::RefCell<BTreeMap<String, Option<std::rc::Rc<Value>>>>,
431    /// Full repository: arena indices of its packages once loaded
432    /// (`getPackages()`), [alias, base] per aliased version.
433    members: std::cell::OnceCell<Vec<usize>>,
434    /// Metadata cache in Composer's format (`cache-repo-dir`).
435    pub cache: Option<crate::metacache::MetadataCache>,
436    /// The repository was already reported in degraded mode (network down,
437    /// cache used): a single warning.
438    degraded: std::cell::Cell<bool>,
439    /// `hold_output`: the warnings this repository writes while it loads,
440    /// kept for the caller instead of printed — a repository working on a
441    /// thread of its own would otherwise write them at whatever point the
442    /// main thread has reached.
443    held: Option<std::cell::RefCell<Vec<String>>>,
444    /// `filter` option of the repository definition: `None` = `false` (no
445    /// list), otherwise the disabled lists.
446    pub user_filter: Option<Vec<String>>,
447    /// `freshMetadataUrls`: a metadata file was loaded in this process (the
448    /// `summary-url`/`api-url` paths of the lists are then ignored).
449    fresh_metadata: std::cell::Cell<bool>,
450    /// `FilterRepository` (`only` / `exclude` of the definition): the names
451    /// this repository serves; the advisory and list paths are limited to
452    /// them.
453    name_filter: Option<NameFilter>,
454}
455
456/// `only` (allow) or `exclude` (deny) a list of patterns.
457pub struct NameFilter {
458    regex: Regex,
459    only: bool,
460}
461
462/// PHP `empty()` on a JSON value.
463fn 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
474/// Path of a URL (without scheme, host, query or fragment).
475fn 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    /// Constructor (no reading: `loadRootServerFile` is lazy).
492    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        // `getPackagesJsonUrl`: `.json` looked up in the path only.
521        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    /// From now on, keep the warnings written while loading
545    /// (`take_held`) instead of printing them.
546    pub fn hold_output(&mut self) {
547        self.held = Some(std::cell::RefCell::new(Vec::new()));
548    }
549
550    /// The warnings kept since `hold_output`, in the order they were written.
551    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    /// `FilterRepository::__construct`: `only` or `exclude` (not both).
559    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    /// `FilterRepository::isAllowed`.
606    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    /// `parseUserFilterConfig` of the repository's `filter` option.
621    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    /// `loadRootServerFile`, once.
655    fn root_data(&self) -> Result<&RootData, RepoError> {
656        self.root_data_max_age(None)
657    }
658
659    /// `loadRootServerFile($rootMaxAge)`: with a maximum age, a more recent
660    /// cached packages.json is taken without a request (the advisory and
661    /// list paths pass 600 s).
662    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            // The v1 protocol is not ported for resolution; its packages.json
800            // files remain readable for what they declare (advisories,
801            // lists), as Composer does.
802            r.v1_protocol = true;
803        }
804        if has_partial {
805            // `initializePartialPackages`: keyed by the `name` of each
806            // version, not by the array key.
807            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            // "Full" repository (Satis, static `packages.json`): all packages
830            // come from `packages` and the `includes` (`loadIncludes`),
831            // loaded in one go like `initialize()`.
832            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    /// `loadIncludes($data)`: metadata from `packages` (by name, by
839    /// version) then from the `includes` files, recursively.
840    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    /// `canonicalizeUrl`.
889    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    /// `lazyProvidersRepoContains`.
902    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    /// Cache read: (decoded JSON, `last-modified`).
912    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    /// `fetchFile` / `fetchFileIfLastModified` / `asyncFetchFile` + `Cache`:
923    /// after the response, what Composer keeps: 304 -> the cache; 404 ->
924    /// nothing (not written); 200 -> the JSON, re-encoded with
925    /// `last-modified` if the header is there, written as is otherwise.
926    ///
927    /// A transport error degrades the repository — its warning once — the
928    /// way the flavour of the request does (`asynchronous`: the package
929    /// files and the filter summary, `asyncFetchFile`; otherwise
930    /// packages.json and its includes, `fetchFile`):
931    /// - `fetchFile`: any cached copy is used, with the warning; no copy,
932    ///   the error;
933    /// - `fetchFileIfLastModified` (a conditional request, so a cached copy
934    ///   with its `last-modified`): the warning and the copy;
935    /// - `asyncFetchFile`: the warning in any case, then the copy if the
936    ///   request was conditional; else a request the network refused
937    ///   ([`NETWORK_DISABLED`], Composer's status 499) answers like a 404;
938    ///   else the error.
939    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        // `fetchFile` (packages.json, includes) encodes with flags 0,
948        // `asyncFetchFile` (package files) without escaping.
949        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    /// A repository file, through the conditional cache; `asynchronous` as
1010    /// in [`Self::settle`].
1011    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    /// `startCachedAsyncDownload`: the JSON of a name's p2 file (with
1024    /// `~dev`), None on 404 or without the expected key.
1025    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    /// `getRepoName`.
1047    pub fn repo_name(&self) -> String {
1048        format!("composer repo ({})", self.url)
1049    }
1050
1051    /// `getProviders` through the `providers-api` of packages.json:
1052    /// `None` when the repository declares none (the caller then walks
1053    /// the loaded packages), `Some(list)` otherwise — `(name, description)`
1054    /// entries, empty on 404.
1055    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    /// The packages `getProviders` walks without the providers API: the
1089    /// partial packages of packages.json (every version, every stability)
1090    /// and the plain `packages` list (`parent::getProviders` over
1091    /// `getPackages()`). Empty for a lazy p2-only repository.
1092    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    /// `hasSecurityAdvisories`.
1138    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    /// `getSecurityAdvisories`: advisories by name for the requested
1147    /// constraints: metadata path (p2 files, partial advisories) then API
1148    /// (POST) for what remains. `allow_partial` false = complete load
1149    /// required (error if an embedded advisory is only partial and no API
1150    /// can complete it).
1151    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            // Composer 2.11 (`ADVISORY_API_BATCH_SIZE`, ported ahead of the
1227            // 2.10.3 reference): one POST per 500 names. Each name is one
1228            // form input, and PHP truncates `$_POST` past `max_input_vars`
1229            // (1000 by default) without an error — a bigger lock lost
1230            // advisories silently. Responses handled in batch order.
1231            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    /// `hasFilter` / `getFilterLists`: the advertised lists, minus those
1299    /// the repository's `filter` option disables.
1300    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        // `hasFilter()`: without `metadata`, the repository is not a provider.
1306        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    /// `getFilter`: the list entries for the requested constraints: API
1317    /// (not ported: error), otherwise summary then p2 files of the
1318    /// candidates, otherwise p2 files of all names.
1319    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    /// `loadFilterSummary`: `summary.json` (cache `filter-summary.json`,
1398    /// conditional request) -> list -> name (lowercase) -> constraint.
1399    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    /// The files of a batch not yet cached, downloaded at once
1454    /// (`loadAsyncPackages` starts all promises before waiting).
1455    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    /// `isVersionAcceptable`.
1499    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    /// `whatProvides` restricted to the inline packages of packages.json:
1532    /// versions of the name, deduplicated by `uid`, filtered by stability,
1533    /// and loaded in batch; [base, alias] per version (`$result[$uid]`,
1534    /// `$result[$uid.'-alias']`).
1535    #[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    /// Continuation of `createPackages`: `setSourceMirrors` (per type),
1605    /// `setDistMirrors` (always, overwrites those of the metadata),
1606    /// `configurePackageTransportOptions` (the repository's `options` if a
1607    /// dist URL is under `baseUrl`); and the metadata's `transport-options`
1608    /// are not loaded (`loadOptions` false).
1609    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    /// `createPackages`: `$data['notification-url'] ??= $this->notifyUrl`.
1647    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    /// `version_normalized` absent or equal to the default branch alias ->
1668    /// recomputed from `version`.
1669    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    /// `loadPackages`: inline packages first (`whatProvides`), then the v2
1690    /// path (`loadAsyncPackages`). Returns `(namesFound, ids)`;
1691    /// `already_loaded`: name -> normalized versions already in the pool
1692    /// for this repository.
1693    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            // `parent::loadPackages` (ArrayRepository) on `getPackages()`.
1711            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        // `$packageNames[$name.'~dev'] = $constraint` (appended at the end);
1791        // dev only -> the bare name is removed.
1792        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
1891/// `Locker::getLockedRepository(true)`'s `RuntimeException` (Locker.php:157-165)
1892/// for a lock without `packages-dev`.
1893pub 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
1895/// `Locker::getLockedRepository(true)`: lock packages (+ dev) then the
1896/// root aliases (`aliases`), each alias before its package.
1897pub fn locked_repository(lock: &Value, arena: &mut Vec<Package>) -> Result<Vec<usize>, RepoError> {
1898    locked_repository_with(lock, arena, true)
1899}
1900
1901/// `Locker::getLockedRepository($withDevReqs)`.
1902pub fn locked_repository_with(
1903    lock: &Value,
1904    arena: &mut Vec<Package>,
1905    with_dev: bool,
1906) -> Result<Vec<usize>, RepoError> {
1907    // Borrowed, not copied: the lock's `packages` array can be hundreds of
1908    // kilobytes and every entry is cloned again into `Package::raw`.
1909    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    // `$packageByName[$name] = $package`: for an alias, both names point
1927    // (alias -> last write wins: the base package).
1928    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
1958/// `ComposerMirror::processUrl`.
1959fn 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
1993/// `Package::getDistUrls`: the URL (placeholders processed) then the
1994/// mirrors, preferred ones first.
1995fn 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
2041/// `http_build_query`: RFC 1738 encoding of a value (`/` -> `%2F`).
2042fn 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    /// Fake transport: answers according to a script and records requests.
2060    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        // First run: 200 with Last-Modified -> written to the cache.
2095        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        // Second run: If-Modified-Since sent, 304 -> served from the cache.
2130        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    /// Answers packages.json from a script, fails every other request with
2168    /// `error`.
2169    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        // `asyncFetchFile`'s reject: the warning (no `Warning:` prefix, the
2221        // URL sanitized), then status 499 read as a 404 — no package, no error.
2222        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        // `fetchFile`: a copy without `last-modified` still serves, with the
2253        // warning; without a copy, the error.
2254        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    /// Serves packages.json, records every POST body and answers each with
2291    /// an advisory for the last name of that batch.
2292    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        // One advisory per batch, in batch order: the names past the first
2345        // batch are not lost.
2346        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}