Skip to main content

vivacity_resolver/
repository.rs

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