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    #[test]
618    fn probe_cache_watches_every_ini_file_and_the_scan_dir() {
619        let dir = tempfile::tempdir().expect("tempdir");
620        let ini = dir.path().join("php.ini");
621        std::fs::write(&ini, "memory_limit=1G\n").expect("write");
622        let scan = dir.path().join("conf.d");
623        std::fs::create_dir(&scan).expect("mkdir");
624        let ext = scan.join("ext-mbstring.ini");
625        std::fs::write(&ext, "extension=mbstring\n").expect("write");
626        let probed = vec![serde_json::json!({
627            "kind": "ini", "name": "ini", "version": "",
628            "loaded": ini.to_string_lossy(),
629            "scanned": format!("{},\n{}", ext.to_string_lossy(), ext.to_string_lossy()),
630            "scan_dir": scan.to_string_lossy(),
631        })];
632        let deps = ini_dependencies(&probed);
633        let paths: Vec<&str> = deps.iter().map(|f| f.path.as_str()).collect();
634        assert_eq!(
635            paths,
636            vec![
637                ini.to_string_lossy().as_ref(),
638                scan.to_string_lossy().as_ref(),
639                ext.to_string_lossy().as_ref(),
640                ext.to_string_lossy().as_ref()
641            ]
642        );
643        assert!(deps
644            .iter()
645            .all(|f| f.mtime_ns.is_some() && f.size.is_some()));
646        // Unchanged: every id still matches.
647        assert!(deps.iter().all(|f| FileId::of(&f.path) == *f));
648        // A file edited (size changes) or removed invalidates.
649        std::fs::write(&ext, "extension=mbstring\nextension=intl\n").expect("write");
650        assert!(deps.iter().any(|f| FileId::of(&f.path) != *f));
651        std::fs::remove_file(&ext).expect("rm");
652        assert_eq!(FileId::of(&ext.to_string_lossy()).mtime_ns, None);
653        // No loaded php.ini ('' in the probe): nothing to watch for it, the
654        // scan dir still is.
655        let probed = vec![
656            serde_json::json!({"kind": "ini", "loaded": "", "scanned": null,
657                                            "scan_dir": scan.to_string_lossy()}),
658        ];
659        assert_eq!(ini_dependencies(&probed).len(), 1);
660    }
661
662    #[test]
663    fn locate_php_resolves_an_explicit_path_only_when_executable() {
664        let dir = tempfile::tempdir().expect("tempdir");
665        let plain = dir.path().join("php");
666        std::fs::write(&plain, "").expect("write");
667        #[cfg(unix)]
668        {
669            assert_eq!(locate_php(&plain.to_string_lossy()), None);
670            use std::os::unix::fs::PermissionsExt;
671            std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755))
672                .expect("chmod");
673        }
674        assert!(locate_php(&plain.to_string_lossy()).is_some());
675        assert_eq!(
676            locate_php(&dir.path().join("missing").to_string_lossy()),
677            None
678        );
679    }
680
681    #[test]
682    fn platform_names() {
683        assert!(is_platform_package("php"));
684        assert!(is_platform_package("ext-mbstring"));
685        assert!(is_platform_package("lib-icu-cldr"));
686        assert!(is_platform_package("composer-plugin-api"));
687        assert!(!is_platform_package("php-http/discovery"));
688        assert!(!is_platform_package("ext-"));
689    }
690
691    #[test]
692    fn builds_from_a_probe_with_overrides() {
693        let probed = vec![
694            serde_json::json!({"kind": "php", "name": "php", "version": "8.5.10", "description": "The PHP interpreter"}),
695            serde_json::json!({"kind": "php", "name": "php-64bit", "version": "8.5.10", "description": "x"}),
696            serde_json::json!({"kind": "ext", "name": "Zend OPcache", "version": "8.5.10"}),
697            serde_json::json!({"kind": "ext", "name": "weird", "version": "not a version"}),
698            serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
699            serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
700            serde_json::json!({"kind": "lib", "name": "libxml", "version": "2.13.4", "description": "libxml library version", "replaces": [], "provides": ["dom-libxml"]}),
701        ];
702        let mut overrides = Map::new();
703        overrides.insert("php".into(), Value::String("8.2.0".into()));
704        overrides.insert("ext-weird".into(), Value::Bool(false));
705        let pk = platform_packages(&probed, &overrides).unwrap();
706        let names: Vec<&str> = pk.iter().map(|p| p.name.as_str()).collect();
707        assert_eq!(
708            names,
709            vec![
710                "php",
711                "composer",
712                "composer-plugin-api",
713                "composer-runtime-api",
714                "php-64bit",
715                "ext-zend-opcache",
716                "lib-libsodium",
717                "lib-libxml"
718            ]
719        );
720        assert_eq!(pk[0].version, "8.2.0.0");
721        assert_eq!(
722            pk[4].version, "8.2.0.0",
723            "php-64bit takes the overridden php version"
724        );
725        // PHP key without `lib-`, target with it (addLibrary).
726        let link = pk[7].provides.get("dom-libxml").unwrap();
727        assert_eq!(link.target, "lib-dom-libxml");
728        assert_eq!(link.constraint.to_string(), "== 2.13.4.0");
729        assert!(pk[7].provides.get("lib-dom-libxml").is_none());
730        // Insertion order of the overrides, not alphabetical.
731        let mut overrides = Map::new();
732        overrides.insert("php".into(), Value::String("8.2.0".into()));
733        overrides.insert("ext-mbstring".into(), Value::String("1.0".into()));
734        overrides.insert("Ext-Json".into(), Value::String("2.0".into()));
735        let pk = platform_packages(&probed, &overrides).unwrap();
736        let names: Vec<&str> = pk.iter().take(3).map(|p| p.name.as_str()).collect();
737        assert_eq!(names, vec!["php", "ext-mbstring", "ext-json"]);
738    }
739}