Skip to main content

vivacity_resolver/
repository.rs

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