1use 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
17pub 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
29pub 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#[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
54pub 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
79pub 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
91pub 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
116pub fn platform_packages(
119 probed: &[Value],
120 overrides_cfg: &Map<String, Value>,
121) -> Result<Vec<Package>, PlatformError> {
122 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 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 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 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(()); }
204 note_actual(packages, &p);
205 return Ok(()); }
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 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#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn platform_names() {
359 assert!(is_platform_package("php"));
360 assert!(is_platform_package("ext-mbstring"));
361 assert!(is_platform_package("lib-icu-cldr"));
362 assert!(is_platform_package("composer-plugin-api"));
363 assert!(!is_platform_package("php-http/discovery"));
364 assert!(!is_platform_package("ext-"));
365 }
366
367 #[test]
368 fn builds_from_a_probe_with_overrides() {
369 let probed = vec![
370 serde_json::json!({"kind": "php", "name": "php", "version": "8.5.10", "description": "The PHP interpreter"}),
371 serde_json::json!({"kind": "php", "name": "php-64bit", "version": "8.5.10", "description": "x"}),
372 serde_json::json!({"kind": "ext", "name": "Zend OPcache", "version": "8.5.10"}),
373 serde_json::json!({"kind": "ext", "name": "weird", "version": "not a version"}),
374 serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
375 serde_json::json!({"kind": "lib", "name": "libsodium", "version": "1.0.20", "replaces": [], "provides": []}),
376 serde_json::json!({"kind": "lib", "name": "libxml", "version": "2.13.4", "description": "libxml library version", "replaces": [], "provides": ["dom-libxml"]}),
377 ];
378 let mut overrides = Map::new();
379 overrides.insert("php".into(), Value::String("8.2.0".into()));
380 overrides.insert("ext-weird".into(), Value::Bool(false));
381 let pk = platform_packages(&probed, &overrides).unwrap();
382 let names: Vec<&str> = pk.iter().map(|p| p.name.as_str()).collect();
383 assert_eq!(
384 names,
385 vec![
386 "php",
387 "composer",
388 "composer-plugin-api",
389 "composer-runtime-api",
390 "php-64bit",
391 "ext-zend-opcache",
392 "lib-libsodium",
393 "lib-libxml"
394 ]
395 );
396 assert_eq!(pk[0].version, "8.2.0.0");
397 assert_eq!(
398 pk[4].version, "8.2.0.0",
399 "php-64bit takes the overridden php version"
400 );
401 let link = pk[7].provides.get("dom-libxml").unwrap();
403 assert_eq!(link.target, "lib-dom-libxml");
404 assert_eq!(link.constraint.to_string(), "== 2.13.4.0");
405 assert!(pk[7].provides.get("lib-dom-libxml").is_none());
406 let mut overrides = Map::new();
408 overrides.insert("php".into(), Value::String("8.2.0".into()));
409 overrides.insert("ext-mbstring".into(), Value::String("1.0".into()));
410 overrides.insert("Ext-Json".into(), Value::String("2.0".into()));
411 let pk = platform_packages(&probed, &overrides).unwrap();
412 let names: Vec<&str> = pk.iter().take(3).map(|p| p.name.as_str()).collect();
413 assert_eq!(names, vec!["php", "ext-mbstring", "ext-json"]);
414 }
415}