Skip to main content

vivacity_core/
layout.rs

1//! Where each package of the lock gets installed: `vendor/<name>[/<target-dir>]`
2//! by LibraryInstaller, or the path composer/installers gives it when that
3//! plugin is locked, allowed (`config.allow-plugins`) and ported
4//! (`installers::table_for`). A single pass, before touching the disk;
5//! anything not reproducible byte for byte becomes an `issue` (falls back to
6//! Composer).
7//!
8//! The path is relative to the project root and normalised (`normalizePath`,
9//! no trailing slash); that is the form Composer normalises before computing
10//! `install-path` (FilesystemRepository::write).
11
12use crate::dirs::Dirs;
13use crate::installers::{self, Placement};
14use crate::lock::{Lock, LockPackage};
15use crate::pathutil::{find_shortest_path, normalize_path};
16use serde_json::Value;
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone)]
21pub struct Layout {
22    /// Project root, absolute (as given, not canonicalised: the relative paths
23    /// derived from it do not depend on symlinks).
24    root: PathBuf,
25    /// `config.vendor-dir` / `bin-dir`, project-relative.
26    dirs: Dirs,
27    /// name -> project-relative path (absent for a metapackage).
28    paths: BTreeMap<String, String>,
29    /// Emulated composer/installers tag, if the plugin is active.
30    pub installers_tag: Option<String>,
31    /// Installed packages (installed.json) to remove: name -> relative
32    /// directory to delete (`vendor/<name>` or the plugin's target), after
33    /// checking that Composer would recompute the same path today.
34    removals: BTreeMap<String, String>,
35}
36
37/// `allow-plugins` verdict for a package, like PluginManager in
38/// non-interactive mode.
39#[derive(Debug, PartialEq, Eq)]
40pub enum PluginVerdict {
41    Allowed,
42    /// Explicitly refused: Composer skips the plugin with a warning.
43    Blocked,
44    /// No rule covers it: Composer stops with an error.
45    Unlisted,
46}
47
48fn absolutize(dir: &Path) -> PathBuf {
49    if dir.is_absolute() {
50        dir.to_path_buf()
51    } else {
52        std::env::current_dir()
53            .map(|c| c.join(dir))
54            .unwrap_or_else(|_| dir.to_path_buf())
55    }
56}
57
58/// `BasePackage::packageNameToRegexp`: `{^<quote(pattern) with * -> .*>$}i`.
59fn pattern_matches(pattern: &str, name: &str) -> bool {
60    let p = pattern.to_ascii_lowercase();
61    let n = name.to_ascii_lowercase();
62    let parts: Vec<&str> = p.split('*').collect();
63    if parts.len() == 1 {
64        return p == n;
65    }
66    let mut rest = n.as_str();
67    for (i, part) in parts.iter().enumerate() {
68        if i == 0 {
69            let Some(r) = rest.strip_prefix(part) else {
70                return false;
71            };
72            rest = r;
73        } else if i == parts.len() - 1 {
74            return rest.ends_with(part);
75        } else {
76            let Some(pos) = rest.find(part) else {
77                return false;
78            };
79            rest = &rest[pos + part.len()..];
80        }
81    }
82    true
83}
84
85/// `Config::merge` for `allow-plugins`: the project value replaces the global
86/// one unless both are objects, in which case
87/// `array_merge($project, $global, $project)`: the project keys first, in
88/// their order, then those only the global config brings. Order matters: the
89/// first matching rule decides.
90pub fn merged_allow_plugins(project: Option<&Value>, global: Option<&Value>) -> Option<Value> {
91    match (project, global) {
92        (Some(Value::Object(p)), Some(Value::Object(g))) => {
93            let mut m = p.clone();
94            for (k, v) in g {
95                if !m.contains_key(k) {
96                    m.insert(k.clone(), v.clone());
97                }
98            }
99            Some(Value::Object(m))
100        }
101        (Some(p), _) => Some(p.clone()),
102        (None, Some(g)) => Some(g.clone()),
103        (None, None) => None,
104    }
105}
106
107/// `PluginManager::parseAllowedPlugins` + `isPluginAllowed` (non-interactive).
108fn plugin_verdict(allow: Option<&Value>, package: &str) -> PluginVerdict {
109    match allow {
110        Some(Value::Bool(true)) => PluginVerdict::Allowed,
111        Some(Value::Bool(false)) => PluginVerdict::Blocked,
112        Some(Value::Object(rules)) => {
113            for (pattern, v) in rules {
114                if pattern_matches(pattern, package) {
115                    return if v == &Value::Bool(true) {
116                        PluginVerdict::Allowed
117                    } else {
118                        PluginVerdict::Blocked
119                    };
120                }
121            }
122            PluginVerdict::Unlisted
123        }
124        _ => PluginVerdict::Unlisted,
125    }
126}
127
128/// Verdict for `package` under the project config merged with the global one.
129pub fn plugin_allowed(manifest: &Value, package: &str) -> PluginVerdict {
130    let allow = merged_allow_plugins(
131        manifest.get("config").and_then(|c| c.get("allow-plugins")),
132        global_allow_plugins().as_ref(),
133    );
134    plugin_verdict(allow.as_ref(), package)
135}
136
137/// `config.allow-plugins` from COMPOSER_HOME/config.json.
138pub fn global_allow_plugins() -> Option<Value> {
139    global_config_value("allow-plugins")
140}
141
142/// One `config.<key>` value of COMPOSER_HOME/config.json — the global
143/// layer of `Config::merge`, below the root composer.json.
144pub fn global_config_value(key: &str) -> Option<Value> {
145    let path = crate::fetch::composer_home()?.join("config.json");
146    let text = std::fs::read_to_string(path).ok()?;
147    let v: Value = serde_json::from_str(&text).ok()?;
148    v.get("config")?.get(key).cloned()
149}
150
151/// Project-relative path of a package handled by LibraryInstaller:
152/// `<vendor-dir>/<name>[/<target-dir>]`.
153fn vendor_rel(vendor: &str, name: &str, target_dir: Option<&str>) -> String {
154    match target_dir {
155        Some(t) => format!("{vendor}/{name}/{t}"),
156        None => format!("{vendor}/{name}"),
157    }
158}
159
160/// Decision for a package (name, type, extra) under the current configuration.
161fn place(
162    vendor: &str,
163    table: Option<&installers::Table>,
164    root_extra: Option<&Value>,
165    name: &str,
166    package_type: &str,
167    package_extra: Option<&Value>,
168    target_dir: Option<&str>,
169) -> Result<String, String> {
170    let Some(table) = table else {
171        return Ok(vendor_rel(vendor, name, target_dir));
172    };
173    match installers::placement(table, root_extra, name, package_type, package_extra) {
174        Ok(Placement::Vendor) => Ok(vendor_rel(vendor, name, target_dir)),
175        Ok(Placement::Custom(p)) => {
176            if crate::pathutil::is_absolute_path(&p) {
177                return Err(format!(
178                    "installers: {name} would install at an absolute path `{p}`"
179                ));
180            }
181            let rel = normalize_path(&p);
182            if rel.is_empty() || rel == "." {
183                return Err(format!(
184                    "installers: {name} would install at the project root"
185                ));
186            }
187            if rel.starts_with("../") || rel == ".." {
188                return Err(format!(
189                    "installers: {name} would install outside the project (`{p}`)"
190                ));
191            }
192            if rel == vendor || rel.starts_with(&format!("{vendor}/")) {
193                return Err(format!(
194                    "installers: {name} targets `{p}` inside {vendor}/ (not emulated: use the default vendor layout)"
195                ));
196            }
197            Ok(rel)
198        }
199        Err(e) => Err(format!("installers: {name} ({package_type}): {e}")),
200    }
201}
202
203impl Layout {
204    /// Everything in vendor/ (no layout plugin), for tests and code paths
205    /// that have no plugin-aware lock.
206    pub fn vendor_only(project_dir: &Path, lock: &Lock, with_dev: bool) -> Layout {
207        Self::vendor_only_with(project_dir, lock, with_dev, Dirs::default())
208    }
209
210    /// `vendor_only` under the given `config.vendor-dir` / `bin-dir`.
211    pub fn vendor_only_with(project_dir: &Path, lock: &Lock, with_dev: bool, dirs: Dirs) -> Layout {
212        let mut paths = BTreeMap::new();
213        for p in lock.wanted_packages(with_dev) {
214            if !p.is_virtual(false) {
215                paths.insert(
216                    p.name().to_owned(),
217                    vendor_rel(dirs.vendor_rel(), p.name(), p.target_dir()),
218                );
219            }
220        }
221        Layout {
222            root: absolutize(project_dir),
223            dirs,
224            paths,
225            installers_tag: None,
226            removals: BTreeMap::new(),
227        }
228    }
229
230    /// The full pass: plugin, allow-plugins, paths, refused targets, and the
231    /// removal plan for installed.json packages that went away.
232    pub fn resolve(
233        project_dir: &Path,
234        lock: &Lock,
235        manifest: &Value,
236        with_dev: bool,
237        plugins_enabled: bool,
238    ) -> Result<Layout, Vec<String>> {
239        let root = absolutize(project_dir);
240        let dirs = Dirs::resolve(manifest).map_err(|e| vec![e])?;
241        let vendor = dirs.vendor_rel().to_owned();
242        let vendor_prefix = format!("{vendor}/");
243        let mut issues: Vec<String> = Vec::new();
244        let wanted: Vec<&LockPackage> = lock.wanted_packages(with_dev).collect();
245        let previous = installed_packages(&root, &dirs);
246        let has_state = dirs.composer_dir(&root).join("installed.json").is_file();
247
248        // Is the plugin active? Composer loads it from installed.json
249        // (PluginManager::loadInstalledPlugins) and installs it first in the
250        // transaction; vivacity only emulates the states where both views
251        // agree. A plugin present on one side only (added, removed, or in
252        // require-dev with --no-dev) is a transition left to Composer.
253        let lock_plugin = wanted.iter().find(|p| p.name() == "composer/installers");
254        let prev_plugin = previous
255            .iter()
256            .find(|p| p["name"].as_str() == Some("composer/installers"));
257        let mut table: Option<&installers::Table> = None;
258        let mut installers_tag = None;
259        if plugins_enabled && (lock_plugin.is_some() || prev_plugin.is_some()) {
260            let allow = merged_allow_plugins(
261                manifest.get("config").and_then(|c| c.get("allow-plugins")),
262                global_allow_plugins().as_ref(),
263            );
264            match plugin_verdict(allow.as_ref(), "composer/installers") {
265                PluginVerdict::Allowed => {
266                    let version = lock_plugin
267                        .map(|p| p.version().to_owned())
268                        .or_else(|| {
269                            prev_plugin.and_then(|p| p["version"].as_str().map(str::to_owned))
270                        })
271                        .unwrap_or_default();
272                    let Some(t) = installers::table_for(&version) else {
273                        return Err(vec![format!(
274                            "composer/installers {version} is not a ported version (ported: {})",
275                            installers::ported_versions().collect::<Vec<_>>().join(", ")
276                        )]);
277                    };
278                    if has_state && lock_plugin.is_some() != prev_plugin.is_some() {
279                        let root_extra = manifest.get("extra");
280                        let taken = |name: &str, ty: &str, extra: Option<&Value>| {
281                            !matches!(
282                                installers::placement(t, root_extra, name, ty, extra),
283                                Ok(Placement::Vendor)
284                            )
285                        };
286                        let any_taken = wanted
287                            .iter()
288                            .any(|p| taken(p.name(), p.package_type(), p.raw.get("extra")))
289                            || previous.iter().any(|p| {
290                                taken(
291                                    p["name"].as_str().unwrap_or(""),
292                                    p["type"].as_str().unwrap_or("library"),
293                                    p.get("extra"),
294                                )
295                            });
296                        if any_taken {
297                            let how = if lock_plugin.is_some() {
298                                "added to"
299                            } else {
300                                "removed from"
301                            };
302                            return Err(vec![format!(
303                                "composer/installers is being {how} an existing install (installed.json and composer.lock disagree): let Composer handle this transition"
304                            )]);
305                        }
306                    }
307                    if lock_plugin.is_some() {
308                        installers_tag = Some(t.tag.clone());
309                        table = Some(t);
310                    }
311                }
312                PluginVerdict::Blocked => {} // Composer ignores it: everything in vendor/
313                PluginVerdict::Unlisted => {
314                    return Err(vec![
315                        "composer/installers is a plugin not covered by config.allow-plugins (Composer would refuse to run it)"
316                            .to_owned(),
317                    ]);
318                }
319            }
320        }
321
322        let root_extra = manifest.get("extra");
323        let flex_packs = lock.flex_packs(manifest, with_dev, plugins_enabled);
324        let mut paths: BTreeMap<String, String> = BTreeMap::new();
325        for p in &wanted {
326            if p.is_virtual(flex_packs) {
327                continue;
328            }
329            match place(
330                &vendor,
331                table,
332                root_extra,
333                p.name(),
334                p.package_type(),
335                p.raw.get("extra"),
336                p.target_dir(),
337            ) {
338                Ok(rel) => {
339                    paths.insert(p.name().to_owned(), rel);
340                }
341                Err(e) => issues.push(e),
342            }
343        }
344
345        // Conflicting targets: two packages at the same place, or one under the other.
346        if table.is_some() {
347            let mut by_path: BTreeMap<&str, &str> = BTreeMap::new();
348            for (name, rel) in &paths {
349                if let Some(other) = by_path.insert(rel.as_str(), name.as_str()) {
350                    issues.push(format!(
351                        "installers: {name} and {other} would both install at `{rel}`"
352                    ));
353                }
354            }
355            let customs: Vec<(&str, &str)> = paths
356                .iter()
357                .filter(|(_, rel)| !rel.starts_with(&vendor_prefix))
358                .map(|(n, r)| (n.as_str(), r.as_str()))
359                .collect();
360            for (name, rel) in &customs {
361                for (other, other_rel) in &paths {
362                    if other.as_str() != *name && other_rel.starts_with(&format!("{rel}/")) {
363                        issues.push(format!(
364                            "installers: {name} at `{rel}` would contain {other} at `{other_rel}`"
365                        ));
366                    }
367                }
368            }
369        }
370
371        // Removal plan: Composer recomputes the path of a removed package with
372        // the current configuration; we only delete if that path is the one
373        // where the package was laid out (installed.json), else fallback.
374        let mut removals = BTreeMap::new();
375        let wanted_names: std::collections::BTreeSet<&str> =
376            wanted.iter().map(|p| p.name()).collect();
377        let vendor_composer =
378            normalize_path(&format!("{}/{vendor}/composer", root.to_string_lossy()));
379        let root_norm = normalize_path(&root.to_string_lossy());
380        for prev in &previous {
381            let name = prev["name"].as_str().unwrap_or("");
382            if name.is_empty() || wanted_names.contains(name) {
383                continue;
384            }
385            let Some(old_ip) = prev.get("install-path").and_then(Value::as_str) else {
386                continue; // metapackage
387            };
388            let old_abs = if crate::pathutil::is_absolute_path(old_ip) {
389                normalize_path(old_ip)
390            } else {
391                normalize_path(&format!("{vendor_composer}/{old_ip}"))
392            };
393            let Some(old_rel) = old_abs
394                .strip_prefix(&format!("{root_norm}/"))
395                .filter(|r| !r.is_empty())
396            else {
397                issues.push(format!(
398                    "installed package {name} lives outside the project (`{old_ip}`): not removing it"
399                ));
400                continue;
401            };
402            let expected = place(
403                &vendor,
404                table,
405                root_extra,
406                name,
407                prev.get("type")
408                    .and_then(Value::as_str)
409                    .unwrap_or("library"),
410                prev.get("extra"),
411                prev.get("target-dir")
412                    .and_then(Value::as_str)
413                    .map(|t| t.trim_matches('/'))
414                    .filter(|t| !t.is_empty()),
415            );
416            match expected {
417                Ok(rel) if rel == old_rel => {
418                    // LibraryInstaller::removeCode deletes getPackageBasePath:
419                    // vendor/<name> without the target-dir.
420                    let dir = if rel.starts_with(&vendor_prefix) {
421                        format!("{vendor}/{name}")
422                    } else {
423                        rel
424                    };
425                    removals.insert(name.to_owned(), dir);
426                }
427                Ok(rel) => issues.push(format!(
428                    "installed package {name} is at `{old_rel}` but the current layout puts it at `{rel}`: let Composer handle this removal"
429                )),
430                Err(e) => issues.push(format!("removal of {name}: {e}")),
431            }
432        }
433
434        if issues.is_empty() {
435            Ok(Layout {
436                root,
437                dirs,
438                paths,
439                installers_tag,
440                removals,
441            })
442        } else {
443            Err(issues)
444        }
445    }
446
447    pub fn root(&self) -> &Path {
448        &self.root
449    }
450
451    pub fn dirs(&self) -> &Dirs {
452        &self.dirs
453    }
454
455    /// `<root>/<vendor-dir>` (not canonicalised).
456    pub fn vendor_dir(&self) -> PathBuf {
457        self.dirs.vendor_dir(&self.root)
458    }
459
460    /// `<root>/<bin-dir>`.
461    pub fn bin_dir(&self) -> PathBuf {
462        self.dirs.bin_dir(&self.root)
463    }
464
465    /// `<root>/<vendor-dir>/composer`.
466    pub fn composer_dir(&self) -> PathBuf {
467        self.dirs.composer_dir(&self.root)
468    }
469
470    /// The root package's `install_path` in installed.php: relative to
471    /// `<vendor-dir>/composer` like the packages' (`'../../'` by default).
472    pub fn root_install_path(&self) -> String {
473        let root = self.root.to_string_lossy();
474        find_shortest_path(
475            &format!("{root}/{}/composer", self.dirs.vendor_rel()),
476            &root,
477            true,
478        )
479    }
480
481    /// Project-relative path (None: metapackage or unknown package).
482    pub fn rel(&self, name: &str) -> Option<&str> {
483        self.paths.get(name).map(String::as_str)
484    }
485
486    /// Absolute install path.
487    pub fn abs(&self, name: &str) -> Option<PathBuf> {
488        self.rel(name).map(|r| self.root.join(r))
489    }
490
491    /// Root to empty before laying out the package: `vendor/<name>`
492    /// (target-dir included) for LibraryInstaller, the target itself otherwise.
493    pub fn package_root(&self, name: &str) -> Option<PathBuf> {
494        let rel = self.rel(name)?;
495        Some(
496            if rel.starts_with(&format!("{}/", self.dirs.vendor_rel())) {
497                self.vendor_dir().join(name)
498            } else {
499                self.root.join(rel)
500            },
501        )
502    }
503
504    /// `install-path` of installed.json / installed.php: relative to
505    /// vendor/composer (`findShortestPath($repoDir, $path, true)`).
506    pub fn install_path(&self, name: &str) -> Option<String> {
507        let rel = self.rel(name)?;
508        let root = self.root.to_string_lossy();
509        Some(find_shortest_path(
510            &format!("{root}/{}/composer", self.dirs.vendor_rel()),
511            &format!("{root}/{rel}"),
512            true,
513        ))
514    }
515
516    /// installed.json packages to remove, with their absolute path.
517    pub fn removals(&self) -> impl Iterator<Item = (&str, PathBuf)> {
518        self.removals
519            .iter()
520            .map(|(n, rel)| (n.as_str(), self.root.join(rel)))
521    }
522}
523
524fn installed_packages(root: &Path, dirs: &Dirs) -> Vec<Value> {
525    let path = dirs.composer_dir(root).join("installed.json");
526    let Ok(text) = std::fs::read_to_string(&path) else {
527        return Vec::new();
528    };
529    let Ok(v) = serde_json::from_str::<Value>(&text) else {
530        return Vec::new();
531    };
532    v.get("packages")
533        .and_then(Value::as_array)
534        .cloned()
535        .unwrap_or_default()
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use serde_json::json;
542
543    fn lock_with(packages: Value) -> Lock {
544        Lock::parse(
545            &json!({"packages": packages, "packages-dev": [], "plugin-api-version": "2.6.0"})
546                .to_string(),
547        )
548        .expect("lock")
549    }
550
551    fn pkg(name: &str, ty: &str, version: &str) -> Value {
552        json!({"name": name, "version": version, "type": ty,
553               "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
554    }
555
556    fn root() -> PathBuf {
557        // On Windows `/proj` is not absolute (no drive) and would be
558        // absolutized under the cwd; an explicit drive keeps the test stable.
559        PathBuf::from(if cfg!(windows) { "C:/proj" } else { "/proj" })
560    }
561
562    #[test]
563    fn allow_plugins_patterns_and_merge() {
564        assert!(pattern_matches("composer/*", "composer/installers"));
565        assert!(pattern_matches(
566            "Composer/Installers",
567            "composer/installers"
568        ));
569        assert!(pattern_matches("*", "anything/here"));
570        assert!(!pattern_matches("composer/*", "other/installers"));
571        assert!(pattern_matches("*/installers", "composer/installers"));
572        let rules = json!({"composer/*": false, "composer/installers": true});
573        // First matching rule: `composer/*` -> refused.
574        assert_eq!(
575            plugin_verdict(Some(&rules), "composer/installers"),
576            PluginVerdict::Blocked
577        );
578        assert_eq!(
579            plugin_verdict(Some(&json!(true)), "x/y"),
580            PluginVerdict::Allowed
581        );
582        assert_eq!(
583            plugin_verdict(Some(&json!({})), "x/y"),
584            PluginVerdict::Unlisted
585        );
586        assert_eq!(plugin_verdict(None, "x/y"), PluginVerdict::Unlisted);
587        let merged = merged_allow_plugins(
588            Some(&json!({"a/b": false})),
589            Some(&json!({"a/b": true, "c/d": true})),
590        );
591        assert_eq!(merged, Some(json!({"a/b": false, "c/d": true})));
592    }
593
594    #[test]
595    fn vendor_dir_moves_every_derived_path() {
596        let lock = lock_with(json!([pkg("a/b", "library", "1.0.0")]));
597        let manifest = json!({"config": {"vendor-dir": "./lib/vendor/", "bin-dir": "bin"}});
598        let l = Layout::resolve(&root(), &lock, &manifest, true, true).expect("layout");
599        assert_eq!(l.rel("a/b"), Some("lib/vendor/a/b"));
600        assert_eq!(l.abs("a/b"), Some(root().join("lib/vendor/a/b")));
601        assert_eq!(
602            l.package_root("a/b"),
603            Some(root().join("lib/vendor").join("a/b"))
604        );
605        assert_eq!(l.install_path("a/b").as_deref(), Some("../a/b"));
606        assert_eq!(l.root_install_path(), "../../../");
607        assert_eq!(l.vendor_dir(), root().join("lib/vendor"));
608        assert_eq!(l.bin_dir(), root().join("bin"));
609        assert_eq!(l.composer_dir(), root().join("lib/vendor/composer"));
610        let l = Layout::resolve(&root(), &lock, &json!({}), true, true).expect("layout");
611        assert_eq!(l.root_install_path(), "../../");
612        assert_eq!(l.bin_dir(), root().join("vendor/bin"));
613        // A refused form is a layout issue, not a silent default.
614        let manifest = json!({"config": {"vendor-dir": "../shared/vendor"}});
615        let err = Layout::resolve(&root(), &lock, &manifest, true, true).unwrap_err();
616        assert!(err[0].contains("outside the project"), "{err:?}");
617    }
618
619    #[test]
620    fn without_plugin_everything_goes_to_vendor() {
621        let lock = lock_with(json!([
622            pkg("a/b", "wordpress-plugin", "1.0.0"),
623            pkg("a/meta", "metapackage", "1.0.0")
624        ]));
625        let manifest =
626            json!({"extra": {"installer-paths": {"web/{$name}": ["type:wordpress-plugin"]}}});
627        let l = Layout::resolve(&root(), &lock, &manifest, true, true).expect("layout");
628        assert_eq!(l.rel("a/b"), Some("vendor/a/b"));
629        assert_eq!(l.rel("a/meta"), None);
630        assert_eq!(l.install_path("a/b").as_deref(), Some("../a/b"));
631        assert!(l.installers_tag.is_none());
632    }
633
634    #[test]
635    fn plugin_allowed_places_packages_and_blocked_keeps_vendor() {
636        let lock = lock_with(json!([
637            pkg("composer/installers", "composer-plugin", "v2.3.0"),
638            pkg("wpackagist-plugin/akismet", "wordpress-plugin", "5.3"),
639            pkg(
640                "wpackagist-theme/twentytwentyfour",
641                "wordpress-theme",
642                "1.0"
643            ),
644            pkg("monolog/monolog", "library", "3.0.0"),
645            pkg("composer/pcre", "library", "3.0.0"),
646        ]));
647        let manifest = json!({
648            "config": {"allow-plugins": {"composer/installers": true}},
649            "extra": {"installer-paths": {"web/app/plugins/{$name}/": ["type:wordpress-plugin"]}}
650        });
651        let l = Layout::resolve(&root(), &lock, &manifest, true, true).expect("layout");
652        assert_eq!(l.installers_tag.as_deref(), Some("v2.3.0"));
653        assert_eq!(
654            l.rel("wpackagist-plugin/akismet"),
655            Some("web/app/plugins/akismet")
656        );
657        assert_eq!(
658            l.rel("wpackagist-theme/twentytwentyfour"),
659            Some("wp-content/themes/twentytwentyfour")
660        );
661        assert_eq!(l.rel("monolog/monolog"), Some("vendor/monolog/monolog"));
662        assert_eq!(
663            l.rel("composer/installers"),
664            Some("vendor/composer/installers")
665        );
666        assert_eq!(
667            l.install_path("wpackagist-plugin/akismet").as_deref(),
668            Some("../../web/app/plugins/akismet")
669        );
670        assert_eq!(l.install_path("composer/pcre").as_deref(), Some("./pcre"));
671        assert_eq!(
672            l.install_path("monolog/monolog").as_deref(),
673            Some("../monolog/monolog")
674        );
675        assert_eq!(
676            l.package_root("wpackagist-plugin/akismet"),
677            Some(root().join("web/app/plugins/akismet"))
678        );
679
680        let blocked = json!({"config": {"allow-plugins": {"composer/installers": false}}});
681        let l = Layout::resolve(&root(), &lock, &blocked, true, true).expect("layout");
682        assert_eq!(
683            l.rel("wpackagist-plugin/akismet"),
684            Some("vendor/wpackagist-plugin/akismet")
685        );
686
687        let unlisted = json!({"config": {"allow-plugins": {"other/x": true}}});
688        let err = Layout::resolve(&root(), &lock, &unlisted, true, true).expect_err("fallback");
689        assert!(err[0].contains("allow-plugins"), "{err:?}");
690
691        let missing = json!({});
692        assert!(Layout::resolve(&root(), &lock, &missing, true, true).is_err());
693    }
694
695    #[test]
696    fn unported_version_custom_framework_and_dangerous_targets_are_issues() {
697        let manifest = json!({"config": {"allow-plugins": true}});
698        let old = lock_with(json!([
699            pkg("composer/installers", "composer-plugin", "v1.12.0"),
700            pkg("a/b", "drupal-module", "1.0")
701        ]));
702        let err = Layout::resolve(&root(), &old, &manifest, true, true).expect_err("v1");
703        assert!(err[0].contains("not a ported version"), "{err:?}");
704
705        let cake = lock_with(json!([
706            pkg("composer/installers", "composer-plugin", "v2.2.0"),
707            pkg("a/b", "cakephp-plugin", "1.0")
708        ]));
709        let err = Layout::resolve(&root(), &cake, &manifest, true, true).expect_err("cake");
710        assert!(err[0].contains("custom path logic"), "{err:?}");
711
712        let lock = lock_with(json!([
713            pkg("composer/installers", "composer-plugin", "v2.3.0"),
714            pkg("a/b", "drupal-module", "1.0"),
715            pkg("a/c", "drupal-module", "1.0")
716        ]));
717        for (paths, needle) in [
718            (json!({"{$name}/../../x": ["a/b"]}), "outside the project"),
719            (json!({"vendor/{$name}": ["a/b"]}), "inside vendor/"),
720            (json!({"/abs/{$name}": ["a/b"]}), "absolute path"),
721            (json!({"same/": ["a/b", "a/c"]}), "would both install"),
722            (json!({"modules/": ["a/b"]}), "would contain"),
723        ] {
724            let m = json!({"config": {"allow-plugins": true}, "extra": {"installer-paths": paths}});
725            let err = Layout::resolve(&root(), &lock, &m, true, true).expect_err(needle);
726            assert!(err.iter().any(|e| e.contains(needle)), "{needle}: {err:?}");
727        }
728        // The root project: empty template.
729        let m =
730            json!({"config": {"allow-plugins": true}, "extra": {"installer-paths": {"": ["a/b"]}}});
731        let err = Layout::resolve(&root(), &lock, &m, true, true).expect_err("root");
732        assert!(err.iter().any(|e| e.contains("project root")), "{err:?}");
733    }
734
735    #[test]
736    fn removals_follow_installed_json_when_paths_agree() {
737        let dir = tempfile::tempdir().expect("tmp");
738        let vc = dir.path().join("vendor/composer");
739        std::fs::create_dir_all(&vc).expect("mkdir");
740        let plugin_entry = json!({"name": "composer/installers", "version": "v2.3.0", "type": "composer-plugin", "install-path": "./installers"});
741        std::fs::write(
742            vc.join("installed.json"),
743            json!({"packages": [
744                plugin_entry,
745                {"name": "gone/lib", "version": "1.0", "type": "library", "install-path": "../gone/lib"},
746                {"name": "gone/legacy", "version": "1.0", "type": "library", "target-dir": "Acme/Legacy", "install-path": "../gone/legacy/Acme/Legacy"},
747                {"name": "gone/plugin", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../wp-content/plugins/plugin"},
748                {"name": "moved/plugin", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../old/plugin"},
749                {"name": "gone/meta", "version": "1.0", "type": "metapackage", "install-path": null}
750            ], "dev": true, "dev-package-names": []})
751            .to_string(),
752        )
753        .expect("write");
754        let lock = lock_with(json!([pkg(
755            "composer/installers",
756            "composer-plugin",
757            "v2.3.0"
758        )]));
759        let manifest = json!({"config": {"allow-plugins": true}});
760        let err = Layout::resolve(dir.path(), &lock, &manifest, true, true).expect_err("moved");
761        assert!(
762            err.iter()
763                .any(|e| e.contains("moved/plugin") && e.contains("old/plugin")),
764            "{err:?}"
765        );
766
767        // Without the moved package, the plan is accepted; a target-dir package
768        // is deleted at vendor/<name> (getPackageBasePath), not at the sub-path.
769        std::fs::write(
770            vc.join("installed.json"),
771            json!({"packages": [
772                plugin_entry,
773                {"name": "gone/lib", "version": "1.0", "type": "library", "install-path": "../gone/lib"},
774                {"name": "gone/legacy", "version": "1.0", "type": "library", "target-dir": "Acme/Legacy", "install-path": "../gone/legacy/Acme/Legacy"},
775                {"name": "gone/plugin", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../wp-content/plugins/plugin"}
776            ], "dev": true, "dev-package-names": []})
777            .to_string(),
778        )
779        .expect("write");
780        let l = Layout::resolve(dir.path(), &lock, &manifest, true, true).expect("layout");
781        let removals: Vec<(String, PathBuf)> =
782            l.removals().map(|(n, p)| (n.to_owned(), p)).collect();
783        assert_eq!(
784            removals,
785            vec![
786                (
787                    "gone/legacy".to_owned(),
788                    l.root().join("vendor/gone/legacy")
789                ),
790                ("gone/lib".to_owned(), l.root().join("vendor/gone/lib")),
791                (
792                    "gone/plugin".to_owned(),
793                    l.root().join("wp-content/plugins/plugin")
794                ),
795            ]
796        );
797    }
798
799    #[test]
800    fn plugin_present_on_one_side_only_is_a_transition_for_composer() {
801        let dir = tempfile::tempdir().expect("tmp");
802        let vc = dir.path().join("vendor/composer");
803        std::fs::create_dir_all(&vc).expect("mkdir");
804        let manifest = json!({"config": {"allow-plugins": true}});
805        let with_plugin = lock_with(json!([
806            pkg("composer/installers", "composer-plugin", "v2.3.0"),
807            pkg("a/wp", "wordpress-plugin", "1.0"),
808        ]));
809        let without_plugin = lock_with(json!([pkg("a/wp", "wordpress-plugin", "1.0")]));
810        let libs_only = lock_with(json!([
811            pkg("composer/installers", "composer-plugin", "v2.3.0"),
812            pkg("a/lib", "library", "1.0"),
813        ]));
814
815        // Addition: installed.json without the plugin, lock with it, and an affected package.
816        std::fs::write(
817            vc.join("installed.json"),
818            json!({"packages": [{"name": "a/wp", "version": "1.0", "type": "wordpress-plugin", "install-path": "../a/wp"}], "dev": true, "dev-package-names": []}).to_string(),
819        )
820        .expect("write");
821        let err =
822            Layout::resolve(dir.path(), &with_plugin, &manifest, true, true).expect_err("added");
823        assert!(err[0].contains("added to an existing install"), "{err:?}");
824        // Same addition without a package of a type taken by the plugin: nothing to transition.
825        std::fs::write(
826            vc.join("installed.json"),
827            json!({"packages": [{"name": "a/lib", "version": "1.0", "type": "library", "install-path": "../a/lib"}], "dev": true, "dev-package-names": []}).to_string(),
828        )
829        .expect("write");
830        assert!(Layout::resolve(dir.path(), &libs_only, &manifest, true, true).is_ok());
831
832        // Removal: installed.json with the plugin and a package outside vendor/, lock without.
833        std::fs::write(
834            vc.join("installed.json"),
835            json!({"packages": [
836                {"name": "composer/installers", "version": "v2.3.0", "type": "composer-plugin", "install-path": "./installers"},
837                {"name": "a/wp", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../wp-content/plugins/wp"}
838            ], "dev": true, "dev-package-names": []}).to_string(),
839        )
840        .expect("write");
841        let err = Layout::resolve(dir.path(), &without_plugin, &manifest, true, true)
842            .expect_err("removed");
843        assert!(
844            err[0].contains("removed from an existing install"),
845            "{err:?}"
846        );
847
848        // --no-plugins: Composer ignores the plugin on both sides, everything in vendor/.
849        let l =
850            Layout::resolve(dir.path(), &with_plugin, &manifest, true, false).expect("no-plugins");
851        assert_eq!(l.rel("a/wp"), Some("vendor/a/wp"));
852        assert!(l.installers_tag.is_none());
853        // No on-disk state: the lock decides (fresh install).
854        std::fs::remove_file(vc.join("installed.json")).expect("rm");
855        let l = Layout::resolve(dir.path(), &with_plugin, &manifest, true, true).expect("fresh");
856        assert_eq!(l.rel("a/wp"), Some("wp-content/plugins/wp"));
857    }
858}