1use 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 root: PathBuf,
25 dirs: Dirs,
27 paths: BTreeMap<String, String>,
29 pub installers_tag: Option<String>,
31 removals: BTreeMap<String, String>,
35}
36
37#[derive(Debug, PartialEq, Eq)]
40pub enum PluginVerdict {
41 Allowed,
42 Blocked,
44 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
58fn 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
85pub 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
107fn 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
128pub 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
137pub fn global_allow_plugins() -> Option<Value> {
139 global_config_value("allow-plugins")
140}
141
142pub 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
150fn 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#[derive(Clone, Copy, Default)]
162struct Placers<'a> {
163 table: Option<&'a installers::Table>,
164 wp_core: bool,
165 custom_dirs: bool,
168}
169
170fn 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 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
202fn 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
231pub const CUSTOM_DIRS_PLUGIN: &str = "mnsami/composer-custom-directory-installer";
238
239fn 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 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 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 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 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 => {} 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 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 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 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 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; };
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 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 pub fn vendor_dir(&self) -> PathBuf {
675 self.dirs.vendor_dir(&self.root)
676 }
677
678 pub fn bin_dir(&self) -> PathBuf {
680 self.dirs.bin_dir(&self.root)
681 }
682
683 pub fn composer_dir(&self) -> PathBuf {
685 self.dirs.composer_dir(&self.root)
686 }
687
688 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 pub fn rel(&self, name: &str) -> Option<&str> {
701 self.paths.get(name).map(String::as_str)
702 }
703
704 pub fn abs(&self, name: &str) -> Option<PathBuf> {
706 self.rel(name).map(|r| self.root.join(r))
707 }
708
709 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 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 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
746pub 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
757pub 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 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 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 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 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 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 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 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 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 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 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 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 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}