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