Skip to content

Composer vendor copies a --prefer-source package's .git into .socket/vendor, so git commits it as an embedded repo and a fresh clone installs an empty package #355

Description

[agent] Found by the scheduled Composer bug-hunt routine (ledger #321).

Summary

When a Composer package was installed from source, vendor/<vendor>/<name>/ is a git clone with its own .git/. That happens with --prefer-source, with config.preferred-install: "source", and on a dist download failure where Composer falls back to source. The composer vendor backend copies the installed tree into .socket/vendor/composer/<uuid>/<vendor>/<name>@<version>/ with no exclusion, so the .git/ directory is copied too.

The vendored copy is then a nested git repository. git add records it as a gitlink (mode 160000) that points at the pristine upstream commit, and warns "adding embedded git repository". None of the patched files are committed. In a fresh clone the copy directory is empty. composer install then exits 0 while "mirroring" the empty path dist, so vendor/psr/log has no files and nothing reports the problem.

vendor itself reports success (applied: 1, exit 0), and the local tree looks fine. The breakage only appears on CI or on a teammate's machine.

Impact

  • The patched package never reaches anyone else: the commit contains a gitlink, not the files.
  • The fresh install is silently broken (an empty package, composer install exit 0). Autoloading the package then fails at runtime.
  • The routine's own run confirms the mechanism: two repo capstones fail locally whenever the fixture's psr/log gets installed from source. composer_vendor_keeps_files_mirror_filters_would_drop and composer_vendor_fast_path_heals_legacy_copy both fail with "composer's path mirror dropped or changed .git/HEAD", because the vendored copy contains .git/HEAD. The capstones don't catch the git half: composer_e2e_common::fresh_checkout copies .socket/ with the filesystem, not through git.
  • vex on the fresh clone correctly refuses (omitting pkg:composer/psr/[email protected] from VEX: a patched file is missing), so there's no false attestation.

Repro

Offline vendor with a staged manifest, as in e2e_vendor_composer_build.rs:

export COMPOSER_ALLOW_SUPERUSER=1   # sandbox runs as root
mkdir -p src/proj && cd src/proj && git init -q
cat > composer.json <<'J'
{ "name": "acme/app", "require": {"psr/log": "3.0.2"} }
J
composer update -n -q --prefer-source          # or "config": {"preferred-install": "source"}
ls -d vendor/psr/log/.git                      # present
# stage .socket/manifest.json + blob for pkg:composer/psr/[email protected] patching src/LoggerInterface.php
socket-patch vendor --json --offline --cwd .   # exit 0, summary.applied = 1
ls -d .socket/vendor/composer/<uuid>/psr/[email protected]/.git    # present (276K of upstream history)
printf '/vendor/\n' > .gitignore
git add -A        # warning: adding embedded git repository: .socket/vendor/composer/<uuid>/psr/[email protected]
git commit -qm vendored
git ls-files -s .socket/vendor/composer/
# 160000 f16e1d5863e37f8d8c2a01719f5b34baa2b714d3 0  .socket/vendor/composer/<uuid>/psr/[email protected]
cd .. && git clone -q proj fresh && cd fresh
ls -A .socket/vendor/composer/<uuid>/psr/[email protected]   # empty
composer install -n     # "Installing psr/log (3.0.2): Mirroring from .socket/…"  exit 0
ls vendor/psr/log       # No such file or directory: nothing installed, patched file absent

This reproduced 3 out of 3 times: --prefer-source, config.preferred-install: "source", and a --prefer-dist install that fell back to source in the sandbox.

Expected vs actual

  • Expected: CLI_CONTRACT.md's composer vendor row says composer install works "from the lock alone, real copy not symlink", and docs/testing/composer-compatibility.md says "The patched copy is committed under .socket/vendor/composer/" and "A fresh checkout installs the patched bytes on every version". The copy should hold the package files only. A VCS metadata dir (.git, and by the same logic .svn / .hg) is not part of the package. Composer's own path mirror and composer archive skip it, which is exactly why the capstone sees .git/HEAD as "dropped".
  • Actual: .git/ is copied verbatim, the vendored copy becomes an embedded repo, and a fresh clone installs an empty package with exit 0.

Matrix

OS Composer PHP Install from source → vendor → git clone → install
Linux 2.8.12 8.4.19 fail (3/3)
Linux 2.8.12 8.4.19, dist install pass in upstream CI. No .git in the installed tree, so not affected.
macOS / Windows — — not run. The copy is OS-independent; git's embedded-repo handling is the same everywhere.
Composer 1.x — — not run. --prefer-source clones with .git there too, so it's expected to behave the same.

Tested on main f6b7fb9 (CLI 4.0.0, the latest release).

Suspect code

  • crates/socket-patch-core/src/vendor/composer_lock.rs:750: installed_dir.stage_into(&stage, None) copies the whole installed tree. The skip parameter only supports one file name (cargo uses it for .cargo-checksum.json, vendor/cargo.rs:507), so there's no VCS-dir exclusion.
  • The service path extracts a dist zip, so it's unaffected. Only the local build from an installed source checkout is.
  • Also worth checking: any heal/verify that inventories the copy (a .git/ would change the copy's file set when git gc repacks objects).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions