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 v = crate::jsonfile::read(&path)?;
147    v.get("config")?.get(key).cloned()
148}
149
150/// Project-relative path of a package handled by LibraryInstaller:
151/// `<vendor-dir>/<name>[/<target-dir>]`.
152fn vendor_rel(vendor: &str, name: &str, target_dir: Option<&str>) -> String {
153    match target_dir {
154        Some(t) => format!("{vendor}/{name}/{t}"),
155        None => format!("{vendor}/{name}"),
156    }
157}
158
159/// The active installers of a run: composer/installers' table, and
160/// whether roots/wordpress-core-installer places `wordpress-core`.
161#[derive(Clone, Copy, Default)]
162struct Placers<'a> {
163    table: Option<&'a installers::Table>,
164    wp_core: bool,
165    /// mnsami/composer-custom-directory-installer places `library` and
166    /// `composer-plugin` packages named in `extra.installer-paths`.
167    custom_dirs: bool,
168}
169
170/// Decision for a package (name, type, extra) under the current configuration.
171fn place(
172    vendor: &str,
173    placers: Placers<'_>,
174    root_extra: Option<&Value>,
175    name: &str,
176    package_type: &str,
177    package_extra: Option<&Value>,
178    target_dir: Option<&str>,
179) -> Result<String, String> {
180    if placers.custom_dirs && matches!(package_type, "library" | "composer-plugin") {
181        if let Some(p) = custom_dir(root_extra, name, package_extra) {
182            return custom_target(vendor, name, &p, "custom-directory-installer");
183        }
184    }
185    // roots/wordpress-core-installer's `getInstallPath` for its type.
186    if placers.wp_core && package_type == WP_CORE_TYPE {
187        return match wp_core_dir(root_extra, name, package_extra, vendor) {
188            Ok(p) => custom_target(vendor, name, &p, "wordpress-core-installer"),
189            Err(e) => Err(format!("wordpress-core-installer: {e}")),
190        };
191    }
192    let Some(table) = placers.table else {
193        return Ok(vendor_rel(vendor, name, target_dir));
194    };
195    match installers::placement(table, root_extra, name, package_type, package_extra) {
196        Ok(Placement::Vendor) => Ok(vendor_rel(vendor, name, target_dir)),
197        Ok(Placement::Custom(p)) => custom_target(vendor, name, &p, "installers"),
198        Err(e) => Err(format!("installers: {name} ({package_type}): {e}")),
199    }
200}
201
202/// A custom install path's checks: relative, not the project root, not
203/// outside it, not inside vendor/.
204fn custom_target(vendor: &str, name: &str, p: &str, who: &str) -> Result<String, String> {
205    {
206        {
207            if crate::pathutil::is_absolute_path(p) {
208                return Err(format!(
209                    "{who}: {name} would install at an absolute path `{p}`"
210                ));
211            }
212            let rel = normalize_path(p);
213            if rel.is_empty() || rel == "." {
214                return Err(format!("{who}: {name} would install at the project root"));
215            }
216            if rel.starts_with("../") || rel == ".." {
217                return Err(format!(
218                    "{who}: {name} would install outside the project (`{p}`)"
219                ));
220            }
221            if rel == vendor || rel.starts_with(&format!("{vendor}/")) {
222                return Err(format!(
223                    "{who}: {name} targets `{p}` inside {vendor}/ (not emulated: use the default vendor layout)"
224                ));
225            }
226            Ok(rel)
227        }
228    }
229}
230
231/// roots/wordpress-core-installer (docs/reference/plugins/
232/// wordpress-core-installer/, read at v4.0.0 — 85d3589): the package type it
233/// installs, and `getInstallPath` — the root's `extra.wordpress-install-dir`
234/// (a string, or a map by pretty name; PHP `empty()` rules), else the
235/// package's own, else `wordpress`; `.` and the vendor directory refused
236/// with the plugin's exception.
237pub const CUSTOM_DIRS_PLUGIN: &str = "mnsami/composer-custom-directory-installer";
238
239/// mnsami/composer-custom-directory-installer (docs/reference/plugins/
240/// composer-custom-directory-installer/, read at 2.0.0):
241/// `PackageUtils::getPackageInstallPath` — the first `extra.installer-paths`
242/// entry of the root whose list names the package (exact pretty name),
243/// with `{$name}` / `{$vendor}` filled in (`extra.installer-name` of the
244/// package overrides the name); `None` (the default path) otherwise.
245fn custom_dir(
246    root_extra: Option<&Value>,
247    pretty_name: &str,
248    package_extra: Option<&Value>,
249) -> Option<String> {
250    let paths = root_extra
251        .and_then(|e| e.get("installer-paths"))
252        .and_then(Value::as_object)
253        .filter(|m| !m.is_empty())?;
254    let path = paths.iter().find_map(|(path, names)| {
255        names
256            .as_array()
257            .is_some_and(|l| l.iter().any(|n| n.as_str() == Some(pretty_name)))
258            .then(|| path.clone())
259    })?;
260    let (vendor, mut name) = match pretty_name.split_once('/') {
261        Some((v, n)) => (v.to_owned(), n.to_owned()),
262        None => (String::new(), pretty_name.to_owned()),
263    };
264    if let Some(n) = package_extra
265        .and_then(|e| e.get("installer-name"))
266        .and_then(Value::as_str)
267        .filter(|n| !n.is_empty() && *n != "0")
268    {
269        name = n.to_owned();
270    }
271    Some(path.replace("{$name}", &name).replace("{$vendor}", &vendor))
272}
273
274pub const WP_CORE_PLUGIN: &str = "roots/wordpress-core-installer";
275pub const WP_CORE_TYPE: &str = "wordpress-core";
276
277fn wp_core_dir(
278    root_extra: Option<&Value>,
279    pretty_name: &str,
280    package_extra: Option<&Value>,
281    vendor: &str,
282) -> Result<String, String> {
283    let php_empty = |v: Option<&Value>| match v {
284        None | Some(Value::Null) => true,
285        Some(Value::Bool(b)) => !*b,
286        Some(Value::String(s)) => s.is_empty() || s == "0",
287        Some(Value::Number(n)) => n.as_f64() == Some(0.0),
288        Some(Value::Array(a)) => a.is_empty(),
289        Some(Value::Object(o)) => o.is_empty(),
290    };
291    let as_dir = |v: &Value| -> Option<String> {
292        match v {
293            Value::String(s) => Some(s.clone()),
294            Value::Number(n) => Some(n.to_string()),
295            Value::Bool(true) => Some("1".to_owned()),
296            _ => None,
297        }
298    };
299    let mut dir: Option<String> = None;
300    let top = root_extra.and_then(|e| e.get("wordpress-install-dir"));
301    if !php_empty(top) {
302        match top {
303            Some(Value::Object(map)) => {
304                let entry = map.get(pretty_name);
305                if !php_empty(entry) {
306                    dir = entry.and_then(as_dir);
307                }
308            }
309            Some(v) => dir = as_dir(v),
310            None => {}
311        }
312    }
313    if dir.is_none() {
314        let own = package_extra.and_then(|e| e.get("wordpress-install-dir"));
315        if !php_empty(own) {
316            dir = own.and_then(as_dir);
317        }
318    }
319    let dir = dir.unwrap_or_else(|| "wordpress".to_owned());
320    if dir == "." || dir == vendor {
321        return Err(format!(
322            "Warning! {dir} is an invalid WordPress install directory (from {pretty_name})!"
323        ));
324    }
325    Ok(dir)
326}
327
328impl Layout {
329    /// Everything in vendor/ (no layout plugin), for tests and code paths
330    /// that have no plugin-aware lock.
331    pub fn vendor_only(project_dir: &Path, lock: &Lock, with_dev: bool) -> Layout {
332        Self::vendor_only_with(project_dir, lock, with_dev, Dirs::default())
333    }
334
335    /// `vendor_only` under the given `config.vendor-dir` / `bin-dir`.
336    pub fn vendor_only_with(project_dir: &Path, lock: &Lock, with_dev: bool, dirs: Dirs) -> Layout {
337        let mut paths = BTreeMap::new();
338        for p in lock.wanted_packages(with_dev) {
339            if !p.is_virtual(false) {
340                paths.insert(
341                    p.name().to_owned(),
342                    vendor_rel(dirs.vendor_rel(), p.name(), p.target_dir()),
343                );
344            }
345        }
346        Layout {
347            root: absolutize(project_dir),
348            dirs,
349            paths,
350            installers_tag: None,
351            removals: BTreeMap::new(),
352        }
353    }
354
355    /// The full pass: plugin, allow-plugins, paths, refused targets, and the
356    /// removal plan for installed.json packages that went away.
357    pub fn resolve(
358        project_dir: &Path,
359        lock: &Lock,
360        manifest: &Value,
361        with_dev: bool,
362        plugins_enabled: bool,
363    ) -> Result<Layout, Vec<String>> {
364        let root = absolutize(project_dir);
365        let dirs = Dirs::resolve(manifest).map_err(|e| vec![e])?;
366        let vendor = dirs.vendor_rel().to_owned();
367        let vendor_prefix = format!("{vendor}/");
368        let mut issues: Vec<String> = Vec::new();
369        let wanted: Vec<&LockPackage> = lock.wanted_packages(with_dev).collect();
370        let previous = installed_packages(&root, &dirs);
371        let has_state = dirs.composer_dir(&root).join("installed.json").is_file();
372
373        // Is the plugin active? Composer loads it from installed.json
374        // (PluginManager::loadInstalledPlugins) and installs it first in the
375        // transaction; vivacity only emulates the states where both views
376        // agree. A plugin present on one side only (added, removed, or in
377        // require-dev with --no-dev) is a transition left to Composer.
378        let lock_plugin = wanted.iter().find(|p| p.name() == "composer/installers");
379        let prev_plugin = previous
380            .iter()
381            .find(|p| p["name"].as_str() == Some("composer/installers"));
382        let mut table: Option<&installers::Table> = None;
383        let mut installers_tag = None;
384        if plugins_enabled && (lock_plugin.is_some() || prev_plugin.is_some()) {
385            let allow = merged_allow_plugins(
386                manifest.get("config").and_then(|c| c.get("allow-plugins")),
387                global_allow_plugins().as_ref(),
388            );
389            match plugin_verdict(allow.as_ref(), "composer/installers") {
390                PluginVerdict::Allowed => {
391                    let version = lock_plugin
392                        .map(|p| p.version().to_owned())
393                        .or_else(|| {
394                            prev_plugin.and_then(|p| p["version"].as_str().map(str::to_owned))
395                        })
396                        .unwrap_or_default();
397                    let Some(t) = installers::table_for(&version) else {
398                        return Err(vec![format!(
399                            "composer/installers {version} is not a ported version (ported: {})",
400                            installers::ported_versions().collect::<Vec<_>>().join(", ")
401                        )]);
402                    };
403                    if has_state && lock_plugin.is_some() != prev_plugin.is_some() {
404                        let root_extra = manifest.get("extra");
405                        let taken = |name: &str, ty: &str, extra: Option<&Value>| {
406                            !matches!(
407                                installers::placement(t, root_extra, name, ty, extra),
408                                Ok(Placement::Vendor)
409                            )
410                        };
411                        let any_taken = wanted
412                            .iter()
413                            .any(|p| taken(p.name(), p.package_type(), p.raw.get("extra")))
414                            || previous.iter().any(|p| {
415                                taken(
416                                    p["name"].as_str().unwrap_or(""),
417                                    p["type"].as_str().unwrap_or("library"),
418                                    p.get("extra"),
419                                )
420                            });
421                        if any_taken {
422                            let how = if lock_plugin.is_some() {
423                                "added to"
424                            } else {
425                                "removed from"
426                            };
427                            return Err(vec![format!(
428                                "composer/installers is being {how} an existing install (installed.json and composer.lock disagree): let Composer handle this transition"
429                            )]);
430                        }
431                    }
432                    if lock_plugin.is_some() {
433                        installers_tag = Some(t.tag.clone());
434                        table = Some(t);
435                    }
436                }
437                PluginVerdict::Blocked => {} // Composer ignores it: everything in vendor/
438                PluginVerdict::Unlisted => {
439                    return Err(vec![
440                        "composer/installers is a plugin not covered by config.allow-plugins (Composer would refuse to run it)"
441                            .to_owned(),
442                    ]);
443                }
444            }
445        }
446
447        // roots/wordpress-core-installer: the same two views must agree
448        // (installed.json and the lock), like composer/installers above.
449        let wp_lock = wanted.iter().any(|p| p.name() == WP_CORE_PLUGIN);
450        let wp_prev = previous
451            .iter()
452            .any(|p| p["name"].as_str() == Some(WP_CORE_PLUGIN));
453        let mut wp_core = false;
454        if plugins_enabled && (wp_lock || wp_prev) {
455            let allow = merged_allow_plugins(
456                manifest.get("config").and_then(|c| c.get("allow-plugins")),
457                global_allow_plugins().as_ref(),
458            );
459            match plugin_verdict(allow.as_ref(), WP_CORE_PLUGIN) {
460                PluginVerdict::Allowed => {
461                    let any_core = wanted.iter().any(|p| p.package_type() == WP_CORE_TYPE)
462                        || previous
463                            .iter()
464                            .any(|p| p["type"].as_str() == Some(WP_CORE_TYPE));
465                    if has_state && wp_lock != wp_prev && any_core {
466                        return Err(vec![format!(
467                            "{WP_CORE_PLUGIN} is being {} an existing install (installed.json and composer.lock disagree): let Composer handle this transition",
468                            if wp_lock { "added to" } else { "removed from" }
469                        )]);
470                    }
471                    wp_core = wp_lock;
472                }
473                PluginVerdict::Blocked => {}
474                PluginVerdict::Unlisted => {
475                    return Err(vec![format!(
476                        "{WP_CORE_PLUGIN} is a plugin not covered by config.allow-plugins (Composer would refuse to run it)"
477                    )]);
478                }
479            }
480        }
481
482        // mnsami/composer-custom-directory-installer: same views rule;
483        // with composer/installers also active the installer order
484        // follows the plugins' activation order — not reproduced.
485        let cd_lock = wanted.iter().any(|p| p.name() == CUSTOM_DIRS_PLUGIN);
486        let cd_prev = previous
487            .iter()
488            .any(|p| p["name"].as_str() == Some(CUSTOM_DIRS_PLUGIN));
489        let mut custom_dirs = false;
490        if plugins_enabled && (cd_lock || cd_prev) {
491            let allow = merged_allow_plugins(
492                manifest.get("config").and_then(|c| c.get("allow-plugins")),
493                global_allow_plugins().as_ref(),
494            );
495            match plugin_verdict(allow.as_ref(), CUSTOM_DIRS_PLUGIN) {
496                PluginVerdict::Allowed => {
497                    if table.is_some() {
498                        return Err(vec![format!(
499                            "{CUSTOM_DIRS_PLUGIN} together with composer/installers: the installer precedence follows the plugins' activation order (not emulated)"
500                        )]);
501                    }
502                    let root_extra = manifest.get("extra");
503                    let mapped = |name: &str, ty: &str, extra: Option<&Value>| {
504                        matches!(ty, "library" | "composer-plugin")
505                            && custom_dir(root_extra, name, extra).is_some()
506                    };
507                    let any_mapped = wanted
508                        .iter()
509                        .any(|p| mapped(p.name(), p.package_type(), p.raw.get("extra")))
510                        || previous.iter().any(|p| {
511                            mapped(
512                                p["name"].as_str().unwrap_or(""),
513                                p["type"].as_str().unwrap_or("library"),
514                                p.get("extra"),
515                            )
516                        });
517                    if has_state && cd_lock != cd_prev && any_mapped {
518                        return Err(vec![format!(
519                            "{CUSTOM_DIRS_PLUGIN} is being {} an existing install (installed.json and composer.lock disagree): let Composer handle this transition",
520                            if cd_lock { "added to" } else { "removed from" }
521                        )]);
522                    }
523                    custom_dirs = cd_lock;
524                }
525                PluginVerdict::Blocked => {}
526                PluginVerdict::Unlisted => {
527                    return Err(vec![format!(
528                        "{CUSTOM_DIRS_PLUGIN} is a plugin not covered by config.allow-plugins (Composer would refuse to run it)"
529                    )]);
530                }
531            }
532        }
533
534        let root_extra = manifest.get("extra");
535        let flex_packs = lock.flex_packs(manifest, with_dev, plugins_enabled);
536        let mut paths: BTreeMap<String, String> = BTreeMap::new();
537        for p in &wanted {
538            if p.is_virtual(flex_packs) {
539                continue;
540            }
541            match place(
542                &vendor,
543                Placers {
544                    table,
545                    wp_core,
546                    custom_dirs,
547                },
548                root_extra,
549                p.name(),
550                p.package_type(),
551                p.raw.get("extra"),
552                p.target_dir(),
553            ) {
554                Ok(rel) => {
555                    paths.insert(p.name().to_owned(), rel);
556                }
557                Err(e) => issues.push(e),
558            }
559        }
560
561        // Conflicting targets: two packages at the same place, or one under the other.
562        if table.is_some() || wp_core || custom_dirs {
563            let mut by_path: BTreeMap<&str, &str> = BTreeMap::new();
564            for (name, rel) in &paths {
565                if let Some(other) = by_path.insert(rel.as_str(), name.as_str()) {
566                    issues.push(format!("{name} and {other} would both install at `{rel}`"));
567                }
568            }
569            let customs: Vec<(&str, &str)> = paths
570                .iter()
571                .filter(|(_, rel)| !rel.starts_with(&vendor_prefix))
572                .map(|(n, r)| (n.as_str(), r.as_str()))
573                .collect();
574            for (name, rel) in &customs {
575                for (other, other_rel) in &paths {
576                    if other.as_str() != *name && other_rel.starts_with(&format!("{rel}/")) {
577                        issues.push(format!(
578                            "{name} at `{rel}` would contain {other} at `{other_rel}`"
579                        ));
580                    }
581                }
582            }
583        }
584
585        // Removal plan: Composer recomputes the path of a removed package with
586        // the current configuration; we only delete if that path is the one
587        // where the package was laid out (installed.json), else fallback.
588        let mut removals = BTreeMap::new();
589        let wanted_names: std::collections::BTreeSet<&str> =
590            wanted.iter().map(|p| p.name()).collect();
591        let vendor_composer =
592            normalize_path(&format!("{}/{vendor}/composer", root.to_string_lossy()));
593        let root_norm = normalize_path(&root.to_string_lossy());
594        for prev in &previous {
595            let name = prev["name"].as_str().unwrap_or("");
596            if name.is_empty() || wanted_names.contains(name) {
597                continue;
598            }
599            let Some(old_ip) = prev.get("install-path").and_then(Value::as_str) else {
600                continue; // metapackage
601            };
602            let old_abs = if crate::pathutil::is_absolute_path(old_ip) {
603                normalize_path(old_ip)
604            } else {
605                normalize_path(&format!("{vendor_composer}/{old_ip}"))
606            };
607            let Some(old_rel) = old_abs
608                .strip_prefix(&format!("{root_norm}/"))
609                .filter(|r| !r.is_empty())
610            else {
611                issues.push(format!(
612                    "installed package {name} lives outside the project (`{old_ip}`): not removing it"
613                ));
614                continue;
615            };
616            let expected = place(
617                &vendor,
618                Placers {
619                    table,
620                    wp_core,
621                    custom_dirs,
622                },
623                root_extra,
624                name,
625                prev.get("type")
626                    .and_then(Value::as_str)
627                    .unwrap_or("library"),
628                prev.get("extra"),
629                prev.get("target-dir")
630                    .and_then(Value::as_str)
631                    .map(|t| t.trim_matches('/'))
632                    .filter(|t| !t.is_empty()),
633            );
634            match expected {
635                Ok(rel) if rel == old_rel => {
636                    // LibraryInstaller::removeCode deletes getPackageBasePath:
637                    // vendor/<name> without the target-dir.
638                    let dir = if rel.starts_with(&vendor_prefix) {
639                        format!("{vendor}/{name}")
640                    } else {
641                        rel
642                    };
643                    removals.insert(name.to_owned(), dir);
644                }
645                Ok(rel) => issues.push(format!(
646                    "installed package {name} is at `{old_rel}` but the current layout puts it at `{rel}`: let Composer handle this removal"
647                )),
648                Err(e) => issues.push(format!("removal of {name}: {e}")),
649            }
650        }
651
652        if issues.is_empty() {
653            Ok(Layout {
654                root,
655                dirs,
656                paths,
657                installers_tag,
658                removals,
659            })
660        } else {
661            Err(issues)
662        }
663    }
664
665    pub fn root(&self) -> &Path {
666        &self.root
667    }
668
669    pub fn dirs(&self) -> &Dirs {
670        &self.dirs
671    }
672
673    /// `<root>/<vendor-dir>` (not canonicalised).
674    pub fn vendor_dir(&self) -> PathBuf {
675        self.dirs.vendor_dir(&self.root)
676    }
677
678    /// `<root>/<bin-dir>`.
679    pub fn bin_dir(&self) -> PathBuf {
680        self.dirs.bin_dir(&self.root)
681    }
682
683    /// `<root>/<vendor-dir>/composer`.
684    pub fn composer_dir(&self) -> PathBuf {
685        self.dirs.composer_dir(&self.root)
686    }
687
688    /// The root package's `install_path` in installed.php: relative to
689    /// `<vendor-dir>/composer` like the packages' (`'../../'` by default).
690    pub fn root_install_path(&self) -> String {
691        let root = self.root.to_string_lossy();
692        find_shortest_path(
693            &format!("{root}/{}/composer", self.dirs.vendor_rel()),
694            &root,
695            true,
696        )
697    }
698
699    /// Project-relative path (None: metapackage or unknown package).
700    pub fn rel(&self, name: &str) -> Option<&str> {
701        self.paths.get(name).map(String::as_str)
702    }
703
704    /// Absolute install path.
705    pub fn abs(&self, name: &str) -> Option<PathBuf> {
706        self.rel(name).map(|r| self.root.join(r))
707    }
708
709    /// Root to empty before laying out the package: `vendor/<name>`
710    /// (target-dir included) for LibraryInstaller, the target itself otherwise.
711    pub fn package_root(&self, name: &str) -> Option<PathBuf> {
712        let rel = self.rel(name)?;
713        Some(
714            if rel.starts_with(&format!("{}/", self.dirs.vendor_rel())) {
715                self.vendor_dir().join(name)
716            } else {
717                self.root.join(rel)
718            },
719        )
720    }
721
722    /// `install-path` of installed.json / installed.php: relative to
723    /// vendor/composer (`findShortestPath($repoDir, $path, true)`).
724    pub fn install_path(&self, name: &str) -> Option<String> {
725        let rel = self.rel(name)?;
726        let root = self.root.to_string_lossy();
727        Some(find_shortest_path(
728            &format!("{root}/{}/composer", self.dirs.vendor_rel()),
729            &format!("{root}/{rel}"),
730            true,
731        ))
732    }
733
734    /// installed.json packages to remove, with their absolute path.
735    pub fn removals(&self) -> impl Iterator<Item = (&str, PathBuf)> {
736        self.removals
737            .iter()
738            .map(|(n, rel)| (n.as_str(), self.root.join(rel)))
739    }
740}
741
742fn installed_packages(root: &Path, dirs: &Dirs) -> Vec<Value> {
743    installed_packages_at(&dirs.composer_dir(root))
744}
745
746/// The `packages` of `<dir>/installed.json`, raw, or nothing.
747pub fn installed_packages_at(composer_dir: &Path) -> Vec<Value> {
748    let Some(v) = crate::jsonfile::read(&composer_dir.join("installed.json")) else {
749        return Vec::new();
750    };
751    v.get("packages")
752        .and_then(Value::as_array)
753        .cloned()
754        .unwrap_or_default()
755}
756
757/// The project's installed.json packages, raw, under the manifest's
758/// vendor-dir (or `vendor/` when that configuration is one the layout
759/// refuses — it will say so).
760pub fn installed_packages_of(root: &Path, manifest: &Value) -> Vec<Value> {
761    let dirs = Dirs::resolve(manifest).unwrap_or_default();
762    installed_packages(root, &dirs)
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use serde_json::json;
769
770    fn lock_with(packages: Value) -> Lock {
771        Lock::parse(
772            &json!({"packages": packages, "packages-dev": [], "plugin-api-version": "2.6.0"})
773                .to_string(),
774        )
775        .expect("lock")
776    }
777
778    fn pkg(name: &str, ty: &str, version: &str) -> Value {
779        json!({"name": name, "version": version, "type": ty,
780               "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
781    }
782
783    fn root() -> PathBuf {
784        // On Windows `/proj` is not absolute (no drive) and would be
785        // absolutized under the cwd; an explicit drive keeps the test stable.
786        PathBuf::from(if cfg!(windows) { "C:/proj" } else { "/proj" })
787    }
788
789    #[test]
790    fn allow_plugins_patterns_and_merge() {
791        assert!(pattern_matches("composer/*", "composer/installers"));
792        assert!(pattern_matches(
793            "Composer/Installers",
794            "composer/installers"
795        ));
796        assert!(pattern_matches("*", "anything/here"));
797        assert!(!pattern_matches("composer/*", "other/installers"));
798        assert!(pattern_matches("*/installers", "composer/installers"));
799        let rules = json!({"composer/*": false, "composer/installers": true});
800        // First matching rule: `composer/*` -> refused.
801        assert_eq!(
802            plugin_verdict(Some(&rules), "composer/installers"),
803            PluginVerdict::Blocked
804        );
805        assert_eq!(
806            plugin_verdict(Some(&json!(true)), "x/y"),
807            PluginVerdict::Allowed
808        );
809        assert_eq!(
810            plugin_verdict(Some(&json!({})), "x/y"),
811            PluginVerdict::Unlisted
812        );
813        assert_eq!(plugin_verdict(None, "x/y"), PluginVerdict::Unlisted);
814        let merged = merged_allow_plugins(
815            Some(&json!({"a/b": false})),
816            Some(&json!({"a/b": true, "c/d": true})),
817        );
818        assert_eq!(merged, Some(json!({"a/b": false, "c/d": true})));
819    }
820
821    #[test]
822    fn vendor_dir_moves_every_derived_path() {
823        let lock = lock_with(json!([pkg("a/b", "library", "1.0.0")]));
824        let manifest = json!({"config": {"vendor-dir": "./lib/vendor/", "bin-dir": "bin"}});
825        let l = Layout::resolve(&root(), &lock, &manifest, true, true).expect("layout");
826        assert_eq!(l.rel("a/b"), Some("lib/vendor/a/b"));
827        assert_eq!(l.abs("a/b"), Some(root().join("lib/vendor/a/b")));
828        assert_eq!(
829            l.package_root("a/b"),
830            Some(root().join("lib/vendor").join("a/b"))
831        );
832        assert_eq!(l.install_path("a/b").as_deref(), Some("../a/b"));
833        assert_eq!(l.root_install_path(), "../../../");
834        assert_eq!(l.vendor_dir(), root().join("lib/vendor"));
835        assert_eq!(l.bin_dir(), root().join("bin"));
836        assert_eq!(l.composer_dir(), root().join("lib/vendor/composer"));
837        let l = Layout::resolve(&root(), &lock, &json!({}), true, true).expect("layout");
838        assert_eq!(l.root_install_path(), "../../");
839        assert_eq!(l.bin_dir(), root().join("vendor/bin"));
840        // A refused form is a layout issue, not a silent default.
841        let manifest = json!({"config": {"vendor-dir": "../shared/vendor"}});
842        let err = Layout::resolve(&root(), &lock, &manifest, true, true).unwrap_err();
843        assert!(err[0].contains("outside the project"), "{err:?}");
844    }
845
846    #[test]
847    fn without_plugin_everything_goes_to_vendor() {
848        let lock = lock_with(json!([
849            pkg("a/b", "wordpress-plugin", "1.0.0"),
850            pkg("a/meta", "metapackage", "1.0.0")
851        ]));
852        let manifest =
853            json!({"extra": {"installer-paths": {"web/{$name}": ["type:wordpress-plugin"]}}});
854        let l = Layout::resolve(&root(), &lock, &manifest, true, true).expect("layout");
855        assert_eq!(l.rel("a/b"), Some("vendor/a/b"));
856        assert_eq!(l.rel("a/meta"), None);
857        assert_eq!(l.install_path("a/b").as_deref(), Some("../a/b"));
858        assert!(l.installers_tag.is_none());
859    }
860
861    #[test]
862    fn plugin_allowed_places_packages_and_blocked_keeps_vendor() {
863        let lock = lock_with(json!([
864            pkg("composer/installers", "composer-plugin", "v2.3.0"),
865            pkg("wpackagist-plugin/akismet", "wordpress-plugin", "5.3"),
866            pkg(
867                "wpackagist-theme/twentytwentyfour",
868                "wordpress-theme",
869                "1.0"
870            ),
871            pkg("monolog/monolog", "library", "3.0.0"),
872            pkg("composer/pcre", "library", "3.0.0"),
873        ]));
874        let manifest = json!({
875            "config": {"allow-plugins": {"composer/installers": true}},
876            "extra": {"installer-paths": {"web/app/plugins/{$name}/": ["type:wordpress-plugin"]}}
877        });
878        let l = Layout::resolve(&root(), &lock, &manifest, true, true).expect("layout");
879        assert_eq!(l.installers_tag.as_deref(), Some("v2.3.0"));
880        assert_eq!(
881            l.rel("wpackagist-plugin/akismet"),
882            Some("web/app/plugins/akismet")
883        );
884        assert_eq!(
885            l.rel("wpackagist-theme/twentytwentyfour"),
886            Some("wp-content/themes/twentytwentyfour")
887        );
888        assert_eq!(l.rel("monolog/monolog"), Some("vendor/monolog/monolog"));
889        assert_eq!(
890            l.rel("composer/installers"),
891            Some("vendor/composer/installers")
892        );
893        assert_eq!(
894            l.install_path("wpackagist-plugin/akismet").as_deref(),
895            Some("../../web/app/plugins/akismet")
896        );
897        assert_eq!(l.install_path("composer/pcre").as_deref(), Some("./pcre"));
898        assert_eq!(
899            l.install_path("monolog/monolog").as_deref(),
900            Some("../monolog/monolog")
901        );
902        assert_eq!(
903            l.package_root("wpackagist-plugin/akismet"),
904            Some(root().join("web/app/plugins/akismet"))
905        );
906
907        let blocked = json!({"config": {"allow-plugins": {"composer/installers": false}}});
908        let l = Layout::resolve(&root(), &lock, &blocked, true, true).expect("layout");
909        assert_eq!(
910            l.rel("wpackagist-plugin/akismet"),
911            Some("vendor/wpackagist-plugin/akismet")
912        );
913
914        let unlisted = json!({"config": {"allow-plugins": {"other/x": true}}});
915        let err = Layout::resolve(&root(), &lock, &unlisted, true, true).expect_err("fallback");
916        assert!(err[0].contains("allow-plugins"), "{err:?}");
917
918        let missing = json!({});
919        assert!(Layout::resolve(&root(), &lock, &missing, true, true).is_err());
920    }
921
922    #[test]
923    fn custom_dir_like_package_utils() {
924        use serde_json::json;
925        let root = json!({"installer-paths": {"modules/Log": ["psr/log"], "lib/{$vendor}-{$name}": ["psr/container", "acme/x"]}});
926        assert_eq!(
927            custom_dir(Some(&root), "psr/log", None).unwrap(),
928            "modules/Log"
929        );
930        assert_eq!(
931            custom_dir(Some(&root), "psr/container", None).unwrap(),
932            "lib/psr-container"
933        );
934        assert_eq!(
935            custom_dir(
936                Some(&root),
937                "acme/x",
938                Some(&json!({"installer-name": "renamed"}))
939            )
940            .unwrap(),
941            "lib/acme-renamed"
942        );
943        assert!(custom_dir(Some(&root), "psr/other", None).is_none());
944        assert!(custom_dir(None, "psr/log", None).is_none());
945    }
946
947    #[test]
948    fn wordpress_core_dir_like_the_plugin() {
949        use serde_json::json;
950        let d = |root: Option<serde_json::Value>, own: Option<serde_json::Value>| {
951            wp_core_dir(
952                root.as_ref(),
953                "roots/wordpress-no-content",
954                own.as_ref(),
955                "vendor",
956            )
957        };
958        assert_eq!(d(None, None).unwrap(), "wordpress");
959        assert_eq!(
960            d(Some(json!({"wordpress-install-dir": "web/wp"})), None).unwrap(),
961            "web/wp"
962        );
963        // A map by pretty name; a missing or empty entry falls through.
964        assert_eq!(
965            d(
966                Some(json!({"wordpress-install-dir": {"roots/wordpress-no-content": "cms"}})),
967                None
968            )
969            .unwrap(),
970            "cms"
971        );
972        assert_eq!(
973            d(
974                Some(json!({"wordpress-install-dir": {"other/pkg": "cms"}})),
975                Some(json!({"wordpress-install-dir": "own"}))
976            )
977            .unwrap(),
978            "own"
979        );
980        // PHP `empty()`: "" and "0" do not count.
981        assert_eq!(
982            d(Some(json!({"wordpress-install-dir": ""})), None).unwrap(),
983            "wordpress"
984        );
985        assert!(d(Some(json!({"wordpress-install-dir": "."})), None)
986            .unwrap_err()
987            .contains("invalid WordPress install directory"));
988        assert!(d(Some(json!({"wordpress-install-dir": "vendor"})), None).is_err());
989    }
990
991    #[test]
992    fn unported_version_custom_framework_and_dangerous_targets_are_issues() {
993        let manifest = json!({"config": {"allow-plugins": true}});
994        let old = lock_with(json!([
995            pkg("composer/installers", "composer-plugin", "v1.12.0"),
996            pkg("a/b", "drupal-module", "1.0")
997        ]));
998        let err = Layout::resolve(&root(), &old, &manifest, true, true).expect_err("v1");
999        assert!(err[0].contains("not a ported version"), "{err:?}");
1000
1001        let cake = lock_with(json!([
1002            pkg("composer/installers", "composer-plugin", "v2.2.0"),
1003            pkg("a/b", "cakephp-plugin", "1.0")
1004        ]));
1005        let err = Layout::resolve(&root(), &cake, &manifest, true, true).expect_err("cake");
1006        assert!(err[0].contains("custom path logic"), "{err:?}");
1007
1008        let lock = lock_with(json!([
1009            pkg("composer/installers", "composer-plugin", "v2.3.0"),
1010            pkg("a/b", "drupal-module", "1.0"),
1011            pkg("a/c", "drupal-module", "1.0")
1012        ]));
1013        for (paths, needle) in [
1014            (json!({"{$name}/../../x": ["a/b"]}), "outside the project"),
1015            (json!({"vendor/{$name}": ["a/b"]}), "inside vendor/"),
1016            (json!({"/abs/{$name}": ["a/b"]}), "absolute path"),
1017            (json!({"same/": ["a/b", "a/c"]}), "would both install"),
1018            (json!({"modules/": ["a/b"]}), "would contain"),
1019        ] {
1020            let m = json!({"config": {"allow-plugins": true}, "extra": {"installer-paths": paths}});
1021            let err = Layout::resolve(&root(), &lock, &m, true, true).expect_err(needle);
1022            assert!(err.iter().any(|e| e.contains(needle)), "{needle}: {err:?}");
1023        }
1024        // The root project: empty template.
1025        let m =
1026            json!({"config": {"allow-plugins": true}, "extra": {"installer-paths": {"": ["a/b"]}}});
1027        let err = Layout::resolve(&root(), &lock, &m, true, true).expect_err("root");
1028        assert!(err.iter().any(|e| e.contains("project root")), "{err:?}");
1029    }
1030
1031    #[test]
1032    fn removals_follow_installed_json_when_paths_agree() {
1033        let dir = tempfile::tempdir().expect("tmp");
1034        let vc = dir.path().join("vendor/composer");
1035        std::fs::create_dir_all(&vc).expect("mkdir");
1036        let plugin_entry = json!({"name": "composer/installers", "version": "v2.3.0", "type": "composer-plugin", "install-path": "./installers"});
1037        std::fs::write(
1038            vc.join("installed.json"),
1039            json!({"packages": [
1040                plugin_entry,
1041                {"name": "gone/lib", "version": "1.0", "type": "library", "install-path": "../gone/lib"},
1042                {"name": "gone/legacy", "version": "1.0", "type": "library", "target-dir": "Acme/Legacy", "install-path": "../gone/legacy/Acme/Legacy"},
1043                {"name": "gone/plugin", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../wp-content/plugins/plugin"},
1044                {"name": "moved/plugin", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../old/plugin"},
1045                {"name": "gone/meta", "version": "1.0", "type": "metapackage", "install-path": null}
1046            ], "dev": true, "dev-package-names": []})
1047            .to_string(),
1048        )
1049        .expect("write");
1050        let lock = lock_with(json!([pkg(
1051            "composer/installers",
1052            "composer-plugin",
1053            "v2.3.0"
1054        )]));
1055        let manifest = json!({"config": {"allow-plugins": true}});
1056        let err = Layout::resolve(dir.path(), &lock, &manifest, true, true).expect_err("moved");
1057        assert!(
1058            err.iter()
1059                .any(|e| e.contains("moved/plugin") && e.contains("old/plugin")),
1060            "{err:?}"
1061        );
1062
1063        // Without the moved package, the plan is accepted; a target-dir package
1064        // is deleted at vendor/<name> (getPackageBasePath), not at the sub-path.
1065        std::fs::write(
1066            vc.join("installed.json"),
1067            json!({"packages": [
1068                plugin_entry,
1069                {"name": "gone/lib", "version": "1.0", "type": "library", "install-path": "../gone/lib"},
1070                {"name": "gone/legacy", "version": "1.0", "type": "library", "target-dir": "Acme/Legacy", "install-path": "../gone/legacy/Acme/Legacy"},
1071                {"name": "gone/plugin", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../wp-content/plugins/plugin"}
1072            ], "dev": true, "dev-package-names": []})
1073            .to_string(),
1074        )
1075        .expect("write");
1076        let l = Layout::resolve(dir.path(), &lock, &manifest, true, true).expect("layout");
1077        let removals: Vec<(String, PathBuf)> =
1078            l.removals().map(|(n, p)| (n.to_owned(), p)).collect();
1079        assert_eq!(
1080            removals,
1081            vec![
1082                (
1083                    "gone/legacy".to_owned(),
1084                    l.root().join("vendor/gone/legacy")
1085                ),
1086                ("gone/lib".to_owned(), l.root().join("vendor/gone/lib")),
1087                (
1088                    "gone/plugin".to_owned(),
1089                    l.root().join("wp-content/plugins/plugin")
1090                ),
1091            ]
1092        );
1093    }
1094
1095    #[test]
1096    fn plugin_present_on_one_side_only_is_a_transition_for_composer() {
1097        let dir = tempfile::tempdir().expect("tmp");
1098        let vc = dir.path().join("vendor/composer");
1099        std::fs::create_dir_all(&vc).expect("mkdir");
1100        let manifest = json!({"config": {"allow-plugins": true}});
1101        let with_plugin = lock_with(json!([
1102            pkg("composer/installers", "composer-plugin", "v2.3.0"),
1103            pkg("a/wp", "wordpress-plugin", "1.0"),
1104        ]));
1105        let without_plugin = lock_with(json!([pkg("a/wp", "wordpress-plugin", "1.0")]));
1106        let libs_only = lock_with(json!([
1107            pkg("composer/installers", "composer-plugin", "v2.3.0"),
1108            pkg("a/lib", "library", "1.0"),
1109        ]));
1110
1111        // Addition: installed.json without the plugin, lock with it, and an affected package.
1112        std::fs::write(
1113            vc.join("installed.json"),
1114            json!({"packages": [{"name": "a/wp", "version": "1.0", "type": "wordpress-plugin", "install-path": "../a/wp"}], "dev": true, "dev-package-names": []}).to_string(),
1115        )
1116        .expect("write");
1117        let err =
1118            Layout::resolve(dir.path(), &with_plugin, &manifest, true, true).expect_err("added");
1119        assert!(err[0].contains("added to an existing install"), "{err:?}");
1120        // Same addition without a package of a type taken by the plugin: nothing to transition.
1121        std::fs::write(
1122            vc.join("installed.json"),
1123            json!({"packages": [{"name": "a/lib", "version": "1.0", "type": "library", "install-path": "../a/lib"}], "dev": true, "dev-package-names": []}).to_string(),
1124        )
1125        .expect("write");
1126        assert!(Layout::resolve(dir.path(), &libs_only, &manifest, true, true).is_ok());
1127
1128        // Removal: installed.json with the plugin and a package outside vendor/, lock without.
1129        std::fs::write(
1130            vc.join("installed.json"),
1131            json!({"packages": [
1132                {"name": "composer/installers", "version": "v2.3.0", "type": "composer-plugin", "install-path": "./installers"},
1133                {"name": "a/wp", "version": "1.0", "type": "wordpress-plugin", "install-path": "../../wp-content/plugins/wp"}
1134            ], "dev": true, "dev-package-names": []}).to_string(),
1135        )
1136        .expect("write");
1137        let err = Layout::resolve(dir.path(), &without_plugin, &manifest, true, true)
1138            .expect_err("removed");
1139        assert!(
1140            err[0].contains("removed from an existing install"),
1141            "{err:?}"
1142        );
1143
1144        // --no-plugins: Composer ignores the plugin on both sides, everything in vendor/.
1145        let l =
1146            Layout::resolve(dir.path(), &with_plugin, &manifest, true, false).expect("no-plugins");
1147        assert_eq!(l.rel("a/wp"), Some("vendor/a/wp"));
1148        assert!(l.installers_tag.is_none());
1149        // No on-disk state: the lock decides (fresh install).
1150        std::fs::remove_file(vc.join("installed.json")).expect("rm");
1151        let l = Layout::resolve(dir.path(), &with_plugin, &manifest, true, true).expect("fresh");
1152        assert_eq!(l.rel("a/wp"), Some("wp-content/plugins/wp"));
1153    }
1154}