Skip to content

magento-composer-installer silently leaves setup/ (and other mapped dirs) unpopulated when a deploy destination is a symlink #40864

Description

@ssx

Preconditions and environment

  • Magento version: 2.4.8-p5 (reproduced; affects any version shipping magento/magento-composer-installer 0.4.0)
  • PHP 8.3, Composer 2.x
  • Deploy strategy: copy (the default for Magento community projects — extra.magento-deploystrategy)
  • Affected package: magento/magento-composer-installer 0.4.0
    • src/MagentoHackathon/Composer/Magento/Deploystrategy/Copy.php
    • src/MagentoHackathon/Composer/Magento/DeployManager.php
  • A mapped destination (anything in a magento2-component package's extra.map — e.g. magento/magento2-base maps setup, pub, lib, …) exists as a symlink whose target is not present. This is common with shared persistent storage, e.g.:
    pub/media/custom_options -> /shared/pub/media/custom_options   (target absent at deploy time)
    

Steps to reproduce

  1. Create a fresh project:
    composer create-project --repository-url=https://repo.magento.com/ \
      magento/project-community-edition=2.4.8-p5 magento-bug
    cd magento-bug
    find setup -type f | wc -l        # 566 — populated correctly on first install
  2. Replace a mapped destination with a symlink to a non-existent target. Either reproduces it; the
    pub/media/custom_options case mirrors a real shared-storage layout:
    rm -rf pub/media/custom_options
    ln -s /shared/pub/media/custom_options pub/media/custom_options
    
    php -r 'var_dump(is_link("pub/media/custom_options"), file_exists("pub/media/custom_options"));'
    # bool(true)  bool(false)   <-- link node exists, but file_exists() follows it -> false
  3. Trigger a redeploy of the base package (equivalent to what composer install runs):
    composer reinstall magento/magento2-base
  4. Inspect setup/:
    find setup -type f | wc -l

Expected result

  • A pre-existing symlink at a mapped destination is preserved (not clobbered, not recursed into).
  • All other mapped files — including the entire setup/ directory — are deployed.
  • Any genuine deploy error aborts the install (hard fail) with a clear, path-bearing message, instead of passing silently.

Actual result

  • setup/ is left partially or completely unpopulated (in the custom_options case the whole
    setup/ directory is missing — find: setup: No such file or directory).
  • No error or warning is printed at default verbosity. Running with -vvv reveals the hidden
    exception:
    mkdir(): File exists
    
    Origin: Copy.php:103.

Additional information

Root cause

1. Copy::createDelegate() is not symlink-aware (dir-to-dir branch):

// Copy dir to dir
if (file_exists($destPath)) {                 // dereferences the symlink -> false when target missing
    $destPath .= DIRECTORY_SEPARATOR . basename($sourcePath);
}
mkdir($destPath, 0755, true);                 // link node exists -> "mkdir(): File exists"

file_exists() follows symlinks, so a link to a missing target reads as absent; the guard is
skipped and mkdir() is called on a path that already exists as a link node.

2. DeployManager::doDeploy() swallows the error:

try {
    $package->getDeployStrategy()->deploy();
} catch (\ErrorException $e) {
    if ($this->io->isDebug()) {               // only printed with -vvv
        $this->io->write($e->getMessage());
    }
}

The \ErrorException unwinds the deploy() loop over getMappings(), so the package's remaining
mappings (including setup/) are never copied — and the failure is invisible unless -vvv is used.

Proposed fix

A PR is open: magento/magento-composer-installer#39. The fix has two parts.

1. Make the copy strategy symlink-aware, and fail with the exact path (Copy.php). A
pre-existing symlink at the destination (e.g. a shared-storage mount) is preserved instead of being
clobbered or mkdir-ed over. A destination that exists but is not a directory (and is not a
symlink) throws an \ErrorException naming the exact path, instead of letting mkdir() emit a
path-less "File exists" warning:

if (file_exists($destPath)) {
    $destPath .= DIRECTORY_SEPARATOR . basename($sourcePath);
}
if (is_link($destPath)) {
    return true;
}
if (!is_dir($destPath)) {
    if (file_exists($destPath)) {
        throw new \ErrorException(
            sprintf('Cannot deploy to "%s": the path exists but is not a directory', $destPath)
        );
    }
    mkdir($destPath, 0755, true);
}

2. Hard-fail on deploy errors instead of swallowing them (DeployManager.php). A failed file
deploy leaves Magento unable to run, so it must abort the install loudly rather than continue. The
previously-swallowed \ErrorException is re-thrown as a \RuntimeException that names the package,
the underlying message, and the originating file:line, giving composer a non-zero exit:

} catch (\ErrorException $e) {
    throw new \RuntimeException(
        sprintf(
            'Magento deploy failed for "%s": %s (%s:%d)',
            $package->getPackageName(),
            $e->getMessage(),
            $e->getFile(),
            $e->getLine()
        ),
        0,
        $e
    );
}

Note on the earlier draft of this issue, which suggested only logging the error as a <warning> at
normal verbosity: that was changed to a hard fail. A partial deploy produces a broken installation
(e.g. missing setup/), so continuing is incorrect — the install should stop with a clear error.

Verification

Verified on a clean magento/project-community-edition=2.4.8-p5 install (default copy strategy)
and on a real project using shared-storage symlinks:

  • Legitimate symlink preserved: a dangling pub/media/* shared-storage symlink no longer aborts
    the deploy. The redeploy completes with no error, setup/ returns to its full file count, and the
    symlinks are left in place.
  • Genuine failure hard-fails with the exact path: forcing a real filesystem error (e.g. a
    read-only setup/) now aborts the install with a non-zero exit and a message such as:
    In DeployManager.php line 104:
      Magento deploy failed for "magento/magento2-base":
      unlink(.../setup/index.php): Permission denied (.../Copy.php:84)
    
  • The change is clean under the Magento2 PHPCS standard (no silenced errors). The remaining
    DiscouragedFunction warnings on mkdir/is_dir/is_link/file_exists are pre-existing
    throughout this file and unavoidable here — Magento\Framework\Filesystem\DriverInterface is not
    available at Composer-plugin runtime.

Release note

No response

Triage and priority

  • Severity: S0 - Affects critical data or functionality and leaves users without workaround.
  • Severity: S1 - Affects critical data or functionality and forces users to employ a workaround.
  • Severity: S2 - Affects non-critical data or functionality and forces users to employ a workaround.
  • Severity: S3 - Affects non-critical data or functionality and does not force users to employ a workaround.
  • Severity: S4 - Affects aesthetics, professional look and feel, “quality” or “usability”.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status
    Ready for Development

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions