Skip to main content

vivacity_core/
platform.rs

1//! Detection of the local platform (PHP, extensions) and check that the lock
2//! is installable on it, the practical equivalent of Composer's "lock
3//! installability" step, without a solver: `platform`/`platform-dev`
4//! constraints of the lock + php/ext-* `require` of each locked package.
5//!
6//! Detection shells out ONCE to `php -r` and caches the result (key:
7//! canonical path + mtime + size of the php binary), which is essential to
8//! the no-op budget of < 50 ms, since PHP startup alone costs ~30-60 ms.
9
10use 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    /// extension name (lowercase) -> version (phpversion(ext), else the PHP version).
23    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    /// "php", "ext-mbstring", ...
37    pub requirement: String,
38    pub constraint: String,
39    /// Requesting package (None = the lock's platform section).
40    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    /// Detects through `php` (overridable with $VIVACITY_PHP), with a disk cache.
90    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); // no php: the caller decides (requirements present -> error)
94        };
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    /// Applies `config.platform` from the root composer.json (overrides the
140    /// detected versions; `false` disables an entry).
141    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        // `which` does not exist on Windows; `where` prints one line per
185        // match, the first being the one the shell would launch.
186        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
205/// Checks the lock against the platform. `ignored`: names to ignore, trailing
206/// `*` accepted (`ext-*`), or the special list `["*"]` to ignore everything.
207pub 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                // A platform package never has a `/` (otherwise it is a vendor
229                // such as php-http/*). composer-plugin-api / composer-runtime-api
230                // are excluded: satisfied by construction on the vivacity side.
231                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        // lib-*: not detected in v1, so only its presence in the lock's
250        // platform section concerns us, and we report it as Unsupported.
251        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}