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 it
92/// exists, its mtime (nanoseconds), size, and on unix its ctime and inode — a
93/// copy made with `cp -p`, `tar` or `rsync -t` keeps the mtime, it cannot keep
94/// the ctime. An absent file is recorded as absent, so its appearance
95/// invalidates the cache too.
96#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
97struct FileId {
98    path: String,
99    mtime_ns: Option<i128>,
100    size: Option<u64>,
101    ctime_ns: Option<i128>,
102    ino: Option<u64>,
103}
104
105impl FileId {
106    fn of(path: &str) -> FileId {
107        FileId::from_meta(path, std::fs::metadata(path).ok())
108    }
109
110    fn from_meta(path: &str, meta: Option<std::fs::Metadata>) -> FileId {
111        let mtime_ns = meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| {
112            t.duration_since(std::time::UNIX_EPOCH)
113                .ok()
114                .map(|d| d.as_nanos() as i128)
115        });
116        #[cfg(unix)]
117        let (ctime_ns, ino) = {
118            use std::os::unix::fs::MetadataExt as _;
119            (
120                meta.as_ref()
121                    .map(|m| i128::from(m.ctime()) * 1_000_000_000 + i128::from(m.ctime_nsec())),
122                meta.as_ref().map(|m| m.ino()),
123            )
124        };
125        #[cfg(not(unix))]
126        let (ctime_ns, ino) = (None, None);
127        FileId {
128            path: path.to_owned(),
129            mtime_ns,
130            size: meta.map(|m| m.len()),
131            ctime_ns,
132            ino,
133        }
134    }
135
136    fn exists(&self) -> bool {
137        self.size.is_some()
138    }
139}
140
141/// The environment the probe's output depends on, captured by value: the
142/// xdebug handling in the probe (`COMPOSER_ALLOW_XDEBUG` was read by the probe
143/// and missing here until 0.21), PHP's own ini discovery, and what the dynamic
144/// linker reads to decide WHICH libraries to load — a change there loads other
145/// files without touching any file the key watches.
146const PROBE_ENV: &[&str] = &[
147    "PHPRC",
148    "PHP_INI_SCAN_DIR",
149    "XDEBUG_MODE",
150    "XDEBUG_CONFIG",
151    "COMPOSER_ALLOW_XDEBUG",
152    "LD_LIBRARY_PATH",
153    "LD_PRELOAD",
154    "DYLD_LIBRARY_PATH",
155    "DYLD_FALLBACK_LIBRARY_PATH",
156    "DYLD_INSERT_LIBRARIES",
157];
158
159/// Bumped whenever the key's shape or meaning changes, and COMPARED on read: an
160/// older cache must not validate by the accident of a field that deserialises
161/// to a default.
162const CACHE_FORMAT: u32 = 2;
163
164/// One cached probe, valid while everything it was derived from is the same:
165/// the probe script itself, the php binary, every ini file PHP read, the
166/// environment, every image the process loaded, the package-manager links
167/// those images were reached through, and the system's own markers.
168#[derive(Debug, serde::Serialize, serde::Deserialize)]
169struct ProbeCache {
170    format: u32,
171    script: String,
172    php: FileId,
173    inis: Vec<FileId>,
174    env: Vec<(String, Option<String>)>,
175    images: Vec<FileId>,
176    links: Vec<(String, Option<String>)>,
177    markers: Vec<FileId>,
178    probed: Vec<Value>,
179}
180
181/// The files PHP read its configuration from, per the probe's `ini` entry:
182/// the loaded php.ini, the scanned files, and the scan directory itself
183/// (its mtime changes when a file is added or removed).
184fn ini_dependencies(probed: &[Value]) -> Vec<FileId> {
185    let Some(entry) = probed
186        .iter()
187        .find(|v| v.get("kind").and_then(Value::as_str) == Some("ini"))
188    else {
189        return Vec::new();
190    };
191    let mut paths: Vec<String> = Vec::new();
192    for key in ["loaded", "scan_dir"] {
193        if let Some(p) = entry.get(key).and_then(Value::as_str) {
194            if !p.is_empty() {
195                paths.push(p.to_owned());
196            }
197        }
198    }
199    if let Some(scanned) = entry.get("scanned").and_then(Value::as_str) {
200        paths.extend(
201            scanned
202                .split(',')
203                .map(str::trim)
204                .filter(|s| !s.is_empty())
205                .map(str::to_owned),
206        );
207    }
208    paths.iter().map(|p| FileId::of(p)).collect()
209}
210
211fn probe_env() -> Vec<(String, Option<String>)> {
212    PROBE_ENV
213        .iter()
214        .map(|k| ((*k).to_owned(), std::env::var(k).ok()))
215        .collect()
216}
217
218/// The probe script, by content: a vivacity that ships a different
219/// transcription must not serve the output of the previous one.
220fn script_hash() -> String {
221    use sha1::Digest as _;
222    let digest = sha1::Sha1::digest(PROBE.as_bytes());
223    digest.iter().map(|b| format!("{b:02x}")).collect()
224}
225
226/// For every image under a Homebrew-style keg — `<prefix>/Cellar/<formula>/…` —
227/// the link `<prefix>/opt/<formula>` and where it points. Measured on
228/// 2026-10-08: `brew upgrade pcre2` left `Cellar/pcre2/10.47_1` untouched next
229/// to `Cellar/pcre2/10.49`, and only the link moved; a key of resolved paths
230/// stayed valid across the upgrade. The link's target is what moves, so it is
231/// what the key holds — exact, and blind to a `brew install` of anything else.
232fn keg_links(images: &[String]) -> Vec<(String, Option<String>)> {
233    let mut seen = std::collections::BTreeMap::new();
234    for image in images {
235        let Some(at) = image.find("/Cellar/") else {
236            continue;
237        };
238        let prefix = &image[..at];
239        let Some(formula) = image[at + "/Cellar/".len()..].split('/').next() else {
240            continue;
241        };
242        if formula.is_empty() {
243            continue;
244        }
245        let link = format!("{prefix}/opt/{formula}");
246        seen.entry(link.clone()).or_insert_with(|| read_link(&link));
247    }
248    seen.into_iter().collect()
249}
250
251fn read_link(path: &str) -> Option<String> {
252    std::fs::read_link(path)
253        .ok()
254        .map(|t| t.to_string_lossy().into_owned())
255}
256
257/// Files that change when the system's libraries or their data change, whatever
258/// the images say: the dynamic linker's cache, the package databases (they also
259/// move for data files no image list can see — tzdata, ICU), and on macOS the
260/// dyld shared cache, where `/usr/lib` and the system frameworks actually live.
261fn system_markers() -> Vec<FileId> {
262    let mut out: Vec<FileId> = [
263        "/etc/ld.so.cache",
264        "/var/lib/dpkg/status",
265        "/var/lib/rpm/rpmdb.sqlite",
266        "/var/lib/rpm/Packages",
267        "/lib/apk/db/installed",
268        "/var/lib/pacman/local",
269    ]
270    .iter()
271    .map(|p| FileId::of(p))
272    .filter(FileId::exists)
273    .collect();
274    for dir in [
275        "/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld",
276        "/System/Library/dyld",
277    ] {
278        if let Ok(entries) = std::fs::read_dir(dir) {
279            let mut caches: Vec<FileId> = entries
280                .flatten()
281                .filter(|e| {
282                    e.file_name()
283                        .to_string_lossy()
284                        .starts_with("dyld_shared_cache_")
285                })
286                .map(|e| FileId::from_meta(&e.path().to_string_lossy(), e.metadata().ok()))
287                .collect();
288            caches.sort_by(|a, b| a.path.cmp(&b.path));
289            out.extend(caches);
290        }
291    }
292    out
293}
294
295/// The `opt` directories a `brew` operation rewrites: the standard Homebrew
296/// prefixes, and the one the php binary lives in when it lives in a keg. A
297/// concurrent `brew upgrade` touches one of them, which is what the race check
298/// needs to see — the images' own prefixes are only known after the run.
299fn brew_opt_dirs(php_path: &str) -> Vec<FileId> {
300    let mut dirs: Vec<String> = ["/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"]
301        .iter()
302        .map(|p| format!("{p}/opt"))
303        .collect();
304    if let Some(at) = php_path.find("/Cellar/") {
305        let own = format!("{}/opt", &php_path[..at]);
306        if !dirs.contains(&own) {
307            dirs.push(own);
308        }
309    }
310    dirs.iter()
311        .map(|d| FileId::of(d))
312        .filter(FileId::exists)
313        .collect()
314}
315
316/// The php binary `php` names: as given when it holds a path separator,
317/// else the first executable of that name on PATH (with PATHEXT's `.exe`,
318/// `.bat`, `.cmd` on Windows), canonicalized. None when there is none.
319fn locate_php(php: &str) -> Option<std::path::PathBuf> {
320    let candidates: Vec<std::path::PathBuf> = if php.contains('/') || php.contains('\\') {
321        vec![std::path::PathBuf::from(php)]
322    } else {
323        let exts: &[&str] = if cfg!(windows) {
324            &["", ".exe", ".bat", ".cmd"]
325        } else {
326            &[""]
327        };
328        std::env::var_os("PATH")
329            .map(|p| {
330                std::env::split_paths(&p)
331                    .flat_map(|dir| exts.iter().map(move |e| dir.join(format!("{php}{e}"))))
332                    .collect()
333            })
334            .unwrap_or_default()
335    };
336    candidates
337        .into_iter()
338        .find(|c| is_executable(c))
339        .and_then(|c| vivacity_core::pathutil::canonicalize(c).ok())
340}
341
342#[cfg(unix)]
343fn is_executable(path: &std::path::Path) -> bool {
344    use std::os::unix::fs::PermissionsExt;
345    std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
346}
347
348#[cfg(not(unix))]
349fn is_executable(path: &std::path::Path) -> bool {
350    std::fs::metadata(path).is_ok_and(|m| m.is_file())
351}
352
353fn probe_cache_file() -> std::path::PathBuf {
354    vivacity_core::platform::cache_dir().join("platform-probe.json")
355}
356
357/// Runs the probe on the current PHP (`VIVACITY_PHP` or `php`), through a
358/// disk cache: a php process costs 30–60 ms, most of a no-op install.
359///
360/// The cache only exists for a php whose loaded files it can SEE (DECISIONS
361/// 2026-10-09): the probe must report that it is the binary that was located,
362/// and on unix the images it loaded must have been listed. A wrapper script,
363/// a version-manager shim, a hardened-runtime binary that drops `DYLD_*`,
364/// a system without `/proc`: the probe runs every time instead, which costs
365/// 40 ms where a stale cache costs a lock Composer would not write.
366/// `VIVACITY_NO_PLATFORM_CACHE=1` bypasses it outright.
367pub fn probe() -> Result<Vec<Value>, PlatformError> {
368    let php = std::env::var("VIVACITY_PHP").unwrap_or_else(|_| "php".to_owned());
369    if std::env::var_os("VIVACITY_NO_PLATFORM_CACHE").is_some() {
370        return run_probe(&php).map(|r| r.entries);
371    }
372    probe_at(&php, &probe_cache_file())
373}
374
375/// `probe` with the php and the cache file as arguments: the seam the tests
376/// use to drive a fake php against a cache of their own.
377fn probe_at(php: &str, cache_file: &std::path::Path) -> Result<Vec<Value>, PlatformError> {
378    let Some(php_path) = locate_php(php) else {
379        return run_probe(php).map(|r| r.entries);
380    };
381    let php_path = php_path.to_string_lossy().into_owned();
382    let php_id = FileId::of(&php_path);
383    let env = probe_env();
384    let script = script_hash();
385    if let Ok(bytes) = std::fs::read(cache_file) {
386        if let Ok(cached) = serde_json::from_slice::<ProbeCache>(&bytes) {
387            if cached.format == CACHE_FORMAT
388                && cached.script == script
389                && cached.php == php_id
390                && cached.env == env
391                && cached.inis.iter().all(|f| FileId::of(&f.path) == *f)
392                && cached.images.iter().all(|f| FileId::of(&f.path) == *f)
393                && cached.links.iter().all(|(l, t)| read_link(l) == *t)
394                && cached.markers == system_markers()
395            {
396                return Ok(cached.probed);
397            }
398        }
399    }
400    // Read once, before the run, and stored as read: the race check below
401    // proves they did not move while PHP answered, so these are the values
402    // the answer goes with.
403    let markers = system_markers();
404    let opt_before = brew_opt_dirs(&php_path);
405    let run = run_probe(php)?;
406    let Some(images) = run.loaded_files() else {
407        return Ok(run.entries);
408    };
409    // The php that answered must be the php that was located: a wrapper or a
410    // shim that picks another binary would make the key watch the wrong file.
411    if run.binary.as_deref() != Some(php_path.as_str()) {
412        return Ok(run.entries);
413    }
414    // Something moved while PHP was answering (a `brew upgrade`, a package
415    // install): the answer may belong to either side of it, so it is not kept.
416    if system_markers() != markers || brew_opt_dirs(&php_path) != opt_before {
417        return Ok(run.entries);
418    }
419    let cache = ProbeCache {
420        format: CACHE_FORMAT,
421        script,
422        php: php_id,
423        inis: ini_dependencies(&run.entries),
424        env,
425        links: keg_links(&images.iter().map(|f| f.path.clone()).collect::<Vec<_>>()),
426        images,
427        markers,
428        probed: run.entries.clone(),
429    };
430    if let Some(dir) = cache_file.parent() {
431        if std::fs::create_dir_all(dir).is_ok() {
432            if let Ok(json) = serde_json::to_vec(&cache) {
433                let tmp = cache_file.with_extension(format!("json.{}.tmp", std::process::id()));
434                if std::fs::write(&tmp, json).is_ok() && std::fs::rename(&tmp, cache_file).is_err()
435                {
436                    let _ = std::fs::remove_file(&tmp);
437                }
438            }
439        }
440    }
441    Ok(run.entries)
442}
443
444/// One real run of the probe: Composer's entries, and what this php loaded —
445/// the key's raw material, never the resolution's.
446struct ProbeRun {
447    entries: Vec<Value>,
448    binary: Option<String>,
449    #[cfg_attr(not(windows), allow(dead_code))]
450    ext_dir: Option<String>,
451    /// The images the process mapped (`/proc/self/maps` on Linux, the dyld
452    /// trace on macOS), or `None` when they could not be seen.
453    #[cfg_attr(windows, allow(dead_code))]
454    images: Option<Vec<String>>,
455}
456
457impl ProbeRun {
458    /// The files whose identity the cache holds, or `None` when this php's
459    /// loaded files cannot be seen — and then nothing is cached. On unix, the
460    /// images that exist on disk (the dyld shared cache is not on disk as
461    /// files: it is a marker of its own). On Windows, where no image list
462    /// exists, the DLLs next to `php.exe` and in `extension_dir` — the PHP
463    /// distribution ships its libraries there — read with `read_dir`, whose
464    /// entries carry their metadata without opening each file.
465    fn loaded_files(&self) -> Option<Vec<FileId>> {
466        #[cfg(unix)]
467        {
468            let images = self.images.as_ref()?;
469            let mut files: Vec<FileId> = images
470                .iter()
471                .map(|p| FileId::of(p))
472                .filter(FileId::exists)
473                .collect();
474            files.sort_by(|a, b| a.path.cmp(&b.path));
475            files.dedup_by(|a, b| a.path == b.path);
476            (!files.is_empty()).then_some(files)
477        }
478        #[cfg(not(unix))]
479        {
480            // Fail closed here too: a directory that cannot be read, or an
481            // `extension_dir` that is relative (PHP resolves it against ITS
482            // working directory, not ours), means the DLLs cannot be watched.
483            let binary = self.binary.as_ref()?;
484            let mut dirs: Vec<std::path::PathBuf> =
485                vec![std::path::Path::new(binary).parent()?.to_path_buf()];
486            if let Some(ext) = self.ext_dir.as_deref().filter(|d| !d.is_empty()) {
487                let ext = std::path::PathBuf::from(ext);
488                if !ext.is_absolute() {
489                    return None;
490                }
491                dirs.push(ext);
492            }
493            let mut files = Vec::new();
494            for dir in dirs {
495                for e in std::fs::read_dir(&dir).ok()?.flatten() {
496                    let name = e.file_name().to_string_lossy().to_lowercase();
497                    if name.ends_with(".dll") || name.ends_with(".exe") {
498                        // `FileId::of`, as the validation reads it: the same
499                        // source on both sides.
500                        files.push(FileId::of(&e.path().to_string_lossy()));
501                    }
502                }
503            }
504            files.sort_by(|a, b| a.path.cmp(&b.path));
505            Some(files)
506        }
507    }
508}
509
510/// The images dyld reports loading under `DYLD_PRINT_LIBRARIES=1`, in either
511/// format seen: `dyld[pid]: <UUID> /path` (macOS 12 and later, measured on 26)
512/// and `dyld: loaded: /path` (earlier). Paths may contain spaces.
513#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
514fn dyld_images(stderr: &str) -> Vec<String> {
515    stderr
516        .lines()
517        .filter_map(|line| {
518            if let Some(rest) = line.strip_prefix("dyld: loaded: ") {
519                return Some(rest.to_owned());
520            }
521            let rest = line.strip_prefix("dyld[")?;
522            let (_, rest) = rest.split_once("]: ")?;
523            let rest = rest.strip_prefix('<')?;
524            let (_, path) = rest.split_once("> ")?;
525            path.starts_with('/').then(|| path.to_owned())
526        })
527        .collect()
528}
529
530/// stderr without the dyld trace, for an error message a person reads.
531fn without_dyld(stderr: &str) -> String {
532    stderr
533        .lines()
534        .filter(|l| !l.starts_with("dyld[") && !l.starts_with("dyld: "))
535        .collect::<Vec<_>>()
536        .join("\n")
537}
538
539/// One real run of assets/platform-probe.php. On macOS the dyld trace is asked
540/// for; a wrapper that merges stderr into stdout would then corrupt the JSON
541/// (measured with an interpreter SIP does not protect), so an unreadable
542/// output under the trace is retried once without it — and that run's images
543/// are unknown, hence not cached. A probe that worked before must keep working.
544fn run_probe(php: &str) -> Result<ProbeRun, PlatformError> {
545    #[cfg(target_os = "macos")]
546    {
547        match run_probe_once(php, true) {
548            Ok(run) => Ok(run),
549            Err(_) => run_probe_once(php, false),
550        }
551    }
552    #[cfg(not(target_os = "macos"))]
553    {
554        run_probe_once(php, false)
555    }
556}
557
558fn run_probe_once(php: &str, trace: bool) -> Result<ProbeRun, PlatformError> {
559    let dir = tempfile::tempdir().map_err(|e| PlatformError(e.to_string()))?;
560    let script = dir.path().join("platform-probe.php");
561    std::fs::write(&script, PROBE).map_err(|e| PlatformError(e.to_string()))?;
562    let mut cmd = Command::new(php);
563    cmd.arg(&script);
564    // The images this php loads, at no cost on a run that happens anyway
565    // (measured: 30 ms with or without). Stripped by SIP behind `/bin/sh` or
566    // `/usr/bin/env` — measured on a wrapper script: 0 images — which is the
567    // case `loaded_files` turns into "do not cache".
568    if trace {
569        cmd.env("DYLD_PRINT_LIBRARIES", "1");
570    }
571    let out = cmd
572        .output()
573        .map_err(|e| PlatformError(format!("cannot run {php}: {e}")))?;
574    let stderr = String::from_utf8_lossy(&out.stderr);
575    if !out.status.success() {
576        return Err(PlatformError(format!(
577            "platform probe failed: {}",
578            without_dyld(&stderr)
579        )));
580    }
581    let doc: Value = serde_json::from_slice(&out.stdout)
582        .map_err(|e| PlatformError(format!("platform probe output: {e}")))?;
583    let entries = match doc.get("entries") {
584        Some(Value::Array(a)) => a.clone(),
585        _ => {
586            return Err(PlatformError(
587                "platform probe output: no `entries` array".to_owned(),
588            ))
589        }
590    };
591    let text = |k: &str| doc.get(k).and_then(Value::as_str).map(str::to_owned);
592    #[cfg(target_os = "macos")]
593    let images = {
594        let seen = if trace {
595            dyld_images(&stderr)
596        } else {
597            Vec::new()
598        };
599        (!seen.is_empty()).then_some(seen)
600    };
601    #[cfg(not(target_os = "macos"))]
602    let images = doc.get("maps").and_then(Value::as_array).map(|a| {
603        a.iter()
604            .filter_map(Value::as_str)
605            .map(str::to_owned)
606            .collect::<Vec<_>>()
607    });
608    Ok(ProbeRun {
609        entries,
610        binary: text("binary"),
611        ext_dir: text("ext_dir"),
612        images,
613    })
614}
615
616struct Override {
617    name: String,
618    version: Option<String>,
619}
620
621/// The ordered list of platform packages (`getPackages()`), `probed` being
622/// the probe output and `overrides` `config.platform`.
623pub fn platform_packages(
624    probed: &[Value],
625    overrides_cfg: &Map<String, Value>,
626) -> Result<Vec<Package>, PlatformError> {
627    // Insertion order of `config.platform` (PHP array), not sorted.
628    let mut overrides: Vec<(String, Override)> = Vec::new();
629    for (name, version) in overrides_cfg {
630        let v = match version {
631            Value::String(s) => Some(s.clone()),
632            Value::Bool(false) => None,
633            other => {
634                return Err(PlatformError(format!(
635                    "config.platform.{name} should be a string or false, but got {other}"
636                )))
637            }
638        };
639        if name == "php" && v.is_none() {
640            return Err(PlatformError(
641                "config.platform.php cannot be set to false as you cannot disable php entirely."
642                    .into(),
643            ));
644        }
645        let key = name.to_lowercase();
646        let o = Override {
647            name: name.clone(),
648            version: v,
649        };
650        match overrides.iter_mut().find(|(k, _)| *k == key) {
651            Some(slot) => slot.1 = o,
652            None => overrides.push((key, o)),
653        }
654    }
655    let override_of = |name: &str| overrides.iter().find(|(k, _)| k == name).map(|(_, o)| o);
656
657    let mut packages: Vec<Package> = Vec::new();
658    let mut libraries: BTreeMap<String, bool> = BTreeMap::new();
659
660    // addOverriddenPackage
661    let add_overridden = |packages: &mut Vec<Package>,
662                          o: &Override,
663                          name: Option<&str>|
664     -> Result<(), PlatformError> {
665        let Some(pretty) = &o.version else {
666            return Ok(());
667        };
668        let version = normalize(pretty, None).map_err(|e| PlatformError(e.0))?;
669        let mut p = Package::new(name.unwrap_or(&o.name), &version, pretty, Origin::Platform);
670        p.raw = serde_json::json!({"description": "Package overridden via config.platform", "extra": {"config.platform": true}});
671        packages.push(p);
672        Ok(())
673    };
674    for (_, o) in &overrides {
675        if !is_platform_package(&o.name) {
676            return Err(PlatformError(format!(
677                "Invalid platform package name in config.platform: {}",
678                o.name
679            )));
680        }
681        if o.version.is_some() {
682            add_overridden(&mut packages, o, None)?;
683        }
684    }
685    // The actual version is appended to the override's description
686    // (`, same as actual` / `, actual: x.y.z`).
687    let note_actual = |packages: &mut [Package], actual: &Package| {
688        if let Some(over) = packages.iter_mut().find(|q| q.name == actual.name) {
689            let text = if actual.version == over.version {
690                "same as actual".to_owned()
691            } else {
692                format!("actual: {}", actual.pretty_version)
693            };
694            let description = over
695                .raw
696                .get("description")
697                .and_then(serde_json::Value::as_str)
698                .unwrap_or("")
699                .to_owned();
700            over.raw["description"] = serde_json::Value::String(format!("{description}, {text}"));
701        }
702    };
703    // addPackage
704    let add = |packages: &mut Vec<Package>, p: Package| -> Result<(), PlatformError> {
705        if let Some(o) = override_of(&p.name) {
706            if o.version.is_none() {
707                return Ok(()); // disabled
708            }
709            note_actual(packages, &p);
710            return Ok(()); // already added by the override
711        }
712        if let Some(php) = override_of("php") {
713            if p.name.starts_with("php-") {
714                add_overridden(packages, php, Some(&p.pretty_name))?;
715                note_actual(packages, &p);
716                return Ok(());
717            }
718        }
719        packages.push(p);
720        Ok(())
721    };
722    let simple = |name: &str, pretty: &str, description: &str| -> Result<Package, PlatformError> {
723        let version = normalize(pretty, None).map_err(|e| PlatformError(e.0))?;
724        let mut p = Package::new(name, &version, pretty, Origin::Platform);
725        p.raw = serde_json::json!({"description": description});
726        Ok(p)
727    };
728    add(
729        &mut packages,
730        simple("composer", COMPOSER_VERSION, "Composer package")?,
731    )?;
732    add(
733        &mut packages,
734        simple(
735            "composer-plugin-api",
736            PLUGIN_API_VERSION,
737            "The Composer Plugin API",
738        )?,
739    )?;
740    add(
741        &mut packages,
742        simple(
743            "composer-runtime-api",
744            RUNTIME_API_VERSION,
745            "The Composer Runtime API",
746        )?,
747    )?;
748
749    let entries: Vec<Probed> = probed
750        .iter()
751        .map(|v| {
752            serde_json::from_value(v.clone())
753                .map_err(|e| PlatformError(format!("probe entry: {e}")))
754        })
755        .collect::<Result<_, _>>()?;
756
757    static PHP_FALLBACK: OnceLock<Regex> = OnceLock::new();
758    static EXT_FALLBACK: OnceLock<Regex> = OnceLock::new();
759    for e in &entries {
760        match e.kind.as_str() {
761            "php" => {
762                let (version, pretty) = match normalize(&e.version, None) {
763                    Ok(v) => (v, e.version.clone()),
764                    Err(_) => {
765                        let re = regex(&PHP_FALLBACK, r"^([^~+-]+).*$", false);
766                        let pretty = match re.captures(e.version.as_bytes()) {
767                            Ok(Some(caps)) => group(&caps, 1).to_owned(),
768                            _ => e.version.clone(),
769                        };
770                        let v = normalize(&pretty, None).map_err(|x| PlatformError(x.0))?;
771                        (v, pretty)
772                    }
773                };
774                let mut p = Package::new(&e.name, &version, &pretty, Origin::Platform);
775                p.raw = serde_json::json!({"description": e.description});
776                add(&mut packages, p)?;
777            }
778            "ext" => {
779                let mut extra_description = String::new();
780                let (version, pretty) = match normalize(&e.version, None) {
781                    Ok(v) => (v, e.version.clone()),
782                    Err(_) => {
783                        extra_description = format!(" (actual version: {})", e.version);
784                        let re = regex(&EXT_FALLBACK, r"^(\d+\.\d+\.\d+(?:\.\d+)?)", false);
785                        let pretty = match re.captures(e.version.as_bytes()) {
786                            Ok(Some(caps)) => group(&caps, 1).to_owned(),
787                            _ => "0".to_owned(),
788                        };
789                        let v = normalize(&pretty, None).map_err(|x| PlatformError(x.0))?;
790                        (v, pretty)
791                    }
792                };
793                let package_name = format!("ext-{}", e.name.to_lowercase().replace(' ', "-"));
794                let mut p = Package::new(&package_name, &version, &pretty, Origin::Platform);
795                p.package_type = "php-ext".to_owned();
796                p.raw = serde_json::json!({"description": format!("The {} PHP extension{extra_description}", e.name)});
797                if e.name == "uuid" {
798                    p.replaces.insert(Link::new(
799                        "ext-uuid",
800                        "lib-uuid",
801                        Constraint::new(Op::Eq, version.clone()),
802                        &pretty,
803                        LinkType::Replace,
804                    ));
805                }
806                add(&mut packages, p)?;
807            }
808            "lib" => {
809                let Ok(version) = normalize(&e.version, None) else {
810                    continue;
811                };
812                let lib_name = format!("lib-{}", e.name);
813                if !is_platform_package(&lib_name) || libraries.contains_key(&lib_name) {
814                    continue;
815                }
816                libraries.insert(lib_name.clone(), true);
817                let description = e
818                    .description
819                    .clone()
820                    .unwrap_or_else(|| format!("The {} library", e.name));
821                let mut p = Package::new(&lib_name, &version, &e.version, Origin::Platform);
822                p.raw = serde_json::json!({"description": description});
823                let mut replaces = Links::default();
824                for r in &e.replaces {
825                    let r = r.to_lowercase();
826                    // PHP key = bare name, target = `lib-<name>` (addLibrary).
827                    replaces.insert(Link {
828                        key: Some(r.clone()),
829                        source: lib_name.clone(),
830                        target: format!("lib-{r}"),
831                        constraint: Constraint::new(Op::Eq, version.clone()),
832                        pretty_constraint: e.version.clone(),
833                        kind: LinkType::Replace,
834                    });
835                }
836                let mut provides = Links::default();
837                for pr in &e.provides {
838                    let pr = pr.to_lowercase();
839                    provides.insert(Link {
840                        key: Some(pr.clone()),
841                        source: lib_name.clone(),
842                        target: format!("lib-{pr}"),
843                        constraint: Constraint::new(Op::Eq, version.clone()),
844                        pretty_constraint: e.version.clone(),
845                        kind: LinkType::Provide,
846                    });
847                }
848                p.replaces = replaces;
849                p.provides = provides;
850                add(&mut packages, p)?;
851            }
852            _ => {}
853        }
854    }
855    Ok(packages)
856}
857
858/// `install`'s platform verification on the full platform repository
859/// (`PlatformRepository`: php, extensions, `lib-*` libraries, the
860/// composer-*-api packages, `config.platform` overrides), the way
861/// `Installer::doInstall` checks the lock: every platform requirement of
862/// the lock's `platform`/`platform-dev` sections and of the wanted
863/// packages must be satisfied by a platform package's name, or by one of
864/// its `provide`/`replace` links (`lib-dom-libxml` through `lib-libxml`,
865/// `php-64bit` through `php`).
866pub fn check_install(
867    lock: &vivacity_core::lock::Lock,
868    platform: &[Package],
869    with_dev: bool,
870    ignored: &[String],
871) -> Vec<vivacity_core::platform::PlatformFailure> {
872    use vivacity_core::platform::{FailureReason, PlatformFailure};
873    let mut reqs: Vec<(String, String, Option<String>)> = Vec::new();
874    for (name, cons) in &lock.platform {
875        reqs.push((name.clone(), cons.clone(), None));
876    }
877    if with_dev {
878        for (name, cons) in &lock.platform_dev {
879            reqs.push((name.clone(), cons.clone(), None));
880        }
881    }
882    for p in lock.wanted_packages(with_dev) {
883        if let Some(require) = p.raw.get("require").and_then(Value::as_object) {
884            for (name, cons) in require {
885                let lname = name.to_ascii_lowercase();
886                if is_platform_package(&lname) {
887                    if let Some(c) = cons.as_str() {
888                        reqs.push((lname, c.to_owned(), Some(p.name().to_owned())));
889                    }
890                }
891            }
892        }
893    }
894    let mut failures = Vec::new();
895    for (requirement, cons, required_by) in reqs {
896        if vivacity_core::platform::is_ignored(&requirement, ignored) {
897            continue;
898        }
899        // The package itself, else a link providing/replacing the name.
900        let mut candidates: Vec<(String, String)> = Vec::new();
901        for p in platform {
902            if p.name == requirement {
903                candidates.push((p.version.clone(), p.pretty_version.clone()));
904            }
905            for link in p.provides.iter().chain(p.replaces.iter()) {
906                if link.target == requirement {
907                    let v = match &link.constraint {
908                        Constraint::Single {
909                            op: Op::Eq,
910                            version,
911                        } => version.clone(),
912                        _ => continue,
913                    };
914                    candidates.push((v, link.pretty_constraint.clone()));
915                }
916            }
917        }
918        if candidates.is_empty() {
919            failures.push(PlatformFailure {
920                requirement,
921                constraint: cons,
922                required_by,
923                reason: FailureReason::Missing,
924            });
925            continue;
926        }
927        let Ok(parsed) = crate::constraint::parse_constraints(&cons) else {
928            failures.push(PlatformFailure {
929                requirement,
930                constraint: cons,
931                required_by,
932                reason: FailureReason::Unsupported,
933            });
934            continue;
935        };
936        if !candidates
937            .iter()
938            .any(|(v, _)| parsed.constraint.matches_version(v))
939        {
940            failures.push(PlatformFailure {
941                requirement,
942                constraint: cons,
943                required_by,
944                reason: FailureReason::Mismatch {
945                    installed: candidates[0].1.trim_start_matches("== ").to_owned(),
946                },
947            });
948        }
949    }
950    failures
951}
952
953#[cfg(test)]
954mod tests {
955    /// A fake `php` whose answer depends on a library reached through a
956    /// Homebrew-style link (`opt/pcre2 -> ../Cellar/pcre2/<keg>`), the shape
957    /// measured on 2026-10-08: the old keg stays on disk, only the link moves.
958    /// It speaks the probe's format — its entries, its own path as `binary`,
959    /// and the library it "loaded" both as `maps` (Linux's channel) and as a
960    /// dyld trace line on stderr (macOS's) — and appends a line to `count` on
961    /// every launch, so a test can tell a cache hit from a probe.
962    #[cfg(unix)]
963    fn fake_php(root: &std::path::Path) -> std::path::PathBuf {
964        use std::os::unix::fs::PermissionsExt as _;
965        for (keg, v) in [("a", "10.47"), ("b", "10.49")] {
966            let lib = root.join("Cellar/pcre2").join(keg).join("lib");
967            std::fs::create_dir_all(&lib).expect("mkdir");
968            std::fs::write(lib.join("libpcre2-8.0.dylib"), v).expect("lib");
969        }
970        std::fs::create_dir_all(root.join("opt")).expect("mkdir");
971        std::os::unix::fs::symlink("../Cellar/pcre2/a", root.join("opt/pcre2")).expect("link");
972        let php = root.join("php");
973        std::fs::write(
974            &php,
975            format!(
976                "#!/bin/sh\n\
977                 echo x >> '{root}/count'\n\
978                 lib=\"$(cd '{root}/opt/pcre2/lib' && pwd -P)/libpcre2-8.0.dylib\"\n\
979                 v=$(cat \"$lib\")\n\
980                 echo \"dyld[1]: <00000000-0000-0000-0000-000000000000> $lib\" >&2\n\
981                 printf '{{\"entries\":[{{\"kind\":\"lib\",\"name\":\"pcre\",\"version\":\"%s\",\"description\":\"x\"}}],\"binary\":\"{php}\",\"ext_dir\":\"\",\"maps\":[\"%s\"]}}' \"$v\" \"$lib\"\n",
982                root = root.display(),
983                php = root.join("php").display()
984            ),
985        )
986        .expect("php");
987        std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).expect("chmod");
988        php
989    }
990
991    #[cfg(unix)]
992    fn launches(root: &std::path::Path) -> usize {
993        std::fs::read_to_string(root.join("count"))
994            .map(|s| s.lines().count())
995            .unwrap_or(0)
996    }
997
998    #[cfg(unix)]
999    fn pcre_of(probed: &[Value]) -> String {
1000        probed
1001            .iter()
1002            .find(|v| v.get("name").and_then(Value::as_str) == Some("pcre"))
1003            .and_then(|v| v.get("version").and_then(Value::as_str))
1004            .unwrap_or("?")
1005            .to_owned()
1006    }
1007
1008    /// The known issue of 0.20.0, reproduced without Homebrew: the link is
1009    /// repointed to a new keg while the old keg stays untouched, and the cache
1010    /// must notice.
1011    #[test]
1012    #[cfg(unix)]
1013    fn a_repointed_library_invalidates_the_cache() {
1014        let dir = tempfile::tempdir().expect("tmp");
1015        let root = vivacity_core::pathutil::canonicalize(dir.path()).expect("canon");
1016        let php = fake_php(&root);
1017        let cache = root.join("platform-probe.json");
1018        let php = php.to_string_lossy().into_owned();
1019        assert_eq!(pcre_of(&probe_at(&php, &cache).expect("probe")), "10.47");
1020        assert_eq!(launches(&root), 1);
1021        // Same link target: a hit, no second launch — the cache still works.
1022        assert_eq!(pcre_of(&probe_at(&php, &cache).expect("probe")), "10.47");
1023        assert_eq!(launches(&root), 1, "an unchanged system must hit the cache");
1024        // `brew upgrade pcre2`: the link moves, the old keg is left as it was.
1025        std::fs::remove_file(root.join("opt/pcre2")).expect("rm link");
1026        std::os::unix::fs::symlink("../Cellar/pcre2/b", root.join("opt/pcre2")).expect("link");
1027        assert_eq!(
1028            pcre_of(&probe_at(&php, &cache).expect("probe")),
1029            "10.49",
1030            "the cache served the old keg's version after the link moved"
1031        );
1032        assert_eq!(launches(&root), 2);
1033    }
1034
1035    /// A cache written by 0.20.0 or earlier has no `format`, and must not
1036    /// validate — not by a deserialisation that happens to fail, but by a
1037    /// comparison: every field is present here except `format`'s value.
1038    #[test]
1039    #[cfg(unix)]
1040    fn a_cache_of_an_older_format_never_validates() {
1041        let dir = tempfile::tempdir().expect("tmp");
1042        let root = vivacity_core::pathutil::canonicalize(dir.path()).expect("canon");
1043        let php = fake_php(&root).to_string_lossy().into_owned();
1044        let cache = root.join("platform-probe.json");
1045        probe_at(&php, &cache).expect("probe");
1046        assert_eq!(launches(&root), 1);
1047        let mut doc: Value =
1048            serde_json::from_slice(&std::fs::read(&cache).expect("read")).expect("json");
1049        doc["format"] = Value::from(CACHE_FORMAT - 1);
1050        std::fs::write(&cache, serde_json::to_vec(&doc).expect("json")).expect("write");
1051        probe_at(&php, &cache).expect("probe");
1052        assert_eq!(
1053            launches(&root),
1054            2,
1055            "an older format must send the probe again"
1056        );
1057        // And a file without the field at all — 0.20.0's shape — likewise;
1058        // that one fails at deserialisation, the other half of the guarantee.
1059        doc.as_object_mut().expect("object").remove("format");
1060        std::fs::write(&cache, serde_json::to_vec(&doc).expect("json")).expect("write");
1061        probe_at(&php, &cache).expect("probe");
1062        assert_eq!(launches(&root), 3);
1063    }
1064
1065    /// A library replaced IN PLACE (apt, dnf): same path, new content. The
1066    /// image's own identity has to move the key.
1067    #[test]
1068    #[cfg(unix)]
1069    fn a_library_replaced_in_place_invalidates_the_cache() {
1070        let dir = tempfile::tempdir().expect("tmp");
1071        let root = vivacity_core::pathutil::canonicalize(dir.path()).expect("canon");
1072        let php = fake_php(&root).to_string_lossy().into_owned();
1073        let cache = root.join("platform-probe.json");
1074        assert_eq!(pcre_of(&probe_at(&php, &cache).expect("probe")), "10.47");
1075        // Rewritten through a new inode, as a package manager does.
1076        let lib = root.join("Cellar/pcre2/a/lib/libpcre2-8.0.dylib");
1077        let tmp = lib.with_extension("new");
1078        std::fs::write(&tmp, "10.48").expect("write");
1079        std::fs::rename(&tmp, &lib).expect("rename");
1080        assert_eq!(pcre_of(&probe_at(&php, &cache).expect("probe")), "10.48");
1081    }
1082
1083    /// Fail closed: a php that cannot be seen — here a `#!/bin/sh` wrapper,
1084    /// which drops `DYLD_*` on macOS and reports another binary than the one
1085    /// located everywhere — is probed on EVERY call, never cached. Measured
1086    /// before this rule existed: such a wrapper kept the 0.20.0 bug intact.
1087    #[test]
1088    #[cfg(unix)]
1089    fn a_php_behind_a_wrapper_is_never_cached() {
1090        use std::os::unix::fs::PermissionsExt as _;
1091        let Some(real) = locate_php("php") else {
1092            panic!("this test needs a real php on PATH (dev and CI have one)");
1093        };
1094        let dir = tempfile::tempdir().expect("tmp");
1095        let root = vivacity_core::pathutil::canonicalize(dir.path()).expect("canon");
1096        let wrapper = root.join("php");
1097        std::fs::write(
1098            &wrapper,
1099            format!(
1100                "#!/bin/sh\necho x >> '{}/count'\nexec '{}' \"$@\"\n",
1101                root.display(),
1102                real.display()
1103            ),
1104        )
1105        .expect("wrapper");
1106        std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1107        let cache = root.join("platform-probe.json");
1108        let w = wrapper.to_string_lossy().into_owned();
1109        let first = probe_at(&w, &cache).expect("probe");
1110        let second = probe_at(&w, &cache).expect("probe");
1111        assert_eq!(
1112            launches(&root),
1113            2,
1114            "a wrapper must not be served from the cache"
1115        );
1116        assert_eq!(first, second);
1117        assert!(
1118            !cache.exists(),
1119            "nothing may be cached for a php we cannot see"
1120        );
1121    }
1122
1123    /// The facts the cache needs must never cost the probe itself. With
1124    /// `disable_functions=realpath,file` the first version of this change made
1125    /// the probe die (exit 255, measured), and `install` turns a failed probe
1126    /// into "no php" — the platform check skipped in silence. The entries must
1127    /// come back whole; the run is simply not offered to the cache.
1128    #[test]
1129    #[cfg(unix)]
1130    fn disabled_functions_cost_the_cache_never_the_probe() {
1131        use std::os::unix::fs::PermissionsExt as _;
1132        let Some(real) = locate_php("php") else {
1133            panic!("this test needs a real php on PATH (dev and CI have one)");
1134        };
1135        let dir = tempfile::tempdir().expect("tmp");
1136        let root = vivacity_core::pathutil::canonicalize(dir.path()).expect("canon");
1137        let wrapper = root.join("php");
1138        std::fs::write(
1139            &wrapper,
1140            format!(
1141                "#!/bin/sh\nexec '{}' -d disable_functions=realpath,file,ini_get \"$@\"\n",
1142                real.display()
1143            ),
1144        )
1145        .expect("wrapper");
1146        std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1147        let crippled = run_probe(&wrapper.to_string_lossy()).expect("the probe must still answer");
1148        let direct = run_probe(&real.to_string_lossy()).expect("probe");
1149        assert_eq!(
1150            crippled.entries, direct.entries,
1151            "the entries are untouched"
1152        );
1153        assert!(
1154            crippled.binary.is_none() || crippled.loaded_files().is_none(),
1155            "what cannot be seen is not offered to the cache"
1156        );
1157    }
1158
1159    /// The real php, directly: its images are seen, they include its own binary
1160    /// and the PCRE2 library behind `lib-pcre`, and a cached answer is the
1161    /// uncached one — the sentinel the 0.20.0 known issue lacked.
1162    #[test]
1163    #[cfg(unix)]
1164    fn the_real_php_is_seen_and_its_cache_matches_a_fresh_probe() {
1165        let Some(real) = locate_php("php") else {
1166            panic!("this test needs a real php on PATH (dev and CI have one)");
1167        };
1168        let real = real.to_string_lossy().into_owned();
1169        let run = run_probe(&real).expect("probe");
1170        assert_eq!(run.binary.as_deref(), Some(real.as_str()));
1171        let files = run
1172            .loaded_files()
1173            .expect("the images of a direct php are seen");
1174        assert!(
1175            files.iter().any(|f| f.path == real),
1176            "the binary itself is among the images"
1177        );
1178        assert!(
1179            files.iter().any(|f| f.path.contains("pcre2")),
1180            "the PCRE2 library is among the images: {:?}",
1181            files.iter().map(|f| &f.path).collect::<Vec<_>>()
1182        );
1183        let dir = tempfile::tempdir().expect("tmp");
1184        let cache = dir.path().join("platform-probe.json");
1185        let cold = probe_at(&real, &cache).expect("probe");
1186        assert!(cache.exists(), "a php we can see is cached");
1187        let written = FileId::of(&cache.to_string_lossy());
1188        let warm = probe_at(&real, &cache).expect("probe");
1189        // A hit does not rewrite the file; equality of the two answers alone
1190        // would hold even if the cache never validated.
1191        assert_eq!(
1192            FileId::of(&cache.to_string_lossy()),
1193            written,
1194            "the second call must be served from the cache"
1195        );
1196        assert_eq!(cold, warm);
1197        assert_eq!(
1198            warm, run.entries,
1199            "the cache serves exactly what a fresh probe says"
1200        );
1201    }
1202
1203    /// Both trace formats dyld has used, and a path with a space.
1204    #[test]
1205    fn dyld_trace_lines_are_read_in_both_formats() {
1206        let stderr = "dyld[123]: <0729711C-3D0D-3112-9FE8-5BAEC75F9439> /opt/homebrew/Cellar/php/8.5.10/bin/php\n\
1207                      dyld: loaded: /usr/local/lib/libfoo.dylib\n\
1208                      dyld[123]: <AB> /Users/x/Library/Application Support/lib.dylib\n\
1209                      dyld[123]: move loaded to delayed: Montreal\n\
1210                      PHP Warning: something\n";
1211        assert_eq!(
1212            dyld_images(stderr),
1213            vec![
1214                "/opt/homebrew/Cellar/php/8.5.10/bin/php".to_owned(),
1215                "/usr/local/lib/libfoo.dylib".to_owned(),
1216                "/Users/x/Library/Application Support/lib.dylib".to_owned(),
1217            ]
1218        );
1219        assert_eq!(without_dyld(stderr), "PHP Warning: something");
1220    }
1221
1222    /// The links the key watches are the Homebrew `opt` links of the kegs the
1223    /// images live in, once each.
1224    #[test]
1225    fn keg_links_name_the_opt_link_of_each_formula() {
1226        let images = vec![
1227            "/opt/homebrew/Cellar/pcre2/10.49/lib/libpcre2-8.0.dylib".to_owned(),
1228            "/opt/homebrew/Cellar/pcre2/10.49/lib/libpcre2-posix.dylib".to_owned(),
1229            "/opt/homebrew/Cellar/icu4c@78/78.3/lib/libicuuc.78.3.dylib".to_owned(),
1230            "/usr/lib/libz.1.dylib".to_owned(),
1231        ];
1232        let links: Vec<String> = keg_links(&images).into_iter().map(|(l, _)| l).collect();
1233        assert_eq!(
1234            links,
1235            vec![
1236                "/opt/homebrew/opt/icu4c@78".to_owned(),
1237                "/opt/homebrew/opt/pcre2".to_owned()
1238            ]
1239        );
1240    }
1241
1242    use super::*;
1243
1244    /// A probed platform: PHP 8.2.5 (64-bit) with mbstring and intl.
1245    fn probed() -> Vec<Value> {
1246        vec![
1247            serde_json::json!({"kind": "php", "name": "php", "version": "8.2.5", "description": "The PHP interpreter"}),
1248            serde_json::json!({"kind": "php", "name": "php-64bit", "version": "8.2.5", "description": "x"}),
1249            serde_json::json!({"kind": "ext", "name": "mbstring", "version": "8.2.5"}),
1250            serde_json::json!({"kind": "ext", "name": "intl", "version": "8.2.5"}),
1251        ]
1252    }
1253
1254    fn platform_with(overrides: Value) -> Vec<Package> {
1255        let empty = Map::new();
1256        let overrides = overrides.as_object().unwrap_or(&empty).clone();
1257        platform_packages(&probed(), &overrides).expect("platform")
1258    }
1259
1260    fn lock(platform: Value, packages: Value) -> vivacity_core::lock::Lock {
1261        vivacity_core::lock::Lock::parse(
1262            &serde_json::json!({ "packages": packages, "platform": platform }).to_string(),
1263        )
1264        .expect("lock")
1265    }
1266
1267    #[test]
1268    fn satisfied_lock_passes() {
1269        let l = lock(
1270            serde_json::json!({"php": ">=8.1", "ext-mbstring": "*"}),
1271            serde_json::json!([{"name":"a/b","version":"1.0","require":{"php":"^8.0","ext-intl":"*"}}]),
1272        );
1273        assert!(check_install(&l, &platform_with(Value::Null), true, &[]).is_empty());
1274    }
1275
1276    #[test]
1277    fn reports_mismatch_and_missing() {
1278        use vivacity_core::platform::FailureReason;
1279        let l = lock(
1280            serde_json::json!({"php": ">=8.3", "ext-gd": "*", "lib-icu": ">=70"}),
1281            serde_json::json!([]),
1282        );
1283        let f = check_install(&l, &platform_with(Value::Null), true, &[]);
1284        assert_eq!(f.len(), 3, "{f:?}");
1285        assert_eq!(
1286            f[0].reason,
1287            FailureReason::Mismatch {
1288                installed: "8.2.5".to_owned()
1289            }
1290        );
1291        assert_eq!(f[1].reason, FailureReason::Missing);
1292        assert_eq!(f[2].reason, FailureReason::Missing);
1293    }
1294
1295    #[test]
1296    fn package_requirements_are_checked_and_attributed() {
1297        let l = lock(
1298            serde_json::json!({}),
1299            serde_json::json!([{"name":"a/b","version":"1.0","require":{"php":">=8.3","some/dep":"^1.0"}}]),
1300        );
1301        let f = check_install(&l, &platform_with(Value::Null), true, &[]);
1302        assert_eq!(f.len(), 1);
1303        assert_eq!(f[0].required_by.as_deref(), Some("a/b"));
1304        assert_eq!(f[0].requirement, "php");
1305    }
1306
1307    #[test]
1308    fn ignore_patterns_work() {
1309        let l = lock(
1310            serde_json::json!({"php": ">=9.0", "ext-gd": "*"}),
1311            serde_json::json!([]),
1312        );
1313        let p = platform_with(Value::Null);
1314        assert_eq!(check_install(&l, &p, true, &[]).len(), 2);
1315        assert!(check_install(&l, &p, true, &["*".to_owned()]).is_empty());
1316        assert_eq!(check_install(&l, &p, true, &["ext-*".to_owned()]).len(), 1);
1317        assert_eq!(check_install(&l, &p, true, &["php".to_owned()]).len(), 1);
1318        assert_eq!(
1319            check_install(&l, &p, true, &["ext-gd+".to_owned()]).len(),
1320            1
1321        );
1322    }
1323
1324    #[test]
1325    fn config_platform_overrides_apply_to_the_check() {
1326        let l = lock(
1327            serde_json::json!({"php": ">=8.3", "ext-gd": "*", "ext-intl": "*"}),
1328            serde_json::json!([]),
1329        );
1330        let p = platform_with(
1331            serde_json::json!({"php": "8.3.0", "ext-gd": "8.3.0", "ext-intl": false}),
1332        );
1333        let f = check_install(&l, &p, true, &[]);
1334        assert_eq!(f.len(), 1, "{f:?}");
1335        assert_eq!(f[0].requirement, "ext-intl");
1336    }
1337
1338    #[test]
1339    fn probe_cache_watches_every_ini_file_and_the_scan_dir() {
1340        let dir = tempfile::tempdir().expect("tempdir");
1341        let ini = dir.path().join("php.ini");
1342        std::fs::write(&ini, "memory_limit=1G\n").expect("write");
1343        let scan = dir.path().join("conf.d");
1344        std::fs::create_dir(&scan).expect("mkdir");
1345        let ext = scan.join("ext-mbstring.ini");
1346        std::fs::write(&ext, "extension=mbstring\n").expect("write");
1347        let probed = vec![serde_json::json!({
1348            "kind": "ini", "name": "ini", "version": "",
1349            "loaded": ini.to_string_lossy(),
1350            "scanned": format!("{},\n{}", ext.to_string_lossy(), ext.to_string_lossy()),
1351            "scan_dir": scan.to_string_lossy(),
1352        })];
1353        let deps = ini_dependencies(&probed);
1354        let paths: Vec<&str> = deps.iter().map(|f| f.path.as_str()).collect();
1355        assert_eq!(
1356            paths,
1357            vec![
1358                ini.to_string_lossy().as_ref(),
1359                scan.to_string_lossy().as_ref(),
1360                ext.to_string_lossy().as_ref(),
1361                ext.to_string_lossy().as_ref()
1362            ]
1363        );
1364        assert!(deps
1365            .iter()
1366            .all(|f| f.mtime_ns.is_some() && f.size.is_some()));
1367        // Unchanged: every id still matches.
1368        assert!(deps.iter().all(|f| FileId::of(&f.path) == *f));
1369        // A file edited (size changes) or removed invalidates.
1370        std::fs::write(&ext, "extension=mbstring\nextension=intl\n").expect("write");
1371        assert!(deps.iter().any(|f| FileId::of(&f.path) != *f));
1372        std::fs::remove_file(&ext).expect("rm");
1373        assert_eq!(FileId::of(&ext.to_string_lossy()).mtime_ns, None);
1374        // No loaded php.ini ('' in the probe): nothing to watch for it, the
1375        // scan dir still is.
1376        let probed = vec![
1377            serde_json::json!({"kind": "ini", "loaded": "", "scanned": null,
1378                                            "scan_dir": scan.to_string_lossy()}),
1379        ];
1380        assert_eq!(ini_dependencies(&probed).len(), 1);
1381    }
1382
1383    #[test]
1384    fn locate_php_resolves_an_explicit_path_only_when_executable() {
1385        let dir = tempfile::tempdir().expect("tempdir");
1386        let plain = dir.path().join("php");
1387        std::fs::write(&plain, "").expect("write");
1388        #[cfg(unix)]
1389        {
1390            assert_eq!(locate_php(&plain.to_string_lossy()), None);
1391            use std::os::unix::fs::PermissionsExt;
1392            std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755))
1393                .expect("chmod");
1394        }
1395        assert!(locate_php(&plain.to_string_lossy()).is_some());
1396        assert_eq!(
1397            locate_php(&dir.path().join("missing").to_string_lossy()),
1398            None
1399        );
1400    }
1401
1402    #[test]
1403    fn platform_names() {
1404        assert!(is_platform_package("php"));
1405        assert!(is_platform_package("ext-mbstring"));
1406        assert!(is_platform_package("lib-icu-cldr"));
1407        assert!(is_platform_package("composer-plugin-api"));
1408        assert!(!is_platform_package("php-http/discovery"));
1409        assert!(!is_platform_package("ext-"));
1410    }
1411
1412    #[test]
1413    fn builds_from_a_probe_with_overrides() {
1414        let probed = vec![
1415            serde_json::json!({"kind": "php", "name": "php", "version": "8.5.10", "description": "The PHP interpreter"}),
1416            serde_json::json!({"kind": "php", "name": "php-64bit", "version": "8.5.10", "description": "x"}),
1417            serde_json::json!({"kind": "ext", "name": "Zend OPcache", "version": "8.5.10"}),
1418            serde_json::json!({"kind": "ext", "name": "weird", "version": "not a version"}),
1419            serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
1420            serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
1421            serde_json::json!({"kind": "lib", "name": "libxml", "version": "2.13.4", "description": "libxml library version", "replaces": [], "provides": ["dom-libxml"]}),
1422        ];
1423        let mut overrides = Map::new();
1424        overrides.insert("php".into(), Value::String("8.2.0".into()));
1425        overrides.insert("ext-weird".into(), Value::Bool(false));
1426        let pk = platform_packages(&probed, &overrides).unwrap();
1427        let names: Vec<&str> = pk.iter().map(|p| p.name.as_str()).collect();
1428        assert_eq!(
1429            names,
1430            vec![
1431                "php",
1432                "composer",
1433                "composer-plugin-api",
1434                "composer-runtime-api",
1435                "php-64bit",
1436                "ext-zend-opcache",
1437                "lib-libsodium",
1438                "lib-libxml"
1439            ]
1440        );
1441        assert_eq!(pk[0].version, "8.2.0.0");
1442        assert_eq!(
1443            pk[4].version, "8.2.0.0",
1444            "php-64bit takes the overridden php version"
1445        );
1446        // PHP key without `lib-`, target with it (addLibrary).
1447        let link = pk[7].provides.get("dom-libxml").unwrap();
1448        assert_eq!(link.target, "lib-dom-libxml");
1449        assert_eq!(link.constraint.to_string(), "== 2.13.4.0");
1450        assert!(pk[7].provides.get("lib-dom-libxml").is_none());
1451        // Insertion order of the overrides, not alphabetical.
1452        let mut overrides = Map::new();
1453        overrides.insert("php".into(), Value::String("8.2.0".into()));
1454        overrides.insert("ext-mbstring".into(), Value::String("1.0".into()));
1455        overrides.insert("Ext-Json".into(), Value::String("2.0".into()));
1456        let pk = platform_packages(&probed, &overrides).unwrap();
1457        let names: Vec<&str> = pk.iter().take(3).map(|p| p.name.as_str()).collect();
1458        assert_eq!(names, vec!["php", "ext-mbstring", "ext-json"]);
1459    }
1460}