diff --git a/crates/socket-patch-cli/tests/e2e_composer.rs b/crates/socket-patch-cli/tests/e2e_composer.rs index cb378ffb2..c148bcba8 100644 --- a/crates/socket-patch-cli/tests/e2e_composer.rs +++ b/crates/socket-patch-cli/tests/e2e_composer.rs @@ -263,3 +263,50 @@ async fn scan_discovers_composer1_packages() { ); assert_proxy_served_scans(&proxy, 2).await; } + +/// #463: on Composer 1, installed.json has no `install-path`, so a package +/// composer/installers placed outside `vendor/` (here a WordPress plugin +/// under `extra.installer-paths`) was skipped as "not installed". scan must +/// find it where the installer put it and send it to the patch lookup. +#[tokio::test] +async fn scan_discovers_composer1_installer_paths_package() { + let proxy = start_proxy().await; + let dir = tempfile::tempdir().unwrap(); + let project_dir = dir.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::write( + project_dir.join("composer.json"), + r#"{"require": {"acme/tool": "1.0.0", "composer/installers": "*"}, + "extra": {"installer-paths": + {"wp-content/plugins/{$name}/": ["type:wordpress-plugin"]}}}"#, + ) + .unwrap(); + + // What composer-1.10.28 writes: a bare array, no install-path. + let vendor_dir = project_dir.join("vendor"); + let composer_dir = vendor_dir.join("composer"); + std::fs::create_dir_all(&composer_dir).unwrap(); + std::fs::write( + composer_dir.join("installed.json"), + r#"[ + {"name": "composer/installers", "version": "v1.12.0", "type": "composer-plugin"}, + {"name": "acme/tool", "version": "1.0.0", "type": "wordpress-plugin"} + ]"#, + ) + .unwrap(); + std::fs::create_dir_all(vendor_dir.join("composer").join("installers")).unwrap(); + std::fs::create_dir_all(project_dir.join("wp-content").join("plugins").join("tool")).unwrap(); + + let json = scan_json(&project_dir, &proxy.uri()).await; + assert_eq!( + json["scannedPackages"], 2, + "scan must discover composer/installers and the relocated plugin; got:\n{json:#}" + ); + let requests = proxy.received_requests().await.unwrap_or_default(); + assert!( + requests + .iter() + .any(|r| String::from_utf8_lossy(&r.body).contains("pkg:composer/acme/tool@1.0.0")), + "the relocated plugin must be sent to the patch lookup" + ); +} diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 8aecb05c8..0e7143238 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -17,7 +17,7 @@ mod oracle; /// vendor directories. pub struct ComposerCrawler; -/// A single package entry distilled from installed.json. Only the three +/// A single package entry distilled from installed.json. Only the /// fields the crawler needs are retained; everything else (source, /// dist, autoload, ...) is ignored. struct ComposerPackageEntry { @@ -29,6 +29,201 @@ struct ComposerPackageEntry { /// composer/installers target). `None` for Composer 1 entries and any /// entry that omits it. install_path: Option, + /// The package `type` (`library`, `wordpress-plugin`, ...), which + /// decides whether composer/installers relocates it. + package_type: Option, + /// The package's own `extra.installer-name`, which composer/installers + /// uses in place of the package name for `{$name}`. + installer_name: Option, +} + +/// How composer/installers lays out a project, for entries whose +/// installed.json record has no `install-path` (Composer 1). Composer 2 +/// records the directory composer/installers chose; Composer 1 does not, +/// so it has to be worked out again from the same inputs the plugin used. +#[derive(Default)] +struct InstallerLayout { + /// composer/installers is installed. Without it Composer puts every + /// package under `vendor/`, whatever its type or `installer-paths`. + active: bool, + /// Frameworks the root `extra.installer-disable` switches off (`all` + /// for every one); their packages stay in `vendor/`. + disabled: Vec, + /// The root composer.json `extra.installer-paths`, in file order: + /// (path template, rules such as `acme/tool`, `type:x`, `vendor:y`). + paths: Vec<(String, Vec)>, +} + +/// composer/installers' built-in locations, keyed by the package type +/// they apply to (`-`), for the frameworks whose +/// installers leave `{$name}` as is. A type missing here stays in +/// `vendor/`: the plugin does not handle it (`library`, `wordpress-core`, +/// ...), or handles it under a framework that rewrites `{$name}` +/// (CakePHP camel-cases it, MediaWiki, October, ...), whose directory +/// cannot be worked out from the name alone. +const INSTALLER_DEFAULT_LOCATIONS: &[(&str, &str)] = &[ + ("wordpress-plugin", "wp-content/plugins/{$name}/"), + ("wordpress-theme", "wp-content/themes/{$name}/"), + ("wordpress-muplugin", "wp-content/mu-plugins/{$name}/"), + ("wordpress-dropin", "wp-content/{$name}/"), + ("drupal-core", "core/"), + ("drupal-module", "modules/{$name}/"), + ("drupal-theme", "themes/{$name}/"), + ("drupal-library", "libraries/{$name}/"), + ("drupal-profile", "profiles/{$name}/"), + ( + "drupal-database-driver", + "drivers/lib/Drupal/Driver/Database/{$name}/", + ), + ("drupal-drush", "drush/{$name}/"), + ("drupal-custom-theme", "themes/custom/{$name}/"), + ("drupal-custom-module", "modules/custom/{$name}/"), + ("drupal-custom-profile", "profiles/custom/{$name}/"), + // DrupalInstaller's location key is `drupal-multisite`. + ("drupal-drupal-multisite", "sites/{$name}/"), + ("drupal-console", "console/{$name}/"), + ("drupal-console-language", "console/language/{$name}/"), + ("drupal-config", "config/sync/"), +]; + +impl InstallerLayout { + /// Read the layout for one vendor tree: whether composer/installers is + /// among its installed packages and not switched off by the root + /// `extra.installer-disable`, and the project's `installer-paths`. + /// composer.json is read only when some entry lacks an `install-path`; + /// a Composer 2 tree never needs the layout. + async fn load(project_root: &Path, entries: &[ComposerPackageEntry]) -> Self { + let installed = entries + .iter() + .any(|e| e.name.eq_ignore_ascii_case("composer/installers")); + if !installed || entries.iter().all(|e| e.install_path.is_some()) { + return Self::default(); + } + let root = crate::utils::fs::read_regular_to_string(&project_root.join("composer.json")) + .await + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()); + let extra = root.as_ref().and_then(|r| r.get("extra")); + Self { + active: true, + disabled: extra + .and_then(|e| e.get("installer-disable")) + .map(parse_installer_disable) + .unwrap_or_default(), + paths: extra + .and_then(|e| e.get("installer-paths")) + .map(parse_installer_paths) + .unwrap_or_default(), + } + } + + /// Where composer/installers put `entry`, mirroring its + /// `BaseInstaller::getInstallPath`: the first `installer-paths` entry + /// with a rule naming the package, its `type:` or its `vendor:`, else + /// the framework's built-in location. `None` when the plugin would not + /// relocate the package, or the path leaves `project_root`. + fn locate(&self, project_root: &Path, entry: &ComposerPackageEntry) -> Option { + if !self.active { + return None; + } + let package_type = entry.package_type.as_deref()?; + let (_, default) = INSTALLER_DEFAULT_LOCATIONS + .iter() + .find(|(t, _)| *t == package_type)?; + let (framework, _) = package_type.split_once('-')?; + if self.disabled.iter().any(|d| d == "all" || d == framework) { + return None; + } + let (vendor, name) = entry.name.split_once('/')?; + let template = self + .paths + .iter() + .find(|(_, rules)| { + rules.iter().any(|rule| { + *rule == entry.name + || rule.strip_prefix("type:") == Some(package_type) + || rule.strip_prefix("vendor:") == Some(vendor) + }) + }) + .map_or(*default, |(path, _)| path.as_str()); + // installed.json is untrusted: an installer-name is a directory + // name, never a path that could aim the patch at another directory. + let name = match entry.installer_name.as_deref() { + Some(n) if path_safety::is_safe_single_segment(n) => n, + Some(_) => return None, + None => name, + }; + let rendered = render_installer_path(template, name, vendor, package_type)?; + contain_in_project(project_root, &project_root.join(rendered)) + .filter(|dir| dir.as_path() != project_root) + } +} + +/// The root `extra.installer-disable`, as composer/installers' +/// `removeDisabledInstallers` reads it: a scalar is a one-item list, and +/// a list holding `true`, `"all"` or `"*"` switches every framework off +/// (`array_intersect` compares the string forms, so `true` is `"1"`). +/// Otherwise each string names a framework, matched case-sensitively. +fn parse_installer_disable(value: &serde_json::Value) -> Vec { + let items = match value { + serde_json::Value::Array(list) => list.as_slice(), + scalar => std::slice::from_ref(scalar), + }; + let names: Vec = items + .iter() + .filter_map(|item| match item { + serde_json::Value::Bool(true) => Some("1".to_string()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::String(name) => Some(name.clone()), + _ => None, + }) + .collect(); + if names + .iter() + .any(|n| matches!(n.as_str(), "1" | "all" | "*")) + { + vec!["all".to_string()] + } else { + names + } +} + +/// The root composer.json `extra.installer-paths` map, in file order. A +/// path's value is a list of rules or a single rule string. +fn parse_installer_paths(value: &serde_json::Value) -> Vec<(String, Vec)> { + let Some(map) = value.as_object() else { + return Vec::new(); + }; + map.iter() + .map(|(path, rules)| { + let rules = match rules { + serde_json::Value::String(rule) => vec![rule.clone()], + serde_json::Value::Array(list) => list + .iter() + .filter_map(|r| r.as_str().map(str::to_string)) + .collect(), + _ => Vec::new(), + }; + (path.clone(), rules) + }) + .collect() +} + +/// Substitute composer/installers' `{$name}`, `{$vendor}` and `{$type}` +/// placeholders. `None` for a NUL or any other placeholder, which the +/// plugin would render as an empty string; guessing that layout could +/// point the patch at the wrong directory. +fn render_installer_path( + template: &str, + name: &str, + vendor: &str, + package_type: &str, +) -> Option { + let rendered = template + .replace("{$name}", name) + .replace("{$vendor}", vendor) + .replace("{$type}", package_type); + (!rendered.contains("{$") && !rendered.contains('\0')).then_some(rendered) } impl ComposerCrawler { @@ -100,18 +295,17 @@ impl ComposerCrawler { for vendor_path in &vendor_paths { let project_root = resolve_project_root(vendor_path).await; let entries = read_installed_json(vendor_path).await; - // Resolve every entry's directory first (pure path logic), then - // stat them all in one blocking-pool task instead of one hop - // each; the loop below consumes the answers in entry order. - let resolved: Vec> = entries + let installers = InstallerLayout::load(&project_root, &entries).await; + // Resolve every entry's candidate directories first (pure path + // logic), then stat them all in one blocking-pool task instead + // of one hop each; the loop below consumes the answers in entry + // order. + let candidates: Vec> = entries .iter() - .map(|entry| { - entry.name.split_once('/')?; - resolve_package_dir(vendor_path, &project_root, entry) - }) + .map(|entry| resolve_package_dir(vendor_path, &project_root, &installers, entry)) .collect(); - let on_disk = dirs_exist(resolved.clone()).await; - for ((entry, pkg_path), on_disk) in entries.into_iter().zip(resolved).zip(on_disk) { + let resolved = first_existing_dirs(candidates).await; + for (entry, pkg_path) in entries.into_iter().zip(resolved) { if let Some((namespace, name)) = entry.name.split_once('/') { // Skip packages that installed.json lists but that are // not actually on disk (stale metadata, a metapackage). @@ -120,9 +314,6 @@ impl ComposerCrawler { let Some(pkg_path) = pkg_path else { continue; }; - if !on_disk { - continue; - } // Composer's installed.json stores the *pretty* // version (often `v6.4.1`); PURLs use the bare numeric @@ -176,17 +367,18 @@ impl ComposerCrawler { // entry so the real on-disk path can be reconstructed even on // case-sensitive filesystems. let entries = read_installed_json(vendor_path).await; + let project_root = resolve_project_root(vendor_path).await; + let installers = InstallerLayout::load(&project_root, &entries).await; let installed: HashMap = entries .into_iter() .map(|e| (e.name.to_ascii_lowercase(), e)) .collect(); - let project_root = resolve_project_root(vendor_path).await; // Resolve each PURL's directory first (pure lookups), then stat // them all in one blocking-pool task instead of one hop each. // (namespace, name, version), as the purl parser handed them back. type Coords<'a> = (Cow<'a, str>, Cow<'a, str>, Cow<'a, str>); - let mut matches: Vec<(&String, Coords<'_>, PathBuf)> = Vec::new(); + let mut matches: Vec<(&String, Coords<'_>, Vec)> = Vec::new(); for purl in purls { if let Some(((namespace_part, name_part), version_part)) = crate::utils::purl::parse_composer_purl(purl) @@ -210,25 +402,27 @@ impl ComposerCrawler { } // Resolve the on-disk directory from installed.json's own - // record — its `install-path` when present, else the - // conventional layout under the original (case-preserved) - // casing Composer wrote to disk; the canonical (lowercase) - // PURL name would miss it on a case-sensitive filesystem. - let Some(pkg_dir) = resolve_package_dir(vendor_path, &project_root, entry) else { - continue; - }; - - matches.push((purl, (namespace_part, name_part, version_part), pkg_dir)); + // record — its `install-path` when present, else where + // composer/installers or the conventional layout put it, + // under the original (case-preserved) casing Composer wrote + // to disk; the canonical (lowercase) PURL name would miss it + // on a case-sensitive filesystem. + let candidates = + resolve_package_dir(vendor_path, &project_root, &installers, entry); + + matches.push((purl, (namespace_part, name_part, version_part), candidates)); } } - let on_disk = dirs_exist(matches.iter().map(|m| Some(m.2.clone())).collect()).await; - for ((purl, (namespace, name, version), pkg_dir), on_disk) in - matches.into_iter().zip(on_disk) - { - if !on_disk { + let (matches, candidates): (Vec<_>, Vec<_>) = matches + .into_iter() + .map(|(purl, coords, candidates)| ((purl, coords), candidates)) + .unzip(); + let resolved = first_existing_dirs(candidates).await; + for ((purl, (namespace, name, version)), pkg_dir) in matches.into_iter().zip(resolved) { + let Some(pkg_dir) = pkg_dir else { continue; - } + }; result.insert( purl.clone(), @@ -754,8 +948,15 @@ fn resolve_install_path( // `join` substitutes an absolute `install-path` (Composer writes one // for some path repositories) for the base; the containment check // below is what keeps it in bounds either way. - let joined = vendor_path.join("composer").join(install_path); - let resolved = normalize_lexically(&joined)?; + contain_in_project( + project_root, + &vendor_path.join("composer").join(install_path), + ) +} + +/// `joined`, normalized, when it stays inside `project_root`; else `None`. +fn contain_in_project(project_root: &Path, joined: &Path) -> Option { + let resolved = normalize_lexically(joined)?; // A RELATIVE vendor path (the CLI default is `--cwd .`) yields a // relative project root that can normalize to the EMPTY path — and // `starts_with("")` holds for EVERY path, absolute ones included, so @@ -782,27 +983,60 @@ fn is_anchored(path: &Path) -> bool { ) } -/// The on-disk directory of an installed.json entry. +/// The directories an installed.json entry may live in, most likely +/// first; the package is at the first that exists. /// /// `install-path` is authoritative when recorded: Composer 2 writes it for /// every package, and for composer/installers targets (WordPress plugins, /// Drupal modules, `extra.installer-paths`) it is the ONLY record of where /// the package really lives. Entries without one (Composer 1, hand-written -/// metadata) keep the conventional `vendor//` layout. +/// metadata) are where composer/installers put them, when it handles them +/// ([`InstallerLayout::locate`]), else the conventional +/// `vendor//` layout. The installer's directory comes +/// first: it is the copy the application loads, and a `vendor/` copy +/// beside it is a leftover. fn resolve_package_dir( vendor_path: &Path, project_root: &Path, + installers: &InstallerLayout, entry: &ComposerPackageEntry, -) -> Option { +) -> Vec { match entry.install_path.as_deref() { - Some(install_path) => resolve_install_path(vendor_path, project_root, install_path), + Some(install_path) => resolve_install_path(vendor_path, project_root, install_path) + .into_iter() + .collect(), None => { - let (namespace, name) = entry.name.split_once('/')?; - Some(vendor_path.join(namespace).join(name)) + let Some((namespace, name)) = entry.name.split_once('/') else { + return Vec::new(); + }; + let conventional = vendor_path.join(namespace).join(name); + match installers.locate(project_root, entry) { + Some(relocated) => vec![relocated, conventional], + None => vec![conventional], + } } } } +/// The first directory of each candidate list that exists, stating every +/// candidate in one blocking-pool task ([`dirs_exist`]). +async fn first_existing_dirs(candidates: Vec>) -> Vec> { + let flat: Vec> = candidates.iter().flatten().cloned().map(Some).collect(); + let mut on_disk = dirs_exist(flat).await.into_iter(); + candidates + .into_iter() + .map(|list| { + let mut found = None; + for dir in list { + if on_disk.next() == Some(true) && found.is_none() { + found = Some(dir); + } + } + found + }) + .collect() +} + /// Whether an installed.json package name is safe to join onto the /// vendor root. Both `crawl_all` and `find_by_purls` split the recorded /// name at `/` and join the pieces onto the vendor directory, and the @@ -874,10 +1108,22 @@ async fn read_installed_json(vendor_path: &Path) -> Vec { .and_then(|p| p.as_str()) .filter(|p| !p.is_empty()) .map(str::to_string); + let package_type = entry + .get("type") + .and_then(|t| t.as_str()) + .map(str::to_string); + let installer_name = entry + .get("extra") + .and_then(|e| e.get("installer-name")) + .and_then(|n| n.as_str()) + .filter(|n| !n.is_empty()) + .map(str::to_string); Some(ComposerPackageEntry { name: name.to_string(), version: version.to_string(), install_path, + package_type, + installer_name, }) }) .collect() diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler/oracle.rs b/crates/socket-patch-core/src/crawlers/composer_crawler/oracle.rs index 18b5f9ebc..2768986bf 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler/oracle.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler/oracle.rs @@ -7,7 +7,7 @@ use std::path::Path; use super::{ normalize_version, read_installed_json, resolve_package_dir, resolve_project_root, - ComposerCrawler, ComposerPackageEntry, + ComposerCrawler, ComposerPackageEntry, InstallerLayout, }; use crate::crawlers::types::{CrawledPackage, CrawlerOptions}; use crate::utils::composer_version::composer_versions_equivalent; @@ -28,15 +28,19 @@ impl LegacyComposerCrawler { for vendor_path in &vendor_paths { let project_root = resolve_project_root(vendor_path).await; let entries = read_installed_json(vendor_path).await; - for entry in entries { + let installers = InstallerLayout::load(&project_root, &entries).await; + for entry in &entries { if let Some((namespace, name)) = entry.name.split_once('/') { - let Some(pkg_path) = resolve_package_dir(vendor_path, &project_root, &entry) + let Some(pkg_path) = first_dir(resolve_package_dir( + vendor_path, + &project_root, + &installers, + entry, + )) + .await else { continue; }; - if !is_dir(&pkg_path).await { - continue; - } let version = normalize_version(&entry.version).to_string(); @@ -70,11 +74,12 @@ impl LegacyComposerCrawler { let mut result: HashMap = HashMap::new(); let entries = read_installed_json(vendor_path).await; + let project_root = resolve_project_root(vendor_path).await; + let installers = InstallerLayout::load(&project_root, &entries).await; let installed: HashMap = entries .into_iter() .map(|e| (e.name.to_ascii_lowercase(), e)) .collect(); - let project_root = resolve_project_root(vendor_path).await; for purl in purls { if let Some(((namespace, name), version)) = @@ -93,14 +98,17 @@ impl LegacyComposerCrawler { continue; } - let Some(pkg_dir) = resolve_package_dir(vendor_path, &project_root, entry) else { + let Some(pkg_dir) = first_dir(resolve_package_dir( + vendor_path, + &project_root, + &installers, + entry, + )) + .await + else { continue; }; - if !is_dir(&pkg_dir).await { - continue; - } - result.insert( purl.clone(), CrawledPackage { @@ -117,3 +125,13 @@ impl LegacyComposerCrawler { result } } + +/// The first candidate that is a directory, one `is_dir` hop at a time. +async fn first_dir(candidates: Vec) -> Option { + for dir in candidates { + if is_dir(&dir).await { + return Some(dir); + } + } + None +} diff --git a/crates/socket-patch-core/tests/crawler_composer_e2e.rs b/crates/socket-patch-core/tests/crawler_composer_e2e.rs index 96695d365..9dab4a675 100644 --- a/crates/socket-patch-core/tests/crawler_composer_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_composer_e2e.rs @@ -1669,3 +1669,394 @@ async fn get_vendor_paths_global_xdg_config_home_outranks_stale_dot_composer() { "Composer 2 picks $XDG_CONFIG_HOME/composer over ~/.composer" ); } + +// ── Composer 1 + composer/installers (no install-path) ───────── + +const TOOL_PURL: &str = "pkg:composer/acme/tool@1.0.0"; + +/// A Composer 1 project shaped like the one `composer-1.10.28.phar +/// install` leaves behind for #463: installed.json is a bare array with +/// no `install-path`, composer/installers sits in `vendor/`, and the +/// relocated package exists only where the installer put it. +async fn composer1_project(root: &Path, root_extra: &str, tool_entry: &str) { + let composer_dir = root.join("vendor").join("composer"); + tokio::fs::create_dir_all(&composer_dir).await.unwrap(); + tokio::fs::create_dir_all(root.join("vendor").join("composer").join("installers")) + .await + .unwrap(); + tokio::fs::write( + composer_dir.join("installed.json"), + format!( + r#"[ + {{"name":"composer/installers","version":"v1.12.0","type":"composer-plugin"}}, + {tool_entry} + ]"# + ), + ) + .await + .unwrap(); + tokio::fs::write( + root.join("composer.json"), + format!(r#"{{"require":{{"acme/tool":"1.0.0"}},"extra":{root_extra}}}"#), + ) + .await + .unwrap(); +} + +const WP_TOOL: &str = r#"{"name":"acme/tool","version":"1.0.0","type":"wordpress-plugin"}"#; + +async fn tool_paths(root: &Path) -> (Vec, Option) { + let crawler = ComposerCrawler; + let crawled = crawler + .crawl_all(&options_at(root)) + .await + .into_iter() + .filter(|p| p.purl == TOOL_PURL) + .map(|p| p.path) + .collect(); + let found = crawler + .find_by_purls(&root.join("vendor"), &[TOOL_PURL.to_string()]) + .await + .unwrap() + .remove(TOOL_PURL) + .map(|p| p.path); + (crawled, found) +} + +/// #463: the exact repro — `installer-paths` maps `type:wordpress-plugin` +/// to `wp-content/plugins/{$name}/`. Composer 1 records no install-path, +/// so scan skipped the plugin as "not installed" and vex attested from the +/// lock pin. Both crawl_all (scan, vex) and find_by_purls (apply) must +/// find it where composer/installers put it. +#[tokio::test] +#[serial_test::parallel] +async fn composer1_installer_paths_type_rule_finds_relocated_package() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + composer1_project( + root, + r#"{"installer-paths":{"wp-content/plugins/{$name}/":["type:wordpress-plugin"]}}"#, + WP_TOOL, + ) + .await; + let plugin = root.join("wp-content").join("plugins").join("tool"); + tokio::fs::create_dir_all(&plugin).await.unwrap(); + assert!(!root.join("vendor").join("acme").join("tool").exists()); + + let (crawled, found) = tool_paths(root).await; + assert_eq!(crawled, vec![plugin.clone()]); + assert_eq!(found, Some(plugin)); +} + +/// composer/installers matches `installer-paths` rules by exact package +/// name, `type:` and `vendor:`, takes the FIRST path (in file order) with +/// a matching rule, and substitutes `{$vendor}`/`{$name}`/`{$type}`, with +/// the package's own `extra.installer-name` replacing `{$name}`. +#[tokio::test] +#[serial_test::parallel] +async fn composer1_installer_paths_rules_and_placeholders() { + let cases: &[(&str, &str, &[&str])] = &[ + // Exact name rule. + ( + r#"{"installer-paths":{"web/app/{$vendor}-{$name}/":["acme/tool"]}}"#, + WP_TOOL, + &["web", "app", "acme-tool"], + ), + // vendor: rule, {$type} is the full package type. + ( + r#"{"installer-paths":{"ext/{$type}/{$name}/":["vendor:acme"]}}"#, + WP_TOOL, + &["ext", "wordpress-plugin", "tool"], + ), + // First matching path wins; a non-matching earlier path is skipped. + ( + r#"{"installer-paths":{ + "nope/{$name}/":["type:drupal-module"], + "first/{$name}/":["vendor:acme"], + "second/{$name}/":["type:wordpress-plugin"]}}"#, + WP_TOOL, + &["first", "tool"], + ), + // A bare string instead of an array is one rule. + ( + r#"{"installer-paths":{"one/{$name}/":"acme/tool"}}"#, + WP_TOOL, + &["one", "tool"], + ), + // installer-name overrides {$name}. + ( + r#"{"installer-paths":{"wp-content/plugins/{$name}/":["type:wordpress-plugin"]}}"#, + r#"{"name":"acme/tool","version":"1.0.0","type":"wordpress-plugin", + "extra":{"installer-name":"renamed"}}"#, + &["wp-content", "plugins", "renamed"], + ), + ]; + for (extra, entry, segments) in cases { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + composer1_project(root, extra, entry).await; + let dir = segments.iter().fold(root.to_path_buf(), |p, s| p.join(s)); + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let (crawled, found) = tool_paths(root).await; + assert_eq!(crawled, vec![dir.clone()], "{extra}"); + assert_eq!(found, Some(dir), "{extra}"); + } +} + +/// Without `installer-paths`, composer/installers uses its built-in +/// location for the type (`wp-content/plugins/{$name}/` for a WordPress +/// plugin, `modules/{$name}/` for a Drupal module, ...). +#[tokio::test] +#[serial_test::parallel] +async fn composer1_installers_default_locations() { + let cases: &[(&str, &[&str])] = &[ + ("wordpress-plugin", &["wp-content", "plugins", "tool"]), + ("wordpress-theme", &["wp-content", "themes", "tool"]), + ("wordpress-muplugin", &["wp-content", "mu-plugins", "tool"]), + ("drupal-module", &["modules", "tool"]), + ("drupal-theme", &["themes", "tool"]), + ("drupal-custom-module", &["modules", "custom", "tool"]), + ]; + for (package_type, segments) in cases { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + composer1_project( + root, + "{}", + &format!(r#"{{"name":"acme/tool","version":"1.0.0","type":"{package_type}"}}"#), + ) + .await; + let dir = segments.iter().fold(root.to_path_buf(), |p, s| p.join(s)); + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let (crawled, found) = tool_paths(root).await; + assert_eq!(crawled, vec![dir.clone()], "{package_type}"); + assert_eq!(found, Some(dir), "{package_type}"); + } +} + +/// When composer/installers placed the package, its directory is the live +/// copy even if a stale `vendor//` is lying around; patching the +/// stale one would leave the plugin the site loads unpatched. +#[tokio::test] +#[serial_test::parallel] +async fn composer1_installer_path_preferred_over_stale_vendor_copy() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + composer1_project(root, "{}", WP_TOOL).await; + let plugin = root.join("wp-content").join("plugins").join("tool"); + tokio::fs::create_dir_all(&plugin).await.unwrap(); + tokio::fs::create_dir_all(root.join("vendor").join("acme").join("tool")) + .await + .unwrap(); + + let (crawled, found) = tool_paths(root).await; + assert_eq!(crawled, vec![plugin.clone()]); + assert_eq!(found, Some(plugin)); +} + +/// Controls: only composer/installers relocates packages, and only types +/// it handles. Without it installed, or for a plain `library`, the +/// conventional `vendor//` stays the only location, even when a +/// directory exists where an installer would have put it. +#[tokio::test] +#[serial_test::parallel] +async fn composer1_no_installers_or_library_type_stays_in_vendor() { + // composer/installers not installed. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let composer_dir = root.join("vendor").join("composer"); + tokio::fs::create_dir_all(&composer_dir).await.unwrap(); + tokio::fs::write(composer_dir.join("installed.json"), format!("[{WP_TOOL}]")) + .await + .unwrap(); + tokio::fs::write( + root.join("composer.json"), + r#"{"extra":{"installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}}"#, + ) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join("wp-content").join("plugins").join("tool")) + .await + .unwrap(); + assert_eq!(tool_paths(root).await, (vec![], None)); + let vendored = root.join("vendor").join("acme").join("tool"); + tokio::fs::create_dir_all(&vendored).await.unwrap(); + assert_eq!( + tool_paths(root).await, + (vec![vendored.clone()], Some(vendored)) + ); + + // A library is never relocated, even by a name rule. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + composer1_project( + root, + r#"{"installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + r#"{"name":"acme/tool","version":"1.0.0","type":"library"}"#, + ) + .await; + tokio::fs::create_dir_all(root.join("wp-content").join("plugins").join("tool")) + .await + .unwrap(); + assert_eq!(tool_paths(root).await, (vec![], None)); +} + +/// installed.json and composer.json are untrusted input and the resolved +/// directory is a patch WRITE target: an installer path (or an +/// installer-name) that leaves the project is dropped, like an escaping +/// install-path, even though the target exists. +#[tokio::test] +#[serial_test::parallel] +async fn composer1_installer_path_escaping_project_is_rejected() { + let cases: &[(&str, &str)] = &[ + ( + r#"{"installer-paths":{"../evil/{$name}/":["acme/tool"]}}"#, + WP_TOOL, + ), + ( + r#"{"installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + r#"{"name":"acme/tool","version":"1.0.0","type":"wordpress-plugin", + "extra":{"installer-name":"../../../evil/tool"}}"#, + ), + ]; + for (extra, entry) in cases { + let outer = tempfile::tempdir().unwrap(); + let root = outer.path().join("proj"); + composer1_project(&root, extra, entry).await; + tokio::fs::create_dir_all(outer.path().join("evil").join("tool")) + .await + .unwrap(); + assert_eq!(tool_paths(&root).await, (vec![], None), "{extra} {entry}"); + } +} + +/// composer/installers leaves a package in `vendor/` when it does not +/// handle the type (`wordpress-core`, a `drupal-multisite` that is not +/// DrupalInstaller's `drupal-drupal-multisite`), when the framework +/// rewrites `{$name}` (`cakephp-plugin` is installed camel-cased, so the +/// raw name would point at the wrong directory), and when the root +/// `extra.installer-disable` switches the framework off. A directory where +/// a rule would otherwise point must not be taken for the package. +#[tokio::test] +#[serial_test::parallel] +async fn composer1_unrelocated_types_and_disabled_installers_stay_in_vendor() { + let cases: &[(&str, &str)] = &[ + ( + r#"{"installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + "wordpress-core", + ), + ( + r#"{"installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + "cakephp-plugin", + ), + ( + r#"{"installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + "drupal-multisite", + ), + ( + r#"{"installer-disable":true, + "installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + "wordpress-plugin", + ), + ( + r#"{"installer-disable":["cakephp","wordpress"], + "installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + "wordpress-plugin", + ), + ( + r#"{"installer-disable":"all", + "installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + "wordpress-plugin", + ), + ( + r#"{"installer-disable":"*", + "installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + "wordpress-plugin", + ), + ( + r#"{"installer-disable":["cakephp",true], + "installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + "wordpress-plugin", + ), + ( + r#"{"installer-disable":["*"], + "installer-paths":{"wp-content/plugins/{$name}/":["acme/tool"]}}"#, + "wordpress-plugin", + ), + ]; + for (extra, package_type) in cases { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + composer1_project( + root, + extra, + &format!(r#"{{"name":"acme/tool","version":"1.0.0","type":"{package_type}"}}"#), + ) + .await; + tokio::fs::create_dir_all(root.join("wp-content").join("plugins").join("tool")) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join("sites").join("tool")) + .await + .unwrap(); + let vendored = root.join("vendor").join("acme").join("tool"); + tokio::fs::create_dir_all(&vendored).await.unwrap(); + assert_eq!( + tool_paths(root).await, + (vec![vendored.clone()], Some(vendored)), + "{package_type} {extra}" + ); + } + + // Framework names match case-sensitively, as composer/installers' + // `key_exists` does: `WordPress` does not switch off `wordpress`. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + composer1_project(root, r#"{"installer-disable":["WordPress"]}"#, WP_TOOL).await; + let plugin = root.join("wp-content").join("plugins").join("tool"); + tokio::fs::create_dir_all(&plugin).await.unwrap(); + tokio::fs::create_dir_all(root.join("vendor").join("acme").join("tool")) + .await + .unwrap(); + assert_eq!(tool_paths(root).await, (vec![plugin.clone()], Some(plugin))); + + // DrupalInstaller's real multisite type is relocated to sites/{$name}. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + composer1_project( + root, + "{}", + r#"{"name":"acme/tool","version":"1.0.0","type":"drupal-drupal-multisite"}"#, + ) + .await; + let site = root.join("sites").join("tool"); + tokio::fs::create_dir_all(&site).await.unwrap(); + assert_eq!(tool_paths(root).await, (vec![site.clone()], Some(site))); +} + +/// An installer-name is a directory name. One that is a path (`..`, +/// `a/b`) could aim the patch at another directory inside the project, +/// such as the project root itself, so it is refused. +#[tokio::test] +#[serial_test::parallel] +async fn composer1_installer_name_must_be_one_segment() { + for installer_name in ["..", ".", "../../vendor/acme", "a/b"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("proj"); + composer1_project( + &root, + "{}", + &format!( + r#"{{"name":"acme/tool","version":"1.0.0","type":"wordpress-plugin", + "extra":{{"installer-name":"{installer_name}"}}}}"# + ), + ) + .await; + tokio::fs::create_dir_all(root.join("wp-content").join("plugins").join("a").join("b")) + .await + .unwrap(); + assert_eq!(tool_paths(&root).await, (vec![], None), "{installer_name}"); + } +}