From 06864e0e0c62b72e422598b77bd38139e35d445c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 12:24:48 +0000 Subject: [PATCH 1/5] Start fix for #463 Assisted-by: Claude Code:claude-opus-5-5 From c2d8fca9b1bf19cf6fc0bc500d01e08c531d149a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 12:30:53 +0000 Subject: [PATCH 2/5] Find Composer 1 packages moved by installers On Composer 1, installed.json has no install-path, so the crawler looked for every package under vendor//. Packages that composer/installers puts elsewhere (WordPress plugins, Drupal modules, any extra.installer-paths target) were never found: agent scan skipped them as "not installed" and exited 0, apply reported them missing, and hosted vex attested not_affected from the lock pin without checking the unpatched copy actually installed. When composer/installers is installed and an entry has no install-path, work out its directory the way the plugin does: the first root installer-paths entry whose rules name the package, its type: or its vendor:, with {$name}/{$vendor}/{$type} substituted (the package's installer-name replaces {$name}), else the built-in WordPress and Drupal locations. That directory is preferred over a leftover vendor/ copy, and must stay inside the project like an install-path. scan, apply and vex share the lookup. Fixes #463 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/composer_crawler.rs | 380 ++++++++++++++++-- .../src/crawlers/composer_crawler/oracle.rs | 42 +- .../tests/crawler_composer_e2e.rs | 262 ++++++++++++ 3 files changed, 633 insertions(+), 51 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 8aecb05c8..2a992cc16 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,257 @@ 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, + /// 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)>, +} + +/// Framework prefixes of the package types composer/installers handles +/// (`-`, e.g. `wordpress-plugin`). Any other type +/// (`library`, `composer-plugin`, `symfony-bundle`, ...) stays in +/// `vendor/` even when an `installer-paths` rule names it. +const INSTALLER_FRAMEWORKS: &[&str] = &[ + "agl", + "akaunting", + "annotatecms", + "asgard", + "attogram", + "bitrix", + "bonefish", + "cakephp", + "chef", + "civicrm", + "ccframework", + "cockpit", + "codeigniter", + "concrete5", + "craft", + "croogo", + "dframe", + "decibel", + "dokuwiki", + "dolibarr", + "drupal", + "ee2", + "ee3", + "elgg", + "eliasis", + "ezplatform", + "fuel", + "fuelphp", + "grav", + "hurad", + "imagecms", + "itop", + "joomla", + "kanboard", + "known", + "kodicms", + "kohana", + "lms", + "laravel", + "lavalite", + "lithium", + "magento", + "majima", + "mako", + "mantisbt", + "matomo", + "mautic", + "maya", + "mediawiki", + "miaoxing", + "microweber", + "modulework", + "modx", + "modxevo", + "moodle", + "october", + "ontowiki", + "osclass", + "oxid", + "phpbb", + "piwik", + "pimcore", + "plentymarkets", + "porto", + "ppi", + "processwire", + "puppet", + "pxcms", + "radphp", + "redaxo", + "redaxo5", + "reindex", + "roundcube", + "shopware", + "silverstripe", + "smf", + "starbug", + "sydes", + "sylius", + "symfony1", + "tao", + "tastyigniter", + "thelia", + "tusk", + "typo3", + "userfrosting", + "vanilla", + "whmcs", + "winter", + "wolfcms", + "wordpress", + "yawik", + "zend", + "zikula", +]; + +/// composer/installers' built-in locations for the frameworks whose +/// installers leave `{$name}` as is (WordPress, Drupal), used when no +/// `installer-paths` rule matches. Other frameworks transform the name +/// (CakePHP camel-cases it, ...), so they are found only through an +/// explicit `installer-paths` rule. +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}/"), + ("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 the project's `installer-paths`. + async fn load(project_root: &Path, entries: &[ComposerPackageEntry]) -> Self { + let active = entries + .iter() + .any(|e| e.name.eq_ignore_ascii_case("composer/installers")); + if !active { + return Self::default(); + } + let paths = crate::utils::fs::read_regular_to_string(&project_root.join("composer.json")) + .await + .ok() + .map(|content| parse_installer_paths(&content)) + .unwrap_or_default(); + Self { active, paths } + } + + /// 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 (framework, _) = package_type.split_once('-')?; + if !INSTALLER_FRAMEWORKS.contains(&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(|(path, _)| path.as_str()) + .or_else(|| { + INSTALLER_DEFAULT_LOCATIONS + .iter() + .find(|(t, _)| *t == package_type) + .map(|(_, path)| *path) + })?; + let name = entry.installer_name.as_deref().unwrap_or(name); + let rendered = render_installer_path(template, name, vendor, package_type)?; + contain_in_project(project_root, &project_root.join(rendered)) + } +} + +/// 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(composer_json: &str) -> Vec<(String, Vec)> { + let Ok(root) = serde_json::from_str::(composer_json) else { + return Vec::new(); + }; + let Some(map) = root + .get("extra") + .and_then(|e| e.get("installer-paths")) + .and_then(|p| p.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 +351,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 +370,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 +423,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 +458,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 +1004,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 +1039,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 +1164,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..7bdc3d04f 100644 --- a/crates/socket-patch-core/tests/crawler_composer_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_composer_e2e.rs @@ -1669,3 +1669,265 @@ 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}"); + } +} From 4607de74e4360d4224e1d3d8cc76a5e6786be0ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 12:52:38 +0000 Subject: [PATCH 3/5] Test scan finds Composer 1 installer-paths plugin End-to-end regression for #463: a Composer 1 project whose WordPress plugin lives under extra.installer-paths must be counted by scan and sent to the patch lookup. Fails on main (1 package found, the plugin is missed) and passes with the crawler fix. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/e2e_composer.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) 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" + ); +} From 528220ea2939a1586de6a6150ccaf64811b4c6cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 13:08:38 +0000 Subject: [PATCH 4/5] Only relocate types composer/installers moves The first cut relocated any - type and filled {$name} with the raw package name. composer/installers only moves types its installers list, and many frameworks rewrite {$name} (CakePHP camel-cases it), so a guessed directory could be wrong and was preferred over the real vendor/ copy. Relocate only the WordPress and Drupal types composer/installers handles (names kept as is), using DrupalInstaller's real drupal-drupal-multisite key. Honour extra.installer-disable, require an installer-name to be one safe path segment, and skip reading composer.json when every entry already records an install-path (Composer 2). Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/composer_crawler.rs | 193 ++++++------------ .../tests/crawler_composer_e2e.rs | 102 +++++++++ 2 files changed, 163 insertions(+), 132 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 2a992cc16..d5c652b1f 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -46,117 +46,21 @@ 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)>, } -/// Framework prefixes of the package types composer/installers handles -/// (`-`, e.g. `wordpress-plugin`). Any other type -/// (`library`, `composer-plugin`, `symfony-bundle`, ...) stays in -/// `vendor/` even when an `installer-paths` rule names it. -const INSTALLER_FRAMEWORKS: &[&str] = &[ - "agl", - "akaunting", - "annotatecms", - "asgard", - "attogram", - "bitrix", - "bonefish", - "cakephp", - "chef", - "civicrm", - "ccframework", - "cockpit", - "codeigniter", - "concrete5", - "craft", - "croogo", - "dframe", - "decibel", - "dokuwiki", - "dolibarr", - "drupal", - "ee2", - "ee3", - "elgg", - "eliasis", - "ezplatform", - "fuel", - "fuelphp", - "grav", - "hurad", - "imagecms", - "itop", - "joomla", - "kanboard", - "known", - "kodicms", - "kohana", - "lms", - "laravel", - "lavalite", - "lithium", - "magento", - "majima", - "mako", - "mantisbt", - "matomo", - "mautic", - "maya", - "mediawiki", - "miaoxing", - "microweber", - "modulework", - "modx", - "modxevo", - "moodle", - "october", - "ontowiki", - "osclass", - "oxid", - "phpbb", - "piwik", - "pimcore", - "plentymarkets", - "porto", - "ppi", - "processwire", - "puppet", - "pxcms", - "radphp", - "redaxo", - "redaxo5", - "reindex", - "roundcube", - "shopware", - "silverstripe", - "smf", - "starbug", - "sydes", - "sylius", - "symfony1", - "tao", - "tastyigniter", - "thelia", - "tusk", - "typo3", - "userfrosting", - "vanilla", - "whmcs", - "winter", - "wolfcms", - "wordpress", - "yawik", - "zend", - "zikula", -]; - -/// composer/installers' built-in locations for the frameworks whose -/// installers leave `{$name}` as is (WordPress, Drupal), used when no -/// `installer-paths` rule matches. Other frameworks transform the name -/// (CakePHP camel-cases it, ...), so they are found only through an -/// explicit `installer-paths` rule. +/// 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}/"), @@ -175,7 +79,8 @@ const INSTALLER_DEFAULT_LOCATIONS: &[(&str, &str)] = &[ ("drupal-custom-theme", "themes/custom/{$name}/"), ("drupal-custom-module", "modules/custom/{$name}/"), ("drupal-custom-profile", "profiles/custom/{$name}/"), - ("drupal-multisite", "sites/{$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/"), @@ -183,20 +88,33 @@ const INSTALLER_DEFAULT_LOCATIONS: &[(&str, &str)] = &[ impl InstallerLayout { /// Read the layout for one vendor tree: whether composer/installers is - /// among its installed packages, and the project's `installer-paths`. + /// 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 active = entries + let installed = entries .iter() .any(|e| e.name.eq_ignore_ascii_case("composer/installers")); - if !active { + if !installed || entries.iter().all(|e| e.install_path.is_some()) { return Self::default(); } - let paths = crate::utils::fs::read_regular_to_string(&project_root.join("composer.json")) + let root = crate::utils::fs::read_regular_to_string(&project_root.join("composer.json")) .await .ok() - .map(|content| parse_installer_paths(&content)) - .unwrap_or_default(); - Self { active, paths } + .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 @@ -209,8 +127,11 @@ impl InstallerLayout { 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 !INSTALLER_FRAMEWORKS.contains(&framework) { + if self.disabled.iter().any(|d| d == "all" || d == framework) { return None; } let (vendor, name) = entry.name.split_once('/')?; @@ -224,30 +145,38 @@ impl InstallerLayout { || rule.strip_prefix("vendor:") == Some(vendor) }) }) - .map(|(path, _)| path.as_str()) - .or_else(|| { - INSTALLER_DEFAULT_LOCATIONS - .iter() - .find(|(t, _)| *t == package_type) - .map(|(_, path)| *path) - })?; - let name = entry.installer_name.as_deref().unwrap_or(name); + .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`: `true` (every framework), one +/// framework name, or a list of them (`all` meaning every framework). +fn parse_installer_disable(value: &serde_json::Value) -> Vec { + match value { + serde_json::Value::Bool(true) => vec!["all".to_string()], + serde_json::Value::String(name) => vec![name.to_ascii_lowercase()], + serde_json::Value::Array(list) => list + .iter() + .filter_map(|n| n.as_str().map(str::to_ascii_lowercase)) + .collect(), + _ => Vec::new(), } } /// 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(composer_json: &str) -> Vec<(String, Vec)> { - let Ok(root) = serde_json::from_str::(composer_json) else { - return Vec::new(); - }; - let Some(map) = root - .get("extra") - .and_then(|e| e.get("installer-paths")) - .and_then(|p| p.as_object()) - else { +fn parse_installer_paths(value: &serde_json::Value) -> Vec<(String, Vec)> { + let Some(map) = value.as_object() else { return Vec::new(); }; map.iter() diff --git a/crates/socket-patch-core/tests/crawler_composer_e2e.rs b/crates/socket-patch-core/tests/crawler_composer_e2e.rs index 7bdc3d04f..dae82f3c7 100644 --- a/crates/socket-patch-core/tests/crawler_composer_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_composer_e2e.rs @@ -1931,3 +1931,105 @@ async fn composer1_installer_path_escaping_project_is_rejected() { 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", + ), + ]; + 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}" + ); + } + + // 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}"); + } +} From a841498e66204071b8ed20994f8d6c062a0f0788 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:17:33 +0000 Subject: [PATCH 5/5] Read installer-disable as composer/installers does composer/installers turns every installer off when installer-disable is "*", or is a list holding true, "all" or "*". This parser treated "*" as a framework name and dropped true inside a list, so it still guessed a WordPress or Drupal directory for a package Composer left in vendor/, and could patch a stray copy there instead. Framework names now also match case-sensitively, as upstream does. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/composer_crawler.rs | 35 +++++++++++++------ .../tests/crawler_composer_e2e.rs | 27 ++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index d5c652b1f..0e7143238 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -159,17 +159,32 @@ impl InstallerLayout { } } -/// The root `extra.installer-disable`: `true` (every framework), one -/// framework name, or a list of them (`all` meaning every framework). +/// 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 { - match value { - serde_json::Value::Bool(true) => vec!["all".to_string()], - serde_json::Value::String(name) => vec![name.to_ascii_lowercase()], - serde_json::Value::Array(list) => list - .iter() - .filter_map(|n| n.as_str().map(str::to_ascii_lowercase)) - .collect(), - _ => Vec::new(), + 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 } } diff --git a/crates/socket-patch-core/tests/crawler_composer_e2e.rs b/crates/socket-patch-core/tests/crawler_composer_e2e.rs index dae82f3c7..9dab4a675 100644 --- a/crates/socket-patch-core/tests/crawler_composer_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_composer_e2e.rs @@ -1970,6 +1970,21 @@ async fn composer1_unrelocated_types_and_disabled_installers_stay_in_vendor() { "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(); @@ -1995,6 +2010,18 @@ async fn composer1_unrelocated_types_and_disabled_installers_stay_in_vendor() { ); } + // 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();