Skip to main content

vivacity_core/
installers.rs

1//! Port of composer/installers (`Installer` + `BaseInstaller`, tags 2.0.0 to
2//! 2.3.0; docs/reference/installers/ holds the 2.3.0 source). The logic is
3//! identical across the whole 2.x series; only the location tables change,
4//! hence one table per tag in assets/installers/<tag>.json (generated by
5//! tools/gen-installers-table.php, never edited by hand).
6//!
7//! This module only decides the **project-relative path** a package would
8//! get from the plugin; normalisation, refusal of dangerous targets and the
9//! integration (installed.json, autoload, proxies) live in `layout`.
10
11use serde::Deserialize;
12use serde_json::Value;
13use std::collections::BTreeMap;
14use std::sync::OnceLock;
15
16#[derive(Deserialize)]
17struct TableFile {
18    tag: String,
19    frameworks: Vec<Framework>,
20}
21
22/// A framework installer: its key (`wordpress`), its PHP class, `custom` if
23/// the class overrides a BaseInstaller method (paths not ported), and its
24/// type-to-template table (`plugin` -> `wp-content/plugins/{$name}/`).
25#[derive(Debug, Clone, Deserialize)]
26pub struct Framework {
27    pub key: String,
28    pub class: String,
29    pub custom: bool,
30    pub locations: BTreeMap<String, String>,
31}
32
33/// The table of a given tag, keys sorted like `krsort` (descending).
34#[derive(Debug)]
35pub struct Table {
36    pub tag: String,
37    /// Order of `findFrameworkType`: krsort of the keys = descending strcmp.
38    frameworks_desc: Vec<Framework>,
39}
40
41const TABLE_SOURCES: &[(&str, &str)] = &[
42    ("2.0.0", include_str!("../assets/installers/v2.0.0.json")),
43    ("2.0.1", include_str!("../assets/installers/v2.0.1.json")),
44    ("2.1.0", include_str!("../assets/installers/v2.1.0.json")),
45    ("2.1.1", include_str!("../assets/installers/v2.1.1.json")),
46    ("2.2.0", include_str!("../assets/installers/v2.2.0.json")),
47    ("2.3.0", include_str!("../assets/installers/v2.3.0.json")),
48];
49
50fn tables() -> &'static BTreeMap<&'static str, Table> {
51    static TABLES: OnceLock<BTreeMap<&'static str, Table>> = OnceLock::new();
52    TABLES.get_or_init(|| {
53        TABLE_SOURCES
54            .iter()
55            .map(|(version, json)| {
56                // The assets are generated and checked by test: invalid JSON
57                // is a build bug, not a runtime error.
58                let file: TableFile =
59                    serde_json::from_str(json).unwrap_or_else(|e| panic!("asset {version}: {e}"));
60                let mut frameworks = file.frameworks;
61                frameworks.sort_by(|a, b| b.key.cmp(&a.key));
62                (
63                    *version,
64                    Table {
65                        tag: file.tag,
66                        frameworks_desc: frameworks,
67                    },
68                )
69            })
70            .collect()
71    })
72}
73
74/// composer/installers versions whose table is embedded.
75pub fn ported_versions() -> impl Iterator<Item = &'static str> {
76    TABLE_SOURCES.iter().map(|(v, _)| *v)
77}
78
79/// Table of the locked tag (`v2.3.0` or `2.3.0`), None if not ported (1.x,
80/// future tag, dev branch).
81pub fn table_for(locked_version: &str) -> Option<&'static Table> {
82    let v = locked_version.strip_prefix('v').unwrap_or(locked_version);
83    tables().get(v)
84}
85
86impl Table {
87    pub fn frameworks(&self) -> impl Iterator<Item = &Framework> {
88        self.frameworks_desc.iter()
89    }
90
91    pub fn framework(&self, key: &str) -> Option<&Framework> {
92        self.frameworks_desc.iter().find(|f| f.key == key)
93    }
94}
95
96/// Reason why vivacity does not compute the path (falls back to Composer).
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum Unsupported {
99    /// The framework overrides BaseInstaller (custom inflection).
100    CustomFramework { key: String, class: String },
101    /// `<fw>-<type>` without a location: Composer throws an exception.
102    UnknownLocation { package_type: String },
103    /// `extra.installer-paths` outside the `object<string, string|string[]>` schema.
104    BadInstallerPaths(String),
105    /// `extra.installer-name` not a string, or containing `/`, `..` or `{`.
106    BadInstallerName(String),
107    /// Template variable other than `{$name}`, `{$vendor}`, `{$type}`.
108    UnknownTemplateVar(String),
109}
110
111impl std::fmt::Display for Unsupported {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Unsupported::CustomFramework { key, class } => write!(
115                f,
116                "framework `{key}` ({class}) has custom path logic (not emulated yet)"
117            ),
118            Unsupported::UnknownLocation { package_type } => {
119                write!(
120                    f,
121                    "package type `{package_type}` has no location in composer/installers"
122                )
123            }
124            Unsupported::BadInstallerPaths(why) => {
125                write!(f, "extra.installer-paths is malformed: {why}")
126            }
127            Unsupported::BadInstallerName(why) => {
128                write!(f, "extra.installer-name is malformed: {why}")
129            }
130            Unsupported::UnknownTemplateVar(var) => {
131                write!(
132                    f,
133                    "installer path template uses unknown variable `{{${var}}}`"
134                )
135            }
136        }
137    }
138}
139
140/// Result of the `InstallationManager::getInstaller` dispatch: the plugin
141/// takes the package (project-relative path, as templated, trailing slash
142/// included) or leaves it to LibraryInstaller (`vendor/<name>`).
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum Placement {
145    Vendor,
146    Custom(String),
147}
148
149/// `Installer::removeDisabledInstallers`: keys removed by
150/// `extra.installer-disable` (root). `true`, `"all"`, `"*"` (or `1`)
151/// disable everything (array_intersect compares as strings: `true` -> `"1"`).
152fn disabled_keys(root_extra: Option<&Value>) -> DisabledKeys {
153    let Some(v) = root_extra.and_then(|e| e.get("installer-disable")) else {
154        return DisabledKeys::None;
155    };
156    if matches!(v, Value::Bool(false) | Value::Null) {
157        return DisabledKeys::None;
158    }
159    // PHP `is_array`: list or object, iterated over their values.
160    let items: Vec<&Value> = match v {
161        Value::Array(a) => a.iter().collect(),
162        Value::Object(o) => o.values().collect(),
163        other => vec![other],
164    };
165    let all = items.iter().any(|i| match i {
166        Value::Bool(true) => true,
167        Value::String(s) => s == "1" || s == "all" || s == "*",
168        Value::Number(n) => n.as_i64() == Some(1) || n.as_f64() == Some(1.0),
169        _ => false,
170    });
171    if all {
172        return DisabledKeys::All;
173    }
174    DisabledKeys::Some(
175        items
176            .iter()
177            .filter_map(|i| i.as_str().map(str::to_owned))
178            .collect(),
179    )
180}
181
182enum DisabledKeys {
183    None,
184    All,
185    Some(Vec<String>),
186}
187
188impl DisabledKeys {
189    fn contains(&self, key: &str) -> bool {
190        match self {
191            DisabledKeys::None => false,
192            DisabledKeys::All => true,
193            DisabledKeys::Some(keys) => keys.iter().any(|k| k == key),
194        }
195    }
196}
197
198/// `Installer::findFrameworkType`: first key (krsort order, descending) that
199/// is a prefix of the type.
200fn find_framework<'t>(
201    table: &'t Table,
202    disabled: &DisabledKeys,
203    package_type: &str,
204) -> Option<&'t Framework> {
205    table
206        .frameworks_desc
207        .iter()
208        .filter(|f| !disabled.contains(&f.key))
209        .find(|f| package_type.starts_with(&f.key))
210}
211
212/// `Installer::supports`: `preg_match('#<fw>-(<loc>|...)#', $type)`, not
213/// anchored; empty table -> `(\w+)`.
214fn supports(fw: &Framework, package_type: &str) -> bool {
215    let prefix = format!("{}-", fw.key);
216    let mut start = 0;
217    while let Some(i) = package_type[start..].find(&prefix) {
218        let rest = &package_type[start + i + prefix.len()..];
219        let hit = if fw.locations.is_empty() {
220            rest.bytes()
221                .next()
222                .is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_')
223        } else {
224            fw.locations.keys().any(|k| rest.starts_with(k.as_str()))
225        };
226        if hit {
227            return true;
228        }
229        start += i + 1;
230    }
231    false
232}
233
234/// `BaseInstaller::mapCustomInstallPaths`: first key whose list contains the
235/// name, `type:<type>` or `vendor:<vendor>` (non-strict `in_array`: `true`
236/// in the list matches everything; other non-strings match nothing).
237fn custom_install_path<'a>(
238    paths: &'a Value,
239    name: &str,
240    package_type: &str,
241    vendor: &str,
242) -> Result<Option<&'a str>, Unsupported> {
243    let Some(map) = paths.as_object() else {
244        return Err(Unsupported::BadInstallerPaths(
245            "expected an object of template => package list".into(),
246        ));
247    };
248    let by_type = format!("type:{package_type}");
249    let by_vendor = format!("vendor:{vendor}");
250    for (template, names) in map {
251        let list: Vec<&Value> = match names {
252            Value::Array(a) => a.iter().collect(),
253            other => vec![other],
254        };
255        for n in &list {
256            match n {
257                Value::String(s) => {
258                    if s == name || *s == by_type || *s == by_vendor {
259                        return Ok(Some(template.as_str()));
260                    }
261                }
262                Value::Bool(true) => return Ok(Some(template.as_str())),
263                Value::Bool(false) | Value::Null | Value::Number(_) => {}
264                Value::Array(_) | Value::Object(_) => {
265                    return Err(Unsupported::BadInstallerPaths(format!(
266                        "nested value under `{template}`"
267                    )))
268                }
269            }
270        }
271    }
272    Ok(None)
273}
274
275/// `BaseInstaller::templatePath`: `{$var}` (`[A-Za-z0-9_]*`) replaced by the
276/// variable; unknown variable -> Unsupported (Composer would produce an
277/// empty string with a warning).
278fn template_path(template: &str, vars: &BTreeMap<&str, &str>) -> Result<String, Unsupported> {
279    let mut out = String::with_capacity(template.len());
280    let mut rest = template;
281    while let Some(i) = rest.find("{$") {
282        out.push_str(&rest[..i]);
283        let after = &rest[i + 2..];
284        let len = after
285            .bytes()
286            .take_while(|b| b.is_ascii_alphanumeric() || *b == b'_')
287            .count();
288        if after[len..].starts_with('}') {
289            let var = &after[..len];
290            match vars.get(var) {
291                Some(v) => out.push_str(v),
292                None => return Err(Unsupported::UnknownTemplateVar(var.to_owned())),
293            }
294            rest = &after[len + 1..];
295        } else {
296            out.push_str("{$");
297            rest = after;
298        }
299    }
300    out.push_str(rest);
301    Ok(out)
302}
303
304/// Path a package gets with composer/installers active (plugin allowed,
305/// ported version): `Placement::Vendor` if the plugin does not take this
306/// type (`supports` false, framework disabled), else the templated path.
307pub fn placement(
308    table: &Table,
309    root_extra: Option<&Value>,
310    name: &str,
311    package_type: &str,
312    package_extra: Option<&Value>,
313) -> Result<Placement, Unsupported> {
314    let disabled = disabled_keys(root_extra);
315    let Some(fw) = find_framework(table, &disabled, package_type) else {
316        return Ok(Placement::Vendor);
317    };
318    if !supports(fw, package_type) {
319        return Ok(Placement::Vendor);
320    }
321    if fw.custom {
322        return Err(Unsupported::CustomFramework {
323            key: fw.key.clone(),
324            class: fw.class.clone(),
325        });
326    }
327
328    // BaseInstaller::getInstallPath
329    let (vendor, short_name) = match name.split_once('/') {
330        Some((v, n)) => (v, n.split('/').next().unwrap_or(n)),
331        None => ("", name),
332    };
333    let mut var_name = short_name.to_owned();
334    if let Some(v) = package_extra.and_then(|e| e.get("installer-name")) {
335        match v {
336            Value::String(s) if s.is_empty() || s == "0" => {} // !empty
337            Value::String(s) => {
338                // `{`: templatePath would run a sequential str_replace on the
339                // name itself; refused rather than imitated.
340                if s.contains('/') || s.contains('{') || s.split('/').any(|c| c == "..") {
341                    return Err(Unsupported::BadInstallerName(format!("`{s}`")));
342                }
343                var_name = s.clone();
344            }
345            Value::Null | Value::Bool(false) => {}
346            other => {
347                return Err(Unsupported::BadInstallerName(format!(
348                    "not a string: {other}"
349                )))
350            }
351        }
352    }
353    let vars: BTreeMap<&str, &str> = BTreeMap::from([
354        ("name", var_name.as_str()),
355        ("vendor", vendor),
356        ("type", package_type),
357    ]);
358
359    if let Some(paths) = root_extra.and_then(|e| e.get("installer-paths")) {
360        // `!empty($extra['installer-paths'])`
361        let empty = match paths {
362            Value::Null | Value::Bool(false) => true,
363            Value::String(s) => s.is_empty() || s == "0",
364            Value::Array(a) => a.is_empty(),
365            Value::Object(o) => o.is_empty(),
366            Value::Number(n) => n.as_f64() == Some(0.0),
367            Value::Bool(true) => false,
368        };
369        if !empty {
370            if let Some(template) = custom_install_path(paths, name, package_type, vendor)? {
371                return Ok(Placement::Custom(template_path(template, &vars)?));
372            }
373        }
374    }
375
376    let location_key = &package_type[fw.key.len() + 1..];
377    let Some(template) = fw.locations.get(location_key) else {
378        return Err(Unsupported::UnknownLocation {
379            package_type: package_type.to_owned(),
380        });
381    };
382    Ok(Placement::Custom(template_path(template, &vars)?))
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use serde_json::json;
389
390    fn t() -> &'static Table {
391        table_for("v2.3.0").expect("table 2.3.0")
392    }
393
394    #[test]
395    fn tables_load_and_versions_are_known() {
396        for v in ported_versions() {
397            let tbl = table_for(v).expect(v);
398            assert_eq!(tbl.tag, format!("v{v}"));
399            assert!(tbl.framework("wordpress").is_some());
400        }
401        assert!(table_for("v1.12.0").is_none());
402        assert!(table_for("2.4.0").is_none());
403        assert!(table_for("dev-main").is_none());
404    }
405
406    #[test]
407    fn framework_prefix_uses_krsort_order() {
408        let d = DisabledKeys::None;
409        assert_eq!(
410            find_framework(t(), &d, "fuelphp-package").map(|f| f.key.as_str()),
411            Some("fuelphp")
412        );
413        assert_eq!(
414            find_framework(t(), &d, "fuel-package").map(|f| f.key.as_str()),
415            Some("fuel")
416        );
417        assert_eq!(
418            find_framework(t(), &d, "redaxo5-addon").map(|f| f.key.as_str()),
419            Some("redaxo5")
420        );
421        assert_eq!(
422            find_framework(t(), &d, "concretecms-package").map(|f| f.key.as_str()),
423            Some("concretecms")
424        );
425        assert!(find_framework(t(), &d, "library").is_none());
426    }
427
428    #[test]
429    fn wordpress_default_and_custom_paths() {
430        let p = placement(
431            t(),
432            None,
433            "wpackagist-plugin/akismet",
434            "wordpress-plugin",
435            None,
436        );
437        assert_eq!(
438            p,
439            Ok(Placement::Custom("wp-content/plugins/akismet/".into()))
440        );
441
442        let root = json!({"installer-paths": {
443            "web/app/mu-plugins/{$name}/": ["type:wordpress-muplugin"],
444            "web/app/plugins/{$name}/": ["type:wordpress-plugin"],
445            "web/app/themes/{$vendor}-{$name}/": ["type:wordpress-theme"],
446            "custom/{$name}": ["wpackagist-plugin/hello-dolly"]
447        }});
448        assert_eq!(
449            placement(
450                t(),
451                Some(&root),
452                "wpackagist-plugin/akismet",
453                "wordpress-plugin",
454                None
455            ),
456            Ok(Placement::Custom("web/app/plugins/akismet/".into()))
457        );
458        // The exact name only wins if it comes first in key order.
459        assert_eq!(
460            placement(
461                t(),
462                Some(&root),
463                "wpackagist-plugin/hello-dolly",
464                "wordpress-plugin",
465                None
466            ),
467            Ok(Placement::Custom("web/app/plugins/hello-dolly/".into()))
468        );
469        assert_eq!(
470            placement(
471                t(),
472                Some(&root),
473                "wpackagist-theme/twentytwentyfour",
474                "wordpress-theme",
475                None
476            ),
477            Ok(Placement::Custom(
478                "web/app/themes/wpackagist-theme-twentytwentyfour/".into()
479            ))
480        );
481        // Type not taken by the plugin -> LibraryInstaller.
482        assert_eq!(
483            placement(t(), Some(&root), "a/b", "library", None),
484            Ok(Placement::Vendor)
485        );
486        assert_eq!(
487            placement(t(), Some(&root), "a/b", "wordpress-core", None),
488            Ok(Placement::Vendor)
489        );
490        // Unanchored regex: `wordpress-plugin-x` is supported, but without a
491        // location -> exception in Composer.
492        assert_eq!(
493            placement(t(), None, "a/b", "wordpress-plugin-x", None),
494            Err(Unsupported::UnknownLocation {
495                package_type: "wordpress-plugin-x".into()
496            })
497        );
498    }
499
500    #[test]
501    fn installer_name_disable_and_custom_frameworks() {
502        let extra = json!({"installer-name": "renamed"});
503        assert_eq!(
504            placement(t(), None, "a/b", "drupal-module", Some(&extra)),
505            Ok(Placement::Custom("modules/renamed/".into()))
506        );
507        let empty = json!({"installer-name": ""});
508        assert_eq!(
509            placement(t(), None, "a/b", "drupal-module", Some(&empty)),
510            Ok(Placement::Custom("modules/b/".into()))
511        );
512        let root_all = json!({"installer-disable": true});
513        assert_eq!(
514            placement(t(), Some(&root_all), "a/b", "drupal-module", None),
515            Ok(Placement::Vendor)
516        );
517        let root_some = json!({"installer-disable": ["drupal"]});
518        assert_eq!(
519            placement(t(), Some(&root_some), "a/b", "drupal-module", None),
520            Ok(Placement::Vendor)
521        );
522        assert_eq!(
523            placement(t(), Some(&root_some), "a/b", "wordpress-plugin", None),
524            Ok(Placement::Custom("wp-content/plugins/b/".into()))
525        );
526        assert!(matches!(
527            placement(t(), None, "a/b", "cakephp-plugin", None),
528            Err(Unsupported::CustomFramework { .. })
529        ));
530        assert_eq!(
531            placement(
532                t(),
533                Some(&json!({"installer-paths": {"x/{$nope}": ["a/b"]}})),
534                "a/b",
535                "drupal-module",
536                None
537            ),
538            Err(Unsupported::UnknownTemplateVar("nope".into()))
539        );
540    }
541}