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/// Runs the probe on the current PHP (`VIVACITY_PHP` or `php`).
92pub fn probe() -> Result<Vec<Value>, PlatformError> {
93    let php = std::env::var("VIVACITY_PHP").unwrap_or_else(|_| "php".to_owned());
94    let dir = tempfile::tempdir().map_err(|e| PlatformError(e.to_string()))?;
95    let script = dir.path().join("platform-probe.php");
96    std::fs::write(&script, PROBE).map_err(|e| PlatformError(e.to_string()))?;
97    let out = Command::new(&php)
98        .arg(&script)
99        .output()
100        .map_err(|e| PlatformError(format!("cannot run {php}: {e}")))?;
101    if !out.status.success() {
102        return Err(PlatformError(format!(
103            "platform probe failed: {}",
104            String::from_utf8_lossy(&out.stderr)
105        )));
106    }
107    serde_json::from_slice(&out.stdout)
108        .map_err(|e| PlatformError(format!("platform probe output: {e}")))
109}
110
111struct Override {
112    name: String,
113    version: Option<String>,
114}
115
116/// The ordered list of platform packages (`getPackages()`), `probed` being
117/// the probe output and `overrides` `config.platform`.
118pub fn platform_packages(
119    probed: &[Value],
120    overrides_cfg: &Map<String, Value>,
121) -> Result<Vec<Package>, PlatformError> {
122    // Insertion order of `config.platform` (PHP array), not sorted.
123    let mut overrides: Vec<(String, Override)> = Vec::new();
124    for (name, version) in overrides_cfg {
125        let v = match version {
126            Value::String(s) => Some(s.clone()),
127            Value::Bool(false) => None,
128            other => {
129                return Err(PlatformError(format!(
130                    "config.platform.{name} should be a string or false, but got {other}"
131                )))
132            }
133        };
134        if name == "php" && v.is_none() {
135            return Err(PlatformError(
136                "config.platform.php cannot be set to false as you cannot disable php entirely."
137                    .into(),
138            ));
139        }
140        let key = name.to_lowercase();
141        let o = Override {
142            name: name.clone(),
143            version: v,
144        };
145        match overrides.iter_mut().find(|(k, _)| *k == key) {
146            Some(slot) => slot.1 = o,
147            None => overrides.push((key, o)),
148        }
149    }
150    let override_of = |name: &str| overrides.iter().find(|(k, _)| k == name).map(|(_, o)| o);
151
152    let mut packages: Vec<Package> = Vec::new();
153    let mut libraries: BTreeMap<String, bool> = BTreeMap::new();
154
155    // addOverriddenPackage
156    let add_overridden = |packages: &mut Vec<Package>,
157                          o: &Override,
158                          name: Option<&str>|
159     -> Result<(), PlatformError> {
160        let Some(pretty) = &o.version else {
161            return Ok(());
162        };
163        let version = normalize(pretty, None).map_err(|e| PlatformError(e.0))?;
164        let mut p = Package::new(name.unwrap_or(&o.name), &version, pretty, Origin::Platform);
165        p.raw = serde_json::json!({"description": "Package overridden via config.platform", "extra": {"config.platform": true}});
166        packages.push(p);
167        Ok(())
168    };
169    for (_, o) in &overrides {
170        if !is_platform_package(&o.name) {
171            return Err(PlatformError(format!(
172                "Invalid platform package name in config.platform: {}",
173                o.name
174            )));
175        }
176        if o.version.is_some() {
177            add_overridden(&mut packages, o, None)?;
178        }
179    }
180    // The actual version is appended to the override's description
181    // (`, same as actual` / `, actual: x.y.z`).
182    let note_actual = |packages: &mut [Package], actual: &Package| {
183        if let Some(over) = packages.iter_mut().find(|q| q.name == actual.name) {
184            let text = if actual.version == over.version {
185                "same as actual".to_owned()
186            } else {
187                format!("actual: {}", actual.pretty_version)
188            };
189            let description = over
190                .raw
191                .get("description")
192                .and_then(serde_json::Value::as_str)
193                .unwrap_or("")
194                .to_owned();
195            over.raw["description"] = serde_json::Value::String(format!("{description}, {text}"));
196        }
197    };
198    // addPackage
199    let add = |packages: &mut Vec<Package>, p: Package| -> Result<(), PlatformError> {
200        if let Some(o) = override_of(&p.name) {
201            if o.version.is_none() {
202                return Ok(()); // disabled
203            }
204            note_actual(packages, &p);
205            return Ok(()); // already added by the override
206        }
207        if let Some(php) = override_of("php") {
208            if p.name.starts_with("php-") {
209                add_overridden(packages, php, Some(&p.pretty_name))?;
210                note_actual(packages, &p);
211                return Ok(());
212            }
213        }
214        packages.push(p);
215        Ok(())
216    };
217    let simple = |name: &str, pretty: &str, description: &str| -> Result<Package, PlatformError> {
218        let version = normalize(pretty, None).map_err(|e| PlatformError(e.0))?;
219        let mut p = Package::new(name, &version, pretty, Origin::Platform);
220        p.raw = serde_json::json!({"description": description});
221        Ok(p)
222    };
223    add(
224        &mut packages,
225        simple("composer", COMPOSER_VERSION, "Composer package")?,
226    )?;
227    add(
228        &mut packages,
229        simple(
230            "composer-plugin-api",
231            PLUGIN_API_VERSION,
232            "The Composer Plugin API",
233        )?,
234    )?;
235    add(
236        &mut packages,
237        simple(
238            "composer-runtime-api",
239            RUNTIME_API_VERSION,
240            "The Composer Runtime API",
241        )?,
242    )?;
243
244    let entries: Vec<Probed> = probed
245        .iter()
246        .map(|v| {
247            serde_json::from_value(v.clone())
248                .map_err(|e| PlatformError(format!("probe entry: {e}")))
249        })
250        .collect::<Result<_, _>>()?;
251
252    static PHP_FALLBACK: OnceLock<Regex> = OnceLock::new();
253    static EXT_FALLBACK: OnceLock<Regex> = OnceLock::new();
254    for e in &entries {
255        match e.kind.as_str() {
256            "php" => {
257                let (version, pretty) = match normalize(&e.version, None) {
258                    Ok(v) => (v, e.version.clone()),
259                    Err(_) => {
260                        let re = regex(&PHP_FALLBACK, r"^([^~+-]+).*$", false);
261                        let pretty = match re.captures(e.version.as_bytes()) {
262                            Ok(Some(caps)) => group(&caps, 1).to_owned(),
263                            _ => e.version.clone(),
264                        };
265                        let v = normalize(&pretty, None).map_err(|x| PlatformError(x.0))?;
266                        (v, pretty)
267                    }
268                };
269                let mut p = Package::new(&e.name, &version, &pretty, Origin::Platform);
270                p.raw = serde_json::json!({"description": e.description});
271                add(&mut packages, p)?;
272            }
273            "ext" => {
274                let mut extra_description = String::new();
275                let (version, pretty) = match normalize(&e.version, None) {
276                    Ok(v) => (v, e.version.clone()),
277                    Err(_) => {
278                        extra_description = format!(" (actual version: {})", e.version);
279                        let re = regex(&EXT_FALLBACK, r"^(\d+\.\d+\.\d+(?:\.\d+)?)", false);
280                        let pretty = match re.captures(e.version.as_bytes()) {
281                            Ok(Some(caps)) => group(&caps, 1).to_owned(),
282                            _ => "0".to_owned(),
283                        };
284                        let v = normalize(&pretty, None).map_err(|x| PlatformError(x.0))?;
285                        (v, pretty)
286                    }
287                };
288                let package_name = format!("ext-{}", e.name.to_lowercase().replace(' ', "-"));
289                let mut p = Package::new(&package_name, &version, &pretty, Origin::Platform);
290                p.package_type = "php-ext".to_owned();
291                p.raw = serde_json::json!({"description": format!("The {} PHP extension{extra_description}", e.name)});
292                if e.name == "uuid" {
293                    p.replaces.insert(Link::new(
294                        "ext-uuid",
295                        "lib-uuid",
296                        Constraint::new(Op::Eq, version.clone()),
297                        &pretty,
298                        LinkType::Replace,
299                    ));
300                }
301                add(&mut packages, p)?;
302            }
303            "lib" => {
304                let Ok(version) = normalize(&e.version, None) else {
305                    continue;
306                };
307                let lib_name = format!("lib-{}", e.name);
308                if !is_platform_package(&lib_name) || libraries.contains_key(&lib_name) {
309                    continue;
310                }
311                libraries.insert(lib_name.clone(), true);
312                let description = e
313                    .description
314                    .clone()
315                    .unwrap_or_else(|| format!("The {} library", e.name));
316                let mut p = Package::new(&lib_name, &version, &e.version, Origin::Platform);
317                p.raw = serde_json::json!({"description": description});
318                let mut replaces = Links::default();
319                for r in &e.replaces {
320                    let r = r.to_lowercase();
321                    // PHP key = bare name, target = `lib-<name>` (addLibrary).
322                    replaces.insert(Link {
323                        key: Some(r.clone()),
324                        source: lib_name.clone(),
325                        target: format!("lib-{r}"),
326                        constraint: Constraint::new(Op::Eq, version.clone()),
327                        pretty_constraint: e.version.clone(),
328                        kind: LinkType::Replace,
329                    });
330                }
331                let mut provides = Links::default();
332                for pr in &e.provides {
333                    let pr = pr.to_lowercase();
334                    provides.insert(Link {
335                        key: Some(pr.clone()),
336                        source: lib_name.clone(),
337                        target: format!("lib-{pr}"),
338                        constraint: Constraint::new(Op::Eq, version.clone()),
339                        pretty_constraint: e.version.clone(),
340                        kind: LinkType::Provide,
341                    });
342                }
343                p.replaces = replaces;
344                p.provides = provides;
345                add(&mut packages, p)?;
346            }
347            _ => {}
348        }
349    }
350    Ok(packages)
351}
352
353/// `install`'s platform verification on the full platform repository
354/// (`PlatformRepository`: php, extensions, `lib-*` libraries, the
355/// composer-*-api packages, `config.platform` overrides), the way
356/// `Installer::doInstall` checks the lock: every platform requirement of
357/// the lock's `platform`/`platform-dev` sections and of the wanted
358/// packages must be satisfied by a platform package's name, or by one of
359/// its `provide`/`replace` links (`lib-dom-libxml` through `lib-libxml`,
360/// `php-64bit` through `php`).
361pub fn check_install(
362    lock: &vivacity_core::lock::Lock,
363    platform: &[Package],
364    with_dev: bool,
365    ignored: &[String],
366) -> Vec<vivacity_core::platform::PlatformFailure> {
367    use vivacity_core::platform::{FailureReason, PlatformFailure};
368    let mut reqs: Vec<(String, String, Option<String>)> = Vec::new();
369    for (name, cons) in &lock.platform {
370        reqs.push((name.clone(), cons.clone(), None));
371    }
372    if with_dev {
373        for (name, cons) in &lock.platform_dev {
374            reqs.push((name.clone(), cons.clone(), None));
375        }
376    }
377    for p in lock.wanted_packages(with_dev) {
378        if let Some(require) = p.raw.get("require").and_then(Value::as_object) {
379            for (name, cons) in require {
380                let lname = name.to_ascii_lowercase();
381                if is_platform_package(&lname) {
382                    if let Some(c) = cons.as_str() {
383                        reqs.push((lname, c.to_owned(), Some(p.name().to_owned())));
384                    }
385                }
386            }
387        }
388    }
389    let mut failures = Vec::new();
390    for (requirement, cons, required_by) in reqs {
391        if vivacity_core::platform::is_ignored(&requirement, ignored) {
392            continue;
393        }
394        // The package itself, else a link providing/replacing the name.
395        let mut candidates: Vec<(String, String)> = Vec::new();
396        for p in platform {
397            if p.name == requirement {
398                candidates.push((p.version.clone(), p.pretty_version.clone()));
399            }
400            for link in p.provides.iter().chain(p.replaces.iter()) {
401                if link.target == requirement {
402                    let v = match &link.constraint {
403                        Constraint::Single {
404                            op: Op::Eq,
405                            version,
406                        } => version.clone(),
407                        _ => continue,
408                    };
409                    candidates.push((v, link.pretty_constraint.clone()));
410                }
411            }
412        }
413        if candidates.is_empty() {
414            failures.push(PlatformFailure {
415                requirement,
416                constraint: cons,
417                required_by,
418                reason: FailureReason::Missing,
419            });
420            continue;
421        }
422        let Ok(parsed) = crate::constraint::parse_constraints(&cons) else {
423            failures.push(PlatformFailure {
424                requirement,
425                constraint: cons,
426                required_by,
427                reason: FailureReason::Unsupported,
428            });
429            continue;
430        };
431        if !candidates
432            .iter()
433            .any(|(v, _)| parsed.constraint.matches_version(v))
434        {
435            failures.push(PlatformFailure {
436                requirement,
437                constraint: cons,
438                required_by,
439                reason: FailureReason::Mismatch {
440                    installed: candidates[0].1.trim_start_matches("== ").to_owned(),
441                },
442            });
443        }
444    }
445    failures
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn platform_names() {
454        assert!(is_platform_package("php"));
455        assert!(is_platform_package("ext-mbstring"));
456        assert!(is_platform_package("lib-icu-cldr"));
457        assert!(is_platform_package("composer-plugin-api"));
458        assert!(!is_platform_package("php-http/discovery"));
459        assert!(!is_platform_package("ext-"));
460    }
461
462    #[test]
463    fn builds_from_a_probe_with_overrides() {
464        let probed = vec![
465            serde_json::json!({"kind": "php", "name": "php", "version": "8.5.10", "description": "The PHP interpreter"}),
466            serde_json::json!({"kind": "php", "name": "php-64bit", "version": "8.5.10", "description": "x"}),
467            serde_json::json!({"kind": "ext", "name": "Zend OPcache", "version": "8.5.10"}),
468            serde_json::json!({"kind": "ext", "name": "weird", "version": "not a version"}),
469            serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
470            serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
471            serde_json::json!({"kind": "lib", "name": "libxml", "version": "2.13.4", "description": "libxml library version", "replaces": [], "provides": ["dom-libxml"]}),
472        ];
473        let mut overrides = Map::new();
474        overrides.insert("php".into(), Value::String("8.2.0".into()));
475        overrides.insert("ext-weird".into(), Value::Bool(false));
476        let pk = platform_packages(&probed, &overrides).unwrap();
477        let names: Vec<&str> = pk.iter().map(|p| p.name.as_str()).collect();
478        assert_eq!(
479            names,
480            vec![
481                "php",
482                "composer",
483                "composer-plugin-api",
484                "composer-runtime-api",
485                "php-64bit",
486                "ext-zend-opcache",
487                "lib-libsodium",
488                "lib-libxml"
489            ]
490        );
491        assert_eq!(pk[0].version, "8.2.0.0");
492        assert_eq!(
493            pk[4].version, "8.2.0.0",
494            "php-64bit takes the overridden php version"
495        );
496        // PHP key without `lib-`, target with it (addLibrary).
497        let link = pk[7].provides.get("dom-libxml").unwrap();
498        assert_eq!(link.target, "lib-dom-libxml");
499        assert_eq!(link.constraint.to_string(), "== 2.13.4.0");
500        assert!(pk[7].provides.get("lib-dom-libxml").is_none());
501        // Insertion order of the overrides, not alphabetical.
502        let mut overrides = Map::new();
503        overrides.insert("php".into(), Value::String("8.2.0".into()));
504        overrides.insert("ext-mbstring".into(), Value::String("1.0".into()));
505        overrides.insert("Ext-Json".into(), Value::String("2.0".into()));
506        let pk = platform_packages(&probed, &overrides).unwrap();
507        let names: Vec<&str> = pk.iter().take(3).map(|p| p.name.as_str()).collect();
508        assert_eq!(names, vec!["php", "ext-mbstring", "ext-json"]);
509    }
510}