1use crate::constraint;
11use crate::error::{Error, Result};
12use crate::lock::Lock;
13use serde_json::Value;
14use std::collections::BTreeMap;
15use std::path::PathBuf;
16use std::process::Command;
17
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19pub struct Platform {
20 pub php_version: String,
21 pub is_64bit: bool,
22 pub extensions: BTreeMap<String, String>,
24}
25
26#[derive(Debug, serde::Serialize, serde::Deserialize)]
27struct CachedPlatform {
28 php_path: String,
29 mtime_unix: i64,
30 size: u64,
31 platform: Platform,
32}
33
34#[derive(Debug, PartialEq, Eq)]
35pub struct PlatformFailure {
36 pub requirement: String,
38 pub constraint: String,
39 pub required_by: Option<String>,
41 pub reason: FailureReason,
42}
43
44#[derive(Debug, PartialEq, Eq)]
45pub enum FailureReason {
46 Missing,
47 Mismatch { installed: String },
48 Unsupported,
49}
50
51pub fn parallel_io() -> bool {
57 match std::env::var("VIVACITY_PARALLEL_IO") {
58 Ok(v) => v != "0" && !v.is_empty(),
59 Err(_) => cfg!(target_os = "linux"),
60 }
61}
62
63pub fn cache_dir() -> PathBuf {
64 if let Ok(d) = std::env::var("VIVACITY_CACHE_DIR") {
65 return PathBuf::from(d);
66 }
67 #[cfg(windows)]
68 {
69 if let Ok(l) = std::env::var("LOCALAPPDATA") {
70 if !l.is_empty() {
71 return PathBuf::from(l).join("vivacity");
72 }
73 }
74 }
75 if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
76 return PathBuf::from(xdg).join("vivacity");
77 }
78 let home = std::env::var("HOME")
79 .or_else(|_| std::env::var("USERPROFILE"))
80 .unwrap_or_else(|_| ".".to_owned());
81 if cfg!(target_os = "macos") {
82 PathBuf::from(home).join("Library/Caches/vivacity")
83 } else {
84 PathBuf::from(home).join(".cache/vivacity")
85 }
86}
87
88const DETECT_SNIPPET: &str = r#"
89$exts = [];
90foreach (get_loaded_extensions() as $e) {
91 $exts[strtolower($e)] = phpversion($e) ?: PHP_VERSION;
92}
93echo json_encode([
94 "php_version" => PHP_VERSION,
95 "is_64bit" => PHP_INT_SIZE === 8,
96 "extensions" => $exts,
97]);
98"#;
99
100impl Platform {
101 pub fn detect() -> Result<Option<Platform>> {
103 let php = std::env::var("VIVACITY_PHP").unwrap_or_else(|_| "php".to_owned());
104 let Some((path, mtime_unix, size)) = binary_identity(&php) else {
105 return Ok(None); };
107
108 let cache_file = cache_dir().join("platform.json");
109 if let Ok(bytes) = std::fs::read(&cache_file) {
110 if let Ok(cached) = serde_json::from_slice::<CachedPlatform>(&bytes) {
111 if cached.php_path == path && cached.mtime_unix == mtime_unix && cached.size == size
112 {
113 return Ok(Some(cached.platform));
114 }
115 }
116 }
117
118 let out = Command::new(&php)
119 .args(["-d", "error_reporting=0", "-r", DETECT_SNIPPET])
120 .output()
121 .map_err(|source| Error::ReadFile {
122 path: PathBuf::from(&php),
123 source,
124 })?;
125 if !out.status.success() {
126 return Ok(None);
127 }
128 let platform: Platform =
129 serde_json::from_slice(&out.stdout).map_err(|source| Error::Json {
130 context: "php platform detection".to_owned(),
131 source,
132 })?;
133
134 let cached = CachedPlatform {
135 php_path: path,
136 mtime_unix,
137 size,
138 platform: platform.clone(),
139 };
140 if std::fs::create_dir_all(cache_dir()).is_ok() {
141 if let Ok(json) = serde_json::to_vec(&cached) {
142 let tmp = cache_file.with_extension("json.tmp");
143 if std::fs::write(&tmp, json).is_ok() {
144 let _ = std::fs::rename(&tmp, &cache_file);
145 }
146 }
147 }
148 Ok(Some(platform))
149 }
150
151 pub fn apply_overrides(&mut self, root_manifest: &Value) {
154 let Some(overrides) = root_manifest
155 .get("config")
156 .and_then(|c| c.get("platform"))
157 .and_then(Value::as_object)
158 else {
159 return;
160 };
161 for (name, v) in overrides {
162 let name = name.to_ascii_lowercase();
163 match (name.as_str(), v) {
164 ("php", Value::String(s)) => self.php_version = s.clone(),
165 (n, Value::String(s)) => {
166 if let Some(ext) = n.strip_prefix("ext-") {
167 self.extensions.insert(ext.to_owned(), s.clone());
168 }
169 }
170 (n, Value::Bool(false)) => {
171 if let Some(ext) = n.strip_prefix("ext-") {
172 self.extensions.remove(ext);
173 }
174 }
175 _ => {}
176 }
177 }
178 }
179
180 fn version_of(&self, requirement: &str) -> Option<&str> {
181 match requirement {
182 "php" => Some(&self.php_version),
183 "php-64bit" => self.is_64bit.then_some(self.php_version.as_str()),
184 r => r
185 .strip_prefix("ext-")
186 .and_then(|e| self.extensions.get(&e.to_ascii_lowercase()))
187 .map(String::as_str),
188 }
189 }
190}
191
192fn binary_identity(php: &str) -> Option<(String, i64, u64)> {
193 let path = if php.contains('/') || php.contains('\\') {
194 PathBuf::from(php)
195 } else {
196 let finder = if cfg!(windows) { "where" } else { "which" };
199 let out = Command::new(finder).arg(php).output().ok()?;
200 if !out.status.success() {
201 return None;
202 }
203 let stdout = String::from_utf8(out.stdout).ok()?;
204 PathBuf::from(stdout.lines().next()?.trim())
205 };
206 let canonical = crate::pathutil::canonicalize(&path).ok()?;
207 let meta = std::fs::metadata(&canonical).ok()?;
208 let mtime = meta
209 .modified()
210 .ok()?
211 .duration_since(std::time::UNIX_EPOCH)
212 .ok()?
213 .as_secs() as i64;
214 Some((canonical.to_string_lossy().into_owned(), mtime, meta.len()))
215}
216
217pub fn check(
220 lock: &Lock,
221 platform: &Platform,
222 with_dev: bool,
223 ignored: &[String],
224) -> Vec<PlatformFailure> {
225 let mut failures = Vec::new();
226
227 let mut reqs: Vec<(String, String, Option<String>)> = Vec::new();
228 for (name, cons) in &lock.platform {
229 reqs.push((name.clone(), cons.clone(), None));
230 }
231 if with_dev {
232 for (name, cons) in &lock.platform_dev {
233 reqs.push((name.clone(), cons.clone(), None));
234 }
235 }
236 for p in lock.wanted_packages(with_dev) {
237 if let Some(require) = p.raw.get("require").and_then(Value::as_object) {
238 for (name, cons) in require {
239 let lname = name.to_ascii_lowercase();
240 let is_platform = !lname.contains('/')
244 && (lname == "php"
245 || lname.starts_with("php-")
246 || lname.starts_with("ext-")
247 || lname.starts_with("lib-"));
248 if is_platform {
249 if let Some(c) = cons.as_str() {
250 reqs.push((lname, c.to_owned(), Some(p.name().to_owned())));
251 }
252 }
253 }
254 }
255 }
256
257 for (requirement, cons, required_by) in reqs {
258 if is_ignored(&requirement, ignored) {
259 continue;
260 }
261 if requirement.starts_with("lib-") {
264 failures.push(PlatformFailure {
265 requirement,
266 constraint: cons,
267 required_by,
268 reason: FailureReason::Unsupported,
269 });
270 continue;
271 }
272 let Some(installed) = platform.version_of(&requirement) else {
273 failures.push(PlatformFailure {
274 requirement,
275 constraint: cons,
276 required_by,
277 reason: FailureReason::Missing,
278 });
279 continue;
280 };
281 match constraint::satisfies(installed, &cons) {
282 Ok(true) => {}
283 Ok(false) => failures.push(PlatformFailure {
284 requirement,
285 constraint: cons,
286 required_by,
287 reason: FailureReason::Mismatch {
288 installed: installed.to_owned(),
289 },
290 }),
291 Err(_) => failures.push(PlatformFailure {
292 requirement,
293 constraint: cons,
294 required_by,
295 reason: FailureReason::Unsupported,
296 }),
297 }
298 }
299 failures
300}
301
302fn is_ignored(requirement: &str, ignored: &[String]) -> bool {
303 ignored.iter().any(|pat| {
304 let pat = pat.strip_suffix('+').unwrap_or(pat);
305 pat == "*"
306 || pat == requirement
307 || pat
308 .strip_suffix('*')
309 .is_some_and(|prefix| requirement.starts_with(prefix))
310 })
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use serde_json::json;
317
318 fn platform() -> Platform {
319 Platform {
320 php_version: "8.2.5".to_owned(),
321 is_64bit: true,
322 extensions: [("mbstring", "8.2.5"), ("intl", "8.2.5")]
323 .into_iter()
324 .map(|(k, v)| (k.to_owned(), v.to_owned()))
325 .collect(),
326 }
327 }
328
329 fn lock(platform: Value, packages: Value) -> Lock {
330 Lock::parse(&json!({ "packages": packages, "platform": platform }).to_string())
331 .expect("lock")
332 }
333
334 #[test]
335 fn satisfied_lock_passes() {
336 let l = lock(
337 json!({"php": ">=8.1", "ext-mbstring": "*"}),
338 json!([{"name":"a/b","version":"1.0","require":{"php":"^8.0","ext-intl":"*"}}]),
339 );
340 assert!(check(&l, &platform(), true, &[]).is_empty());
341 }
342
343 #[test]
344 fn reports_mismatch_missing_and_unsupported() {
345 let l = lock(
346 json!({"php": ">=8.3", "ext-gd": "*", "lib-icu": ">=70"}),
347 json!([]),
348 );
349 let f = check(&l, &platform(), true, &[]);
350 assert_eq!(f.len(), 3);
351 assert_eq!(
352 f[0].reason,
353 FailureReason::Mismatch {
354 installed: "8.2.5".to_owned()
355 }
356 );
357 assert_eq!(f[1].reason, FailureReason::Missing);
358 assert_eq!(f[2].reason, FailureReason::Unsupported);
359 }
360
361 #[test]
362 fn package_requirements_are_checked_and_attributed() {
363 let l = lock(
364 json!({}),
365 json!([{"name":"a/b","version":"1.0","require":{"php":">=8.3","some/dep":"^1.0"}}]),
366 );
367 let f = check(&l, &platform(), true, &[]);
368 assert_eq!(f.len(), 1);
369 assert_eq!(f[0].required_by.as_deref(), Some("a/b"));
370 assert_eq!(f[0].requirement, "php");
371 }
372
373 #[test]
374 fn ignore_patterns_work() {
375 let l = lock(json!({"php": ">=9.0", "ext-gd": "*"}), json!([]));
376 let all = check(&l, &platform(), true, &[]);
377 assert_eq!(all.len(), 2);
378 assert!(check(&l, &platform(), true, &["*".to_owned()]).is_empty());
379 assert_eq!(check(&l, &platform(), true, &["ext-*".to_owned()]).len(), 1);
380 assert_eq!(check(&l, &platform(), true, &["php".to_owned()]).len(), 1);
381 assert_eq!(
382 check(&l, &platform(), true, &["ext-gd+".to_owned()]).len(),
383 1
384 );
385 }
386
387 #[test]
388 fn overrides_apply() {
389 let mut p = platform();
390 p.apply_overrides(&json!({"config": {"platform": {"php": "8.3.0", "ext-gd": "8.3.0", "ext-intl": false}}}));
391 assert_eq!(p.php_version, "8.3.0");
392 assert!(p.extensions.contains_key("gd"));
393 assert!(!p.extensions.contains_key("intl"));
394 }
395}