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/// Runs the probe on the current PHP (`VIVACITY_PHP` or `php`).
55pub fn probe() -> Result<Vec<Value>, PlatformError> {
56    let php = std::env::var("VIVACITY_PHP").unwrap_or_else(|_| "php".to_owned());
57    let dir = tempfile::tempdir().map_err(|e| PlatformError(e.to_string()))?;
58    let script = dir.path().join("platform-probe.php");
59    std::fs::write(&script, PROBE).map_err(|e| PlatformError(e.to_string()))?;
60    let out = Command::new(&php)
61        .arg(&script)
62        .output()
63        .map_err(|e| PlatformError(format!("cannot run {php}: {e}")))?;
64    if !out.status.success() {
65        return Err(PlatformError(format!(
66            "platform probe failed: {}",
67            String::from_utf8_lossy(&out.stderr)
68        )));
69    }
70    serde_json::from_slice(&out.stdout)
71        .map_err(|e| PlatformError(format!("platform probe output: {e}")))
72}
73
74struct Override {
75    name: String,
76    version: Option<String>,
77}
78
79/// The ordered list of platform packages (`getPackages()`), `probed` being
80/// the probe output and `overrides` `config.platform`.
81pub fn platform_packages(
82    probed: &[Value],
83    overrides_cfg: &Map<String, Value>,
84) -> Result<Vec<Package>, PlatformError> {
85    // Insertion order of `config.platform` (PHP array), not sorted.
86    let mut overrides: Vec<(String, Override)> = Vec::new();
87    for (name, version) in overrides_cfg {
88        let v = match version {
89            Value::String(s) => Some(s.clone()),
90            Value::Bool(false) => None,
91            other => {
92                return Err(PlatformError(format!(
93                    "config.platform.{name} should be a string or false, but got {other}"
94                )))
95            }
96        };
97        if name == "php" && v.is_none() {
98            return Err(PlatformError(
99                "config.platform.php cannot be set to false as you cannot disable php entirely."
100                    .into(),
101            ));
102        }
103        let key = name.to_lowercase();
104        let o = Override {
105            name: name.clone(),
106            version: v,
107        };
108        match overrides.iter_mut().find(|(k, _)| *k == key) {
109            Some(slot) => slot.1 = o,
110            None => overrides.push((key, o)),
111        }
112    }
113    let override_of = |name: &str| overrides.iter().find(|(k, _)| k == name).map(|(_, o)| o);
114
115    let mut packages: Vec<Package> = Vec::new();
116    let mut libraries: BTreeMap<String, bool> = BTreeMap::new();
117
118    // addOverriddenPackage
119    let add_overridden = |packages: &mut Vec<Package>,
120                          o: &Override,
121                          name: Option<&str>|
122     -> Result<(), PlatformError> {
123        let Some(pretty) = &o.version else {
124            return Ok(());
125        };
126        let version = normalize(pretty, None).map_err(|e| PlatformError(e.0))?;
127        let mut p = Package::new(name.unwrap_or(&o.name), &version, pretty, Origin::Platform);
128        p.raw = serde_json::json!({"description": "Package overridden via config.platform", "extra": {"config.platform": true}});
129        packages.push(p);
130        Ok(())
131    };
132    for (_, o) in &overrides {
133        if !is_platform_package(&o.name) {
134            return Err(PlatformError(format!(
135                "Invalid platform package name in config.platform: {}",
136                o.name
137            )));
138        }
139        if o.version.is_some() {
140            add_overridden(&mut packages, o, None)?;
141        }
142    }
143    // The actual version is appended to the override's description
144    // (`, same as actual` / `, actual: x.y.z`).
145    let note_actual = |packages: &mut [Package], actual: &Package| {
146        if let Some(over) = packages.iter_mut().find(|q| q.name == actual.name) {
147            let text = if actual.version == over.version {
148                "same as actual".to_owned()
149            } else {
150                format!("actual: {}", actual.pretty_version)
151            };
152            let description = over
153                .raw
154                .get("description")
155                .and_then(serde_json::Value::as_str)
156                .unwrap_or("")
157                .to_owned();
158            over.raw["description"] = serde_json::Value::String(format!("{description}, {text}"));
159        }
160    };
161    // addPackage
162    let add = |packages: &mut Vec<Package>, p: Package| -> Result<(), PlatformError> {
163        if let Some(o) = override_of(&p.name) {
164            if o.version.is_none() {
165                return Ok(()); // disabled
166            }
167            note_actual(packages, &p);
168            return Ok(()); // already added by the override
169        }
170        if let Some(php) = override_of("php") {
171            if p.name.starts_with("php-") {
172                add_overridden(packages, php, Some(&p.pretty_name))?;
173                note_actual(packages, &p);
174                return Ok(());
175            }
176        }
177        packages.push(p);
178        Ok(())
179    };
180    let simple = |name: &str, pretty: &str, description: &str| -> Result<Package, PlatformError> {
181        let version = normalize(pretty, None).map_err(|e| PlatformError(e.0))?;
182        let mut p = Package::new(name, &version, pretty, Origin::Platform);
183        p.raw = serde_json::json!({"description": description});
184        Ok(p)
185    };
186    add(
187        &mut packages,
188        simple("composer", COMPOSER_VERSION, "Composer package")?,
189    )?;
190    add(
191        &mut packages,
192        simple(
193            "composer-plugin-api",
194            PLUGIN_API_VERSION,
195            "The Composer Plugin API",
196        )?,
197    )?;
198    add(
199        &mut packages,
200        simple(
201            "composer-runtime-api",
202            RUNTIME_API_VERSION,
203            "The Composer Runtime API",
204        )?,
205    )?;
206
207    let entries: Vec<Probed> = probed
208        .iter()
209        .map(|v| {
210            serde_json::from_value(v.clone())
211                .map_err(|e| PlatformError(format!("probe entry: {e}")))
212        })
213        .collect::<Result<_, _>>()?;
214
215    static PHP_FALLBACK: OnceLock<Regex> = OnceLock::new();
216    static EXT_FALLBACK: OnceLock<Regex> = OnceLock::new();
217    for e in &entries {
218        match e.kind.as_str() {
219            "php" => {
220                let (version, pretty) = match normalize(&e.version, None) {
221                    Ok(v) => (v, e.version.clone()),
222                    Err(_) => {
223                        let re = regex(&PHP_FALLBACK, r"^([^~+-]+).*$", false);
224                        let pretty = match re.captures(e.version.as_bytes()) {
225                            Ok(Some(caps)) => group(&caps, 1).to_owned(),
226                            _ => e.version.clone(),
227                        };
228                        let v = normalize(&pretty, None).map_err(|x| PlatformError(x.0))?;
229                        (v, pretty)
230                    }
231                };
232                let mut p = Package::new(&e.name, &version, &pretty, Origin::Platform);
233                p.raw = serde_json::json!({"description": e.description});
234                add(&mut packages, p)?;
235            }
236            "ext" => {
237                let mut extra_description = String::new();
238                let (version, pretty) = match normalize(&e.version, None) {
239                    Ok(v) => (v, e.version.clone()),
240                    Err(_) => {
241                        extra_description = format!(" (actual version: {})", e.version);
242                        let re = regex(&EXT_FALLBACK, r"^(\d+\.\d+\.\d+(?:\.\d+)?)", false);
243                        let pretty = match re.captures(e.version.as_bytes()) {
244                            Ok(Some(caps)) => group(&caps, 1).to_owned(),
245                            _ => "0".to_owned(),
246                        };
247                        let v = normalize(&pretty, None).map_err(|x| PlatformError(x.0))?;
248                        (v, pretty)
249                    }
250                };
251                let package_name = format!("ext-{}", e.name.to_lowercase().replace(' ', "-"));
252                let mut p = Package::new(&package_name, &version, &pretty, Origin::Platform);
253                p.package_type = "php-ext".to_owned();
254                p.raw = serde_json::json!({"description": format!("The {} PHP extension{extra_description}", e.name)});
255                if e.name == "uuid" {
256                    p.replaces.insert(Link::new(
257                        "ext-uuid",
258                        "lib-uuid",
259                        Constraint::new(Op::Eq, version.clone()),
260                        &pretty,
261                        LinkType::Replace,
262                    ));
263                }
264                add(&mut packages, p)?;
265            }
266            "lib" => {
267                let Ok(version) = normalize(&e.version, None) else {
268                    continue;
269                };
270                let lib_name = format!("lib-{}", e.name);
271                if !is_platform_package(&lib_name) || libraries.contains_key(&lib_name) {
272                    continue;
273                }
274                libraries.insert(lib_name.clone(), true);
275                let description = e
276                    .description
277                    .clone()
278                    .unwrap_or_else(|| format!("The {} library", e.name));
279                let mut p = Package::new(&lib_name, &version, &e.version, Origin::Platform);
280                p.raw = serde_json::json!({"description": description});
281                let mut replaces = Links::default();
282                for r in &e.replaces {
283                    let r = r.to_lowercase();
284                    // PHP key = bare name, target = `lib-<name>` (addLibrary).
285                    replaces.insert(Link {
286                        key: Some(r.clone()),
287                        source: lib_name.clone(),
288                        target: format!("lib-{r}"),
289                        constraint: Constraint::new(Op::Eq, version.clone()),
290                        pretty_constraint: e.version.clone(),
291                        kind: LinkType::Replace,
292                    });
293                }
294                let mut provides = Links::default();
295                for pr in &e.provides {
296                    let pr = pr.to_lowercase();
297                    provides.insert(Link {
298                        key: Some(pr.clone()),
299                        source: lib_name.clone(),
300                        target: format!("lib-{pr}"),
301                        constraint: Constraint::new(Op::Eq, version.clone()),
302                        pretty_constraint: e.version.clone(),
303                        kind: LinkType::Provide,
304                    });
305                }
306                p.replaces = replaces;
307                p.provides = provides;
308                add(&mut packages, p)?;
309            }
310            _ => {}
311        }
312    }
313    Ok(packages)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn platform_names() {
322        assert!(is_platform_package("php"));
323        assert!(is_platform_package("ext-mbstring"));
324        assert!(is_platform_package("lib-icu-cldr"));
325        assert!(is_platform_package("composer-plugin-api"));
326        assert!(!is_platform_package("php-http/discovery"));
327        assert!(!is_platform_package("ext-"));
328    }
329
330    #[test]
331    fn builds_from_a_probe_with_overrides() {
332        let probed = vec![
333            serde_json::json!({"kind": "php", "name": "php", "version": "8.5.10", "description": "The PHP interpreter"}),
334            serde_json::json!({"kind": "php", "name": "php-64bit", "version": "8.5.10", "description": "x"}),
335            serde_json::json!({"kind": "ext", "name": "Zend OPcache", "version": "8.5.10"}),
336            serde_json::json!({"kind": "ext", "name": "weird", "version": "not a version"}),
337            serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
338            serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
339            serde_json::json!({"kind": "lib", "name": "libxml", "version": "2.13.4", "description": "libxml library version", "replaces": [], "provides": ["dom-libxml"]}),
340        ];
341        let mut overrides = Map::new();
342        overrides.insert("php".into(), Value::String("8.2.0".into()));
343        overrides.insert("ext-weird".into(), Value::Bool(false));
344        let pk = platform_packages(&probed, &overrides).unwrap();
345        let names: Vec<&str> = pk.iter().map(|p| p.name.as_str()).collect();
346        assert_eq!(
347            names,
348            vec![
349                "php",
350                "composer",
351                "composer-plugin-api",
352                "composer-runtime-api",
353                "php-64bit",
354                "ext-zend-opcache",
355                "lib-libsodium",
356                "lib-libxml"
357            ]
358        );
359        assert_eq!(pk[0].version, "8.2.0.0");
360        assert_eq!(
361            pk[4].version, "8.2.0.0",
362            "php-64bit takes the overridden php version"
363        );
364        // PHP key without `lib-`, target with it (addLibrary).
365        let link = pk[7].provides.get("dom-libxml").unwrap();
366        assert_eq!(link.target, "lib-dom-libxml");
367        assert_eq!(link.constraint.to_string(), "== 2.13.4.0");
368        assert!(pk[7].provides.get("lib-dom-libxml").is_none());
369        // Insertion order of the overrides, not alphabetical.
370        let mut overrides = Map::new();
371        overrides.insert("php".into(), Value::String("8.2.0".into()));
372        overrides.insert("ext-mbstring".into(), Value::String("1.0".into()));
373        overrides.insert("Ext-Json".into(), Value::String("2.0".into()));
374        let pk = platform_packages(&probed, &overrides).unwrap();
375        let names: Vec<&str> = pk.iter().take(3).map(|p| p.name.as_str()).collect();
376        assert_eq!(names, vec!["php", "ext-mbstring", "ext-json"]);
377    }
378}