Skip to main content

vivacity_core/
scope.rs

1//! Out-of-scope detector: decides, BEFORE touching the disk, whether vivacity
2//! can install this lock natively or must delegate to `composer install`
3//! (default fallback) / fail explicitly (when Composer is not available).
4//!
5//! Principle (plan r1/F3-F5): never a silently divergent vendor/. An unknown
6//! plugin, or one that changes the layout, is out of scope. Plugins proven
7//! harmless at boot (fixture qualification) are installed like ordinary
8//! libraries, with a warning.
9
10use crate::layout::Layout;
11use crate::lock::{DistKind, Lock, LockPackage};
12use serde_json::Value;
13use std::path::Path;
14
15/// Plugins emulated natively by vivacity (identical output, drift test).
16/// composer/installers (see `layout`) is, under conditions checked before
17/// any write. drupal/core-composer-scaffold is deliberately absent: its
18/// source is GPL-2.0-or-later and cannot be ported here (NOTICE.md).
19pub const EMULATED_PLUGINS: &[&str] = &[
20    "symfony/runtime",
21    "composer/installers",
22    "pestphp/pest-plugin",
23    "dealerdirect/phpcodesniffer-composer-installer",
24    "phpstan/extension-installer",
25    "rector/extension-installer",
26    "wikimedia/composer-merge-plugin",
27    "composer/package-versions-deprecated",
28    // At install: `POST_INSTALL_CMD` copies `.env` from its `.dist` and
29    // prints, `symfony-pack` packages are laid out as metapackages, and
30    // nothing else fires (`fetchRecipes` is POST_UPDATE_CMD only). The
31    // resolution commands have their own rules (`resolution_effect`).
32    "symfony/flex",
33    "yiisoft/yii2-composer",
34    "codeception/c3",
35    "bamarni/composer-bin-plugin",
36    "roots/wordpress-core-installer",
37    "mnsami/composer-custom-directory-installer",
38];
39
40/// Plugins proven to write nothing at install time under a Composer whose
41/// plugins are active (the corpus baseline, docs/corpus/): installed as
42/// libraries, reported with a note. A plugin that writes a file is either
43/// emulated (EMULATED_PLUGINS) or unknown — never listed here. Removed on
44/// the corpus's evidence (2026-09-16): pestphp/pest-plugin
45/// (vendor/pest-plugins.json), phpstan/extension-installer and
46/// rector/extension-installer (GeneratedConfig.php),
47/// dealerdirect/phpcodesniffer-composer-installer (CodeSniffer.conf).
48/// `metasyntactical/composer-plugin-license-check` checks each installed
49/// package's licence against the root's `extra` lists and throws when one
50/// is denied — Composer stops, vendor/ half written. With no list
51/// configured every package passes and the plugin writes nothing: benign
52/// then, handed over when it is configured.
53pub const LICENSE_CHECK_PLUGIN: &str = "metasyntactical/composer-plugin-license-check";
54
55/// Its configuration, as `ComposerConfig` reads it: `array_filter` over
56/// `allow-list`, `deny-list` and `allowed-packages`, so empty (or absent)
57/// lists mean no check at all.
58pub fn license_check_configured(root_manifest: &Value) -> bool {
59    let Some(cfg) = root_manifest
60        .get("extra")
61        .and_then(|e| e.get(LICENSE_CHECK_PLUGIN))
62    else {
63        return false;
64    };
65    ["allow-list", "deny-list"].iter().any(|k| {
66        cfg.get(*k).and_then(Value::as_array).is_some_and(|l| {
67            l.iter()
68                .any(|v| !matches!(v, Value::Null | Value::Bool(false)))
69        })
70    })
71}
72
73pub const BENIGN_PLUGINS: &[&str] = &[
74    "php-http/discovery",
75    // Only listens to POST_CREATE_PROJECT_CMD / POST_INSTALL_CMD to print a
76    // message (MessagePlugin::getSubscribedEvents): no disk effect.
77    "drupal/core-project-message",
78    // Only listens to POST_UPDATE_CMD / POST_CREATE_PROJECT_CMD, and only acts
79    // in a `require` context (Plugin::getSubscribedEvents): inert at install.
80    "drupal/core-recipe-unpack",
81    LICENSE_CHECK_PLUGIN,
82    // A `CommandProvider` (`ibexa setup`) whose activate/deactivate only
83    // write at DEBUG verbosity; no listener (read at v5.0.10).
84    "ibexa/post-install",
85    // `Thanks::activate` arms its reminder only when the command is
86    // `update`; `POST_PACKAGE_UPDATE` then `POST_UPDATE_CMD` print it after
87    // a GitHub GraphQL call. On `install`: two commands added, nothing
88    // else (read at v1.4.1).
89    "symfony/thanks",
90    // A `CommandProvider` (`composer normalize`) with an empty `activate`
91    // and no listener (NormalizePlugin, read at 2.45.0).
92    "ergebnis/composer-normalize",
93];
94
95/// Plugins known to change the install layout or the package contents:
96/// always out of scope.
97pub const LAYOUT_PLUGINS: &[&str] = &[
98    "cweagans/composer-patches",
99    "oomphinc/composer-installers-extender",
100];
101
102/// The resolution commands (`update`, `require`, `remove`): Composer loads
103/// the installed plugins before resolving (`PluginManager::loadInstalledPlugins`
104/// from installed.json, global ones included), and some of them change what
105/// gets resolved or written. Read in the plugins' sources (plan v0.16):
106/// these have no listener that touches the resolution or the lock —
107/// `POST_INSTALL/UPDATE_CMD` writers of files under vendor/ (handled at
108/// install), install-path mappers, message printers.
109pub const RESOLUTION_INERT: &[&str] = &[
110    "composer/installers",
111    "symfony/runtime",
112    "pestphp/pest-plugin",
113    "dealerdirect/phpcodesniffer-composer-installer",
114    "phpstan/extension-installer",
115    "rector/extension-installer",
116    "composer/package-versions-deprecated",
117    "ergebnis/composer-normalize",
118    // `POST_INSTALL_CMD` / `POST_UPDATE_CMD` copy `c3.php` to the root:
119    // the install side's business, emulated there.
120    "codeception/c3",
121    // `COMMAND` forwards `install` / `update` to vendor-bin/* when
122    // `forward-command` is true — nested installs, the root lock untouched;
123    // the install side refuses that configuration.
124    "bamarni/composer-bin-plugin",
125    // An installer for the `wordpress-core` type: install paths only.
126    "roots/wordpress-core-installer",
127    // `COMMAND` (a verbose-only line) and `POST_PACKAGE_INSTALL/UPDATE`,
128    // where it only ever throws: the lock is untouched either way.
129    "metasyntactical/composer-plugin-license-check",
130    "ibexa/post-install",
131    // `POST_INSTALL/UPDATE_CMD`: an in-process `require` of a PSR
132    // implementation only when one is missing — with a complete lock, a
133    // no-op (verified on install with the corpus).
134    "php-http/discovery",
135    "drupal/core-project-message",
136    // `PRE_UPDATE_CMD` gathers patches, `POST_PACKAGE_*` applies them: the
137    // lock is untouched; the install side already hands these to Composer.
138    "cweagans/composer-patches",
139    "mlocati/composer-patcher",
140    "oomphinc/composer-installers-extender",
141    "mnsami/composer-custom-directory-installer",
142];
143
144/// A resolution command, for the per-plugin rules below.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ResolutionCommand {
147    Update,
148    Require,
149    Remove,
150}
151
152impl ResolutionCommand {
153    pub fn name(self) -> &'static str {
154        match self {
155            ResolutionCommand::Update => "update",
156            ResolutionCommand::Require => "require",
157            ResolutionCommand::Remove => "remove",
158        }
159    }
160}
161
162/// What an active plugin does to `command`, as far as the lock is
163/// concerned: nothing (inert, or emulated for this command), or something
164/// vivacity does not emulate (the reason, for the fallback line).
165/// `with_install`: the command installs after resolving (no `--no-install`).
166pub fn resolution_effect(
167    plugin: &str,
168    command: ResolutionCommand,
169    with_install: bool,
170) -> Option<String> {
171    if RESOLUTION_INERT.contains(&plugin) {
172        return None;
173    }
174    Some(match plugin {
175        // `PRE_POOL_CREATE` filters the pool against `extra.symfony.require`
176        // (`Restricting packages listed in "symfony/symfony" to …`) —
177        // emulated (`vivacity_resolver::flex_filter`) for `update` and
178        // `remove` without install. `require` resolves Flex aliases and
179        // `POST_UPDATE_CMD` applies recipes to the installed packages
180        // (files, symfony.lock): with install, or on `require`, Composer.
181        "symfony/flex" => {
182            if command == ResolutionCommand::Require {
183                "resolves Flex aliases and applies recipes on require (not emulated)".to_owned()
184            } else if with_install && command == ResolutionCommand::Remove {
185                "applies recipes to the packages it installs (not emulated; --no-install resolves natively)".to_owned()
186            } else {
187                // `update` with install is decided once the lock is solved
188                // (`flex_install_reason`): what Flex would do depends on the
189                // packages the install lays out.
190                return None;
191            }
192        }
193        // `POST_PACKAGE_UPDATE` arms the reminder, `POST_UPDATE_CMD` prints
194        // it after a GitHub GraphQL query (`GitHubClient`): without a token
195        // that is Composer's GitHub auth prompt (an exception under
196        // `--no-interaction`) — stderr and exit code differ. Only once a
197        // package was actually updated, which is not known before
198        // resolving: `update` with install, Composer (`activate` arms it
199        // for the `update` command only: `require` / `remove` stay inert).
200        "symfony/thanks" => {
201            if command == ResolutionCommand::Update && with_install {
202                "prints a reminder after a package update, after querying GitHub (not emulated; --no-install resolves natively)".to_owned()
203            } else {
204                return None;
205            }
206        }
207        // `POST_PACKAGE_UPDATE` records updates, `POST_UPDATE_CMD` prints
208        // yiisoft/yii2's UPGRADE.md notes when it was updated — only with
209        // install, and only known after resolving: with install, Composer.
210        "yiisoft/yii2-composer" => {
211            if with_install {
212                "prints yiisoft/yii2's upgrade notes after an update with install (not emulated; --no-install resolves natively)".to_owned()
213            } else {
214                return None;
215            }
216        }
217        // INIT / `PRE_UPDATE_CMD` merge other manifests into the root
218        // before resolving: emulated (`vivacity_resolver::merge_plugin`),
219        // the merged links, stability flags, aliases and references in
220        // the pool. A merged `repositories` section is the exception,
221        // checked by the caller on the merge itself.
222        "wikimedia/composer-merge-plugin" => return None,
223        // `POST_UPDATE_CMD`, acting only in a `require` context: unpacks
224        // recipes into composer.json.
225        "drupal/core-recipe-unpack" => {
226            if command == ResolutionCommand::Require {
227                "unpacks recipes into composer.json on require (not emulated)".to_owned()
228            } else {
229                return None;
230            }
231        }
232        _ => "is not on vivacity's known-plugin list".to_owned(),
233    })
234}
235
236/// The plugins Composer would load for a resolution command, and what
237/// each does to it: those installed (installed.json of the project and
238/// of `COMPOSER_HOME`) that `allow-plugins` allows. Empty under
239/// `--no-plugins`. One issue per plugin that is not inert for `command`.
240pub fn resolution_issues(
241    project_dir: &Path,
242    root_manifest: &Value,
243    command: ResolutionCommand,
244    plugins_enabled: bool,
245    with_install: bool,
246) -> Vec<ScopeIssue> {
247    let mut issues = Vec::new();
248    for name in active_plugins(project_dir, root_manifest, plugins_enabled) {
249        if let Some(effect) = resolution_effect(&name, command, with_install) {
250            issues.push(ScopeIssue::ResolutionPlugin(name, effect));
251        }
252    }
253    issues
254}
255
256/// The plugins Composer loads for a command: `composer-plugin` packages of
257/// installed.json (project and `COMPOSER_HOME`) that `allow-plugins`
258/// allows. Empty under `--no-plugins`.
259pub fn active_plugins(
260    project_dir: &Path,
261    root_manifest: &Value,
262    plugins_enabled: bool,
263) -> Vec<String> {
264    if !plugins_enabled {
265        return Vec::new();
266    }
267    let mut names: Vec<String> = Vec::new();
268    let mut collect = |packages: Vec<Value>| {
269        for p in packages {
270            if p.get("type").and_then(Value::as_str) == Some("composer-plugin") {
271                if let Some(n) = p.get("name").and_then(Value::as_str) {
272                    if !names.iter().any(|x| x == n) {
273                        names.push(n.to_owned());
274                    }
275                }
276            }
277        }
278    };
279    collect(crate::layout::installed_packages_of(
280        project_dir,
281        root_manifest,
282    ));
283    if let Some(home) = crate::fetch::composer_home() {
284        collect(crate::layout::installed_packages_at(
285            &home.join("vendor").join("composer"),
286        ));
287    }
288    names.retain(|name| {
289        matches!(
290            crate::layout::plugin_allowed(root_manifest, name),
291            crate::layout::PluginVerdict::Allowed
292        )
293    });
294    names
295}
296
297#[derive(Debug, PartialEq, Eq)]
298pub enum ScopeIssue {
299    /// Plugin absent from the known lists: unpredictable behaviour.
300    UnknownPlugin(String),
301    /// An installed, allowed plugin that changes what `update`/`require`/
302    /// `remove` resolve or write (name, what it does).
303    ResolutionPlugin(String, String),
304    /// Plugin known to change the layout (patches, installers-extender...).
305    LayoutPlugin(String),
306    /// Non-reproducible layout (composer/installers: version not ported,
307    /// framework with custom logic, refused target...).
308    Layout(String),
309    /// Package without a usable zip dist (source-only, exotic dist).
310    NoUsableDist(String),
311    /// A `config` key vivacity does not read and that changes the layout
312    /// (`vendor-dir`, `bin-dir`, `preferred-install: source`): Composer's
313    /// output would differ, so the lock is handed over.
314    Config(String),
315    /// `wikimedia/composer-merge-plugin` could not be emulated for this
316    /// run (a `require` pattern without match, an invalid included file,
317    /// or a bare vendor whose lock misses a merged requirement — where
318    /// Composer would run the plugin's implicit update).
319    MergePlugin(String),
320}
321
322impl std::fmt::Display for ScopeIssue {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        match self {
325            ScopeIssue::UnknownPlugin(p) => {
326                write!(f, "plugin {p} is not on vivacity's known-plugin list")
327            }
328            ScopeIssue::LayoutPlugin(p) => {
329                write!(f, "plugin {p} changes the install layout (not emulated)")
330            }
331            ScopeIssue::ResolutionPlugin(p, what) => write!(f, "plugin {p} {what}"),
332            ScopeIssue::Layout(why) => write!(f, "{why}"),
333            ScopeIssue::NoUsableDist(p) => {
334                write!(
335                    f,
336                    "package {p} has no usable dist (no zip or tar dist, no path source)"
337                )
338            }
339            ScopeIssue::Config(why) => write!(f, "config {why} is not supported natively"),
340            ScopeIssue::MergePlugin(why) => write!(f, "wikimedia/composer-merge-plugin: {why}"),
341        }
342    }
343}
344
345#[derive(Debug, Default)]
346pub struct ScopeReport {
347    /// Blocking: at least one -> fallback (or error without Composer).
348    pub issues: Vec<ScopeIssue>,
349    /// Non-blocking: harmless plugins ignored, to report on stderr.
350    pub skipped_plugins: Vec<String>,
351    /// Resolved layout (None if a layout issue blocks).
352    pub layout: Option<Layout>,
353}
354
355impl ScopeReport {
356    pub fn is_native_ok(&self) -> bool {
357        self.issues.is_empty()
358    }
359}
360
361/// `plugins_enabled` = no `--no-plugins`: with the flag, Composer ignores
362/// every plugin, composer/installers included; everything goes into vendor/.
363pub fn analyze(
364    project_dir: &Path,
365    lock: &Lock,
366    root_manifest: &Value,
367    with_dev: bool,
368    plugins_enabled: bool,
369) -> ScopeReport {
370    let mut report = ScopeReport::default();
371
372    for p in lock.wanted_packages(with_dev) {
373        classify_package(project_dir, p, plugins_enabled, &mut report);
374    }
375    // yii2-composer's installer writes three `Yii.php` shims for
376    // `yiisoft/yii2-dev` (`linkBaseYiiFiles`): not emulated.
377    if plugins_enabled
378        && matches!(
379            crate::layout::plugin_allowed(root_manifest, crate::yii2_composer::PLUGIN_NAME),
380            crate::layout::PluginVerdict::Allowed
381        )
382        && lock
383            .wanted_packages(with_dev)
384            .any(|p| p.name() == crate::yii2_composer::PLUGIN_NAME)
385        && lock
386            .wanted_packages(with_dev)
387            .any(|p| p.name() == crate::yii2_composer::YII2_DEV)
388    {
389        report.issues.push(ScopeIssue::UnknownPlugin(format!(
390            "{} with {} (the Yii.php shims are not emulated); it",
391            crate::yii2_composer::PLUGIN_NAME,
392            crate::yii2_composer::YII2_DEV
393        )));
394    }
395    // The licence check, configured: a denied licence stops Composer
396    // mid-install, which vivacity does not reproduce.
397    if plugins_enabled
398        && license_check_configured(root_manifest)
399        && matches!(
400            crate::layout::plugin_allowed(root_manifest, LICENSE_CHECK_PLUGIN),
401            crate::layout::PluginVerdict::Allowed
402        )
403        && lock
404            .wanted_packages(with_dev)
405            .any(|p| p.name() == LICENSE_CHECK_PLUGIN)
406    {
407        report.issues.push(ScopeIssue::UnknownPlugin(format!(
408            "{LICENSE_CHECK_PLUGIN} with an allow-list or deny-list in `extra` (it stops the install on a denied licence); it"
409        )));
410    }
411    // bamarni/composer-bin-plugin: `forward-command: true` runs nested
412    // installs under vendor-bin/ (not emulated); a wrong setting type is
413    // Composer's abort.
414    if plugins_enabled
415        && matches!(
416            crate::layout::plugin_allowed(root_manifest, crate::bamarni_bin::PLUGIN_NAME),
417            crate::layout::PluginVerdict::Allowed
418        )
419        && lock
420            .wanted_packages(with_dev)
421            .any(|p| p.name() == crate::bamarni_bin::PLUGIN_NAME)
422    {
423        match crate::bamarni_bin::config(root_manifest.get("extra")) {
424            Ok(cfg) if cfg.forward_command => report.issues.push(ScopeIssue::UnknownPlugin(
425                format!(
426                    "{} with extra.bamarni-bin.forward-command true (forwards the command to vendor-bin/*, not emulated); it",
427                    crate::bamarni_bin::PLUGIN_NAME
428                ),
429            )),
430            Ok(_) => {}
431            Err(msg) => report.issues.push(ScopeIssue::Config(format!(
432                "extra.bamarni-bin ({msg})"
433            ))),
434        }
435    }
436    report.issues.extend(
437        config_issues(root_manifest, lock, with_dev)
438            .into_iter()
439            .map(ScopeIssue::Config),
440    );
441    match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
442        Ok(layout) => report.layout = Some(layout),
443        Err(issues) => report
444            .issues
445            .extend(issues.into_iter().map(ScopeIssue::Layout)),
446    }
447    report
448}
449
450/// Blocking plugin issues alone (unknown or layout-changing plugins in the
451/// lock), for commands that do not install but would still let Composer run
452/// plugin listeners — `dump-autoload` and its PRE_AUTOLOAD_DUMP.
453pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
454    let mut report = ScopeReport::default();
455    for p in lock.wanted_packages(with_dev) {
456        classify_plugin(p, &mut report);
457    }
458    report.issues
459}
460
461/// The `config` keys of the manifest (or the global config) that vivacity
462/// does not honour: a `preferred-install` that is not exactly `dist`.
463///
464/// `DownloadManager::resolvePackageInstallPreference` takes the FIRST pattern
465/// that matches, and with no match at all it answers
466/// `$package->isDev() ? 'source' : 'dist'`. So `auto` and any partial pattern
467/// map install a dev-version package from **source** — a git clone where
468/// vivacity extracts a dist, i.e. a different vendor/ and not a missing line.
469/// Composer's own default is the string `dist` (`Config`), which is why the
470/// common case needs no refusal. A map is accepted only when every value is
471/// `dist` AND a `*` pattern makes it total; anything else hands over.
472/// (`vendor-dir` / `bin-dir` are resolved by `dirs::Dirs`; the forms it
473/// refuses come back as layout issues.)
474/// `BaseCommand::getPreferredInstallOptions`: the `preferred-install` this
475/// command really runs with, once `--prefer-source`, `--prefer-dist` and
476/// `--prefer-install` have had their say — `None` when they say nothing and the
477/// config stands.
478///
479/// The cascade, ported: the config's string sets one of the two flags (`auto`, a
480/// map, or anything else sets neither); `--prefer-install` may not be combined
481/// with either flag (`InvalidArgumentException`) and its value stands in for the
482/// matching flag, `auto` clearing both; finally, if either flag is set, the two
483/// flags replace whatever the config said.
484///
485/// Reported as the effective `preferred-install`, which is what the refusal is
486/// about: `config_issues` then judges it per package, exactly as
487/// `resolvePackageInstallPreference` does. `Err` is Composer's own usage
488/// message, printed as text with exit 1.
489pub fn effective_preferred_install(
490    root_manifest: &Value,
491    prefer_source: bool,
492    prefer_dist: bool,
493    prefer_install: Option<&str>,
494) -> Result<Option<Value>, String> {
495    if prefer_install.is_none() && !prefer_source && !prefer_dist {
496        return Ok(None);
497    }
498    let config = root_manifest
499        .get("config")
500        .and_then(|c| c.get("preferred-install"))
501        .cloned()
502        .or_else(|| crate::layout::global_config_value("preferred-install"));
503    let mut flag_source = prefer_source;
504    let mut flag_dist = prefer_dist;
505    let mut cleared = false;
506    if let Some(value) = prefer_install {
507        if prefer_source {
508            return Err(
509                "--prefer-source can not be used together with --prefer-install".to_owned(),
510            );
511        }
512        if prefer_dist {
513            return Err("--prefer-dist can not be used together with --prefer-install".to_owned());
514        }
515        match value {
516            "dist" => flag_dist = true,
517            "source" => flag_source = true,
518            "auto" => cleared = true,
519            other => {
520                return Err(format!(
521                    "--prefer-install accepts one of \"dist\", \"source\" or \"auto\", got {other}"
522                ))
523            }
524        }
525    }
526    if flag_source {
527        return Ok(Some(Value::String("source".to_owned())));
528    }
529    if flag_dist {
530        return Ok(Some(Value::String("dist".to_owned())));
531    }
532    if cleared {
533        // Both flags off: the config's string no longer applies, but its map
534        // still reaches `setPreferences`, so only a string is replaced.
535        return Ok(Some(match config {
536            Some(Value::Object(m)) => Value::Object(m),
537            _ => Value::String("auto".to_owned()),
538        }));
539    }
540    Ok(None)
541}
542
543/// `DownloadManager::resolvePackageInstallPreference` plus `Factory`'s switch,/// `DownloadManager::resolvePackageInstallPreference` plus `Factory`'s switch,
544/// for one package: would Composer install it from source?
545///
546/// A string is handled by the switch — `dist` forces dist, `source` forces
547/// source, anything else (including `auto`) leaves both flags off and falls
548/// through to the per-package rule with no patterns. A map goes to
549/// `setPreferences`, where the FIRST matching pattern decides: `dist` is dist,
550/// `auto` is dist only for a non-dev package, and anything else is source.
551/// With no pattern matching at all: `$package->isDev() ? 'source' : 'dist'`.
552fn prefers_source(preference: &Value, name: &str, is_dev: bool) -> bool {
553    match preference {
554        Value::String(s) => match s.as_str() {
555            "dist" => false,
556            "source" => true,
557            _ => is_dev,
558        },
559        Value::Object(map) => {
560            for (pattern, pref) in map {
561                if pattern_matches(pattern, name) {
562                    return match pref.as_str() {
563                        Some("dist") => false,
564                        Some("auto") => is_dev,
565                        _ => true,
566                    };
567                }
568            }
569            is_dev
570        }
571        _ => is_dev,
572    }
573}
574
575/// `'{^'.str_replace('\*', '.*', preg_quote($pattern)).'$}i'`: a whole-name
576/// The pattern of `resolvePackageInstallPreference`, built as
577/// `preg_quote($pattern)` with `\*` turned into `.*`, anchored, `i`: the whole
578/// name, case-insensitive, `*` the only metacharacter and everything else
579/// literal. Greedy left to right, as `.*` is.
580fn pattern_matches(pattern: &str, name: &str) -> bool {
581    let name = name.to_ascii_lowercase();
582    let pieces: Vec<String> = pattern
583        .to_ascii_lowercase()
584        .split('*')
585        .map(str::to_owned)
586        .collect();
587    if pieces.len() == 1 {
588        return name == pieces[0];
589    }
590    let first = &pieces[0];
591    let last = &pieces[pieces.len() - 1];
592    if !name.starts_with(first.as_str()) || !name.ends_with(last.as_str()) {
593        return false;
594    }
595    // The first and last pieces may not overlap in the name.
596    if name.len() < first.len() + last.len() {
597        return false;
598    }
599    let mut rest = &name[first.len()..name.len() - last.len()];
600    for piece in &pieces[1..pieces.len() - 1] {
601        match rest.find(piece.as_str()) {
602            Some(pos) => rest = &rest[pos + piece.len()..],
603            None => return false,
604        }
605    }
606    true
607}
608
609/// `Package::isDev()`: the version's stability is `dev`.
610fn is_dev_version(version: &str) -> bool {
611    version.starts_with("dev-") || version.ends_with("-dev")
612}
613
614pub fn config_issues(root_manifest: &Value, lock: &Lock, with_dev: bool) -> Vec<String> {
615    let value = |key: &str| -> Option<Value> {
616        root_manifest
617            .get("config")
618            .and_then(|c| c.get(key))
619            .cloned()
620            .or_else(|| crate::layout::global_config_value(key))
621    };
622    let mut out = Vec::new();
623    if let Some(v) = value("preferred-install") {
624        if lock
625            .wanted_packages(with_dev)
626            .any(|p| prefers_source(&v, p.name(), is_dev_version(p.version())))
627        {
628            out.push(format!("preferred-install {v}"));
629        }
630    }
631    out
632}
633
634fn classify_package(
635    project_dir: &Path,
636    p: &LockPackage,
637    plugins_enabled: bool,
638    report: &mut ScopeReport,
639) {
640    // Under --no-plugins Composer never loads a plugin (PluginManager::
641    // registerPackage returns at once): the package is a plain library in
642    // vendor/, whatever it would do when active, and nothing is printed.
643    if plugins_enabled {
644        classify_plugin(p, report);
645    }
646    if p.is_metapackage() {
647        return;
648    }
649    let usable = match p.dist_kind() {
650        DistKind::Zip | DistKind::Tar => true,
651        // A `path` package is laid out natively (symlink or mirror) on
652        // Linux/macOS when its source directory is there; Windows
653        // (junctions) is left to Composer.
654        DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
655        DistKind::Other | DistKind::Missing => false,
656    };
657    if !usable {
658        report
659            .issues
660            .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
661    }
662}
663
664fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
665    if p.package_type() != "composer-plugin" {
666        return;
667    }
668    let name = p.name().to_owned();
669    if EMULATED_PLUGINS.contains(&name.as_str()) {
670        // Emulated natively: nothing to report.
671    } else if BENIGN_PLUGINS.contains(&name.as_str()) {
672        report.skipped_plugins.push(name);
673    } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
674        report.issues.push(ScopeIssue::LayoutPlugin(name));
675    } else {
676        report.issues.push(ScopeIssue::UnknownPlugin(name));
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use crate::lock::Lock;
684    use serde_json::json;
685
686    fn lock_with(packages: serde_json::Value) -> Lock {
687        Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
688    }
689
690    fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
691        json!({"name": name, "version": "1.0.0", "type": r#type,
692               "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
693    }
694
695    fn proj() -> std::path::PathBuf {
696        std::path::PathBuf::from("/nonexistent-vivacity-scope")
697    }
698
699    #[test]
700    fn preferred_install_auto_hands_over_for_a_dev_package() {
701        // `resolvePackageInstallPreference` with no pattern answers
702        // `$package->isDev() ? 'source' : 'dist'`, so `auto` clones a dev
703        // version from source — a git checkout where vivacity extracts a dist.
704        // Measured against Composer 2.10.3: `  - Syncing … into cache` for the
705        // dev packages, `  - Downloading …` for the stable ones.
706        let dev = json!({"name": "a/b", "version": "dev-main", "type": "library",
707                         "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}});
708        for preference in [
709            json!("auto"),
710            json!("source"),
711            json!({"*": "auto"}),
712            json!({"a/*": "auto"}),
713            // No pattern matches, so the default rule applies to a dev package.
714            json!({"other/*": "dist"}),
715        ] {
716            let lock = lock_with(json!([dev.clone()]));
717            let manifest = json!({"config": {"preferred-install": preference}});
718            assert!(
719                !analyze(&proj(), &lock, &manifest, true, true).is_native_ok(),
720                "{preference} with a dev package must hand over"
721            );
722            // The same preference with a stable package is dist throughout.
723            let stable = lock_with(json!([zip_pkg("a/b", "library")]));
724            let native = analyze(&proj(), &stable, &manifest, true, true).is_native_ok();
725            assert_eq!(
726                native,
727                preference != json!("source"),
728                "{preference} with a stable package"
729            );
730        }
731    }
732
733    #[test]
734    fn install_preference_patterns_match_like_pcre() {
735        // `preg_quote` then `\*` -> `.*`, anchored, case-insensitive.
736        for (pattern, name, want) in [
737            ("a/b", "a/b", true),
738            ("a/b", "a/bc", false),
739            ("A/B", "a/b", true),
740            ("*", "anything/at-all", true),
741            ("acme/*", "acme/thing", true),
742            ("acme/*", "acmex/thing", false),
743            ("*/thing", "acme/thing", true),
744            ("a*b", "a/xb", true),
745            // `.*` matches the empty string.
746            ("a*b", "ab", true),
747            ("a**b", "a/b", true),
748            // A dot is literal, not "any character".
749            ("a.b/c", "axb/c", false),
750            ("a.b/c", "a.b/c", true),
751            ("acme/*-bundle", "acme/my-bundle", true),
752            ("acme/*-bundle", "acme/bundle", false),
753        ] {
754            assert_eq!(
755                pattern_matches(pattern, name),
756                want,
757                "pattern {pattern:?} against {name:?}"
758            );
759        }
760    }
761
762    #[test]
763    fn plain_library_is_native() {
764        let lock = lock_with(json!([zip_pkg("a/b", "library")]));
765        let r = analyze(&proj(), &lock, &json!({}), true, true);
766        assert!(r.is_native_ok());
767        assert!(r.skipped_plugins.is_empty());
768        assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
769    }
770
771    #[test]
772    fn emulated_and_benign_plugins_stay_native() {
773        let lock = lock_with(json!([
774            zip_pkg("symfony/runtime", "composer-plugin"),
775            zip_pkg("symfony/flex", "composer-plugin"),
776        ]));
777        let r = analyze(&proj(), &lock, &json!({}), true, true);
778        assert!(r.is_native_ok());
779        // Both are emulated now (symfony/flex since its POST_INSTALL_CMD
780        // listener is): nothing to report as merely skipped.
781        assert!(r.skipped_plugins.is_empty(), "{:?}", r.skipped_plugins);
782    }
783
784    #[test]
785    fn thanks_and_normalize_are_benign_at_install() {
786        let lock = lock_with(json!([
787            zip_pkg("symfony/thanks", "composer-plugin"),
788            zip_pkg("ergebnis/composer-normalize", "composer-plugin"),
789        ]));
790        let r = analyze(&proj(), &lock, &json!({}), true, true);
791        assert!(r.is_native_ok(), "{:?}", r.issues);
792        assert_eq!(
793            r.skipped_plugins,
794            vec!["symfony/thanks", "ergebnis/composer-normalize"]
795        );
796    }
797
798    #[test]
799    fn thanks_reminder_only_on_update_with_install() {
800        use ResolutionCommand::*;
801        let effect = |c, i| resolution_effect("symfony/thanks", c, i);
802        assert!(effect(Update, true).is_some());
803        assert!(effect(Update, false).is_none());
804        assert!(effect(Require, true).is_none());
805        assert!(effect(Remove, true).is_none());
806        assert!(resolution_effect("ergebnis/composer-normalize", Update, true).is_none());
807    }
808
809    #[test]
810    fn yii2_composer_is_emulated_except_with_yii2_dev() {
811        let allow = json!({"config": {"allow-plugins": {"yiisoft/yii2-composer": true}}});
812        let lock = lock_with(json!([
813            zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
814            zip_pkg("yiisoft/yii2-gii", "yii2-extension"),
815        ]));
816        assert!(analyze(&proj(), &lock, &allow, true, true).is_native_ok());
817        let lock = lock_with(json!([
818            zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
819            zip_pkg("yiisoft/yii2-dev", "yii2-extension"),
820        ]));
821        let r = analyze(&proj(), &lock, &allow, true, true);
822        assert!(!r.is_native_ok());
823        assert!(
824            r.issues[0].to_string().contains("yiisoft/yii2-dev"),
825            "{}",
826            r.issues[0]
827        );
828        // Under --no-plugins the shims are not written by Composer either.
829        assert!(analyze(&proj(), &lock, &allow, true, false).is_native_ok());
830    }
831
832    #[test]
833    fn license_check_native_unless_configured() {
834        let lock = lock_with(json!([zip_pkg(
835            "metasyntactical/composer-plugin-license-check",
836            "composer-plugin"
837        )]));
838        let manifest = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"metasyntactical/composer-plugin-license-check": true}}, "extra": extra});
839        // No list: `ComposerConfig` filters everything away, nothing checked.
840        for extra in [
841            json!({}),
842            json!({"metasyntactical/composer-plugin-license-check": {}}),
843            json!({"metasyntactical/composer-plugin-license-check": {"allow-list": []}}),
844            json!({"metasyntactical/composer-plugin-license-check": {"allowed-packages": {"a/b": "why"}}}),
845        ] {
846            let r = analyze(&proj(), &lock, &manifest(extra.clone()), true, true);
847            assert!(
848                r.is_native_ok(),
849                "{extra} should stay native: {:?}",
850                r.issues
851            );
852        }
853        for key in ["allow-list", "deny-list"] {
854            let extra = json!({"metasyntactical/composer-plugin-license-check": {key: ["MIT"]}});
855            let r = analyze(&proj(), &lock, &manifest(extra), true, true);
856            assert!(
857                r.issues[0].to_string().contains("allow-list or deny-list"),
858                "{key}: {:?}",
859                r.issues
860            );
861        }
862    }
863
864    #[test]
865    fn bamarni_bin_native_unless_it_forwards() {
866        let lock = lock_with(json!([zip_pkg(
867            "bamarni/composer-bin-plugin",
868            "composer-plugin"
869        )]));
870        let allow = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"bamarni/composer-bin-plugin": true}}, "extra": extra});
871        assert!(analyze(&proj(), &lock, &allow(json!({})), true, true).is_native_ok());
872        let r = analyze(
873            &proj(),
874            &lock,
875            &allow(json!({"bamarni-bin": {"forward-command": true}})),
876            true,
877            true,
878        );
879        assert!(
880            r.issues[0].to_string().contains("forward-command true"),
881            "{}",
882            r.issues[0]
883        );
884        let r = analyze(
885            &proj(),
886            &lock,
887            &allow(json!({"bamarni-bin": {"bin-links": "no"}})),
888            true,
889            true,
890        );
891        assert!(
892            r.issues[0].to_string().contains("boolean value"),
893            "{}",
894            r.issues[0]
895        );
896    }
897
898    #[test]
899    fn no_plugins_makes_every_plugin_a_plain_library() {
900        let lock = lock_with(json!([
901            zip_pkg("acme/mystery-plugin", "composer-plugin"),
902            zip_pkg("cweagans/composer-patches", "composer-plugin"),
903            zip_pkg("symfony/flex", "composer-plugin"),
904        ]));
905        let r = analyze(&proj(), &lock, &json!({}), true, false);
906        assert!(r.is_native_ok(), "{:?}", r.issues);
907        assert!(r.skipped_plugins.is_empty());
908        assert_eq!(
909            r.layout.expect("layout").rel("acme/mystery-plugin"),
910            Some("vendor/acme/mystery-plugin")
911        );
912    }
913
914    #[test]
915    fn unknown_or_layout_plugin_is_out_of_scope() {
916        let lock = lock_with(json!([
917            zip_pkg("acme/mystery-plugin", "composer-plugin"),
918            zip_pkg("cweagans/composer-patches", "composer-plugin"),
919        ]));
920        let r = analyze(&proj(), &lock, &json!({}), true, true);
921        assert_eq!(
922            r.issues,
923            vec![
924                ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
925                ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
926            ]
927        );
928    }
929
930    #[test]
931    fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
932        let lock = lock_with(json!([
933            {"name": "a/src-only", "version": "1.0.0", "type": "library",
934             "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
935            {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
936            zip_pkg("composer/installers", "composer-plugin"),
937        ]));
938        // installer-paths alone is inert (as in Composer); the plugin without
939        // allow-plugins, however, blocks.
940        let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
941        let r = analyze(&proj(), &lock, &manifest, true, true);
942        assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
943        assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
944        assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
945    }
946
947    #[test]
948    fn path_package_is_native_when_its_source_exists() {
949        let tmp = tempfile::tempdir().expect("tmp");
950        std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
951        let lock = lock_with(json!([
952            {"name": "a/here", "version": "dev-main", "type": "library",
953             "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
954            {"name": "a/gone", "version": "dev-main", "type": "library",
955             "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
956        ]));
957        let r = analyze(tmp.path(), &lock, &json!({}), true, true);
958        let expected = if cfg!(unix) {
959            vec![ScopeIssue::NoUsableDist("a/gone".into())]
960        } else {
961            vec![
962                ScopeIssue::NoUsableDist("a/here".into()),
963                ScopeIssue::NoUsableDist("a/gone".into()),
964            ]
965        };
966        assert_eq!(r.issues, expected);
967    }
968
969    #[test]
970    fn unread_config_keys_are_scope_issues() {
971        // `preferred-install` is judged per package, as
972        // `resolvePackageInstallPreference` judges it: only a package that
973        // would come from source is a reason to hand over.
974        let lock = lock_with(json!([zip_pkg("acme/thing", "library")]));
975        let manifest = json!({"config": {"vendor-dir": "lib/", "bin-dir": "vendor/bin",
976            "preferred-install": {"acme/*": "source", "*": "dist"}}});
977        let r = analyze(&proj(), &lock, &manifest, true, true);
978        assert_eq!(
979            r.issues,
980            vec![ScopeIssue::Config(
981                "preferred-install {\"acme/*\":\"source\",\"*\":\"dist\"}".into()
982            ),]
983        );
984        // The same map with a package no source pattern matches: Composer
985        // installs it from dist, so there is nothing to hand over.
986        let lock = lock_with(json!([zip_pkg("a/b", "library")]));
987        let manifest = json!({"config": {"vendor-dir": "vendor",
988            "preferred-install": {"acme/*": "source", "*": "dist"}}});
989        assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
990        // `auto` with a stable package is dist.
991        let manifest = json!({"config": {"vendor-dir": "vendor", "preferred-install": "auto"}});
992        assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
993    }
994
995    #[test]
996    fn no_dev_skips_dev_packages() {
997        let lock = Lock::parse(
998            &json!({
999                "packages": [zip_pkg("a/b", "library")],
1000                "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
1001            })
1002            .to_string(),
1003        )
1004        .expect("lock");
1005        assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
1006        assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
1007    }
1008}