Skip to main content

vivacity_resolver/
platform.rs

1//! Port of `Composer\Repository\PlatformRepository`: the platform packages
2//! (composer*, php*, ext-*, lib-*, hhvm) in Composer's exact order, with the
3//! `config.platform` overrides. Probing the current PHP is done by
4//! assets/platform-probe.php (a transcription of `initialize()`, raw
5//! versions); normalization and its fallbacks are replayed here with the
6//! exact port of VersionParser.
7
8use crate::constraint::{Constraint, Op};
9use crate::package::{Link, LinkType, Links, Origin, Package};
10use crate::version::{group, normalize, regex};
11use pcre2::bytes::Regex;
12use serde_json::{Map, Value};
13use std::collections::BTreeMap;
14use std::process::Command;
15use std::sync::OnceLock;
16
17/// Emulated Composer version and its APIs (Composer::VERSION,
18/// PluginInterface::PLUGIN_API_VERSION, Composer::RUNTIME_API_VERSION).
19pub const COMPOSER_VERSION: &str = "2.10.3";
20pub const PLUGIN_API_VERSION: &str = "2.9.0";
21pub const RUNTIME_API_VERSION: &str = "2.2.2";
22
23const PROBE: &str = include_str!("../assets/platform-probe.php");
24
25#[derive(Debug, thiserror::Error)]
26#[error("{0}")]
27pub struct PlatformError(pub String);
28
29/// `PlatformRepository::PLATFORM_PACKAGE_REGEX`.
30pub fn is_platform_package(name: &str) -> bool {
31    static RE: OnceLock<Regex> = OnceLock::new();
32    let re = regex(
33        &RE,
34        r"^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)\z",
35        true,
36    );
37    re.is_match(name.as_bytes()).unwrap_or(false)
38}
39
40/// A raw probe entry.
41#[derive(Debug, Clone, serde::Deserialize)]
42struct Probed {
43    kind: String,
44    name: String,
45    version: String,
46    #[serde(default)]
47    description: Option<String>,
48    #[serde(default)]
49    replaces: Vec<String>,
50    #[serde(default)]
51    provides: Vec<String>,
52}
53
54/// `XdebugHandler::getAllIniFiles()` from the probe's `ini` entry:
55/// `COMPOSER_ORIGINAL_INIS` (set by a Composer restarted without xdebug)
56/// wins; else `[php_ini_loaded_file()]` + the trimmed scanned files.
57pub fn ini_files(probed: &[Value]) -> Vec<String> {
58    if let Ok(env) = std::env::var("COMPOSER_ORIGINAL_INIS") {
59        let sep = if cfg!(windows) { ';' } else { ':' };
60        return env.split(sep).map(str::to_owned).collect();
61    }
62    let Some(entry) = probed
63        .iter()
64        .find(|v| v.get("kind").and_then(Value::as_str) == Some("ini"))
65    else {
66        return vec![String::new()];
67    };
68    let mut paths = vec![entry
69        .get("loaded")
70        .and_then(Value::as_str)
71        .unwrap_or("")
72        .to_owned()];
73    if let Some(scanned) = entry.get("scanned").and_then(Value::as_str) {
74        paths.extend(scanned.split(',').map(|s| s.trim().to_owned()));
75    }
76    paths
77}
78
79/// The extensions the probed PHP has loaded (`extension_loaded`), before
80/// any `config.platform` override: `ext-<name>` keys, lowercased.
81pub fn loaded_extensions(probed: &[Value]) -> std::collections::BTreeSet<String> {
82    probed
83        .iter()
84        .filter(|v| v.get("kind").and_then(Value::as_str) == Some("ext"))
85        .filter(|v| v.get("skipped").and_then(Value::as_bool) != Some(true))
86        .filter_map(|v| v.get("name").and_then(Value::as_str))
87        .map(|n| format!("ext-{}", n.to_lowercase()))
88        .collect()
89}
90
91/// Identity of a file the probe's result depends on: its path and, when
92/// it exists, its mtime (nanoseconds) and size. An absent file is recorded
93/// as absent, so its appearance invalidates the cache too.
94#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
95struct FileId {
96    path: String,
97    mtime_ns: Option<i128>,
98    size: Option<u64>,
99}
100
101impl FileId {
102    fn of(path: &str) -> FileId {
103        let meta = std::fs::metadata(path).ok();
104        let mtime_ns = meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| {
105            t.duration_since(std::time::UNIX_EPOCH)
106                .ok()
107                .map(|d| d.as_nanos() as i128)
108        });
109        FileId {
110            path: path.to_owned(),
111            mtime_ns,
112            size: meta.map(|m| m.len()),
113        }
114    }
115}
116
117/// The environment the probe's output depends on (xdebug handling in the
118/// probe, PHP's own ini discovery), captured by value.
119const PROBE_ENV: &[&str] = &["PHPRC", "PHP_INI_SCAN_DIR", "XDEBUG_MODE", "XDEBUG_CONFIG"];
120
121/// One cached probe: valid while the php binary, every ini file PHP read
122/// (and the directory it scans), and the relevant environment are the same.
123#[derive(Debug, serde::Serialize, serde::Deserialize)]
124struct ProbeCache {
125    php: FileId,
126    inis: Vec<FileId>,
127    env: Vec<(String, Option<String>)>,
128    probed: Vec<Value>,
129}
130
131/// The files PHP read its configuration from, per the probe's `ini` entry:
132/// the loaded php.ini, the scanned files, and the scan directory itself
133/// (its mtime changes when a file is added or removed).
134fn ini_dependencies(probed: &[Value]) -> Vec<FileId> {
135    let Some(entry) = probed
136        .iter()
137        .find(|v| v.get("kind").and_then(Value::as_str) == Some("ini"))
138    else {
139        return Vec::new();
140    };
141    let mut paths: Vec<String> = Vec::new();
142    for key in ["loaded", "scan_dir"] {
143        if let Some(p) = entry.get(key).and_then(Value::as_str) {
144            if !p.is_empty() {
145                paths.push(p.to_owned());
146            }
147        }
148    }
149    if let Some(scanned) = entry.get("scanned").and_then(Value::as_str) {
150        paths.extend(
151            scanned
152                .split(',')
153                .map(str::trim)
154                .filter(|s| !s.is_empty())
155                .map(str::to_owned),
156        );
157    }
158    paths.iter().map(|p| FileId::of(p)).collect()
159}
160
161fn probe_env() -> Vec<(String, Option<String>)> {
162    PROBE_ENV
163        .iter()
164        .map(|k| ((*k).to_owned(), std::env::var(k).ok()))
165        .collect()
166}
167
168/// The php binary `php` names: as given when it holds a path separator,
169/// else the first executable of that name on PATH (with PATHEXT's `.exe`,
170/// `.bat`, `.cmd` on Windows), canonicalized. None when there is none.
171fn locate_php(php: &str) -> Option<std::path::PathBuf> {
172    let candidates: Vec<std::path::PathBuf> = if php.contains('/') || php.contains('\\') {
173        vec![std::path::PathBuf::from(php)]
174    } else {
175        let exts: &[&str] = if cfg!(windows) {
176            &["", ".exe", ".bat", ".cmd"]
177        } else {
178            &[""]
179        };
180        std::env::var_os("PATH")
181            .map(|p| {
182                std::env::split_paths(&p)
183                    .flat_map(|dir| exts.iter().map(move |e| dir.join(format!("{php}{e}"))))
184                    .collect()
185            })
186            .unwrap_or_default()
187    };
188    candidates
189        .into_iter()
190        .find(|c| is_executable(c))
191        .and_then(|c| vivacity_core::pathutil::canonicalize(c).ok())
192}
193
194#[cfg(unix)]
195fn is_executable(path: &std::path::Path) -> bool {
196    use std::os::unix::fs::PermissionsExt;
197    std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
198}
199
200#[cfg(not(unix))]
201fn is_executable(path: &std::path::Path) -> bool {
202    std::fs::metadata(path).is_ok_and(|m| m.is_file())
203}
204
205fn probe_cache_file() -> std::path::PathBuf {
206    vivacity_core::platform::cache_dir().join("platform-probe.json")
207}
208
209/// Runs the probe on the current PHP (`VIVACITY_PHP` or `php`), through a
210/// disk cache: a php process costs 30–60 ms, most of a no-op install. The
211/// cache is keyed on what the result depends on — the php binary (path,
212/// mtime, size), every ini file PHP loaded or scanned plus the scan
213/// directory, and PHPRC / PHP_INI_SCAN_DIR / XDEBUG_MODE / XDEBUG_CONFIG.
214/// `VIVACITY_NO_PLATFORM_CACHE=1` bypasses it.
215pub fn probe() -> Result<Vec<Value>, PlatformError> {
216    let php = std::env::var("VIVACITY_PHP").unwrap_or_else(|_| "php".to_owned());
217    if std::env::var_os("VIVACITY_NO_PLATFORM_CACHE").is_some() {
218        return run_probe(&php);
219    }
220    let Some(php_path) = locate_php(&php) else {
221        return run_probe(&php);
222    };
223    let php_id = FileId::of(&php_path.to_string_lossy());
224    let env = probe_env();
225    let cache_file = probe_cache_file();
226    if let Ok(bytes) = std::fs::read(&cache_file) {
227        if let Ok(cached) = serde_json::from_slice::<ProbeCache>(&bytes) {
228            if cached.php == php_id
229                && cached.env == env
230                && cached.inis.iter().all(|f| FileId::of(&f.path) == *f)
231            {
232                return Ok(cached.probed);
233            }
234        }
235    }
236    let probed = run_probe(&php)?;
237    let cache = ProbeCache {
238        php: php_id,
239        inis: ini_dependencies(&probed),
240        env,
241        probed: probed.clone(),
242    };
243    if let Some(dir) = cache_file.parent() {
244        if std::fs::create_dir_all(dir).is_ok() {
245            if let Ok(json) = serde_json::to_vec(&cache) {
246                let tmp = cache_file.with_extension(format!("json.{}.tmp", std::process::id()));
247                if std::fs::write(&tmp, json).is_ok() && std::fs::rename(&tmp, &cache_file).is_err()
248                {
249                    let _ = std::fs::remove_file(&tmp);
250                }
251            }
252        }
253    }
254    Ok(probed)
255}
256
257/// One real run of assets/platform-probe.php.
258fn run_probe(php: &str) -> Result<Vec<Value>, PlatformError> {
259    let dir = tempfile::tempdir().map_err(|e| PlatformError(e.to_string()))?;
260    let script = dir.path().join("platform-probe.php");
261    std::fs::write(&script, PROBE).map_err(|e| PlatformError(e.to_string()))?;
262    let out = Command::new(php)
263        .arg(&script)
264        .output()
265        .map_err(|e| PlatformError(format!("cannot run {php}: {e}")))?;
266    if !out.status.success() {
267        return Err(PlatformError(format!(
268            "platform probe failed: {}",
269            String::from_utf8_lossy(&out.stderr)
270        )));
271    }
272    serde_json::from_slice(&out.stdout)
273        .map_err(|e| PlatformError(format!("platform probe output: {e}")))
274}
275
276struct Override {
277    name: String,
278    version: Option<String>,
279}
280
281/// The ordered list of platform packages (`getPackages()`), `probed` being
282/// the probe output and `overrides` `config.platform`.
283pub fn platform_packages(
284    probed: &[Value],
285    overrides_cfg: &Map<String, Value>,
286) -> Result<Vec<Package>, PlatformError> {
287    // Insertion order of `config.platform` (PHP array), not sorted.
288    let mut overrides: Vec<(String, Override)> = Vec::new();
289    for (name, version) in overrides_cfg {
290        let v = match version {
291            Value::String(s) => Some(s.clone()),
292            Value::Bool(false) => None,
293            other => {
294                return Err(PlatformError(format!(
295                    "config.platform.{name} should be a string or false, but got {other}"
296                )))
297            }
298        };
299        if name == "php" && v.is_none() {
300            return Err(PlatformError(
301                "config.platform.php cannot be set to false as you cannot disable php entirely."
302                    .into(),
303            ));
304        }
305        let key = name.to_lowercase();
306        let o = Override {
307            name: name.clone(),
308            version: v,
309        };
310        match overrides.iter_mut().find(|(k, _)| *k == key) {
311            Some(slot) => slot.1 = o,
312            None => overrides.push((key, o)),
313        }
314    }
315    let override_of = |name: &str| overrides.iter().find(|(k, _)| k == name).map(|(_, o)| o);
316
317    let mut packages: Vec<Package> = Vec::new();
318    let mut libraries: BTreeMap<String, bool> = BTreeMap::new();
319
320    // addOverriddenPackage
321    let add_overridden = |packages: &mut Vec<Package>,
322                          o: &Override,
323                          name: Option<&str>|
324     -> Result<(), PlatformError> {
325        let Some(pretty) = &o.version else {
326            return Ok(());
327        };
328        let version = normalize(pretty, None).map_err(|e| PlatformError(e.0))?;
329        let mut p = Package::new(name.unwrap_or(&o.name), &version, pretty, Origin::Platform);
330        p.raw = serde_json::json!({"description": "Package overridden via config.platform", "extra": {"config.platform": true}});
331        packages.push(p);
332        Ok(())
333    };
334    for (_, o) in &overrides {
335        if !is_platform_package(&o.name) {
336            return Err(PlatformError(format!(
337                "Invalid platform package name in config.platform: {}",
338                o.name
339            )));
340        }
341        if o.version.is_some() {
342            add_overridden(&mut packages, o, None)?;
343        }
344    }
345    // The actual version is appended to the override's description
346    // (`, same as actual` / `, actual: x.y.z`).
347    let note_actual = |packages: &mut [Package], actual: &Package| {
348        if let Some(over) = packages.iter_mut().find(|q| q.name == actual.name) {
349            let text = if actual.version == over.version {
350                "same as actual".to_owned()
351            } else {
352                format!("actual: {}", actual.pretty_version)
353            };
354            let description = over
355                .raw
356                .get("description")
357                .and_then(serde_json::Value::as_str)
358                .unwrap_or("")
359                .to_owned();
360            over.raw["description"] = serde_json::Value::String(format!("{description}, {text}"));
361        }
362    };
363    // addPackage
364    let add = |packages: &mut Vec<Package>, p: Package| -> Result<(), PlatformError> {
365        if let Some(o) = override_of(&p.name) {
366            if o.version.is_none() {
367                return Ok(()); // disabled
368            }
369            note_actual(packages, &p);
370            return Ok(()); // already added by the override
371        }
372        if let Some(php) = override_of("php") {
373            if p.name.starts_with("php-") {
374                add_overridden(packages, php, Some(&p.pretty_name))?;
375                note_actual(packages, &p);
376                return Ok(());
377            }
378        }
379        packages.push(p);
380        Ok(())
381    };
382    let simple = |name: &str, pretty: &str, description: &str| -> Result<Package, PlatformError> {
383        let version = normalize(pretty, None).map_err(|e| PlatformError(e.0))?;
384        let mut p = Package::new(name, &version, pretty, Origin::Platform);
385        p.raw = serde_json::json!({"description": description});
386        Ok(p)
387    };
388    add(
389        &mut packages,
390        simple("composer", COMPOSER_VERSION, "Composer package")?,
391    )?;
392    add(
393        &mut packages,
394        simple(
395            "composer-plugin-api",
396            PLUGIN_API_VERSION,
397            "The Composer Plugin API",
398        )?,
399    )?;
400    add(
401        &mut packages,
402        simple(
403            "composer-runtime-api",
404            RUNTIME_API_VERSION,
405            "The Composer Runtime API",
406        )?,
407    )?;
408
409    let entries: Vec<Probed> = probed
410        .iter()
411        .map(|v| {
412            serde_json::from_value(v.clone())
413                .map_err(|e| PlatformError(format!("probe entry: {e}")))
414        })
415        .collect::<Result<_, _>>()?;
416
417    static PHP_FALLBACK: OnceLock<Regex> = OnceLock::new();
418    static EXT_FALLBACK: OnceLock<Regex> = OnceLock::new();
419    for e in &entries {
420        match e.kind.as_str() {
421            "php" => {
422                let (version, pretty) = match normalize(&e.version, None) {
423                    Ok(v) => (v, e.version.clone()),
424                    Err(_) => {
425                        let re = regex(&PHP_FALLBACK, r"^([^~+-]+).*$", false);
426                        let pretty = match re.captures(e.version.as_bytes()) {
427                            Ok(Some(caps)) => group(&caps, 1).to_owned(),
428                            _ => e.version.clone(),
429                        };
430                        let v = normalize(&pretty, None).map_err(|x| PlatformError(x.0))?;
431                        (v, pretty)
432                    }
433                };
434                let mut p = Package::new(&e.name, &version, &pretty, Origin::Platform);
435                p.raw = serde_json::json!({"description": e.description});
436                add(&mut packages, p)?;
437            }
438            "ext" => {
439                let mut extra_description = String::new();
440                let (version, pretty) = match normalize(&e.version, None) {
441                    Ok(v) => (v, e.version.clone()),
442                    Err(_) => {
443                        extra_description = format!(" (actual version: {})", e.version);
444                        let re = regex(&EXT_FALLBACK, r"^(\d+\.\d+\.\d+(?:\.\d+)?)", false);
445                        let pretty = match re.captures(e.version.as_bytes()) {
446                            Ok(Some(caps)) => group(&caps, 1).to_owned(),
447                            _ => "0".to_owned(),
448                        };
449                        let v = normalize(&pretty, None).map_err(|x| PlatformError(x.0))?;
450                        (v, pretty)
451                    }
452                };
453                let package_name = format!("ext-{}", e.name.to_lowercase().replace(' ', "-"));
454                let mut p = Package::new(&package_name, &version, &pretty, Origin::Platform);
455                p.package_type = "php-ext".to_owned();
456                p.raw = serde_json::json!({"description": format!("The {} PHP extension{extra_description}", e.name)});
457                if e.name == "uuid" {
458                    p.replaces.insert(Link::new(
459                        "ext-uuid",
460                        "lib-uuid",
461                        Constraint::new(Op::Eq, version.clone()),
462                        &pretty,
463                        LinkType::Replace,
464                    ));
465                }
466                add(&mut packages, p)?;
467            }
468            "lib" => {
469                let Ok(version) = normalize(&e.version, None) else {
470                    continue;
471                };
472                let lib_name = format!("lib-{}", e.name);
473                if !is_platform_package(&lib_name) || libraries.contains_key(&lib_name) {
474                    continue;
475                }
476                libraries.insert(lib_name.clone(), true);
477                let description = e
478                    .description
479                    .clone()
480                    .unwrap_or_else(|| format!("The {} library", e.name));
481                let mut p = Package::new(&lib_name, &version, &e.version, Origin::Platform);
482                p.raw = serde_json::json!({"description": description});
483                let mut replaces = Links::default();
484                for r in &e.replaces {
485                    let r = r.to_lowercase();
486                    // PHP key = bare name, target = `lib-<name>` (addLibrary).
487                    replaces.insert(Link {
488                        key: Some(r.clone()),
489                        source: lib_name.clone(),
490                        target: format!("lib-{r}"),
491                        constraint: Constraint::new(Op::Eq, version.clone()),
492                        pretty_constraint: e.version.clone(),
493                        kind: LinkType::Replace,
494                    });
495                }
496                let mut provides = Links::default();
497                for pr in &e.provides {
498                    let pr = pr.to_lowercase();
499                    provides.insert(Link {
500                        key: Some(pr.clone()),
501                        source: lib_name.clone(),
502                        target: format!("lib-{pr}"),
503                        constraint: Constraint::new(Op::Eq, version.clone()),
504                        pretty_constraint: e.version.clone(),
505                        kind: LinkType::Provide,
506                    });
507                }
508                p.replaces = replaces;
509                p.provides = provides;
510                add(&mut packages, p)?;
511            }
512            _ => {}
513        }
514    }
515    Ok(packages)
516}
517
518/// `install`'s platform verification on the full platform repository
519/// (`PlatformRepository`: php, extensions, `lib-*` libraries, the
520/// composer-*-api packages, `config.platform` overrides), the way
521/// `Installer::doInstall` checks the lock: every platform requirement of
522/// the lock's `platform`/`platform-dev` sections and of the wanted
523/// packages must be satisfied by a platform package's name, or by one of
524/// its `provide`/`replace` links (`lib-dom-libxml` through `lib-libxml`,
525/// `php-64bit` through `php`).
526pub fn check_install(
527    lock: &vivacity_core::lock::Lock,
528    platform: &[Package],
529    with_dev: bool,
530    ignored: &[String],
531) -> Vec<vivacity_core::platform::PlatformFailure> {
532    use vivacity_core::platform::{FailureReason, PlatformFailure};
533    let mut reqs: Vec<(String, String, Option<String>)> = Vec::new();
534    for (name, cons) in &lock.platform {
535        reqs.push((name.clone(), cons.clone(), None));
536    }
537    if with_dev {
538        for (name, cons) in &lock.platform_dev {
539            reqs.push((name.clone(), cons.clone(), None));
540        }
541    }
542    for p in lock.wanted_packages(with_dev) {
543        if let Some(require) = p.raw.get("require").and_then(Value::as_object) {
544            for (name, cons) in require {
545                let lname = name.to_ascii_lowercase();
546                if is_platform_package(&lname) {
547                    if let Some(c) = cons.as_str() {
548                        reqs.push((lname, c.to_owned(), Some(p.name().to_owned())));
549                    }
550                }
551            }
552        }
553    }
554    let mut failures = Vec::new();
555    for (requirement, cons, required_by) in reqs {
556        if vivacity_core::platform::is_ignored(&requirement, ignored) {
557            continue;
558        }
559        // The package itself, else a link providing/replacing the name.
560        let mut candidates: Vec<(String, String)> = Vec::new();
561        for p in platform {
562            if p.name == requirement {
563                candidates.push((p.version.clone(), p.pretty_version.clone()));
564            }
565            for link in p.provides.iter().chain(p.replaces.iter()) {
566                if link.target == requirement {
567                    let v = match &link.constraint {
568                        Constraint::Single {
569                            op: Op::Eq,
570                            version,
571                        } => version.clone(),
572                        _ => continue,
573                    };
574                    candidates.push((v, link.pretty_constraint.clone()));
575                }
576            }
577        }
578        if candidates.is_empty() {
579            failures.push(PlatformFailure {
580                requirement,
581                constraint: cons,
582                required_by,
583                reason: FailureReason::Missing,
584            });
585            continue;
586        }
587        let Ok(parsed) = crate::constraint::parse_constraints(&cons) else {
588            failures.push(PlatformFailure {
589                requirement,
590                constraint: cons,
591                required_by,
592                reason: FailureReason::Unsupported,
593            });
594            continue;
595        };
596        if !candidates
597            .iter()
598            .any(|(v, _)| parsed.constraint.matches_version(v))
599        {
600            failures.push(PlatformFailure {
601                requirement,
602                constraint: cons,
603                required_by,
604                reason: FailureReason::Mismatch {
605                    installed: candidates[0].1.trim_start_matches("== ").to_owned(),
606                },
607            });
608        }
609    }
610    failures
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    /// A probed platform: PHP 8.2.5 (64-bit) with mbstring and intl.
618    fn probed() -> Vec<Value> {
619        vec![
620            serde_json::json!({"kind": "php", "name": "php", "version": "8.2.5", "description": "The PHP interpreter"}),
621            serde_json::json!({"kind": "php", "name": "php-64bit", "version": "8.2.5", "description": "x"}),
622            serde_json::json!({"kind": "ext", "name": "mbstring", "version": "8.2.5"}),
623            serde_json::json!({"kind": "ext", "name": "intl", "version": "8.2.5"}),
624        ]
625    }
626
627    fn platform_with(overrides: Value) -> Vec<Package> {
628        let empty = Map::new();
629        let overrides = overrides.as_object().unwrap_or(&empty).clone();
630        platform_packages(&probed(), &overrides).expect("platform")
631    }
632
633    fn lock(platform: Value, packages: Value) -> vivacity_core::lock::Lock {
634        vivacity_core::lock::Lock::parse(
635            &serde_json::json!({ "packages": packages, "platform": platform }).to_string(),
636        )
637        .expect("lock")
638    }
639
640    #[test]
641    fn satisfied_lock_passes() {
642        let l = lock(
643            serde_json::json!({"php": ">=8.1", "ext-mbstring": "*"}),
644            serde_json::json!([{"name":"a/b","version":"1.0","require":{"php":"^8.0","ext-intl":"*"}}]),
645        );
646        assert!(check_install(&l, &platform_with(Value::Null), true, &[]).is_empty());
647    }
648
649    #[test]
650    fn reports_mismatch_and_missing() {
651        use vivacity_core::platform::FailureReason;
652        let l = lock(
653            serde_json::json!({"php": ">=8.3", "ext-gd": "*", "lib-icu": ">=70"}),
654            serde_json::json!([]),
655        );
656        let f = check_install(&l, &platform_with(Value::Null), true, &[]);
657        assert_eq!(f.len(), 3, "{f:?}");
658        assert_eq!(
659            f[0].reason,
660            FailureReason::Mismatch {
661                installed: "8.2.5".to_owned()
662            }
663        );
664        assert_eq!(f[1].reason, FailureReason::Missing);
665        assert_eq!(f[2].reason, FailureReason::Missing);
666    }
667
668    #[test]
669    fn package_requirements_are_checked_and_attributed() {
670        let l = lock(
671            serde_json::json!({}),
672            serde_json::json!([{"name":"a/b","version":"1.0","require":{"php":">=8.3","some/dep":"^1.0"}}]),
673        );
674        let f = check_install(&l, &platform_with(Value::Null), true, &[]);
675        assert_eq!(f.len(), 1);
676        assert_eq!(f[0].required_by.as_deref(), Some("a/b"));
677        assert_eq!(f[0].requirement, "php");
678    }
679
680    #[test]
681    fn ignore_patterns_work() {
682        let l = lock(
683            serde_json::json!({"php": ">=9.0", "ext-gd": "*"}),
684            serde_json::json!([]),
685        );
686        let p = platform_with(Value::Null);
687        assert_eq!(check_install(&l, &p, true, &[]).len(), 2);
688        assert!(check_install(&l, &p, true, &["*".to_owned()]).is_empty());
689        assert_eq!(check_install(&l, &p, true, &["ext-*".to_owned()]).len(), 1);
690        assert_eq!(check_install(&l, &p, true, &["php".to_owned()]).len(), 1);
691        assert_eq!(
692            check_install(&l, &p, true, &["ext-gd+".to_owned()]).len(),
693            1
694        );
695    }
696
697    #[test]
698    fn config_platform_overrides_apply_to_the_check() {
699        let l = lock(
700            serde_json::json!({"php": ">=8.3", "ext-gd": "*", "ext-intl": "*"}),
701            serde_json::json!([]),
702        );
703        let p = platform_with(
704            serde_json::json!({"php": "8.3.0", "ext-gd": "8.3.0", "ext-intl": false}),
705        );
706        let f = check_install(&l, &p, true, &[]);
707        assert_eq!(f.len(), 1, "{f:?}");
708        assert_eq!(f[0].requirement, "ext-intl");
709    }
710
711    #[test]
712    fn probe_cache_watches_every_ini_file_and_the_scan_dir() {
713        let dir = tempfile::tempdir().expect("tempdir");
714        let ini = dir.path().join("php.ini");
715        std::fs::write(&ini, "memory_limit=1G\n").expect("write");
716        let scan = dir.path().join("conf.d");
717        std::fs::create_dir(&scan).expect("mkdir");
718        let ext = scan.join("ext-mbstring.ini");
719        std::fs::write(&ext, "extension=mbstring\n").expect("write");
720        let probed = vec![serde_json::json!({
721            "kind": "ini", "name": "ini", "version": "",
722            "loaded": ini.to_string_lossy(),
723            "scanned": format!("{},\n{}", ext.to_string_lossy(), ext.to_string_lossy()),
724            "scan_dir": scan.to_string_lossy(),
725        })];
726        let deps = ini_dependencies(&probed);
727        let paths: Vec<&str> = deps.iter().map(|f| f.path.as_str()).collect();
728        assert_eq!(
729            paths,
730            vec![
731                ini.to_string_lossy().as_ref(),
732                scan.to_string_lossy().as_ref(),
733                ext.to_string_lossy().as_ref(),
734                ext.to_string_lossy().as_ref()
735            ]
736        );
737        assert!(deps
738            .iter()
739            .all(|f| f.mtime_ns.is_some() && f.size.is_some()));
740        // Unchanged: every id still matches.
741        assert!(deps.iter().all(|f| FileId::of(&f.path) == *f));
742        // A file edited (size changes) or removed invalidates.
743        std::fs::write(&ext, "extension=mbstring\nextension=intl\n").expect("write");
744        assert!(deps.iter().any(|f| FileId::of(&f.path) != *f));
745        std::fs::remove_file(&ext).expect("rm");
746        assert_eq!(FileId::of(&ext.to_string_lossy()).mtime_ns, None);
747        // No loaded php.ini ('' in the probe): nothing to watch for it, the
748        // scan dir still is.
749        let probed = vec![
750            serde_json::json!({"kind": "ini", "loaded": "", "scanned": null,
751                                            "scan_dir": scan.to_string_lossy()}),
752        ];
753        assert_eq!(ini_dependencies(&probed).len(), 1);
754    }
755
756    #[test]
757    fn locate_php_resolves_an_explicit_path_only_when_executable() {
758        let dir = tempfile::tempdir().expect("tempdir");
759        let plain = dir.path().join("php");
760        std::fs::write(&plain, "").expect("write");
761        #[cfg(unix)]
762        {
763            assert_eq!(locate_php(&plain.to_string_lossy()), None);
764            use std::os::unix::fs::PermissionsExt;
765            std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755))
766                .expect("chmod");
767        }
768        assert!(locate_php(&plain.to_string_lossy()).is_some());
769        assert_eq!(
770            locate_php(&dir.path().join("missing").to_string_lossy()),
771            None
772        );
773    }
774
775    #[test]
776    fn platform_names() {
777        assert!(is_platform_package("php"));
778        assert!(is_platform_package("ext-mbstring"));
779        assert!(is_platform_package("lib-icu-cldr"));
780        assert!(is_platform_package("composer-plugin-api"));
781        assert!(!is_platform_package("php-http/discovery"));
782        assert!(!is_platform_package("ext-"));
783    }
784
785    #[test]
786    fn builds_from_a_probe_with_overrides() {
787        let probed = vec![
788            serde_json::json!({"kind": "php", "name": "php", "version": "8.5.10", "description": "The PHP interpreter"}),
789            serde_json::json!({"kind": "php", "name": "php-64bit", "version": "8.5.10", "description": "x"}),
790            serde_json::json!({"kind": "ext", "name": "Zend OPcache", "version": "8.5.10"}),
791            serde_json::json!({"kind": "ext", "name": "weird", "version": "not a version"}),
792            serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
793            serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
794            serde_json::json!({"kind": "lib", "name": "libxml", "version": "2.13.4", "description": "libxml library version", "replaces": [], "provides": ["dom-libxml"]}),
795        ];
796        let mut overrides = Map::new();
797        overrides.insert("php".into(), Value::String("8.2.0".into()));
798        overrides.insert("ext-weird".into(), Value::Bool(false));
799        let pk = platform_packages(&probed, &overrides).unwrap();
800        let names: Vec<&str> = pk.iter().map(|p| p.name.as_str()).collect();
801        assert_eq!(
802            names,
803            vec![
804                "php",
805                "composer",
806                "composer-plugin-api",
807                "composer-runtime-api",
808                "php-64bit",
809                "ext-zend-opcache",
810                "lib-libsodium",
811                "lib-libxml"
812            ]
813        );
814        assert_eq!(pk[0].version, "8.2.0.0");
815        assert_eq!(
816            pk[4].version, "8.2.0.0",
817            "php-64bit takes the overridden php version"
818        );
819        // PHP key without `lib-`, target with it (addLibrary).
820        let link = pk[7].provides.get("dom-libxml").unwrap();
821        assert_eq!(link.target, "lib-dom-libxml");
822        assert_eq!(link.constraint.to_string(), "== 2.13.4.0");
823        assert!(pk[7].provides.get("lib-dom-libxml").is_none());
824        // Insertion order of the overrides, not alphabetical.
825        let mut overrides = Map::new();
826        overrides.insert("php".into(), Value::String("8.2.0".into()));
827        overrides.insert("ext-mbstring".into(), Value::String("1.0".into()));
828        overrides.insert("Ext-Json".into(), Value::String("2.0".into()));
829        let pk = platform_packages(&probed, &overrides).unwrap();
830        let names: Vec<&str> = pk.iter().take(3).map(|p| p.name.as_str()).collect();
831        assert_eq!(names, vec!["php", "ext-mbstring", "ext-json"]);
832    }
833}