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 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
79pub fn platform_packages(
82 probed: &[Value],
83 overrides_cfg: &Map<String, Value>,
84) -> Result<Vec<Package>, PlatformError> {
85 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 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 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 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(()); }
167 note_actual(packages, &p);
168 return Ok(()); }
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 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 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 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}