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
91#[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
141const 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
159const CACHE_FORMAT: u32 = 2;
163
164#[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
181fn 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
218fn 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
226fn 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
257fn 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
295fn 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
316fn 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
357pub 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
375fn 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 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 if run.binary.as_deref() != Some(php_path.as_str()) {
412 return Ok(run.entries);
413 }
414 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
444struct ProbeRun {
447 entries: Vec<Value>,
448 binary: Option<String>,
449 #[cfg_attr(not(windows), allow(dead_code))]
450 ext_dir: Option<String>,
451 #[cfg_attr(windows, allow(dead_code))]
454 images: Option<Vec<String>>,
455}
456
457impl ProbeRun {
458 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 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 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#[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
530fn 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
539fn 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 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
621pub fn platform_packages(
624 probed: &[Value],
625 overrides_cfg: &Map<String, Value>,
626) -> Result<Vec<Package>, PlatformError> {
627 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 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 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 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(()); }
709 note_actual(packages, &p);
710 return Ok(()); }
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 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
858pub 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 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 #[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 #[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 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 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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 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 assert!(deps.iter().all(|f| FileId::of(&f.path) == *f));
1369 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 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 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 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}