From 8d93c74149a1fbce9b5ac0ce649cf7b46d68c509 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 10:46:03 -0400 Subject: [PATCH 01/13] Test exact Composer releases on every OS Composer patching was only proven on Ubuntu against three floating releases, so a regression on macOS, Windows, or a specific 2.x line (2.9 still falls back to the git source; 2.10 does not) went unseen. composer-compatibility.yml runs the real vendored and hosted capstones plus the composer VEX cells against checksum-pinned 1.10.28, 2.0.14, 2.1.14, 2.2.30, 2.5.8, 2.8.12, 2.9.8 and 2.10.3 phars on Ubuntu, and the 1.10/2.2/2.9/2.10 lines on Windows and macOS. A Docker job runs the vendored Docker capstone on exact 2.2.30 and 2.10.3 images. The capstones can now run a given composer.phar through php, which is also what makes them work on Windows. Dockerfile.composer installs an exact, checksum-verified Composer instead of the latest one, and the ci.yml comment on the git-source fallback is corrected. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/ci.yml | 9 +- .github/workflows/composer-compatibility.yml | 200 ++++++++++++++++++ .../tests/composer_e2e_common/mod.rs | 48 ++++- tests/docker/Dockerfile.composer | 33 ++- 4 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/composer-compatibility.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fea73c77..1d43b98f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -691,9 +691,12 @@ jobs: # coverage. # # composer: 1 (packagist stopped serving composer 1 on 2025-09-01, - # so the fixture resolves from an inline repository), 2.2 LTS (the - # other release with the git-source download fallback the hosted - # redirect must drop) and current 2. + # so the fixture resolves from an inline repository), 2.2 LTS and + # current 2. Every release from 1.x through 2.9 falls back to the + # git `source` when the dist download fails (the fallback the + # hosted redirect must drop); 2.10 does not. The exact-release + # matrix, 2.9.8 and macOS/Windows included, is + # composer-compatibility.yml. - {os: ubuntu-latest, suite: e2e_vendor_composer_build, composer: '2'} - {os: ubuntu-latest, suite: e2e_vendor_composer_build, composer: '2.2'} - {os: ubuntu-latest, suite: e2e_vendor_composer_build, composer: '1'} diff --git a/.github/workflows/composer-compatibility.yml b/.github/workflows/composer-compatibility.yml new file mode 100644 index 00000000..6f39099c --- /dev/null +++ b/.github/workflows/composer-compatibility.yml @@ -0,0 +1,200 @@ +name: Composer patch compatibility + +# Exact Composer releases on ubuntu, windows and macOS through the real +# vendored (`e2e_vendor_composer_build`) and hosted +# (`e2e_redirect_composer_build`) capstones, each ending in the +# manifest-less VEX step, plus the hermetic `composer::` VEX cells. +# +# Release boundaries: 1.10.28 = Composer 1 (inline-repository fixture, +# packagist dropped Composer 1 metadata); 2.0.14 / 2.1.14 = the 2.x lines +# whose path mirror still drops `.gitignore`d files; 2.2.30 = LTS; +# 2.5.8 / 2.8.12 = mid 2.x; 2.9.8 = the last release whose dist failure +# falls back to `source`, so the hosted redirect's source drop is load +# bearing there; 2.10.3 = current, no fallback. +# +# PHP: Composer 1 never on 8.5 (broken there). No 7.4 cell: the psr/log +# 3.0.2 capstones need PHP >= 8.0; the depscan harness covers 7.4. +# +# Each leg runs one checksum-pinned composer.phar through `php` (the +# capstones' SOCKET_PATCH_COMPOSER_PHAR mode), which is also what makes +# Windows work: `Command::new("composer")` cannot resolve `composer.bat`. + +on: + pull_request: + paths: + - '.github/workflows/composer-compatibility.yml' + - 'tests/docker/Dockerfile.composer' + - 'crates/socket-patch-core/src/vendor/composer_lock.rs' + - 'crates/socket-patch-core/src/vendor/composer_lock/**' + - 'crates/socket-patch-core/src/vendor/lock_inventory/composer.rs' + - 'crates/socket-patch-core/src/patch/redirect/**' + - 'crates/socket-patch-core/src/crawlers/composer_crawler.rs' + - 'crates/socket-patch-core/src/utils/composer*.rs' + - 'crates/socket-patch-core/src/utils/purl.rs' + - 'crates/socket-patch-core/src/vex/**' + - 'crates/socket-patch-cli/src/commands/vendor*' + - 'crates/socket-patch-cli/src/commands/composer_hints.rs' + - 'crates/socket-patch-cli/src/commands/scan/**' + - 'crates/socket-patch-cli/src/commands/vex*.rs' + - 'crates/socket-patch-cli/tests/e2e_*composer*.rs' + - 'crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs' + - 'crates/socket-patch-cli/tests/composer_e2e_common/**' + - 'crates/socket-patch-cli/tests/docker_vendor_common/**' + - 'crates/socket-patch-cli/tests/e2e_vex_lockfile/composer.rs' + - 'crates/socket-patch-cli/tests/vex_e2e_common/**' + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: composer-compat-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + SOCKET_NO_CONFIG: '1' + SOCKET_NO_UPDATE_CHECK: '1' + +jobs: + native: + name: composer ${{ matrix.composer }} / php ${{ matrix.php }} / ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - {os: ubuntu-latest, composer: '1.10.28', php: '8.1', sha256: 0915af36eb01e3f0e16cd309adff7051832b9ef014e38371756804b20425cd5a} + - {os: ubuntu-latest, composer: '2.0.14', php: '8.0', sha256: 29454b41558968ca634bf5e2d4d07ff2275d91b637a76d7a05e6747d36dd3473} + - {os: ubuntu-latest, composer: '2.1.14', php: '8.1', sha256: d44a904520f9aaa766e8b4b05d2d9a766ad9a6f03fa1a48518224aad703061a4} + - {os: ubuntu-latest, composer: '2.2.30', php: '8.1', sha256: 8c2b4478b64f8f7cdf1574838fdb0033b29049ca821dad452db7a3dcfcdbffc2} + - {os: ubuntu-latest, composer: '2.2.30', php: '8.3', sha256: 8c2b4478b64f8f7cdf1574838fdb0033b29049ca821dad452db7a3dcfcdbffc2} + - {os: ubuntu-latest, composer: '2.5.8', php: '8.2', sha256: f07934fad44f9048c0dc875a506cca31cc2794d6aebfc1867f3b1fbf48dce2c5} + - {os: ubuntu-latest, composer: '2.8.12', php: '8.4', sha256: f446ea719708bb85fcbf4ef18def5d0515f1f9b4d703f6d820c9c1656e10a2f2} + - {os: ubuntu-latest, composer: '2.9.8', php: '8.4', sha256: 59b2c50e10cafa0d8efc19ede9a326d782f096c674a26baf98cf042ce23de890} + - {os: ubuntu-latest, composer: '2.10.3', php: '8.5', sha256: 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6} + - {os: windows-latest, composer: '1.10.28', php: '8.1', sha256: 0915af36eb01e3f0e16cd309adff7051832b9ef014e38371756804b20425cd5a} + - {os: windows-latest, composer: '2.2.30', php: '8.3', sha256: 8c2b4478b64f8f7cdf1574838fdb0033b29049ca821dad452db7a3dcfcdbffc2} + - {os: windows-latest, composer: '2.9.8', php: '8.4', sha256: 59b2c50e10cafa0d8efc19ede9a326d782f096c674a26baf98cf042ce23de890} + - {os: windows-latest, composer: '2.10.3', php: '8.5', sha256: 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6} + - {os: macos-latest, composer: '1.10.28', php: '8.1', sha256: 0915af36eb01e3f0e16cd309adff7051832b9ef014e38371756804b20425cd5a} + - {os: macos-latest, composer: '2.2.30', php: '8.3', sha256: 8c2b4478b64f8f7cdf1574838fdb0033b29049ca821dad452db7a3dcfcdbffc2} + - {os: macos-latest, composer: '2.10.3', php: '8.5', sha256: 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - run: rustup show + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: composer-compat + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: ${{ matrix.php }} + tools: none + extensions: zip, mbstring, curl, openssl + ini-values: memory_limit=-1 + coverage: none + - name: Download and verify composer ${{ matrix.composer }} + shell: bash + env: + COMPOSER_RELEASE: ${{ matrix.composer }} + COMPOSER_PHAR_SHA256: ${{ matrix.sha256 }} + # The pinned digest is the contract; the published .sha256sum is a + # second, independent check. php computes the digest so the step + # needs no sha256sum/shasum on any runner image. + run: | + set -euo pipefail + dir="$RUNNER_TEMP" + if command -v cygpath >/dev/null 2>&1; then + dir="$(cygpath -m "$RUNNER_TEMP")" + fi + phar="$dir/composer-$COMPOSER_RELEASE.phar" + base="https://getcomposer.org/download/$COMPOSER_RELEASE/composer.phar" + curl -fsSL --retry 3 -o "$phar" "$base" + published="$(curl -fsSL --retry 3 "$base.sha256sum" | cut -d' ' -f1)" + # shellcheck disable=SC2016 # $argv is PHP, not shell + actual="$(php -r 'echo hash_file("sha256", $argv[1]);' "$phar")" + if [ "$actual" != "$COMPOSER_PHAR_SHA256" ] || [ "$actual" != "$published" ]; then + echo "::error::composer $COMPOSER_RELEASE phar sha256 $actual (pinned $COMPOSER_PHAR_SHA256, published $published)" + exit 1 + fi + reported="$(php "$phar" --version --no-ansi)" + echo "$reported" + case "$reported" in + *"Composer version $COMPOSER_RELEASE "*) ;; + *) echo "::error::expected composer $COMPOSER_RELEASE"; exit 1 ;; + esac + echo "SOCKET_PATCH_COMPOSER_PHAR=$phar" >> "$GITHUB_ENV" + - name: Real-composer vendored + hosted flows with manifest-less VEX + shell: bash + env: + SOCKET_PATCH_COMPOSER_E2E_REQUIRED: '1' + SOCKET_PATCH_COMPOSER_E2E_VERSION: ${{ matrix.composer }} + # The build capstones are `#[ignore]`-gated (the unpinned `test` job + # skips them). The hermetic `composer::` VEX cells are a module of + # the shared `e2e_vex_lockfile` binary; their filter goes in a second + # command so it does not also filter the build suites. + run: | + cargo test -p socket-patch-cli --no-fail-fast \ + --test e2e_vendor_composer_build --test e2e_redirect_composer_build \ + -- --ignored --nocapture + cargo test -p socket-patch-cli --test e2e_vex_lockfile -- composer:: --nocapture + + docker: + # The vendored Docker capstone against an exact composer image. Its + # fixture resolves psr/log from packagist, which no longer serves + # Composer 1, so 1.10.28 is covered by the native legs only. + name: docker composer ${{ matrix.composer }} + runs-on: ubuntu-latest + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - {composer: '2.2.30', sha256: 8c2b4478b64f8f7cdf1574838fdb0033b29049ca821dad452db7a3dcfcdbffc2} + - {composer: '2.10.3', sha256: 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + # `driver: docker` so the composer image's + # `FROM socket-patch-test-base:latest` resolves against the host + # daemon (as in ci.yml's e2e-docker job). + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + with: + driver: docker + + - name: Install Rust + run: rustup show + + # No `actions/cache`: this job builds Docker images (zizmor + # cache-poisoning audit), as in ci.yml's e2e-docker job. + + - name: Build base image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: tests/docker/Dockerfile.base + tags: socket-patch-test-base:latest + load: true + + - name: Build composer ${{ matrix.composer }} image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: tests/docker/Dockerfile.composer + build-args: | + COMPOSER_VERSION=${{ matrix.composer }} + COMPOSER_SHA256=${{ matrix.sha256 }} + tags: socket-patch-test-composer:latest + load: true + + - name: Run the vendored composer Docker capstone + run: cargo test -p socket-patch-cli --features docker-e2e --test docker_e2e_vendor_composer diff --git a/crates/socket-patch-cli/tests/composer_e2e_common/mod.rs b/crates/socket-patch-cli/tests/composer_e2e_common/mod.rs index c10ef195..99e5474c 100644 --- a/crates/socket-patch-cli/tests/composer_e2e_common/mod.rs +++ b/crates/socket-patch-cli/tests/composer_e2e_common/mod.rs @@ -14,7 +14,8 @@ //! //! ## Composer majors //! -//! The capstones run against whatever `composer` is on `PATH`, and branch +//! The capstones run against whatever [`composer_command`] resolves to +//! (the `composer` on `PATH`, or `SOCKET_PATCH_COMPOSER_PHAR`), and branch //! on its MAJOR where the two majors genuinely differ: //! //! * **Resolution source.** packagist.org shut Composer 1 metadata off on @@ -38,6 +39,13 @@ //! (prefix) the leg pinned, e.g. setup-php's `composer:`; the capstone //! asserts `composer --version` reports exactly it or a release under it //! (`2.2` matches `2.2.30`, never `2.20.0`). +//! * `SOCKET_PATCH_COMPOSER_PHAR=` — run that exact `composer.phar` +//! as `php ` instead of the `composer` on PATH +//! (`composer-compatibility.yml` downloads and sha256-verifies one per +//! leg). On Windows this is the only working mode: `Command::new` does +//! not resolve `composer.bat`. +//! * `SOCKET_PATCH_PHP_BIN=` — the `php` that runs the phar +//! (default: `php` on PATH). Ignored without `SOCKET_PATCH_COMPOSER_PHAR`. #![allow(dead_code)] @@ -48,6 +56,28 @@ use std::process::{Command, Output}; /// install (packagist's own dist for 3.0.2 on composer 2). pub const PSR_LOG_REF: &str = "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"; +/// A non-empty environment variable, as a path. +fn env_path(var: &str) -> Option { + std::env::var_os(var) + .filter(|v| !v.is_empty()) + .map(std::path::PathBuf::from) +} + +/// The composer toolchain command: `php ` when +/// `SOCKET_PATCH_COMPOSER_PHAR` is set (php from `SOCKET_PATCH_PHP_BIN`, +/// else PATH), otherwise the `composer` on PATH. Callers add the args. +pub fn composer_command() -> Command { + match env_path("SOCKET_PATCH_COMPOSER_PHAR") { + Some(phar) => { + let php = env_path("SOCKET_PATCH_PHP_BIN").unwrap_or_else(|| "php".into()); + let mut cmd = Command::new(php); + cmd.arg(phar); + cmd + } + None => Command::new("composer"), + } +} + /// `SOCKET_PATCH_COMPOSER_E2E_REQUIRED` is set to a non-empty value other /// than `0` (the CI legs' "must run" switch). pub fn required() -> bool { @@ -66,17 +96,22 @@ pub fn skip(suite: &str, why: &str) -> Option { None } -/// The major of the `composer` on PATH (`Composer version 2.10.3 …` → +/// The major of [`composer_command`] (`Composer version 2.10.3 …` → /// `2`), or `None` when composer is not runnable. Asserts the full version /// is `SOCKET_PATCH_COMPOSER_E2E_VERSION` (or a release under that prefix) /// when that is set. pub fn composer_major(suite: &str) -> Option { - let mut probe = Command::new("composer"); + let mut probe = composer_command(); probe.arg("--version").arg("--no-ansi"); super::cache_env::isolate(&mut probe); let out = match probe.output() { Ok(out) if out.status.success() => out, - _ => return skip(suite, "`composer` not installed"), + _ => { + return skip( + suite, + &format!("composer not runnable ({:?})", composer_command()), + ) + } }; let text = String::from_utf8_lossy(&out.stdout); let version = text @@ -97,7 +132,8 @@ pub fn composer_major(suite: &str) -> Option { if !want.is_empty() { assert!( version == want || version.starts_with(&format!("{want}.")), - "{suite}: the leg pins composer {want} but PATH has:\n{text}" + "{suite}: the leg pins composer {want} but {:?} reports:\n{text}", + composer_command() ); } } @@ -119,7 +155,7 @@ pub fn composer(cwd: &Path, args: &[&str], home: &Path, cache: &Path) -> Output if !config.exists() { std::fs::write(&config, r#"{"config": {"secure-http": false}}"#).unwrap(); } - let mut cmd = Command::new("composer"); + let mut cmd = composer_command(); cmd.args(args) .arg("--no-interaction") .arg("--no-ansi") diff --git a/tests/docker/Dockerfile.composer b/tests/docker/Dockerfile.composer index c97294d4..13c4708c 100644 --- a/tests/docker/Dockerfile.composer +++ b/tests/docker/Dockerfile.composer @@ -1,6 +1,17 @@ -# composer (PHP) ecosystem test image: base + PHP + Composer. +# composer (PHP) ecosystem test image: base + PHP + an exact Composer. +# +# COMPOSER_VERSION pins the release: the phar is downloaded from +# getcomposer.org, verified against the published .sha256sum (and against +# COMPOSER_SHA256 too when that is passed), and the build fails unless +# `composer --version` reports exactly that release. +# The default is what ci.yml's e2e-docker job builds; +# composer-compatibility.yml's docker job passes other releases. +# PHP is the distro's (Debian 12: 8.2). FROM socket-patch-test-base:latest +ARG COMPOSER_VERSION=2.10.3 +ARG COMPOSER_SHA256= + RUN apt-get update \ && apt-get install -y --no-install-recommends \ php-cli \ @@ -8,6 +19,20 @@ RUN apt-get update \ php-xml \ php-mbstring \ unzip \ - && rm -rf /var/lib/apt/lists/* \ - && curl -fsSL https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ - && php --version && composer --version + && rm -rf /var/lib/apt/lists/* + +RUN set -eu \ + && cd /tmp \ + && curl -fsSL --retry 3 -o composer.phar \ + "https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar" \ + && curl -fsSL --retry 3 -o composer.phar.sha256sum \ + "https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar.sha256sum" \ + && echo "$(cut -d' ' -f1 composer.phar.sha256sum) composer.phar" | sha256sum -c - \ + && if [ -n "${COMPOSER_SHA256}" ]; then \ + echo "${COMPOSER_SHA256} composer.phar" | sha256sum -c -; \ + fi \ + && install -m 0755 composer.phar /usr/local/bin/composer \ + && rm -f composer.phar composer.phar.sha256sum \ + && php --version \ + && composer --version --no-ansi \ + && composer --version --no-ansi | grep -F "Composer version ${COMPOSER_VERSION} " From 2a2de173b79593bef1ad4d76cb2cc28ecb7034c1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 10:59:07 -0400 Subject: [PATCH 02/13] Match composer patch versions by release identity A composer patch's base purl can carry the padded version Socket's SBOM ingestion stores (pkg:composer/psr/log@3.0.2.0), while the project's installed.json and composer.lock say 3.0.2 or v3.0.2. The CLI compared those as strings after stripping a leading v, so such a patch was "not found" by apply and vendor, refused by the hosted redirect as a version mismatch, rejected by VEX discovery, and deleted by scan --prune. Compare composer versions the way Composer does. The new utils::composer_version ports composer/semver 3.4.4 VersionParser::normalize (padding, v tags, stability spellings such as -rc.1 and RC1, +build, x-dev branches); a spelling Composer rejects only matches itself. purl_eq, the crawler, the vendor lock lookup, the hosted lock rewriter, the redirect ledger, VEX discovery and sources, scan discovery and the prune step now use it for composer purls. Stored spellings (manifest keys, vendored leaf dirs, ledger keys) are unchanged. The shared vector file tests/fixtures/composer-version-vectors.json is generated from real Composer 2.10.3 and is byte-identical to depscan's copy, so the CLI and the server agree on every case; the port also matches Composer on 12,129 fuzzed inputs. New tests cover the crawler, lock lookup, ledger, VEX leaf, prune, a padded-version redirect golden, and apply, vendor (with VEX) and hosted runs against a mock API that serves only the padded spelling. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/discovery.rs | 17 +- .../socket-patch-cli/src/commands/scan/gc.rs | 31 +- .../src/commands/vex_sources.rs | 20 +- .../tests/e2e_composer_version_identity.rs | 380 +++++++ .../src/crawlers/composer_crawler.rs | 81 +- .../src/patch/redirect/mod.rs | 10 +- .../src/patch/redirect/state.rs | 51 +- .../src/utils/composer_version.rs | 389 +++++++ crates/socket-patch-core/src/utils/mod.rs | 1 + crates/socket-patch-core/src/utils/purl.rs | 40 +- .../src/vendor/composer_lock.rs | 56 +- .../src/vex/discover/composer.rs | 55 +- .../fixtures/composer-version-vectors.json | 958 ++++++++++++++++++ .../padded-version/expected-edits.json | 10 + .../padded-version/expected/composer.lock | 29 + .../padded-version/input/composer.lock | 29 + .../padded-version/overrides.json | 14 + .../redirect-composer.json | 45 + 18 files changed, 2176 insertions(+), 40 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_composer_version_identity.rs create mode 100644 crates/socket-patch-core/src/utils/composer_version.rs create mode 100644 crates/socket-patch-core/tests/fixtures/composer-version-vectors.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/expected/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/overrides.json diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index c4fb8fde..d3876120 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -7,7 +7,8 @@ use socket_patch_core::api::types::{ BatchPackagePatches, BatchPatchInfo, PatchResponse, PatchSearchResult, }; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; -use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::composer_version::purl_identity_key; +use socket_patch_core::utils::purl::{normalize_purl, purl_eq, strip_purl_qualifiers}; use socket_patch_core::vendor::lock_inventory::LockfileEntry; use socket_patch_core::vendor::VendorState; use std::borrow::Cow; @@ -121,8 +122,12 @@ pub(super) async fn lockfile_supplement( /// the comparison bridges the two via `normalize_purl`. The ONE predicate /// behind the `notInstalled` flag, the `[NOT INSTALLED]` marker, the /// `package_not_installed` skip partition and the vendor baseline pre-check. +/// A composer purl also matches its lock spelling of the same release (the +/// API may serve the padded `@3.0.2.0` for a lock's `3.0.2`). pub(super) fn lockfile_only_contains(purls: &HashSet, api_purl: &str) -> bool { - purls.contains(normalize_purl(strip_purl_qualifiers(api_purl)).as_ref()) + let base = strip_purl_qualifiers(api_purl); + purls.contains(normalize_purl(base).as_ref()) + || (base.starts_with("pkg:composer/") && purls.iter().any(|p| purl_eq(p, base))) } /// A displayable crawl entry fabricated from a purl (decoded form). The @@ -278,7 +283,6 @@ pub(super) async fn preverify_vendor_baselines( ) -> (HashSet, HashMap) { use socket_patch_core::manifest::schema::PatchFileInfo; use socket_patch_core::patch::apply::{verify_file_patch, VerifyStatus}; - use socket_patch_core::utils::purl::purl_eq; use socket_patch_core::vendor::lookup_entry; let mut mismatched: HashSet = HashSet::new(); @@ -436,7 +440,8 @@ pub(super) fn detect_updates( // artifact-pinned ecosystems, qualified (`?artifact_id=...`); the // batch *package* purl is the crawler's literal spelling. Bridge // both divergences like the lockfile-only partition does: exact hit - // first, then a normalized qualifier-stripped comparison. + // first, then a normalized qualifier-stripped comparison (composer + // by release identity: a `@3.0.2.0` key is the crawler's `@3.0.2`). // // Qualifier TWINS (one package recorded under two artifact-pinned // keys, e.g. a pypi wheel + sdist pair) both match the stripped @@ -447,12 +452,12 @@ pub(super) fn detect_updates( // from the candidate, and fall back to the first twin when all // agree. let existing = manifest.patches.get(&pkg.purl).or_else(|| { - let want = normalize_purl(strip_purl_qualifiers(&pkg.purl)); + let want = purl_identity_key(&pkg.purl); let mut twins: Vec<(&String, &socket_patch_core::manifest::schema::PatchRecord)> = manifest .patches .iter() - .filter(|(k, _)| normalize_purl(strip_purl_qualifiers(k)) == want) + .filter(|(k, _)| purl_identity_key(k) == want) .collect(); twins.sort_by(|a, b| a.0.cmp(b.0)); twins diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 58eee602..66f02bbb 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -5,7 +5,8 @@ use socket_patch_core::manifest::cleanup_blobs::CleanupResult; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; -use socket_patch_core::utils::purl::{canonical_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::composer_version::purl_identity_key; +use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::vendor::VENDOR_STATE_REL; use std::collections::HashSet; use std::path::Path; @@ -497,12 +498,13 @@ fn detect_prunable( scanned_purls: &HashSet, vendored: &HashSet, ) -> Vec { - let scanned_bases: HashSet = scanned_purls.iter().map(|p| canonical_purl(p)).collect(); + let scanned_bases: HashSet = + scanned_purls.iter().map(|p| purl_identity_key(p)).collect(); manifest .patches .keys() .filter(|p| { - !scanned_bases.contains(&canonical_purl(p)) + !scanned_bases.contains(&purl_identity_key(p)) && !vendored.contains(p.as_str()) && !vendored.contains(strip_purl_qualifiers(p)) && crate::ecosystem_dispatch::crawl_covers_purl(p.as_str()) @@ -638,6 +640,29 @@ mod tests { ); } + /// A composer manifest key in the API's padded spelling (`@3.0.2.0`) or a + /// `v` tag is the SAME release the crawler reports as `@3.0.2`: prune must + /// keep it. A composer release that really is gone is still pruned. + #[test] + fn detect_prunable_keeps_equivalent_composer_spellings() { + let m = manifest_with(&[ + ("pkg:composer/psr/log@3.0.2.0", "uuid-a"), + ("pkg:composer/psr/cache@v3.0.2", "uuid-b"), + ("pkg:composer/psr/container@1.0.0.0", "uuid-c"), + ("pkg:composer/symfony/console@6.4.1.0", "uuid-d"), + ]); + let s = scanned(&[ + "pkg:composer/psr/log@3.0.2", + "pkg:composer/psr/cache@3.0.2", + "pkg:composer/psr/container@1.0", + "pkg:composer/symfony/console@6.4.10", + ]); + assert_eq!( + detect_prunable(&m, &s, &no_vendored()), + vec!["pkg:composer/symfony/console@6.4.1.0".to_string()] + ); + } + #[test] fn detect_prunable_keeps_pypi_variants_of_installed_base() { // Manifest holds three qualified release variants; the crawler diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 77b69e3c..476528f5 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -74,6 +74,7 @@ use socket_patch_core::api::client::{ }; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::redirect::RedirectState; +use socket_patch_core::utils::composer_version::composer_purls_equivalent; use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::vendor::state::{lookup_entry_kv, VendorArtifact, VendorEntry, VendorState}; use socket_patch_core::vex::discover::{ @@ -232,7 +233,7 @@ impl Cand { let vendor_entry = vendor .entries .values() - .find(|e| e.uuid == r.uuid && canonical_base_purl(&e.base_purl) == r.purl) + .find(|e| e.uuid == r.uuid && same_package(&canonical_base_purl(&e.base_purl), &r.purl)) .cloned(); Cand { key: r.purl.clone(), @@ -280,7 +281,7 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S let mut conflicted_keys: BTreeMap<&str, Vec> = BTreeMap::new(); cands.retain(|c| { let pkg = canonical_base_purl(&c.key); - match conflicts.get_key_value(pkg.as_str()) { + match conflicts.iter().find(|(k, _)| same_package(k, &pkg)) { Some((k, _)) => { conflicted_keys .entry(k.as_str()) @@ -408,7 +409,9 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S } let expected_pkg = expected_package(cand); match local_record_by_uuid(&cand.uuid, &manifest, &redirect_records, &vendor) { - Some((found_key, record)) if canonical_base_purl(&found_key) == expected_pkg => { + Some((found_key, record)) + if same_package(&canonical_base_purl(&found_key), &expected_pkg) => + { if cand.lockfile_only { cand.key = found_key; } @@ -433,7 +436,7 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S match fetched.get(&cand.uuid) { Some((api_purl, record)) if record.uuid == cand.uuid - && canonical_base_purl(api_purl) == expected_package(cand) => + && same_package(&canonical_base_purl(api_purl), &expected_package(cand)) => { if cand.lockfile_only { cand.key = api_purl.clone(); @@ -531,6 +534,13 @@ fn failed(purl: &str, reason: &str) -> FailedPatch { } } +/// Whether two [`canonical_base_purl`] spellings name one package release: +/// equal, or composer spellings of the same release (a lock's `@3.0.2`, a +/// patch purl's padded `@3.0.2.0`). +fn same_package(a: &str, b: &str) -> bool { + a == b || composer_purls_equivalent(a, b) +} + /// The package a candidate's record must name (canonical form). fn expected_package(cand: &Cand) -> String { match (&cand.vendor_entry, cand.lockfile_only) { @@ -664,7 +674,7 @@ fn attach_discovered( let mut superseded = Vec::new(); for (pkg, refs) in groups { let idxs: Vec = (0..cands.len()) - .filter(|&i| canonical_base_purl(&cands[i].key) == pkg) + .filter(|&i| same_package(&canonical_base_purl(&cands[i].key), pkg)) .collect(); // Pass 1: the candidate already attests the wired uuid. let mut unmatched: Vec<&PatchedRef> = Vec::new(); diff --git a/crates/socket-patch-cli/tests/e2e_composer_version_identity.rs b/crates/socket-patch-cli/tests/e2e_composer_version_identity.rs new file mode 100644 index 00000000..dec08558 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_composer_version_identity.rs @@ -0,0 +1,380 @@ +//! Composer patch purls in the padded `version_normalized` spelling. +//! +//! Socket's SBOM ingestion stores composer versions padded (`3.0.2.0`), so a +//! patch's base purl can say `pkg:composer/psr/log@3.0.2.0` while the +//! project's `installed.json` and `composer.lock` say `3.0.2` or `v3.0.2`. +//! Every mode must treat those as the same release: agent-mode `scan --sync` +//! patches the installed copy and its prune keeps the patch, `scan --vendor` +//! vendors and wires the lock entry (and `vex` attests it), and hosted +//! `scan --redirect` repoints the lock entry. Each test runs the built binary +//! against a wiremock API that serves only the padded spelling. + +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +/// The patch purl the API serves: the padded composer spelling. +const API_PURL: &str = "pkg:composer/psr/log@3.0.2.0"; +/// What the crawler reports for the installed `3.0.2` / `v3.0.2`. +const CRAWLED_PURL: &str = "pkg:composer/psr/log@3.0.2"; +const GHSA: &str = "GHSA-cmpv-aaaa-bbbb"; +const SHA1: &str = "abcdef0123456789abcdef0123456789abcdef01"; +const FILE: &str = "src/LoggerInterface.php"; +const ORIGINAL: &[u8] = b" String { + format!( + "http://patch.test/patch/composer/psr/log/3.0.2.0/\ + 77777777-7777-4777-8777-777777777777/{UUID}/log-3.0.2.0.zip" + ) +} + +fn lock_text(version: &str) -> String { + format!( + r#"{{ + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + {{ + "name": "psr/log", + "version": "{version}", + "source": {{ + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }}, + "dist": {{ + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }}, + "type": "library" + }} + ], + "packages-dev": [] +}} +"# + ) +} + +/// A composer project with `psr/log` installed (and locked) as `version`. +fn write_project(root: &Path, version: &str) { + std::fs::write( + root.join("composer.json"), + "{ \"require\": { \"psr/log\": \"^3.0\" } }\n", + ) + .unwrap(); + let installed = root.join("vendor/composer"); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write( + installed.join("installed.json"), + format!( + r#"{{ "packages": [ {{ "name": "psr/log", "version": "{version}", "version_normalized": "3.0.2.0" }} ] }} +"# + ), + ) + .unwrap(); + let pkg = root.join("vendor/psr/log"); + std::fs::create_dir_all(pkg.join("src")).unwrap(); + std::fs::write(pkg.join(FILE), ORIGINAL).unwrap(); + std::fs::write(root.join("composer.lock"), lock_text(version)).unwrap(); +} + +/// Discovery, per-package search, the full view (inline blob), and the +/// hosted package reference — every patch record in the padded spelling. +/// The batch response's outer package purl echoes the crawler's query, as +/// production does. +async fn mount_api(server: &MockServer) { + let before_hash = compute_git_sha256_from_bytes(ORIGINAL); + let after_hash = compute_git_sha256_from_bytes(PATCHED); + let blob = base64::engine::general_purpose::STANDARD.encode(PATCHED); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": CRAWLED_PURL, + "patches": [{ + "uuid": UUID, "purl": API_PURL, "tier": "free", + "cveIds": ["CVE-2026-0003"], "ghsaIds": [GHSA], "severity": "high", + "title": "composer padded version fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": UUID, "purl": API_PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": UUID, + "purl": API_PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + format!("package/{FILE}"): { + "beforeHash": before_hash, + "afterHash": after_hash, + "blobContent": blob, + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2026-0003"], + "summary": "composer padded version fixture", + "severity": "high", + "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { + UUID: { + "status": "granted", + "url": hosted_url(), + "purl": API_PURL, + "artifacts": [{ + "kind": "tarball", + "url": hosted_url(), + "integrity": { "sha1": SHA1 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; +} + +/// The built binary with every ambient `SOCKET_*` variable scrubbed and +/// telemetry off, so the assertions reflect only the argv. +fn cli() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG" { + cmd.env_remove(&key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + cmd +} + +/// `socket-patch --json` against `api_url`, returning `(exit, stdout +/// JSON)`; panics with both streams when stdout is not JSON. +fn run_json(cwd: &Path, api_url: &str, args: &[&str]) -> (i32, Value) { + let out = cli() + .args(args) + .args([ + "--json", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "{args:?} --json must emit JSON: {e}\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code().unwrap_or(-1), env) +} + +fn read_json(file: &Path) -> Value { + serde_json::from_slice( + &std::fs::read(file).unwrap_or_else(|e| panic!("read {}: {e}", file.display())), + ) + .unwrap_or_else(|e| panic!("{} is not JSON: {e}", file.display())) +} + +/// Agent mode: `scan --sync` downloads the `@3.0.2.0` patch, applies it to +/// the installed `v3.0.2` copy, and its prune step keeps the manifest entry +/// (the crawler reports `@3.0.2`, the same release). +#[tokio::test] +async fn agent_sync_applies_and_keeps_a_padded_composer_patch() { + let server = MockServer::start().await; + mount_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path(), "v3.0.2"); + + let (code, env) = run_json(tmp.path(), &server.uri(), &["scan", "--sync"]); + assert_eq!(code, 0, "scan --sync must succeed: {env:#}"); + assert_eq!( + std::fs::read(tmp.path().join("vendor/psr/log").join(FILE)).unwrap(), + PATCHED, + "the installed v3.0.2 copy must carry the patched bytes: {env:#}" + ); + let manifest = read_json(&tmp.path().join(".socket/manifest.json")); + assert_eq!( + manifest["patches"][API_PURL]["uuid"], UUID, + "the manifest keeps the patch under the API spelling (not pruned): {manifest:#}" + ); + + // A second sync is a no-op that still keeps the patch. + let (code, env) = run_json(tmp.path(), &server.uri(), &["scan", "--sync"]); + assert_eq!(code, 0, "re-sync must succeed: {env:#}"); + let manifest = read_json(&tmp.path().join(".socket/manifest.json")); + assert_eq!(manifest["patches"][API_PURL]["uuid"], UUID, "{manifest:#}"); +} + +/// Vendored mode: `scan --vendor` finds the installed `3.0.2`, finds the +/// lock's `3.0.2` entry for the `@3.0.2.0` patch, vendors the copy under the +/// patch spelling and wires the entry; `vex` attests the vendored patch +/// (lock `3.0.2` vs leaf `@3.0.2.0`); `vendor --revert` byte-restores the lock. +#[tokio::test] +async fn vendor_wires_and_attests_a_padded_composer_patch() { + let server = MockServer::start().await; + mount_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_project(root, "3.0.2"); + + let (code, env) = run_json( + root, + &server.uri(), + &["scan", "--vendor", "--vendor-source", "build"], + ); + assert_eq!(code, 0, "scan --vendor must succeed: {env:#}"); + assert_eq!(env["vendor"]["summary"]["applied"], 1, "{env:#}"); + assert_eq!(env["vendor"]["summary"]["failed"], 0, "{env:#}"); + + let copy_rel = format!(".socket/vendor/composer/{UUID}/psr/log@3.0.2.0"); + assert_eq!( + std::fs::read(root.join(©_rel).join(FILE)).unwrap(), + PATCHED, + "the vendored copy must carry the patched bytes" + ); + let lock = read_json(&root.join("composer.lock")); + let entry = &lock["packages"][0]; + assert_eq!( + entry["version"], "3.0.2", + "the lock's version is never rewritten" + ); + assert_eq!(entry["dist"]["type"], "path"); + assert_eq!(entry["dist"]["url"], copy_rel.as_str()); + assert_eq!(entry["dist"]["reference"], UUID); + let state = read_json(&root.join(".socket/vendor/state.json")); + assert_eq!(state["entries"][API_PURL]["uuid"], UUID, "{state:#}"); + + let out = cli() + .args([ + "vex", + "--cwd", + root.to_str().unwrap(), + "--product", + "pkg:github/acme/app@1.0.0", + "--offline", + ]) + .output() + .expect("invoke vex"); + assert!( + out.status.success(), + "vex must attest the vendored patch. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let doc: Value = serde_json::from_slice(&out.stdout).expect("VEX JSON on stdout"); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!(stmts.len(), 1, "one attested vulnerability: {doc:#}"); + assert_eq!(stmts[0]["vulnerability"]["name"], GHSA); + assert_eq!(stmts[0]["status"], "not_affected", "{doc:#}"); + assert_eq!( + stmts[0]["impact_statement"].as_str().unwrap(), + format!("Patched via Socket patch {UUID} (vendored)") + ); + + let out = cli() + .args([ + "vendor", + "--revert", + "--json", + "--offline", + "--cwd", + root.to_str().unwrap(), + ]) + .output() + .expect("invoke vendor --revert"); + assert!( + out.status.success(), + "vendor --revert must succeed. stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + std::fs::read_to_string(root.join("composer.lock")).unwrap(), + lock_text("3.0.2"), + "revert must byte-restore composer.lock" + ); +} + +/// Hosted mode: the package reference names `@3.0.2.0`, the lock `3.0.2`; +/// the redirect repoints the entry, is confirmed, and is recorded. +#[tokio::test] +async fn hosted_redirect_repoints_a_padded_composer_patch() { + let server = MockServer::start().await; + mount_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path(), "3.0.2"); + + let (code, env) = run_json(tmp.path(), &server.uri(), &["scan", "--redirect"]); + assert_eq!(code, 0, "scan --redirect must succeed: {env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let codes: Vec<&str> = env["redirect"]["warnings"] + .as_array() + .map(|w| w.iter().filter_map(|w| w["code"].as_str()).collect()) + .unwrap_or_default(); + assert!( + !codes.contains(&"redirect_composer_version_mismatch"), + "3.0.2 and 3.0.2.0 are one release: {env:#}" + ); + + let lock = read_json(&tmp.path().join("composer.lock")); + let entry = &lock["packages"][0]; + assert_eq!( + entry["version"], "3.0.2", + "the lock's version is never rewritten" + ); + assert_eq!(entry["dist"]["url"], hosted_url().as_str(), "{lock:#}"); + assert_eq!(entry["dist"]["shasum"], SHA1, "{lock:#}"); + let ledger = std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")) + .expect("redirect ledger written"); + assert!( + ledger.contains(API_PURL) && ledger.contains("redirect_composer_dist"), + "the ledger records the redirected patch and its revert edit: {ledger}" + ); +} diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 4253176b..30cb15eb 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use super::types::{CrawledPackage, CrawlerOptions}; use crate::patch::path_safety; +use crate::utils::composer_version::composer_versions_equivalent; use crate::utils::fs::{is_dir, is_file, normalize_lexically}; use crate::utils::process::{CommandRunner, SystemCommandRunner}; @@ -173,11 +174,10 @@ impl ComposerCrawler { continue; }; - // Verify version matches installed.json. Compare on the - // normalized version so a `v`-prefixed installed.json - // version (`v6.4.1`) matches a bare PURL version (`6.4.1`) - // and vice versa. - if normalize_version(&entry.version) != normalize_version(version) { + // Verify version matches installed.json by composer release + // identity: installed.json's pretty `v6.4.1` / `3.0.2` + // matches a purl's `6.4.1` / padded `3.0.2.0`. + if !composer_versions_equivalent(&entry.version, version) { continue; } @@ -834,6 +834,77 @@ mod tests { assert!(result.contains_key("pkg:composer/symfony/console@v6.4.1")); } + /// A patch purl may carry composer's padded `version_normalized` + /// spelling (`3.0.2.0`) while installed.json records the pretty + /// `3.0.2` / `v3.0.2` / `1.0` / `8.1.0-RC1`: the same release, found. + /// A different release is still not found. + #[tokio::test] + async fn test_find_by_purls_matches_equivalent_composer_versions() { + let dir = tempfile::tempdir().unwrap(); + let vendor_dir = dir.path().join("vendor"); + let composer_dir = vendor_dir.join("composer"); + tokio::fs::create_dir_all(&composer_dir).await.unwrap(); + tokio::fs::write( + composer_dir.join("installed.json"), + r#"{"packages": [ + {"name": "psr/log", "version": "3.0.2"}, + {"name": "psr/cache", "version": "v3.0.2"}, + {"name": "psr/container", "version": "1.0"}, + {"name": "symfony/http-kernel", "version": "v8.1.0-RC1"} + ]}"#, + ) + .await + .unwrap(); + for pkg in [ + "psr/log", + "psr/cache", + "psr/container", + "symfony/http-kernel", + ] { + tokio::fs::create_dir_all(vendor_dir.join(pkg)) + .await + .unwrap(); + } + + let found = [ + "pkg:composer/psr/log@3.0.2.0", + "pkg:composer/psr/log@v3.0.2", + "pkg:composer/psr/cache@3.0.2.0", + "pkg:composer/psr/cache@3.0.2", + "pkg:composer/psr/container@1.0.0.0", + "pkg:composer/psr/container@1.0.0", + "pkg:composer/symfony/http-kernel@8.1.0.0-RC1", + "pkg:composer/symfony/http-kernel@8.1.0-rc.1", + ]; + let missing = [ + "pkg:composer/psr/log@3.0.20", + "pkg:composer/psr/log@3.0.2.1", + "pkg:composer/symfony/http-kernel@8.1.0", + "pkg:composer/symfony/http-kernel@8.1.0-RC2", + ]; + let purls: Vec = found + .iter() + .chain(&missing) + .map(|p| p.to_string()) + .collect(); + let result = ComposerCrawler::new() + .find_by_purls(&vendor_dir, &purls) + .await + .unwrap(); + + for purl in found { + let pkg = result + .get(purl) + .unwrap_or_else(|| panic!("{purl} not found")); + assert_eq!(pkg.purl, purl, "the result keeps the requested spelling"); + } + assert_eq!(result.len(), found.len(), "{:?}", result.keys()); + assert_eq!( + result["pkg:composer/psr/log@3.0.2.0"].path, + vendor_dir.join("psr/log") + ); + } + #[tokio::test] async fn test_read_installed_json_skips_malformed_entries() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ccfcadac..47fad79d 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -22,7 +22,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use crate::crawlers::composer_crawler::normalize_version; +use crate::utils::composer_version::composer_versions_equivalent; use crate::utils::digest::is_hex64_lower; use crate::utils::line_endings::{to_lf, LineEndings}; use crate::vendor::yarn_berry_lock::yarnrc_compression_level; @@ -4197,9 +4197,9 @@ enum ComposerEntry { /// Names match CASE-INSENSITIVELY, the way the composer crawler and the vendor /// backend already match them: packagist canonicalizes to lowercase, but /// hand-written mixed-case locks install fine and would otherwise silently miss -/// the redirect. The locked version must match the patched one through -/// composer's leading-`v` normalization (locks carry the pretty `v6.4.1`, PURLs -/// the bare `6.4.1`); matching on name alone repointed whatever version the +/// the redirect. The locked version must match the patched one by composer +/// release identity (locks carry the pretty `v6.4.1`, PURLs the bare `6.4.1` +/// or padded `6.4.1.0`); matching on name alone repointed whatever version the /// lock happened to hold at a patch built for a different one. fn find_composer_entry(content: &str, pkg: &str, version: &str) -> ComposerEntry { let mut mismatched: Option = None; @@ -4216,7 +4216,7 @@ fn find_composer_entry(content: &str, pkg: &str, version: &str) -> ComposerEntry let Some(locked) = json_string_field(entry, "version") else { continue; }; - if normalize_version(locked) == normalize_version(version) { + if composer_versions_equivalent(locked, version) { return ComposerEntry::Found(name_idx, end); } mismatched = Some(locked.to_string()); diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index 129012a0..0abd8026 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -20,6 +20,7 @@ use serde::{Deserialize, Serialize}; use super::FileEdit; use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; +use crate::utils::composer_version::purl_identity_key; use crate::utils::fs::read_regular_to_bytes; use crate::utils::purl::{canonical_purl, purl_name_version}; use crate::utils::socket_dir::{remove_file_and_prune, write_json_ledger}; @@ -64,14 +65,15 @@ impl RedirectState { } /// The stored record keys whose canonical purl (qualifiers stripped, - /// percent-decoded — [`canonical_purl`]) matches `purl`, in ledger - /// order. Normally zero or one; a hand-edited ledger may carry the same - /// package under two spellings, and every caller must drop them all. + /// percent-decoded — [`canonical_purl`]; composer by release identity, + /// so a `@3.0.2.0` record is found for `@3.0.2`) matches `purl`, in + /// ledger order. Normally zero or one; a hand-edited ledger may carry the + /// same package under two spellings, and every caller must drop them all. pub(crate) fn record_keys_for(&self, purl: &str) -> Vec { - let target = canonical_purl(purl); + let target = purl_identity_key(purl); self.records .keys() - .filter(|k| canonical_purl(k) == target) + .filter(|k| purl_identity_key(k) == target) .cloned() .collect() } @@ -813,6 +815,45 @@ mod tests { assert!(state.records.is_empty() && state.edits.is_empty()); } + /// Composer: the hosted record is keyed by the API's padded spelling + /// (`@3.0.2.0`) while the vendor run supersedes the lock's `@3.0.2`. + /// Both halves must still be claimed (the record by release identity, + /// the name-keyed lock edit through the record's uuid anchor), and the + /// other version's halves must survive. + #[test] + fn drop_superseded_purl_matches_composer_version_spellings() { + const UUID_OTHER: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0f9e8d7c6b5a"; + let composer_edit = |uuid: &str| FileEdit { + path: "composer.lock".to_string(), + kind: "redirect_composer_lock_entry".to_string(), + action: "rewritten".to_string(), + key: Some("psr/log".to_string()), + original: Some(serde_json::json!("orig")), + new: Some(serde_json::json!(format!( + "\"url\": \"https:\\/\\/patch.test\\/patch\\/composer\\/{uuid}\\/log.zip\"" + ))), + }; + let mut state = RedirectState::new(); + state + .records + .insert("pkg:composer/psr/log@3.0.2.0".to_string(), sample_record()); + state.records.insert( + "pkg:composer/psr/log@3.0.20".to_string(), + record_with_uuid(UUID_OTHER), + ); + state.edits = vec![composer_edit(SAMPLE_UUID), composer_edit(UUID_OTHER)]; + + assert!(drop_superseded_purl( + &mut state, + "pkg:composer/psr/log@3.0.2" + )); + assert_eq!( + state.records.keys().collect::>(), + vec!["pkg:composer/psr/log@3.0.20"] + ); + assert_eq!(state.edits, vec![composer_edit(UUID_OTHER)]); + } + /// Cargo purls are refused: their takeover must revert the hosted edits /// ON DISK first (`revert_cargo_redirect_purl`), so a bare ledger drop /// would destroy the only revert data. Fail closed by dropping nothing. diff --git a/crates/socket-patch-core/src/utils/composer_version.rs b/crates/socket-patch-core/src/utils/composer_version.rs new file mode 100644 index 00000000..22b39545 --- /dev/null +++ b/crates/socket-patch-core/src/utils/composer_version.rs @@ -0,0 +1,389 @@ +//! Composer version identity: when two spellings name the same release. +//! +//! One composer release reaches the CLI under several spellings. Lockfiles +//! and `installed.json` carry the pretty tag (`v3.0.2`, `3.0.2`), while a +//! patch's base purl may carry the padded `version_normalized` form +//! (`3.0.2.0`) that Socket's SBOM ingestion stores. Comparing them as strings +//! made a patch for `@3.0.2.0` "not found" in a project that locks `3.0.2`, +//! and made `scan --prune` delete it. +//! +//! [`composer_version_normalize`] is a port of composer/semver 3.4 +//! `VersionParser::normalize`, the function Composer and Packagist key a +//! release by (Packagist keeps `version_normalized` unique per package). It is +//! checked against real Composer 2.10.3 by the shared vector file +//! `tests/fixtures/composer-version-vectors.json`, which depscan's +//! `composerPatchIdentityVersion` twin asserts byte for byte. A spelling +//! Composer rejects keys as itself minus one leading `v`, and is equivalent +//! only to other rejected spellings with the same key. +//! +//! Only comparisons go through this module. Stored spellings (manifest keys, +//! vendored leaf directories, ledger keys, crawler purls) are unchanged. +//! +//! Composer's normalize is not idempotent for a few forms (`2010-01-02` → +//! `2010.01.02` → `2010.01.02.0`; `1.0.0-STABLE` keeps `-stable`), so key raw +//! spellings once and never re-key a key. + +use std::sync::LazyLock; + +use regex::Regex; + +use crate::utils::purl::{canonical_purl, normalize_purl, strip_purl_qualifiers}; + +/// PCRE `$` also matches before one final `\n`; PHP `\s` includes `\v`. +const MODIFIER: &str = + r"[._-]?(?:((?i-u:stable|beta|b|RC|alpha|a|patch|pl|p))((?:[.-]?[0-9]+)*)?)?((?i-u:[.-]?dev))?"; + +static ALIAS: LazyLock = LazyLock::new(|| { + Regex::new(r"^([^,\t\n\x0B\x0C\r ]+) +as +([^,\t\n\x0B\x0C\r ]+)\n?$").expect("alias regex") +}); +static STABILITY_FLAG: LazyLock = LazyLock::new(|| { + Regex::new(r"@(?i-u:stable|RC|beta|alpha|dev)(\n?)$").expect("stability flag regex") +}); +static BUILD_METADATA: LazyLock = LazyLock::new(|| { + Regex::new(r"^([^,\t\n\x0B\x0C\r +]+)\+[^\t\n\x0B\x0C\r ]+\n?$").expect("build metadata regex") +}); +static CLASSICAL: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r"^(?i-u:v)?([0-9]{{1,5}})(\.[0-9]+)?(\.[0-9]+)?(\.[0-9]+)?{MODIFIER}\n?$" + )) + .expect("classical regex") +}); +static DATE: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r"^(?i-u:v)?([0-9]{{4}}(?:[.:-]?[0-9]{{2}}){{1,6}}(?:[.:-]?[0-9]{{1,3}}){{0,2}}){MODIFIER}\n?$" + )) + .expect("date regex") +}); +static DEV_SUFFIX: LazyLock = + LazyLock::new(|| Regex::new(r"([^\n]*?)[.-]?(?i-u:dev)\n?$").expect("dev suffix regex")); +static BRANCH: LazyLock = LazyLock::new(|| { + Regex::new( + r"^(?i-u:v)?([0-9]+)(\.(?:[0-9]+|[xX*]))?(\.(?:[0-9]+|[xX*]))?(\.(?:[0-9]+|[xX*]))?\n?$", + ) + .expect("branch regex") +}); + +/// PHP `trim()`'s default character set. +fn php_trim(value: &str) -> &str { + value.trim_matches([' ', '\t', '\n', '\r', '\0', '\x0B']) +} + +fn non_empty<'a>(caps: ®ex::Captures<'a>, index: usize) -> Option<&'a str> { + caps.get(index) + .map(|m| m.as_str()) + .filter(|s| !s.is_empty()) +} + +fn expand_stability(stability: &str) -> String { + let lowered = stability.to_ascii_lowercase(); + match lowered.as_str() { + "a" => "alpha".to_string(), + "b" => "beta".to_string(), + "p" | "pl" => "patch".to_string(), + "rc" => "RC".to_string(), + _ => lowered, + } +} + +/// The stability / dev suffix of a classical or date match, whose modifier +/// groups start at `first`. +fn append_modifiers(mut version: String, caps: ®ex::Captures<'_>, first: usize) -> String { + if let Some(stability) = non_empty(caps, first) { + // Case-sensitive, as in composer: `-STABLE` is kept as `-stable`. + if stability == "stable" { + return version; + } + version.push('-'); + version.push_str(&expand_stability(stability)); + if let Some(number) = non_empty(caps, first + 1) { + version.push_str(number.trim_start_matches(['.', '-'])); + } + } + if non_empty(caps, first + 2).is_some() { + version.push_str("-dev"); + } + version +} + +fn normalize_branch(name: &str) -> String { + let trimmed = php_trim(name); + let Some(caps) = BRANCH.captures(trimmed) else { + return format!("dev-{trimmed}"); + }; + let mut version = String::new(); + for index in 1..5 { + match caps.get(index) { + Some(part) => version.push_str(&part.as_str().replace(['*', 'X'], "x")), + None => version.push_str(".x"), + } + } + format!("{}-dev", version.replace('x', "9999999")) +} + +/// Composer's `VersionParser::normalize` (composer/semver 3.4.4), or `None` +/// where Composer throws `UnexpectedValueException` (not a version). +pub fn composer_version_normalize(version: &str) -> Option { + let mut current = php_trim(version); + if let Some(caps) = ALIAS.captures(current) { + if let Some(target) = caps.get(1) { + current = target.as_str(); + } + } + if let Some(caps) = STABILITY_FLAG.captures(current) { + let whole = caps.get(0).map_or(0, |m| m.len()); + let newline = caps.get(1).map_or(0, |m| m.len()); + // PHP cuts strlen($match[0]) bytes off the END, and the PCRE match + // excludes a final `\n` it matched before. + current = ¤t[..current.len() - (whole - newline)]; + } + let rooted; + if matches!(current, "master" | "trunk" | "default") { + rooted = format!("dev-{current}"); + current = &rooted; + } + if current.len() >= 4 && current.as_bytes()[..4].eq_ignore_ascii_case(b"dev-") { + return Some(format!("dev-{}", ¤t[4..])); + } + if let Some(caps) = BUILD_METADATA.captures(current) { + if let Some(stripped) = caps.get(1) { + current = stripped.as_str(); + } + } + if let Some(caps) = CLASSICAL.captures(current) { + if let Some(major) = caps.get(1) { + let mut core = major.as_str().to_string(); + for index in 2..5 { + core.push_str(non_empty(&caps, index).unwrap_or(".0")); + } + return Some(append_modifiers(core, &caps, 5)); + } + } + if let Some(caps) = DATE.captures(current) { + if let Some(date) = caps.get(1) { + let dotted: String = date + .as_str() + .chars() + .map(|c| if c.is_ascii_digit() { c } else { '.' }) + .collect(); + return Some(append_modifiers(dotted, &caps, 2)); + } + } + if let Some(caps) = DEV_SUFFIX.captures(current) { + if let Some(branch) = caps.get(1) { + let normalized = normalize_branch(branch.as_str()); + if !normalized.contains("dev-") { + return Some(normalized); + } + } + } + None +} + +/// One leading `v`/`V` stripped when a digit follows (`v6.4.1` → `6.4.1`). +fn strip_leading_v(version: &str) -> &str { + let bytes = version.as_bytes(); + if bytes.len() >= 2 && matches!(bytes[0], b'v' | b'V') && bytes[1].is_ascii_digit() { + &version[1..] + } else { + version + } +} + +/// The identity key of a composer version: [`composer_version_normalize`], +/// else the spelling minus one leading `v`. +pub fn composer_version_key(version: &str) -> String { + composer_version_normalize(version).unwrap_or_else(|| strip_leading_v(version).to_string()) +} + +/// Whether two composer version spellings name the same release +/// (`3.0.2` ≡ `v3.0.2` ≡ `3.0.2.0`, `8.1.0-RC1` ≡ `v8.1.0-rc.1`). A spelling +/// Composer rejects matches only another rejected spelling with the same +/// `v`-stripped text. +pub fn composer_versions_equivalent(a: &str, b: &str) -> bool { + match (composer_version_normalize(a), composer_version_normalize(b)) { + (Some(left), Some(right)) => left == right, + (None, None) => strip_leading_v(a) == strip_leading_v(b), + _ => false, + } +} + +/// `(lowercased vendor/name, version)` of an already-decoded, +/// qualifier-free `pkg:composer//@` base. +fn composer_base_parts(base: &str) -> Option<(String, &str)> { + let rest = base + .get(..13) + .filter(|prefix| prefix.eq_ignore_ascii_case("pkg:composer/")) + .map(|_| &base[13..])?; + let (name, version) = rest.rsplit_once('@')?; + let (vendor, package) = name.split_once('/')?; + if vendor.is_empty() || package.is_empty() || package.contains('/') || version.is_empty() { + return None; + } + Some((name.to_lowercase(), version)) +} + +/// `pkg:composer//@` for a composer purl in any spelling +/// (qualifiers and subpath stripped, percent-decoded, name lowercased, +/// version through [`composer_version_key`]); `None` for anything else. +pub fn composer_purl_identity(purl: &str) -> Option { + let base = canonical_purl(purl); + let (name, version) = composer_base_parts(&base)?; + Some(format!( + "pkg:composer/{name}@{}", + composer_version_key(version) + )) +} + +/// The key ledger and prune bookkeeping compare purls by: the composer +/// identity for composer purls, [`canonical_purl`] for every other type. +pub fn purl_identity_key(purl: &str) -> String { + composer_purl_identity(purl).unwrap_or_else(|| canonical_purl(purl)) +} + +/// Whether two already-decoded, qualifier-free composer bases name the same +/// package release. `false` unless both are composer purls. +pub(crate) fn composer_bases_equivalent(a: &str, b: &str) -> bool { + match (composer_base_parts(a), composer_base_parts(b)) { + (Some((left_name, left_version)), Some((right_name, right_version))) => { + left_name == right_name && composer_versions_equivalent(left_version, right_version) + } + _ => false, + } +} + +/// Whether two composer purls, in any spelling, name the same package +/// release (qualifiers and subpath ignored). `false` unless both are +/// composer purls. +pub fn composer_purls_equivalent(a: &str, b: &str) -> bool { + composer_bases_equivalent( + &normalize_purl(strip_purl_qualifiers(a)), + &normalize_purl(strip_purl_qualifiers(b)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VECTORS: &str = include_str!("../../tests/fixtures/composer-version-vectors.json"); + + fn vectors() -> serde_json::Value { + serde_json::from_str(VECTORS).expect("vector file is JSON") + } + + #[test] + fn vector_file_pins_the_composer_oracle() { + let v = vectors(); + assert_eq!(v["composer"], "2.10.3"); + assert_eq!(v["composerSemver"], "3.4.4"); + assert!(v["vectors"].as_array().unwrap().len() >= 60); + assert!(!v["equivalence"].as_array().unwrap().is_empty()); + } + + #[test] + fn every_vector_matches_real_composer() { + let v = vectors(); + let mut failures = Vec::new(); + for case in v["vectors"].as_array().unwrap() { + let input = case["input"].as_str().unwrap(); + let composer = case["composerNormalized"].as_str().map(str::to_string); + let identity = case["identity"].as_str().unwrap(); + let got = composer_version_normalize(input); + if got != composer { + failures.push(format!( + "normalize({input:?}) = {got:?}, composer {composer:?}" + )); + } + let key = composer_version_key(input); + if key != identity { + failures.push(format!("key({input:?}) = {key:?}, want {identity:?}")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + } + + #[test] + fn every_equivalence_vector_holds_both_ways() { + let v = vectors(); + let mut failures = Vec::new(); + for case in v["equivalence"].as_array().unwrap() { + let left = case["left"].as_str().unwrap(); + let right = case["right"].as_str().unwrap(); + let want = case["equivalent"].as_bool().unwrap(); + for (a, b) in [(left, right), (right, left)] { + if composer_versions_equivalent(a, b) != want { + failures.push(format!("equivalent({a:?}, {b:?}) != {want}")); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + } + + #[test] + fn purl_identity_bridges_padding_prefix_case_and_encoding() { + let want = Some("pkg:composer/psr/log@3.0.2.0".to_string()); + for purl in [ + "pkg:composer/psr/log@3.0.2", + "pkg:composer/psr/log@v3.0.2", + "pkg:composer/psr/log@3.0.2.0", + "pkg:composer/Psr/Log@3.0.2", + "pkg:composer/psr/log@3.0.2.0?repository_url=https://repo.packagist.org", + "pkg:composer/psr/log@3.0.2#src", + "pkg:composer/psr/log@3.0.2%2Bbuild.5", + ] { + assert_eq!(composer_purl_identity(purl), want, "{purl}"); + } + assert_eq!( + composer_purl_identity("pkg:composer/symfony/http-kernel@v8.1.0-rc.1").as_deref(), + Some("pkg:composer/symfony/http-kernel@8.1.0.0-RC1") + ); + assert_eq!(composer_purl_identity("pkg:npm/left-pad@1.3.0"), None); + assert_eq!(composer_purl_identity("pkg:composer/log@1.0.0"), None); + assert_eq!(composer_purl_identity("pkg:composer/a/b/c@1.0.0"), None); + assert_eq!(composer_purl_identity("pkg:composer/psr/log@"), None); + } + + #[test] + fn purl_equivalence_is_composer_only_and_version_exact() { + assert!(composer_purls_equivalent( + "pkg:composer/psr/log@3.0.2", + "pkg:composer/psr/log@3.0.2.0" + )); + assert!(composer_purls_equivalent( + "pkg:composer/psr/log@v3.0.2", + "pkg:composer/PSR/LOG@3.0.2.0?x=y" + )); + assert!(composer_purls_equivalent( + "pkg:composer/psr/log@1.0", + "pkg:composer/psr/log@1.0.0.0" + )); + assert!(!composer_purls_equivalent( + "pkg:composer/psr/log@3.0.2", + "pkg:composer/psr/log@3.0.20" + )); + assert!(!composer_purls_equivalent( + "pkg:composer/psr/log@3.0.2", + "pkg:composer/psr/cache@3.0.2" + )); + assert!(!composer_purls_equivalent( + "pkg:npm/left-pad@1.3.0", + "pkg:npm/left-pad@1.3.0" + )); + assert!(!composer_purls_equivalent( + "pkg:composer/psr/log@1.0.0-RC1", + "pkg:composer/psr/log@1.0.0" + )); + } + + #[test] + fn identity_key_falls_back_to_the_canonical_purl() { + assert_eq!( + purl_identity_key("pkg:composer/psr/log@v3.0.2?x=1"), + "pkg:composer/psr/log@3.0.2.0" + ); + assert_eq!( + purl_identity_key("pkg:npm/%40scope/x@1.0.0?y=2"), + "pkg:npm/@scope/x@1.0.0" + ); + } +} diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 9c57a536..95bd901e 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -1,4 +1,5 @@ pub mod cargo_workspace; +pub mod composer_version; pub(crate) mod digest; pub mod env_compat; pub mod fs; diff --git a/crates/socket-patch-core/src/utils/purl.rs b/crates/socket-patch-core/src/utils/purl.rs index 9a84498e..6f72a521 100644 --- a/crates/socket-patch-core/src/utils/purl.rs +++ b/crates/socket-patch-core/src/utils/purl.rs @@ -106,9 +106,20 @@ pub fn normalize_purl(purl: &str) -> Cow<'_, str> { } /// Purl equality up to percent-encoding of the base components -/// (`pkg:npm/%40scope/x@1` ≡ `pkg:npm/@scope/x@1`). +/// (`pkg:npm/%40scope/x@1` ≡ `pkg:npm/@scope/x@1`) and, for composer, up to +/// the version spelling of one release (`@3.0.2` ≡ `@v3.0.2` ≡ `@3.0.2.0`, +/// name case-insensitive; see [`crate::utils::composer_version`]). +/// Qualifiers and subpath must still match exactly. pub fn purl_eq(a: &str, b: &str) -> bool { - normalize_purl(a) == normalize_purl(b) + let (a, b) = (normalize_purl(a), normalize_purl(b)); + if a == b { + return true; + } + let split = |p: &str| p.find(['?', '#']).unwrap_or(p.len()); + let (base_a, suffix_a) = a.split_at(split(&a)); + let (base_b, suffix_b) = b.split_at(split(&b)); + suffix_a == suffix_b + && crate::utils::composer_version::composer_bases_equivalent(base_a, base_b) } /// Extract the value of a single PURL qualifier (`?key=value&…`), if present. @@ -1256,6 +1267,31 @@ mod tests { "pkg:npm/%40scope/x@1.0.0", "pkg:npm/@scope/x@2.0.0" )); + // Composer compares release identity: padding, a `v` tag, name case. + for spelling in [ + "pkg:composer/psr/log@3.0.2.0", + "pkg:composer/psr/log@v3.0.2", + "pkg:composer/Psr/Log@3.0.2", + ] { + assert!( + purl_eq("pkg:composer/psr/log@3.0.2", spelling), + "{spelling}" + ); + assert!( + purl_eq(spelling, "pkg:composer/psr/log@3.0.2"), + "{spelling}" + ); + } + assert!(!purl_eq( + "pkg:composer/psr/log@3.0.2", + "pkg:composer/psr/log@3.0.20" + )); + assert!(!purl_eq( + "pkg:composer/psr/log@3.0.2?a=1", + "pkg:composer/psr/log@3.0.2.0?a=2" + )); + // Only composer: other ecosystems stay spelling-exact. + assert!(!purl_eq("pkg:npm/x@1.0", "pkg:npm/x@1.0.0.0")); // Qualifiers/subpath are preserved verbatim (not decoded). assert_eq!( normalize_purl("pkg:npm/%40s/x@1?artifact_id=a%2Fb"), diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index a20875eb..985972bd 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -19,9 +19,10 @@ //! Lock names are matched CASE-INSENSITIVELY (locks are normally lowercase, //! but hand-written mixed-case locks exist and install fine) while the dist //! URL we write always uses the lowercase canonical `/` — the -//! casing of the directory this backend creates. Versions are matched through -//! the leading-`v` normalization (locks carry the pretty `v6.4.1`, PURLs the -//! bare `6.4.1`) but the lock's own `version` string is never rewritten. +//! casing of the directory this backend creates. Versions are matched by +//! composer release identity (locks carry the pretty `v6.4.1` / `3.0.2`, a +//! patch purl the bare `6.4.1` or padded `3.0.2.0`) but the lock's own +//! `version` string is never rewritten. //! //! Serialization mirrors composer's own writer: 4-space indent //! (`JSON_PRETTY_PRINT`) + trailing newline; serde_json does not escape `/` @@ -33,11 +34,11 @@ use std::path::Path; use serde_json::{json, Map, Value}; use crate::constants::SOCKET_DIR; -use crate::crawlers::composer_crawler::normalize_version; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{ApplyResult, PatchSources}; use crate::patch::copy_tree::{fresh_copy, remove_tree}; use crate::patch::path_safety::{is_safe_multi_segment, is_safe_single_segment}; +use crate::utils::composer_version::composer_versions_equivalent; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::purl::{build_composer_purl, parse_composer_purl}; use crate::utils::socket_dir::remove_tree_and_prune; @@ -738,15 +739,15 @@ async fn composer_service_copy( } /// Locate the package's entry: `packages[]` first, then `packages-dev[]`. -/// Names are compared case-insensitively, versions through the `v`-prefix -/// normalization (see module doc). +/// Names are compared case-insensitively, versions by composer release +/// identity (see module doc). fn find_lock_entry(lock: &Value, pkg_lc: &str, version: &str) -> Option<(&'static str, usize)> { composer_lock_packages(lock) .into_iter() .find(|p| { p.name.is_some_and(|n| n.eq_ignore_ascii_case(pkg_lc)) && p.version - .is_some_and(|v| normalize_version(v) == normalize_version(version)) + .is_some_and(|v| composer_versions_equivalent(v, version)) }) .map(|p| (p.section, p.index)) } @@ -2746,6 +2747,47 @@ mod tests { ); } + /// The patch purl's version may be composer's padded spelling of the + /// release the lock records prettily (`3.0.2.0` for `3.0.2` / `v3.0.2`, + /// `1.0.0.0` for `1.0`, `8.1.0.0-RC1` for `v8.1.0-rc.1`); other releases + /// never match. + #[test] + fn test_find_lock_entry_matches_equivalent_composer_versions() { + let lock = json!({ + "packages": [ + { "name": "psr/log", "version": "3.0.20" }, + { "name": "psr/log", "version": "3.0.2" }, + { "name": "psr/cache", "version": "v3.0.2" }, + { "name": "psr/container", "version": "1.0" } + ], + "packages-dev": [ + { "name": "symfony/http-kernel", "version": "v8.1.0-rc.1" } + ] + }); + for (pkg, version, want) in [ + ("psr/log", "3.0.2.0", Some(("packages", 1))), + ("psr/log", "v3.0.2", Some(("packages", 1))), + ("psr/log", "3.0.2", Some(("packages", 1))), + ("psr/log", "3.0.20.0", Some(("packages", 0))), + ("psr/cache", "3.0.2.0", Some(("packages", 2))), + ("psr/container", "1.0.0.0", Some(("packages", 3))), + ( + "symfony/http-kernel", + "8.1.0.0-RC1", + Some(("packages-dev", 0)), + ), + ("psr/log", "3.0.2.1", None), + ("psr/container", "1.0.1", None), + ("symfony/http-kernel", "8.1.0", None), + ] { + assert_eq!( + find_lock_entry(&lock, pkg, version), + want, + "{pkg}@{version}" + ); + } + } + /// A source-only entry (no `dist`) gets `dist` + `transport-options` /// appended at the end; a pre-existing `transport-options` (wherever it /// sits) is superseded by ours, never duplicated. diff --git a/crates/socket-patch-core/src/vex/discover/composer.rs b/crates/socket-patch-core/src/vex/discover/composer.rs index 8562e74b..9a4b37a0 100644 --- a/crates/socket-patch-core/src/vex/discover/composer.rs +++ b/crates/socket-patch-core/src/vex/discover/composer.rs @@ -61,6 +61,7 @@ use super::{ DiscoverCtx, Discovery, LocateOpts, PatchedRef, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, }; use crate::crawlers::composer_crawler::normalize_version; +use crate::utils::composer_version::composer_purls_equivalent; use crate::vendor::lock_inventory::{composer_lock_packages, ComposerLockPackage}; /// The lock both backends rewrite (root-relative). @@ -155,8 +156,11 @@ fn entry_ref( }; if let Some(vref) = vendored { - let leaf_matches = vendored_leaf_purl("composer", &vref.leaf).as_deref() - == Some(canonical_base_purl(&purl).as_str()); + // The leaf carries the patch purl's spelling (`@3.0.2.0`), the lock + // its own (`3.0.2`): the same release either way. + let leaf_matches = vendored_leaf_purl("composer", &vref.leaf).is_some_and(|leaf| { + leaf == canonical_base_purl(&purl) || composer_purls_equivalent(&leaf, &purl) + }); if vref.eco != "composer" || !leaf_matches { out.diag( DIAG_REF_INVALID, @@ -455,6 +459,53 @@ mod tests { ); } + /// The backend keys the leaf by the PATCH purl's version, which may be + /// composer's padded spelling (`@3.0.2.0`, `@1.0.0.0-RC1`) of the release + /// the lock records (`3.0.2`, `v1.0-rc1`): the same release, accepted and + /// keyed by the lock's own spelling. A leaf for another release is not. + #[tokio::test] + async fn vendored_leaf_in_an_equivalent_version_spelling_is_accepted() { + let wired = |name: &str, version: &str, uuid: &str, leaf: &str| { + Value::Object(crate::vendor::composer_lock::rewrite_lock_entry( + registry_entry(name, version).as_object().unwrap(), + &format!(".socket/vendor/composer/{uuid}/{leaf}"), + uuid, + )) + }; + let padded = wired("psr/log", "3.0.2", UUID_B, "psr/log@3.0.2.0"); + let rc = wired("Acme/Lib", "v1.0-rc1", UUID_A, "acme/lib@1.0.0.0-RC1"); + let other = wired("psr/cache", "3.0.2", UUID_B, "psr/cache@3.0.2.1"); + let p = Project::new(); + p.write("composer.lock", lock(json!([padded, rc, other]), json!([]))); + let out = run(&p).await; + assert_refs( + &out, + &[ + ("pkg:composer/psr/log@3.0.2", UUID_B, WiringMode::Vendored), + ( + "pkg:composer/acme/lib@1.0-rc1", + UUID_A, + WiringMode::Vendored, + ), + ], + ); + let psr = out + .refs + .iter() + .find(|r| r.purl == "pkg:composer/psr/log@3.0.2") + .unwrap(); + assert_eq!( + psr.artifact_rel.as_deref(), + Some(format!(".socket/vendor/composer/{UUID_B}/psr/log@3.0.2.0").as_str()) + ); + assert_eq!( + diag_codes(&out), + vec![DIAG_REF_INVALID], + "{:#?}", + out.diagnostics + ); + } + /// Vendored entries that are not what the backend writes for THIS /// package are diagnosed, never trusted. #[tokio::test] diff --git a/crates/socket-patch-core/tests/fixtures/composer-version-vectors.json b/crates/socket-patch-core/tests/fixtures/composer-version-vectors.json new file mode 100644 index 00000000..d9e8bd57 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-version-vectors.json @@ -0,0 +1,958 @@ +{ + "composer": "2.10.3", + "composerSemver": "3.4.4", + "vectors": [ + { + "input": "3.0.2", + "composerNormalized": "3.0.2.0", + "identity": "3.0.2.0" + }, + { + "input": "v3.0.2", + "composerNormalized": "3.0.2.0", + "identity": "3.0.2.0" + }, + { + "input": "V3.0.2", + "composerNormalized": "3.0.2.0", + "identity": "3.0.2.0" + }, + { + "input": "3.0.2.0", + "composerNormalized": "3.0.2.0", + "identity": "3.0.2.0" + }, + { + "input": "v3.0.2.0", + "composerNormalized": "3.0.2.0", + "identity": "3.0.2.0" + }, + { + "input": "3.0.20", + "composerNormalized": "3.0.20.0", + "identity": "3.0.20.0" + }, + { + "input": "1", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.0", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "v1.0.0", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.0.0.0", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.0.0.1", + "composerNormalized": "1.0.0.1", + "identity": "1.0.0.1" + }, + { + "input": "1.2.3.4", + "composerNormalized": "1.2.3.4", + "identity": "1.2.3.4" + }, + { + "input": "1.2.3.4.5", + "composerNormalized": null, + "identity": "1.2.3.4.5" + }, + { + "input": "v1.2.3.4.5", + "composerNormalized": null, + "identity": "1.2.3.4.5" + }, + { + "input": "01.02.03", + "composerNormalized": "01.02.03.0", + "identity": "01.02.03.0" + }, + { + "input": "0.0.0", + "composerNormalized": "0.0.0.0", + "identity": "0.0.0.0" + }, + { + "input": "10.20.30.40", + "composerNormalized": "10.20.30.40", + "identity": "10.20.30.40" + }, + { + "input": "12345.0", + "composerNormalized": "12345.0.0.0", + "identity": "12345.0.0.0" + }, + { + "input": "123456", + "composerNormalized": "123456", + "identity": "123456" + }, + { + "input": "123456.1", + "composerNormalized": "123456.1", + "identity": "123456.1" + }, + { + "input": "99999.1", + "composerNormalized": "99999.1.0.0", + "identity": "99999.1.0.0" + }, + { + "input": "1.0.0-beta1", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "1.0.0.0-beta1", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "v1.0-BETA1", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "1.0.0-beta2", + "composerNormalized": "1.0.0.0-beta2", + "identity": "1.0.0.0-beta2" + }, + { + "input": "1.0.0-b1", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "1.0.0b1", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "1.0.0beta1", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "1.0.0-beta.1", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "1.0.0-beta-1", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "1.0.0-beta", + "composerNormalized": "1.0.0.0-beta", + "identity": "1.0.0.0-beta" + }, + { + "input": "1.0.0-RC1", + "composerNormalized": "1.0.0.0-RC1", + "identity": "1.0.0.0-RC1" + }, + { + "input": "1.0.0-rc1", + "composerNormalized": "1.0.0.0-RC1", + "identity": "1.0.0.0-RC1" + }, + { + "input": "1.0.0.0-RC1", + "composerNormalized": "1.0.0.0-RC1", + "identity": "1.0.0.0-RC1" + }, + { + "input": "8.1.0-RC1", + "composerNormalized": "8.1.0.0-RC1", + "identity": "8.1.0.0-RC1" + }, + { + "input": "v8.1.0-rc.1", + "composerNormalized": "8.1.0.0-RC1", + "identity": "8.1.0.0-RC1" + }, + { + "input": "1.0.0-RC.1", + "composerNormalized": "1.0.0.0-RC1", + "identity": "1.0.0.0-RC1" + }, + { + "input": "1.0.0rc1", + "composerNormalized": "1.0.0.0-RC1", + "identity": "1.0.0.0-RC1" + }, + { + "input": "1.0.0-rc-1", + "composerNormalized": "1.0.0.0-RC1", + "identity": "1.0.0.0-RC1" + }, + { + "input": "1.0.0-RC1.2", + "composerNormalized": "1.0.0.0-RC1.2", + "identity": "1.0.0.0-RC1.2" + }, + { + "input": "1.0.0-alpha1", + "composerNormalized": "1.0.0.0-alpha1", + "identity": "1.0.0.0-alpha1" + }, + { + "input": "1.0.0-a1", + "composerNormalized": "1.0.0.0-alpha1", + "identity": "1.0.0.0-alpha1" + }, + { + "input": "1.0.0a", + "composerNormalized": "1.0.0.0-alpha", + "identity": "1.0.0.0-alpha" + }, + { + "input": "1.0.0-alpha.2.3", + "composerNormalized": "1.0.0.0-alpha2.3", + "identity": "1.0.0.0-alpha2.3" + }, + { + "input": "1.0.0-patch1", + "composerNormalized": "1.0.0.0-patch1", + "identity": "1.0.0.0-patch1" + }, + { + "input": "1.0.0-p1", + "composerNormalized": "1.0.0.0-patch1", + "identity": "1.0.0.0-patch1" + }, + { + "input": "1.0.0-pl1", + "composerNormalized": "1.0.0.0-patch1", + "identity": "1.0.0.0-patch1" + }, + { + "input": "1.0.0pl2", + "composerNormalized": "1.0.0.0-patch2", + "identity": "1.0.0.0-patch2" + }, + { + "input": "1.0.0-stable", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.0.0-STABLE", + "composerNormalized": "1.0.0.0-stable", + "identity": "1.0.0.0-stable" + }, + { + "input": "1.0.0stable", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.0.0_beta1", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "1.0.0.beta1", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "1.0.0-beta1-dev", + "composerNormalized": "1.0.0.0-beta1-dev", + "identity": "1.0.0.0-beta1-dev" + }, + { + "input": "1.0.0-dev", + "composerNormalized": "1.0.0.0-dev", + "identity": "1.0.0.0-dev" + }, + { + "input": "1.0-dev", + "composerNormalized": "1.0.0.0-dev", + "identity": "1.0.0.0-dev" + }, + { + "input": "2-dev", + "composerNormalized": "2.0.0.0-dev", + "identity": "2.0.0.0-dev" + }, + { + "input": "1.0.0.0-dev", + "composerNormalized": "1.0.0.0-dev", + "identity": "1.0.0.0-dev" + }, + { + "input": "v2.0.0-RC2", + "composerNormalized": "2.0.0.0-RC2", + "identity": "2.0.0.0-RC2" + }, + { + "input": "1.0.0+build", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.0.0+20130313144700", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "v1.0.0-beta.1+exp.sha.5114f85", + "composerNormalized": "1.0.0.0-beta1", + "identity": "1.0.0.0-beta1" + }, + { + "input": "1.0.0+build+x", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.0.0@beta", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.0.0@stable", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.0.0@dev", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "dev-main@dev", + "composerNormalized": "dev-main", + "identity": "dev-main" + }, + { + "input": "1.0.0 as 2.0.0", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "dev-main as 1.0.x-dev", + "composerNormalized": "dev-main", + "identity": "dev-main" + }, + { + "input": "dev-main", + "composerNormalized": "dev-main", + "identity": "dev-main" + }, + { + "input": "dev-master", + "composerNormalized": "dev-master", + "identity": "dev-master" + }, + { + "input": "DEV-Main", + "composerNormalized": "dev-Main", + "identity": "dev-Main" + }, + { + "input": "dev-feature/Foo_Bar", + "composerNormalized": "dev-feature/Foo_Bar", + "identity": "dev-feature/Foo_Bar" + }, + { + "input": "master", + "composerNormalized": "dev-master", + "identity": "dev-master" + }, + { + "input": "trunk", + "composerNormalized": "dev-trunk", + "identity": "dev-trunk" + }, + { + "input": "default", + "composerNormalized": "dev-default", + "identity": "dev-default" + }, + { + "input": "Master", + "composerNormalized": null, + "identity": "Master" + }, + { + "input": "main", + "composerNormalized": null, + "identity": "main" + }, + { + "input": "2.0.x-dev", + "composerNormalized": "2.0.9999999.9999999-dev", + "identity": "2.0.9999999.9999999-dev" + }, + { + "input": "v2.0.x-dev", + "composerNormalized": "2.0.9999999.9999999-dev", + "identity": "2.0.9999999.9999999-dev" + }, + { + "input": "2.0.X-dev", + "composerNormalized": "2.0.9999999.9999999-dev", + "identity": "2.0.9999999.9999999-dev" + }, + { + "input": "2.0.*-dev", + "composerNormalized": "2.0.9999999.9999999-dev", + "identity": "2.0.9999999.9999999-dev" + }, + { + "input": "2.x-dev", + "composerNormalized": "2.9999999.9999999.9999999-dev", + "identity": "2.9999999.9999999.9999999-dev" + }, + { + "input": "2.0.9999999.9999999-dev", + "composerNormalized": "2.0.9999999.9999999-dev", + "identity": "2.0.9999999.9999999-dev" + }, + { + "input": "1.0.x.x-dev", + "composerNormalized": "1.0.9999999.9999999-dev", + "identity": "1.0.9999999.9999999-dev" + }, + { + "input": "2.1.3.x-dev", + "composerNormalized": "2.1.3.9999999-dev", + "identity": "2.1.3.9999999-dev" + }, + { + "input": "foo-dev", + "composerNormalized": null, + "identity": "foo-dev" + }, + { + "input": "feature.x-dev", + "composerNormalized": null, + "identity": "feature.x-dev" + }, + { + "input": "20100102", + "composerNormalized": "20100102", + "identity": "20100102" + }, + { + "input": "2010-01-02", + "composerNormalized": "2010.01.02", + "identity": "2010.01.02" + }, + { + "input": "2010.01.02.5", + "composerNormalized": "2010.01.02.5", + "identity": "2010.01.02.5" + }, + { + "input": "20100102-beta1", + "composerNormalized": "20100102-beta1", + "identity": "20100102-beta1" + }, + { + "input": "201001021530", + "composerNormalized": "201001021530", + "identity": "201001021530" + }, + { + "input": " 1.0.0 ", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "\t3.0.2\n", + "composerNormalized": "3.0.2.0", + "identity": "3.0.2.0" + }, + { + "input": "", + "composerNormalized": null, + "identity": "" + }, + { + "input": "v", + "composerNormalized": null, + "identity": "v" + }, + { + "input": "vfoo", + "composerNormalized": null, + "identity": "vfoo" + }, + { + "input": "v-1.0", + "composerNormalized": null, + "identity": "v-1.0" + }, + { + "input": "latest", + "composerNormalized": null, + "identity": "latest" + }, + { + "input": "*", + "composerNormalized": null, + "identity": "*" + }, + { + "input": "1.0.*", + "composerNormalized": null, + "identity": "1.0.*" + }, + { + "input": "^1.0", + "composerNormalized": null, + "identity": "^1.0" + }, + { + "input": "~1.0", + "composerNormalized": null, + "identity": "~1.0" + }, + { + "input": ">=1.0", + "composerNormalized": null, + "identity": ">=1.0" + }, + { + "input": "1.0.0-foo", + "composerNormalized": null, + "identity": "1.0.0-foo" + }, + { + "input": "1.0.0-beta1a", + "composerNormalized": null, + "identity": "1.0.0-beta1a" + }, + { + "input": "not-a-version", + "composerNormalized": null, + "identity": "not-a-version" + }, + { + "input": "1.0.0.0.0", + "composerNormalized": null, + "identity": "1.0.0.0.0" + }, + { + "input": "1.0.0-beta1-foo", + "composerNormalized": null, + "identity": "1.0.0-beta1-foo" + }, + { + "input": "1.0 .0", + "composerNormalized": null, + "identity": "1.0 .0" + }, + { + "input": "1.0.0-a2", + "composerNormalized": "1.0.0.0-alpha2", + "identity": "1.0.0.0-alpha2" + }, + { + "input": "1.0.0pl3", + "composerNormalized": "1.0.0.0-patch3", + "identity": "1.0.0.0-patch3" + }, + { + "input": "1.0.0+build.5", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "dev-Feature/X", + "composerNormalized": "dev-Feature/X", + "identity": "dev-Feature/X" + }, + { + "input": "\u00001.0.0\u0000", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "\f1.0.0", + "composerNormalized": null, + "identity": "\f1.0.0" + }, + { + "input": "1.0.0\u000b", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.0.0", + "composerNormalized": "1.0.0.0", + "identity": "1.0.0.0" + }, + { + "input": "1.2.3", + "composerNormalized": "1.2.3.0", + "identity": "1.2.3.0" + }, + { + "input": "dev-Main", + "composerNormalized": "dev-Main", + "identity": "dev-Main" + }, + { + "input": "2.1.x-dev", + "composerNormalized": "2.1.9999999.9999999-dev", + "identity": "2.1.9999999.9999999-dev" + }, + { + "input": "2.0.0.0-dev", + "composerNormalized": "2.0.0.0-dev", + "identity": "2.0.0.0-dev" + }, + { + "input": "2010:01:02", + "composerNormalized": "2010.01.02", + "identity": "2010.01.02" + }, + { + "input": "2010.01.02", + "composerNormalized": "2010.01.02.0", + "identity": "2010.01.02.0" + }, + { + "input": "v1.0.0-foo", + "composerNormalized": null, + "identity": "1.0.0-foo" + }, + { + "input": "1.0.0.0-foo", + "composerNormalized": null, + "identity": "1.0.0.0-foo" + }, + { + "input": "foo", + "composerNormalized": null, + "identity": "foo" + }, + { + "input": "0", + "composerNormalized": "0.0.0.0", + "identity": "0.0.0.0" + } + ], + "equivalence": [ + { + "left": "3.0.2", + "right": "v3.0.2", + "equivalent": true + }, + { + "left": "3.0.2", + "right": "V3.0.2", + "equivalent": true + }, + { + "left": "3.0.2", + "right": "3.0.2.0", + "equivalent": true + }, + { + "left": "v3.0.2", + "right": "v3.0.2.0", + "equivalent": true + }, + { + "left": "3.0.2", + "right": "3.0.20", + "equivalent": false + }, + { + "left": "1", + "right": "1.0", + "equivalent": true + }, + { + "left": "1", + "right": "v1.0.0", + "equivalent": true + }, + { + "left": "1.0", + "right": "1.0.0.0", + "equivalent": true + }, + { + "left": "1.0.0", + "right": "1.0.0.1", + "equivalent": false + }, + { + "left": "1.2.3.4.5", + "right": "1.2.3.4.5", + "equivalent": true + }, + { + "left": "1.2.3.4.5", + "right": "v1.2.3.4.5", + "equivalent": true + }, + { + "left": "1.2.3.4.5", + "right": "1.2.3.4", + "equivalent": false + }, + { + "left": "1.0.0.0.0", + "right": "1.0.0.0", + "equivalent": false + }, + { + "left": "01.02.03", + "right": "1.2.3", + "equivalent": false + }, + { + "left": "1.0.0-beta1", + "right": "1.0.0.0-beta1", + "equivalent": true + }, + { + "left": "1.0.0-beta1", + "right": "v1.0-BETA1", + "equivalent": true + }, + { + "left": "1.0.0-beta1", + "right": "1.0.0-b1", + "equivalent": true + }, + { + "left": "1.0.0-beta1", + "right": "1.0.0beta1", + "equivalent": true + }, + { + "left": "1.0.0-beta1", + "right": "1.0.0-beta.1", + "equivalent": true + }, + { + "left": "1.0.0-beta1", + "right": "1.0.0-beta2", + "equivalent": false + }, + { + "left": "1.0.0-beta1", + "right": "1.0.0", + "equivalent": false + }, + { + "left": "1.0.0-RC1", + "right": "1.0.0-rc1", + "equivalent": true + }, + { + "left": "1.0.0-RC1", + "right": "1.0.0.0-RC1", + "equivalent": true + }, + { + "left": "8.1.0-RC1", + "right": "v8.1.0-rc.1", + "equivalent": true + }, + { + "left": "1.0.0-RC1", + "right": "1.0.0", + "equivalent": false + }, + { + "left": "1.0.0-RC1", + "right": "1.0.0-RC1.2", + "equivalent": false + }, + { + "left": "1.0.0-alpha1", + "right": "1.0.0-a1", + "equivalent": true + }, + { + "left": "1.0.0-patch1", + "right": "1.0.0-p1", + "equivalent": true + }, + { + "left": "1.0.0-p1", + "right": "1.0.0-pl1", + "equivalent": true + }, + { + "left": "1.0.0-stable", + "right": "1.0.0", + "equivalent": true + }, + { + "left": "1.0.0-STABLE", + "right": "1.0.0", + "equivalent": false + }, + { + "left": "1.0.0+build", + "right": "1.0.0", + "equivalent": true + }, + { + "left": "v1.0.0-beta.1+exp.sha.5114f85", + "right": "1.0.0-beta1", + "equivalent": true + }, + { + "left": "1.0.0@dev", + "right": "1.0.0", + "equivalent": true + }, + { + "left": "1.0.0 as 2.0.0", + "right": "1.0.0", + "equivalent": true + }, + { + "left": "1.0.0-dev", + "right": "1.0.0", + "equivalent": false + }, + { + "left": "1.0-dev", + "right": "1.0.0.0-dev", + "equivalent": true + }, + { + "left": "dev-main", + "right": "dev-main", + "equivalent": true + }, + { + "left": "dev-main", + "right": "dev-master", + "equivalent": false + }, + { + "left": "DEV-Main", + "right": "dev-Main", + "equivalent": true + }, + { + "left": "dev-Main", + "right": "dev-main", + "equivalent": false + }, + { + "left": "master", + "right": "dev-master", + "equivalent": true + }, + { + "left": "Master", + "right": "dev-master", + "equivalent": false + }, + { + "left": "main", + "right": "dev-main", + "equivalent": false + }, + { + "left": "2.0.x-dev", + "right": "v2.0.x-dev", + "equivalent": true + }, + { + "left": "2.0.x-dev", + "right": "2.0.9999999.9999999-dev", + "equivalent": true + }, + { + "left": "2.0.x-dev", + "right": "2.0.*-dev", + "equivalent": true + }, + { + "left": "2.0.x-dev", + "right": "2.1.x-dev", + "equivalent": false + }, + { + "left": "2.0.x-dev", + "right": "2.0.0.0-dev", + "equivalent": false + }, + { + "left": "2010-01-02", + "right": "2010:01:02", + "equivalent": true + }, + { + "left": "2010-01-02", + "right": "2010.01.02", + "equivalent": false + }, + { + "left": "20100102", + "right": "2010.01.02", + "equivalent": false + }, + { + "left": " 1.0.0 ", + "right": "1.0.0", + "equivalent": true + }, + { + "left": "1.0.0-foo", + "right": "1.0.0-foo", + "equivalent": true + }, + { + "left": "1.0.0-foo", + "right": "v1.0.0-foo", + "equivalent": true + }, + { + "left": "1.0.0-foo", + "right": "1.0.0.0-foo", + "equivalent": false + }, + { + "left": "1.0.0-foo", + "right": "1.0.0", + "equivalent": false + }, + { + "left": "vfoo", + "right": "foo", + "equivalent": false + }, + { + "left": "latest", + "right": "latest", + "equivalent": true + }, + { + "left": "", + "right": "", + "equivalent": true + }, + { + "left": "", + "right": "0", + "equivalent": false + } + ] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/expected-edits.json new file mode 100644 index 00000000..e7bb91e1 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://api.github.com/repos/Seldaek/monolog/zipball/abc123\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"\"\n }", + "new": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/expected/composer.lock new file mode 100644 index 00000000..dad7e6ee --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/expected/composer.lock @@ -0,0 +1,29 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/input/composer.lock new file mode 100644 index 00000000..333e00f4 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/input/composer.lock @@ -0,0 +1,29 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/overrides.json new file mode 100644 index 00000000..7e7b774e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/padded-version/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json index bdace21a..393e8ca9 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json @@ -179,6 +179,51 @@ "elsewhere": [], "live_claims": [] }, + "redirect/composer/composer-lock/padded-version/expected": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, + "redirect/composer/composer-lock/padded-version/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/composer/composer-lock/source-and-dist/expected": { "refs": [ { From 7c4a227bf827887d0131e5a9e4403db2f91980f7 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 12:22:05 -0400 Subject: [PATCH 03/13] Keep Composer patches installed on every version Hosted redirects now remove a patched package's source wherever it sits in its composer.lock entry, and strip dist mirrors. Before, a failed or skipped hosted download let Composer 1 through 2.9 quietly install the unpatched upstream code from git. Locks that an older CLI or the GitHub app already redirected are healed on the next run. Vendored copies no longer lose files when installed. Composer's path mirror skipped anything matched by the copy's .gitignore (Composer 1 to 2.1), .hgignore (Composer 1) or .gitattributes export-ignore rules (every version), so a patched file could silently go missing. Those rules are now neutralized in the copy, and re-runs heal copies vendored earlier. A patch that edits one of those files is refused. vendor, scan and get now tell users to run composer install, and to remove the package directory first on Composer 1, which does not reinstall a changed package. Real-Composer tests cover the new cases on 1.10 through 2.10, and docs/testing/composer-compatibility.md records what each version does. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/composer_hints.rs | 131 ++++ crates/socket-patch-cli/src/commands/mod.rs | 1 + .../src/commands/scan/hosted.rs | 6 +- .../socket-patch-cli/src/commands/vendor.rs | 6 + .../tests/docker_e2e_vendor_composer.rs | 4 +- .../tests/e2e_redirect_composer_build.rs | 341 ++++++++++- .../tests/e2e_vendor_composer_build.rs | 384 ++++++++++++ .../src/patch/redirect/composer_source.rs | 559 ++++++++++++++++++ .../src/patch/redirect/mod.rs | 160 +++-- .../src/vendor/composer_lock.rs | 173 +++++- .../vendor/composer_lock/mirror_filters.rs | 350 +++++++++++ .../src/vex/discover/composer.rs | 76 +++ .../src/vex/discover/testing/golden.rs | 14 + .../psr/log@3.0.2/composer.json | 4 + .../psr/log@3.0.2/src/Patched.php | 2 + .../socket-patch.vendor.json | 8 + .../composer-vendored/basic/composer.json | 6 + .../composer-vendored/basic/composer.lock | 40 ++ .../acme/tooling@dev-main/composer.json | 4 + .../acme/tooling@dev-main/src/Patched.php | 2 + .../socket-patch.vendor.json | 8 + .../composer-vendored/dev/composer.json | 7 + .../composer-vendored/dev/composer.lock | 40 ++ .../psr/log@3.0.2/composer.json | 4 + .../psr/log@3.0.2/src/Patched.php | 2 + .../socket-patch.vendor.json | 8 + .../outside-anchor/composer.json | 6 + .../outside-anchor/composer.lock | 40 ++ .../psr/log@3.0.2/composer.json | 4 + .../psr/log@3.0.2/src/Patched.php | 2 + .../socket-patch.vendor.json | 8 + .../reference-mismatch/composer.json | 6 + .../reference-mismatch/composer.lock | 40 ++ .../psr/log@3.0.2/composer.json | 4 + .../psr/log@3.0.2/src/Patched.php | 2 + .../socket-patch.vendor.json | 8 + .../source-leftover/composer.json | 6 + .../source-leftover/composer.lock | 45 ++ .../socket-patch.vendor.json | 8 + .../deprecation-contracts@3.5.1/composer.json | 4 + .../src/Patched.php | 2 + .../composer-vendored/v-tagged/composer.json | 6 + .../composer-vendored/v-tagged/composer.lock | 40 ++ .../expected-edits.json | 10 + .../expected-warnings.json | 1 + .../expected/composer.lock | 34 ++ .../input/composer.lock | 39 ++ .../overrides.json | 14 + .../dist-before-name/expected-edits.json | 1 + .../dist-before-name/expected-warnings.json | 3 + .../dist-before-name/input/composer.lock | 39 ++ .../dist-before-name/overrides.json | 14 + .../dist-mirrors/expected-edits.json | 10 + .../dist-mirrors/expected-warnings.json | 3 + .../dist-mirrors/expected/composer.lock | 34 ++ .../dist-mirrors/input/composer.lock | 45 ++ .../composer-lock/dist-mirrors/overrides.json | 14 + .../no-lockfile/expected-edits.json | 1 + .../no-lockfile/expected-warnings.json | 3 + .../no-lockfile/input/composer.json | 5 + .../composer-lock/no-lockfile/overrides.json | 14 + .../source-after-dist/expected-edits.json | 10 + .../source-after-dist/expected-warnings.json | 1 + .../source-after-dist/expected/composer.lock | 37 ++ .../source-after-dist/input/composer.lock | 42 ++ .../source-after-dist/overrides.json | 14 + .../source-before-name/expected-edits.json | 10 + .../source-before-name/expected-warnings.json | 1 + .../source-before-name/expected/composer.lock | 34 ++ .../source-before-name/input/composer.lock | 39 ++ .../source-before-name/overrides.json | 14 + .../expected-edits.json | 10 + .../expected-warnings.json | 1 + .../expected/composer.lock | 40 ++ .../input/composer.lock | 45 ++ .../source-in-extra-untouched/overrides.json | 14 + .../source-last-key/expected-edits.json | 10 + .../source-last-key/expected-warnings.json | 1 + .../source-last-key/expected/composer.lock | 34 ++ .../source-last-key/input/composer.lock | 39 ++ .../source-last-key/overrides.json | 14 + .../source-not-adjacent/expected-edits.json | 10 + .../expected-warnings.json | 1 + .../expected/composer.lock | 37 ++ .../source-not-adjacent/input/composer.lock | 42 ++ .../source-not-adjacent/overrides.json | 14 + .../composer-vendored.json | 174 ++++++ .../redirect-composer.json | 360 +++++++++++ docs/ecosystems.md | 2 +- docs/testing/composer-compatibility.md | 142 +++++ 91 files changed, 3960 insertions(+), 89 deletions(-) create mode 100644 crates/socket-patch-cli/src/commands/composer_hints.rs create mode 100644 crates/socket-patch-core/src/patch/redirect/composer_source.rs create mode 100644 crates/socket-patch-core/src/vendor/composer_lock/mirror_filters.rs create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/basic/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/basic/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/basic/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/socket-patch.vendor.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/basic/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/basic/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/dev/.socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/acme/tooling@dev-main/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/dev/.socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/acme/tooling@dev-main/src/Patched.php create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/dev/.socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/socket-patch.vendor.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/dev/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/dev/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/socket-patch.vendor.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/socket-patch.vendor.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/socket-patch.vendor.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/socket-patch.vendor.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/symfony/deprecation-contracts@3.5.1/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/symfony/deprecation-contracts@3.5.1/src/Patched.php create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/input/composer.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/overrides.json create mode 100644 crates/socket-patch-core/tests/fixtures/vex-discover-golden/composer-vendored.json create mode 100644 docs/testing/composer-compatibility.md diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index f6991b70..5a69d1d0 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -698,7 +698,7 @@ to **six flavors**. | npm / bun binary (`bun.lockb`, native binary format 1, 2 or 3) | (same tarball) | Rewrite matching binary package resolutions and integrity in place; preserve topology and unrelated metadata, update binary offsets and the package metadata hash. Text `bun.lock` takes precedence. `bun_lockb_package` wiring snapshots recover pristine registry metadata for repair and support per-package revert and hosted ↔ vendored migration. Binary discovery and rewrites require no installed Bun runtime. Malformed or unsupported content refuses `vendor_bun_lockb_invalid` before download or takeover. | Frozen installs with the original compatible Bun reader; see `docs/testing/bun-compatibility.md` for the release matrix and historical runtime integrity limits. | | cargo | crate dir `-/` (no `.cargo-checksum.json`) | (v5.0) `[patch.crates-io]` path entry in the **workspace-root `Cargo.toml`** (the manifest beside the `Cargo.lock` it detaches — never `.cargo/config*`) **+** Cargo.lock surgery (the `[[package]]` entry's `source`/`checksum` removed and its `version` set to the copy's TAGGED version `+socket.` — `+.socket.` when the version already has build metadata — with every lock reference that spells the old version rewritten, formats v1–v4; the copy's own `Cargo.toml` version carries the same tag, so the patched crate sees it in `CARGO_PKG_VERSION`; revert restores the lock byte for byte). Key: always the Socket-owned `-socket-` with `package = ""` (the full uuid hex when that key is taken), never the bare crate name — cargo lets a config-file `[patch]` item (project, ancestor directory or `$CARGO_HOME`) replace the manifest item with the same key whatever its version, so keys any of those configs use are avoided and a re-run moves an entry off a now-shadowed key; two versions of one crate are wired side by side. Pre-v5 wiring in `.cargo/config.toml` / `.cargo/config` is moved into `Cargo.toml` by a re-run (`vendor`, `scan`/`get --mode vendored`) or `repair` (`cargo_wiring_migrated` note; the ledger's `cargo_patch_entry` record then names `Cargo.toml`); a detached lock entry left unwired by the pre-v5 multi-version overwrite is re-wired the same way (`cargo_wiring_restored`); every revert removes both spellings | `cargo build --locked --offline` on a fresh checkout — single-version manifest `[patch]` also builds with no network on cargo older than 1.56 (the old config-file wiring's floor); two vendored versions of ONE crate need `--offline` on cargo 1.56 and a populated registry index (or network access) on older cargo such as 1.41, which loads the index to tell them apart. Note: path deps build **without** `--cap-lints allow` | | golang | module dir `@/` | `go.mod` `replace => ./.socket/vendor/golang//@` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) | -| composer | package dir `/@/` | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: null}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). `composer update ` reverts it | +| composer | package dir `/@/`; the copy's `.gitignore` / `.hgignore` are emptied and its `.gitattributes` `export-ignore` rules dropped, because Composer's path mirror skips the files they match (`vendor_composer_mirror_filters_neutralized`; a patch that rewrites one of them is refused `vendor_composer_mirror_filter_conflict`). Re-runs heal copies vendored before this | `composer.lock` only: entry's `dist` → `{type: "path", url, reference: ""}`, `source` removed, `transport-options: {symlink: false}` added. `content-hash` unaffected; `composer.json` untouched | `composer install` (from the lock alone, real copy not symlink, works under `--network none`). Composer 1 does not reinstall an already-installed package whose dist changed: remove `vendor//` first. `composer update ` reverts it. See `docs/testing/composer-compatibility.md` | | gem | gem dir `-/` + gemspec materialized from `specifications/` | **Gemfile + Gemfile.lock pair**: the `gem` line gains `path:` (or a managed block for transitive deps); the lock's spec block moves GEM→PATH and the DEPENDENCIES entry becomes ` (= )!`, in bundler's exact canonical form | `bundle install` (normal **and** `BUNDLE_FROZEN=true`), byte-stable lock. Lock-only edits are a silent unpatch — hence the mandatory pair | | pypi / uv (uv.lock) | rebuilt wheel (canonical PEP 427 filename; RECORD regenerated) | `[tool.uv.sources] = {path}` in pyproject + surgical uv.lock rewrite; transitive deps via `[tool.uv] override-dependencies` | `uv sync --locked` / `--frozen --offline` (hash-verified, byte-stable lock) | | pypi / poetry (poetry.lock: legacy `[metadata.hashes]`, lock 1.0/1.1 `[metadata.files]`, 2.x `files`) | (rebuilt wheel) | lock-only: the target `[[package]]` gets `[package.source] type="file"` (+ `reference = ""` on the 0.12/1.0 layouts, which read it unconditionally) and the single `{file, hash: sha256-of-our-wheel}` entry in whichever table the generation keeps it. pyproject + `metadata.content-hash` untouched; CRLF locks keep their line endings. A lock written by Poetry < 1.4 emits `pypi_poetry_integrity_unverified` (that installer verifies no local hashes and skips an already-installed version) | `poetry check --lock && poetry sync`, cold cache (hash fail-closed from Poetry 1.4; byte-stable lock) — see `docs/testing/poetry-compatibility.md` | diff --git a/crates/socket-patch-cli/src/commands/composer_hints.rs b/crates/socket-patch-cli/src/commands/composer_hints.rs new file mode 100644 index 00000000..e4b00260 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/composer_hints.rs @@ -0,0 +1,131 @@ +//! Composer reinstall hints for the human output of vendored and hosted runs. +//! +//! Both modes rewire `composer.lock` only: the installed `vendor/` tree keeps +//! the unpatched bytes until Composer reinstalls the package. Composer 2 +//! reinstalls a locked package whose dist changed on the next `composer +//! install`; Composer 1 does not, so its `vendor//` must be +//! removed first. + +use serde_json::Value; + +const VENDOR_PREFIX: &str = ".socket/vendor/composer/"; + +/// The `/` of every composer.lock package (`packages` and +/// `packages-dev`) whose dist is a vendored `path` copy, sorted and +/// de-duplicated. An unparseable lock yields nothing. +pub(crate) fn vendored_composer_packages(lock_text: &str) -> Vec { + let Ok(lock) = serde_json::from_str::(lock_text) else { + return Vec::new(); + }; + let mut names: Vec = ["packages", "packages-dev"] + .iter() + .filter_map(|section| lock.get(section)?.as_array()) + .flatten() + .filter(|entry| { + let dist = &entry["dist"]; + dist["type"].as_str() == Some("path") + && dist["url"].as_str().is_some_and(|url| { + let url = url.replace('\\', "/"); + let url = url.strip_prefix("file:").unwrap_or(&url); + url.strip_prefix("./") + .unwrap_or(url) + .starts_with(VENDOR_PREFIX) + }) + }) + .filter_map(|entry| Some(entry["name"].as_str()?.to_lowercase())) + .collect(); + names.sort_unstable(); + names.dedup(); + names +} + +/// The lines to print after a vendored run that left `packages` wired, or +/// nothing when none are. +pub(crate) fn vendored_reinstall_hints(packages: &[String]) -> Vec { + if packages.is_empty() { + return Vec::new(); + } + let dirs: Vec = packages.iter().map(|p| format!("vendor/{p}")).collect(); + vec![ + "Run `composer install` to update vendor/ — vendoring rewires composer.lock only, so \ + the installed vendor/ tree keeps the unpatched bytes until Composer reinstalls it." + .to_string(), + format!( + "Composer 1 does not reinstall a locked package whose dist changed: remove {} \ + first, then run `composer install`.", + dirs.join(", ") + ), + ] +} + +/// The reinstall example for a hosted run's next steps when it rewrote +/// `composer.lock`. +pub(crate) fn hosted_reinstall_hint(files: &[String]) -> Option<&'static str> { + files.iter().any(|f| f == "composer.lock").then_some( + " (e.g. `composer install`; on Composer 1 first remove the patched packages' \ + vendor// directories)", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + + fn lock() -> String { + serde_json::json!({ + "packages": [ + {"name": "Psr/Log", "version": "3.0.2", "dist": { + "type": "path", + "url": format!(".socket/vendor/composer/{UUID}/psr/log@3.0.2"), + "reference": UUID}}, + {"name": "acme/own", "version": "1.0.0", "dist": { + "type": "path", "url": "packages/own"}}, + {"name": "acme/zip", "version": "1.0.0", "dist": { + "type": "zip", + "url": format!("https://x/.socket/vendor/composer/{UUID}/a")}}, + ], + "packages-dev": [ + {"name": "phpunit/phpunit", "version": "10.0.0", "dist": { + "type": "path", + "url": format!("file:./.socket\\vendor\\composer\\{UUID}\\phpunit/phpunit@10.0.0")}}, + ], + }) + .to_string() + } + + #[test] + fn only_vendored_path_dists_are_listed() { + assert_eq!( + vendored_composer_packages(&lock()), + vec!["phpunit/phpunit".to_string(), "psr/log".to_string()] + ); + assert!(vendored_composer_packages("not json").is_empty()); + assert!(vendored_composer_packages("{}").is_empty()); + } + + #[test] + fn vendored_hints_name_every_package_dir() { + assert!(vendored_reinstall_hints(&[]).is_empty()); + let hints = vendored_reinstall_hints(&["a/b".to_string(), "c/d".to_string()]); + assert_eq!(hints.len(), 2); + assert!( + hints[0].starts_with("Run `composer install`"), + "{}", + hints[0] + ); + assert!( + hints[1].contains("remove vendor/a/b, vendor/c/d first"), + "{}", + hints[1] + ); + } + + #[test] + fn hosted_hint_needs_a_rewritten_composer_lock() { + assert!(hosted_reinstall_hint(&["package-lock.json".to_string()]).is_none()); + let hint = hosted_reinstall_hint(&["composer.lock".to_string()]).unwrap(); + assert!(hint.contains("composer install") && hint.contains("Composer 1")); + } +} diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 11ce2634..91fa1e49 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod apply; pub(crate) mod bun_preflight; +pub(crate) mod composer_hints; pub(crate) mod fetch_stage; pub mod get; pub mod list; diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 2c218c53..63b63750 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -3285,7 +3285,11 @@ fn format_next_steps( let npm = files .iter() .any(|f| f == "package-lock.json" || f == "npm-shrinkwrap.json"); - let hint = if npm { " (e.g. `npm ci`)" } else { "" }; + let hint = if npm { + " (e.g. `npm ci`)" + } else { + crate::commands::composer_hints::hosted_reinstall_hint(files).unwrap_or("") + }; vec![ format!("Commit {} to keep the redirect.", join_names(&commit, 6)), format!( diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 04cd4227..29405c02 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -2022,6 +2022,12 @@ pub(crate) async fn vendor_records( until reinstalled." ); } + if let Ok(lock) = std::fs::read_to_string(common.cwd.join("composer.lock")) { + let packages = super::composer_hints::vendored_composer_packages(&lock); + super::composer_hints::vendored_reinstall_hints(&packages) + .iter() + .for_each(|hint| println!("{hint}")); + } } } diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs index f3702f99..9e855cf6 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs @@ -14,8 +14,8 @@ //! `dist: {type: path, url: , reference: }` + //! `transport-options: {symlink: false}` + `source` removed, with //! composer.json untouched; then `socket-patch vex` attests the vendored -//! patch (composer has no product auto-detect, so `--product` is -//! explicit) — exit 0 in-container, the statement body re-asserted +//! patch (`--product` is passed explicitly, although composer.json's +//! `name` is auto-detected too) — exit 0 in-container, the statement body re-asserted //! host-side from the mounted out.vex.json. //! stage 2 (`--network none`, empty COMPOSER_HOME): ONLY the committable //! files (composer.json + composer.lock + .socket/) are copied to a diff --git a/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs index 563315fc..39c057a0 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs @@ -87,10 +87,130 @@ enum RedirectCli { ScanRedirectVex, /// `get --mode hosted --json --yes` (get has no `--vex`). GetUuidHosted, + /// `scan --redirect --yes` in human mode (the next-steps text asserted). + ScanRedirectHuman, +} + +/// The real package a capstone redirects. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FixturePkg { + /// psr/log 3.0.x (`composer_e2e_common`'s fixture). + PsrLog, + /// symfony/deprecation-contracts `v3.5.1` (PHP ≥ 8.1) or `v2.5.4`: a + /// release whose lock version carries the `v` tag. + VTagged, +} + +impl FixturePkg { + fn name(self) -> &'static str { + match self { + Self::PsrLog => DEP, + Self::VTagged => VTAG_DEP, + } + } + + fn file_key(self) -> &'static str { + match self { + Self::PsrLog => FILE_KEY, + Self::VTagged => "function.php", + } + } + + /// The GitHub-zipball-style top-level dir of the hosted archive. + fn zip_top(self) -> String { + match self { + Self::PsrLog => format!("php-fig-log-{}", &composer_e2e_common::PSR_LOG_REF[..7]), + Self::VTagged => "symfony-deprecation-contracts-74c71c9".to_string(), + } + } + + fn setup(self, suite: &str, proj: &Path, home: &Path, cache: &Path, major: u32) -> Option<()> { + match self { + Self::PsrLog => setup_psr_log_project(suite, proj, home, cache, major), + Self::VTagged => setup_vtag_project(suite, proj, home, cache, major), + } + } +} + +const VTAG_DEP: &str = "symfony/deprecation-contracts"; + +/// The v-tagged fixture: Composer picks `v3.5.1` or `v2.5.4` by its PHP; +/// Composer 1 resolves them from an inline package repository. +fn setup_vtag_project( + suite: &str, + proj: &Path, + home: &Path, + cache: &Path, + major: u32, +) -> Option<()> { + let release = |version: &str, reference: &str, php: &str| { + serde_json::json!({ + "name": VTAG_DEP, + "version": version, + "type": "library", + "dist": { + "type": "zip", + "url": format!("https://api.github.com/repos/symfony/deprecation-contracts/zipball/{reference}"), + "reference": reference, + }, + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": reference, + }, + "require": { "php": php }, + "autoload": { "files": ["function.php"] }, + }) + }; + let mut doc = serde_json::json!({ + "name": "socket/composer-vtag-capstone", + "require": { VTAG_DEP: "3.5.1 || 2.5.4" }, + }); + if major < 2 { + doc["repositories"] = serde_json::json!([ + { "packagist.org": false }, + { "type": "package", "package": release("v3.5.1", "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", ">=8.1") }, + { "type": "package", "package": release("v2.5.4", "605389f2a7e5625f273b53960dc46aeaf9c62918", ">=7.1") }, + ]); + } + std::fs::write( + proj.join("composer.json"), + format!("{}\n", serde_json::to_string_pretty(&doc).unwrap()), + ) + .unwrap(); + let update = composer(proj, &["update"], home, cache); + if !update.status.success() { + return composer_e2e_common::skip( + suite, + &format!( + "`composer update` failed:\n{}\n{}", + String::from_utf8_lossy(&update.stdout), + String::from_utf8_lossy(&update.stderr) + ), + ); + } + Some(()) +} + +/// `(major, minor)` of the composer toolchain under test. +fn composer_release() -> (u32, u32) { + let mut probe = composer_e2e_common::composer_command(); + probe.arg("--version").arg("--no-ansi"); + cache_env::isolate(&mut probe); + let out = probe.output().expect("run composer --version"); + let text = String::from_utf8_lossy(&out.stdout).into_owned(); + let version = text + .split_whitespace() + .skip_while(|w| *w != "version") + .nth(1) + .unwrap_or_else(|| panic!("unparseable `composer --version`: {text}")); + let mut parts = version.split('.').map(|p| p.parse::().unwrap_or(0)); + (parts.next().unwrap_or(0), parts.next().unwrap_or(0)) } struct Fixture { tmp: tempfile::TempDir, + pkg: FixturePkg, proj: PathBuf, purl: String, orig: Vec, @@ -105,7 +225,7 @@ struct Fixture { /// The dist zip composer downloads: every file of the installed package /// under one GitHub-zipball-style top-level dir (composer strips a lone /// top-level dir on both majors), with the patched entry point. -fn build_patched_zip(pkg_dir: &Path, patched: &[u8]) -> Vec { +fn build_patched_zip(pkg_dir: &Path, top: &str, file_key: &str, patched: &[u8]) -> Vec { fn walk(dir: &Path, rel: &str, out: &mut Vec<(String, PathBuf)>) { let mut entries: Vec<_> = std::fs::read_dir(dir) .unwrap() @@ -133,11 +253,10 @@ fn build_patched_zip(pkg_dir: &Path, patched: &[u8]) -> Vec { let mut zip = zip::ZipWriter::new(&mut buf); let opts = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); - let top = format!("php-fig-log-{}", &composer_e2e_common::PSR_LOG_REF[..7]); zip.add_directory(format!("{top}/"), opts).unwrap(); for (rel, abs) in files { zip.start_file(format!("{top}/{rel}"), opts).unwrap(); - let bytes = if rel == FILE_KEY { + let bytes = if rel == file_key { patched.to_vec() } else { std::fs::read(abs).unwrap() @@ -149,28 +268,28 @@ fn build_patched_zip(pkg_dir: &Path, patched: &[u8]) -> Vec { buf.into_inner() } -fn locked_version(lock: &[u8]) -> String { +fn locked_version(lock: &[u8], name: &str) -> String { let lock: serde_json::Value = serde_json::from_slice(lock).expect("composer.lock parses"); lock["packages"] .as_array() .expect("packages[]") .iter() - .find(|p| p["name"] == DEP) + .find(|p| p["name"] == name) .and_then(|p| p["version"].as_str()) - .unwrap_or_else(|| panic!("{DEP} missing from composer.lock")) + .unwrap_or_else(|| panic!("{name} missing from composer.lock")) .trim_start_matches('v') .to_string() } -fn lock_entry(proj: &Path) -> serde_json::Value { +fn lock_entry(proj: &Path, name: &str) -> serde_json::Value { let lock: serde_json::Value = serde_json::from_slice(&std::fs::read(proj.join("composer.lock")).unwrap()).unwrap(); lock["packages"] .as_array() .unwrap() .iter() - .find(|p| p["name"] == DEP) - .unwrap_or_else(|| panic!("{DEP} missing from composer.lock")) + .find(|p| p["name"] == name) + .unwrap_or_else(|| panic!("{name} missing from composer.lock")) .clone() } @@ -212,6 +331,7 @@ async fn redirected_project( tag: &str, cli: RedirectCli, tamper_served_archive: bool, + pkg: FixturePkg, ) -> Option { let suite = format!("{SUITE}({tag})"); let major = composer_major(&suite)?; @@ -220,12 +340,14 @@ async fn redirected_project( std::fs::create_dir_all(&proj).unwrap(); let home = tmp.path().join("composer-home"); let cache = tmp.path().join("composer-cache"); - setup_psr_log_project(&suite, &proj, &home, &cache, major)?; + pkg.setup(&suite, &proj, &home, &cache, major)?; + let (name, file_key) = (pkg.name(), pkg.file_key()); + let pkg_dir = proj.join("vendor").join(name); let registry_lock = std::fs::read(proj.join("composer.lock")).unwrap(); - let version = locked_version(®istry_lock); - let purl = format!("pkg:composer/{DEP}@{version}"); - let orig = std::fs::read(proj.join("vendor/psr/log").join(FILE_KEY)).expect("installed file"); + let version = locked_version(®istry_lock, name); + let purl = format!("pkg:composer/{name}@{version}"); + let orig = std::fs::read(pkg_dir.join(file_key)).expect("installed file"); let marker = format!("\n// SOCKET-PATCH-HOSTED-E2E-MARKER patch={UUID}\n"); assert!( !String::from_utf8_lossy(&orig).contains("SOCKET-PATCH-HOSTED-E2E-MARKER"), @@ -234,16 +356,19 @@ async fn redirected_project( let patched: Vec = [orig.as_slice(), marker.as_bytes()].concat(); // 2. The patched dist archive + its sha1 (what composer.lock pins). - let zip = build_patched_zip(&proj.join("vendor/psr/log"), &patched); + let top = pkg.zip_top(); + let zip = build_patched_zip(&pkg_dir, &top, file_key, &patched); let sha1 = hex::encode(Sha1::digest(&zip)); let served = if tamper_served_archive { - build_patched_zip(&proj.join("vendor/psr/log"), b" vec!["get", UUID, "--mode", "hosted"], + RedirectCli::GetUuidHosted => vec!["get", UUID, "--mode", "hosted", "--json"], + RedirectCli::ScanRedirectHuman => vec!["scan", "--redirect"], }; + if cli == RedirectCli::ScanRedirectVex { + argv.push("--json"); + } argv.extend([ - "--json", "--yes", "--cwd", &proj_s, @@ -348,9 +476,22 @@ async fn redirected_project( code, 0, "{cli:?} failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - let env: serde_json::Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("{cli:?} --json is not JSON ({e}):\n{stdout}")); + let env: serde_json::Value = if cli == RedirectCli::ScanRedirectHuman { + serde_json::Value::Null + } else { + serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("{cli:?} --json is not JSON ({e}):\n{stdout}")) + }; match cli { + RedirectCli::ScanRedirectHuman => { + assert!( + stdout.contains( + "Reinstall from the updated lockfile (e.g. `composer install`; on Composer 1 \ + first remove the patched packages' vendor// directories)" + ), + "the next steps name the composer reinstall:\n{stdout}\n{stderr}" + ); + } RedirectCli::ScanRedirectVex => { assert_eq!(env["redirect"]["redirected"], 1, "one redirect: {env}"); assert_eq!( @@ -369,7 +510,17 @@ async fn redirected_project( } // composer.lock now resolves psr/log from the hosted archive, pinned. - let entry = lock_entry(&proj); + let entry = lock_entry(&proj, name); + assert_eq!( + entry["version"], + serde_json::from_slice::(®istry_lock).unwrap()["packages"] + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == name) + .unwrap()["version"], + "the lock keeps its own version spelling: {entry}" + ); assert_eq!(entry["dist"]["type"], "zip", "{entry}"); assert_eq!(entry["dist"]["url"], hosted_url, "{entry}"); assert_eq!(entry["dist"]["shasum"], sha1, "{entry}"); @@ -398,6 +549,7 @@ async fn redirected_project( Some(Fixture { tmp, + pkg, proj, purl, orig, @@ -572,7 +724,7 @@ fn assert_manifestless_hosted_vex(fx: &Fixture, fresh: &Path, tag: &str) { } async fn full_chain(tag: &str, cli: RedirectCli) { - let Some(fx) = redirected_project(tag, cli, false).await else { + let Some(fx) = redirected_project(tag, cli, false, FixturePkg::PsrLog).await else { return; }; let (fresh, install) = tokio::task::block_in_place(|| fresh_install(&fx, tag)); @@ -625,7 +777,14 @@ async fn composer_get_uuid_hosted_fresh_checkout_and_manifestless_vex() { #[ignore = "host capstone: shells out to a real composer; the unpinned `test` job skips it, \ the e2e job runs it with a pinned toolchain via --ignored"] async fn composer_redirect_tampered_archive_fails_checksum_verification() { - let Some(fx) = redirected_project("tampered", RedirectCli::ScanRedirectVex, true).await else { + let Some(fx) = redirected_project( + "tampered", + RedirectCli::ScanRedirectVex, + true, + FixturePkg::PsrLog, + ) + .await + else { return; }; let (fresh, install) = tokio::task::block_in_place(|| fresh_install(&fx, "tampered")); @@ -657,3 +816,137 @@ async fn composer_redirect_tampered_archive_fails_checksum_verification() { "nothing patched-looking may be installed" ); } + +/// Control for WHY the redirect drops `source`: the same tampered hosted +/// archive, but with the entry's git `source` put back. Composer 1 and +/// 2.0 – 2.9 then "fall back to source" and install the PRISTINE upstream +/// commit with exit 0; 2.10+ (`source-fallback` off by default) fails. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real composer; the unpinned `test` job skips it, \ + the e2e job runs it with a pinned toolchain via --ignored"] +async fn composer_hosted_keep_source_control_documents_fallback() { + let Some(fx) = redirected_project( + "keep-source", + RedirectCli::ScanRedirectVex, + true, + FixturePkg::PsrLog, + ) + .await + else { + return; + }; + let registry: serde_json::Value = serde_json::from_slice(&fx.registry_lock).unwrap(); + let source = registry["packages"] + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == DEP) + .and_then(|p| p.get("source")) + .filter(|s| s.is_object()) + .cloned() + .expect("the registry lock records a git source"); + let lock_path = fx.proj.join("composer.lock"); + let mut lock: serde_json::Value = + serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap(); + let entry = lock["packages"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|p| p["name"] == DEP) + .unwrap(); + entry["source"] = source; + std::fs::write( + &lock_path, + format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()), + ) + .unwrap(); + + let (major, minor) = tokio::task::block_in_place(composer_release); + let (fresh, install) = tokio::task::block_in_place(|| fresh_install(&fx, "keep-source")); + let text = format!( + "{}\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); + assert!( + archive_hits(&fx.server, &fx.archive_path).await >= 1, + "the tampered archive was tried first:\n{text}" + ); + let installed = std::fs::read(fresh.join("vendor/psr/log").join(FILE_KEY)).ok(); + if major < 2 || minor < 10 { + assert!( + install.status.success(), + "composer {major}.{minor} falls back to the git source:\n{text}" + ); + assert_eq!( + installed.as_deref(), + Some(fx.orig.as_slice()), + "composer {major}.{minor} installs the PRISTINE upstream commit:\n{text}" + ); + } else { + assert!( + !install.status.success(), + "composer {major}.{minor} has no source fallback:\n{text}" + ); + assert_ne!(installed, Some(fx.patched.clone()), "{text}"); + assert_ne!(installed, Some(fx.orig.clone()), "{text}"); + } +} + +/// A `v`-tagged release (`v3.5.1` in the lock, `3.5.1` in the purl) is +/// redirected in human mode — its next steps name the composer reinstall — +/// keeps its lock spelling, and a fresh checkout installs the patched bytes. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real composer; the unpinned `test` job skips it, \ + the e2e job runs it with a pinned toolchain via --ignored"] +async fn composer_hosted_v_tagged_fresh_checkout_install() { + let Some(fx) = redirected_project( + "v-tagged", + RedirectCli::ScanRedirectHuman, + false, + FixturePkg::VTagged, + ) + .await + else { + return; + }; + let (name, file_key) = (fx.pkg.name(), fx.pkg.file_key()); + let pretty = lock_entry(&fx.proj, name)["version"] + .as_str() + .unwrap() + .to_string(); + assert!(pretty.starts_with('v'), "a v-tagged lock: {pretty}"); + assert_eq!(fx.purl, format!("pkg:composer/{name}@{}", &pretty[1..])); + let (fresh, install) = tokio::task::block_in_place(|| fresh_install(&fx, "v-tagged")); + assert!( + install.status.success(), + "cold install from the hosted dist:\n{}\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); + assert_eq!( + std::fs::read(fresh.join("vendor").join(name).join(file_key)).unwrap(), + fx.patched, + "the installed file is the hosted patched bytes" + ); + let origin = fx.server.uri(); + tokio::task::block_in_place(|| { + let vulns: &[(&str, &[&str])] = &[(GHSA, &[CVE])]; + let patched_hash = git_sha256(&fx.patched); + let api = PatchApi::start(vec![( + UUID.to_string(), + patch_view(UUID, &fx.purl, &[(file_key, &patched_hash)], vulns), + )]); + let out = run_vex( + &binary(), + &fresh, + &VexRun { + product: Some(PRODUCT.to_string()), + patch_server_url: Some(origin.clone()), + ..VexRun::online(&api) + }, + ); + assert_eq!(out.code, Some(0), "vex over the v-tagged checkout:\n{out}"); + assert_attested(out.doc(), &fx.purl, UUID, Marker::Redirected, vulns); + }); +} diff --git a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs index 49f9ae20..029f4845 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs @@ -1244,3 +1244,387 @@ fn orphan_sweep_keeps_lock_referenced_dir_when_ledger_is_gone() { "fixture path convention" ); } + +// ── S1: Composer's path-mirror filters ───────────────────────────────── + +/// Every regular file under `root`, relative, `/`-separated, sorted. +fn tree_files(root: &Path) -> Vec { + fn walk(dir: &Path, rel: &str, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let name = entry.file_name().to_string_lossy().into_owned(); + let child = if rel.is_empty() { + name + } else { + format!("{rel}/{name}") + }; + if entry.file_type().unwrap().is_dir() { + walk(&entry.path(), &child, out); + } else { + out.push(child); + } + } + } + let mut out = Vec::new(); + walk(root, "", &mut out); + out.sort(); + out +} + +/// Filter files that make Composer's path mirror skip real psr/log files +/// (the patched one included): `.gitignore` (Composer ≤ 2.1), +/// `.gitattributes` export-ignore (every version), `.hgignore` (Composer 1). +fn plant_mirror_filters(dir: &Path) { + std::fs::write( + dir.join(".gitignore"), + "/src/LoggerInterface.php\n/src/NullLogger.php\n", + ) + .unwrap(); + std::fs::write( + dir.join(".gitattributes"), + "* text=auto\n/src/LoggerTrait.php export-ignore\n", + ) + .unwrap(); + std::fs::write( + dir.join(".hgignore"), + "syntax: glob\nsrc/AbstractLogger.php\n", + ) + .unwrap(); +} + +/// A fresh checkout of `proj` installs every file of the vendored copy +/// byte-for-byte (the patched `src/LoggerInterface.php` included). +fn assert_fresh_install_mirrors_whole_copy( + tmp: &Path, + proj: &Path, + copy_rel: &str, + patched: &[u8], +) { + let fresh = assert_fresh_checkout_installs_patched(tmp, proj, patched); + let copy = proj.join(copy_rel); + let installed = fresh.join("vendor/psr/log"); + let copied = tree_files(©); + for rel in [ + "src/NullLogger.php", + "src/LoggerTrait.php", + "src/AbstractLogger.php", + ] { + assert!( + copied.iter().any(|f| f == rel), + "fixture lost {rel}: {copied:?}" + ); + } + for rel in &copied { + assert_eq!( + std::fs::read(installed.join(rel)).ok(), + Some(std::fs::read(copy.join(rel)).unwrap()), + "composer's path mirror dropped or changed {rel} (installed: {:?})", + tree_files(&installed) + ); + } +} + +/// A package whose own `.gitignore` / `.gitattributes` / `.hgignore` match +/// real files still installs every file from the vendored copy: the filters +/// are neutralized in the copy (warned, human mode), and the human output +/// names the composer reinstall steps. +#[test] +#[ignore = "host capstone: shells out to a real composer; the unpinned `test` job \ + skips it, the e2e job runs it with a pinned toolchain via --ignored"] +fn composer_vendor_keeps_files_mirror_filters_would_drop() { + let suite = "e2e_vendor_composer_build(mirror-filters)"; + let Some(major) = composer_e2e_common::composer_major(suite) else { + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let home = tmp.path().join("composer-home"); + let cache = tmp.path().join("composer-cache"); + if !setup_composer_project(&proj, &home, &cache, "(mirror-filters)", major) { + return; + } + let lock_path = proj.join("composer.lock"); + let version = locked_composer_version(&lock_path, DEP).expect("psr/log locked"); + let installed = proj.join("vendor/psr/log"); + plant_mirror_filters(&installed); + let orig = std::fs::read(installed.join("src/LoggerInterface.php")).unwrap(); + let patched: Vec = [orig.as_slice(), b"\n// SOCKET-PATCH-MIRROR-FILTER-MARKER\n"].concat(); + let purl = format!("pkg:composer/{DEP}@{version}"); + stage_patch_with_vuln(&proj, &purl, "src/LoggerInterface.php", &orig, &patched); + + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--offline", "--cwd", proj.to_str().unwrap()], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stderr.contains("Warning (vendor_composer_mirror_filters_neutralized)"), + "the neutralization is surfaced:\n{stderr}" + ); + assert!( + stdout.contains("Run `composer install` to update vendor/"), + "the composer reinstall hint is printed:\n{stdout}" + ); + assert!( + stdout.contains("Composer 1 does not reinstall") + && stdout.contains("remove vendor/psr/log first"), + "the Composer 1 hint names the package dir:\n{stdout}" + ); + + let copy_rel = format!(".socket/vendor/composer/{UUID}/{DEP}@{version}"); + let copy = proj.join(©_rel); + assert_eq!(std::fs::read(copy.join(".gitignore")).unwrap(), b""); + assert_eq!(std::fs::read(copy.join(".hgignore")).unwrap(), b""); + assert_eq!( + std::fs::read(copy.join(".gitattributes")).unwrap(), + b"* text=auto\n" + ); + assert_eq!( + std::fs::read(installed.join(".gitignore")).unwrap(), + b"/src/LoggerInterface.php\n/src/NullLogger.php\n", + "the installed tree is never touched" + ); + assert_fresh_install_mirrors_whole_copy(tmp.path(), &proj, ©_rel, &patched); +} + +/// A copy vendored by a CLI that predates the neutralization (filter files +/// intact in the committed copy) is healed by the idempotent re-run: the +/// lock stays byte-identical, the heal is warned, and a fresh checkout then +/// installs every file. +#[test] +#[ignore = "host capstone: shells out to a real composer; the unpinned `test` job \ + skips it, the e2e job runs it with a pinned toolchain via --ignored"] +fn composer_vendor_fast_path_heals_legacy_copy() { + let suite = "e2e_vendor_composer_build(legacy-copy)"; + let Some(major) = composer_e2e_common::composer_major(suite) else { + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let home = tmp.path().join("composer-home"); + let cache = tmp.path().join("composer-cache"); + if !setup_composer_project(&proj, &home, &cache, "(legacy-copy)", major) { + return; + } + let lock_path = proj.join("composer.lock"); + let version = locked_composer_version(&lock_path, DEP).expect("psr/log locked"); + let orig = std::fs::read(proj.join("vendor/psr/log/src/LoggerInterface.php")).unwrap(); + let patched: Vec = [orig.as_slice(), b"\n// SOCKET-PATCH-LEGACY-COPY-MARKER\n"].concat(); + let purl = format!("pkg:composer/{DEP}@{version}"); + stage_patch_with_vuln(&proj, &purl, "src/LoggerInterface.php", &orig, &patched); + let (code, stdout, stderr) = run_vendored(&VendorDriver::VendorOffline, &proj); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + let copy_rel = format!(".socket/vendor/composer/{UUID}/{DEP}@{version}"); + let copy = proj.join(©_rel); + plant_mirror_filters(©); + let lock_wired = std::fs::read(&lock_path).unwrap(); + + let (code, stdout, stderr) = run_vendored(&VendorDriver::VendorOffline, &proj); + assert_eq!( + code, 0, + "re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["summary"]["failed"], 0, "{env}"); + assert!( + env["events"].as_array().unwrap().iter().any(|e| { + e["errorCode"] == "vendor_composer_mirror_filters_neutralized" + && e["purl"] == purl.as_str() + }), + "the heal is surfaced: {env}" + ); + assert_eq!( + std::fs::read(&lock_path).unwrap(), + lock_wired, + "composer.lock untouched" + ); + assert_eq!(std::fs::read(copy.join(".gitignore")).unwrap(), b""); + assert_eq!( + std::fs::read(copy.join("src/LoggerInterface.php")).unwrap(), + patched, + "the patched file is untouched by the heal" + ); + + let (code, stdout, stderr) = run_vendored(&VendorDriver::VendorOffline, &proj); + assert_eq!( + code, 0, + "second re-vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert!( + !env["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["errorCode"] == "vendor_composer_mirror_filters_neutralized"), + "a healed copy has nothing left to neutralize: {env}" + ); + assert_fresh_install_mirrors_whole_copy(tmp.path(), &proj, ©_rel, &patched); +} + +// ── S8: a `v`-tagged release ─────────────────────────────────────────── + +/// symfony/deprecation-contracts `v3.5.1` (PHP ≥ 8.1) or `v2.5.4` (older +/// PHP): a release whose lock version carries the `v` tag. Composer picks +/// the one its PHP can run; Composer 1 resolves it from an inline package +/// repository (packagist no longer serves Composer 1). +const VTAG_DEP: &str = "symfony/deprecation-contracts"; +const VTAG_FILE: &str = "function.php"; + +fn vtag_composer_json(major: u32) -> String { + let release = |version: &str, reference: &str, php: &str| { + serde_json::json!({ + "name": VTAG_DEP, + "version": version, + "type": "library", + "dist": { + "type": "zip", + "url": format!("https://api.github.com/repos/symfony/deprecation-contracts/zipball/{reference}"), + "reference": reference, + }, + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": reference, + }, + "require": { "php": php }, + "autoload": { "files": ["function.php"] }, + }) + }; + let mut doc = serde_json::json!({ + "name": "socket/composer-vtag-capstone", + "require": { VTAG_DEP: "3.5.1 || 2.5.4" }, + }); + if major < 2 { + doc["repositories"] = serde_json::json!([ + { "packagist.org": false }, + { "type": "package", "package": release("v3.5.1", "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", ">=8.1") }, + { "type": "package", "package": release("v2.5.4", "605389f2a7e5625f273b53960dc46aeaf9c62918", ">=7.1") }, + ]); + } + format!("{}\n", serde_json::to_string_pretty(&doc).unwrap()) +} + +/// `vendor` of a `v`-tagged release: the lock keeps its `v3.5.1` spelling, +/// the copy's leaf carries the bare purl version, and a fresh checkout +/// installs the patched bytes with the uuid in installed.json. +#[test] +#[ignore = "host capstone: shells out to a real composer; the unpinned `test` job \ + skips it, the e2e job runs it with a pinned toolchain via --ignored"] +fn composer_vendor_v_tagged_fresh_checkout_install() { + let suite = "e2e_vendor_composer_build(v-tagged)"; + let Some(major) = composer_e2e_common::composer_major(suite) else { + return; + }; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + let home = tmp.path().join("composer-home"); + let cache = tmp.path().join("composer-cache"); + std::fs::write(proj.join("composer.json"), vtag_composer_json(major)).unwrap(); + let update = composer(&proj, &["update"], &home, &cache); + if !update.status.success() { + composer_e2e_common::skip::<()>( + suite, + &format!( + "`composer update` failed:\n{}\n{}", + String::from_utf8_lossy(&update.stdout), + String::from_utf8_lossy(&update.stderr) + ), + ); + return; + } + let lock_path = proj.join("composer.lock"); + let pretty = lock_entry(&lock_path, VTAG_DEP)["version"] + .as_str() + .unwrap() + .to_string(); + assert!(pretty == "v3.5.1" || pretty == "v2.5.4", "locked {pretty}"); + let version = pretty.trim_start_matches('v').to_string(); + let installed = proj.join("vendor").join(VTAG_DEP).join(VTAG_FILE); + let orig = std::fs::read(&installed).unwrap(); + let patched: Vec = [orig.as_slice(), b"\n// SOCKET-PATCH-VTAG-MARKER\n"].concat(); + let purl = format!("pkg:composer/{VTAG_DEP}@{version}"); + stage_patch_with_vuln(&proj, &purl, VTAG_FILE, &orig, &patched); + + let (code, stdout, stderr) = run_vendored(&VendorDriver::VendorOffline, &proj); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["summary"]["applied"], 1, "{env}"); + let entry = lock_entry(&lock_path, VTAG_DEP); + let copy_rel = format!(".socket/vendor/composer/{UUID}/{VTAG_DEP}@{version}"); + assert_eq!( + entry["version"], + pretty.as_str(), + "the lock keeps its tag: {entry}" + ); + assert_eq!(entry["dist"]["url"], copy_rel.as_str(), "{entry}"); + assert_eq!(entry["dist"]["reference"], UUID, "{entry}"); + assert!(entry.get("source").is_none(), "{entry}"); + + let vex_path = proj.join("out.vex.json"); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vex", + "--cwd", + proj.to_str().unwrap(), + "--output", + vex_path.to_str().unwrap(), + "--product", + VEX_PRODUCT, + ], + ); + assert_eq!(code, 0, "vex failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let doc: serde_json::Value = + serde_json::from_slice(&std::fs::read(&vex_path).unwrap()).unwrap(); + assert_eq!(doc["statements"].as_array().unwrap().len(), 1, "{doc}"); + assert_eq!( + doc["statements"][0]["products"][0]["subcomponents"][0]["@id"], + purl.as_str() + ); + + let fresh = tmp.path().join("fresh"); + composer_e2e_common::fresh_checkout(&proj, &fresh); + let install = composer( + &fresh, + &["install"], + &tmp.path().join("cold-home"), + &tmp.path().join("cold-cache"), + ); + assert!( + install.status.success(), + "cold install from the vendored path dist:\n{}\n{}", + String::from_utf8_lossy(&install.stdout), + String::from_utf8_lossy(&install.stderr) + ); + assert_eq!( + std::fs::read(fresh.join("vendor").join(VTAG_DEP).join(VTAG_FILE)).unwrap(), + patched + ); + let installed_entry = composer_e2e_common::installed_packages(&fresh) + .into_iter() + .find(|p| p["name"] == VTAG_DEP) + .expect("installed.json names the package"); + assert_eq!( + installed_entry["dist"]["reference"], UUID, + "{installed_entry}" + ); + assert_eq!( + installed_entry["version"], + pretty.as_str(), + "{installed_entry}" + ); +} diff --git a/crates/socket-patch-core/src/patch/redirect/composer_source.rs b/crates/socket-patch-core/src/patch/redirect/composer_source.rs new file mode 100644 index 00000000..a5f3e3d0 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/composer_source.rs @@ -0,0 +1,559 @@ +//! String-aware JSON member scanning for the hosted composer.lock redirect. +//! +//! The rewriter edits composer.lock surgically (no JSON round-trip), so the +//! members it must drop — the entry's top-level `source` and the dist's +//! `mirrors` — are located by a small scanner over the raw text instead of a +//! parse. Both are dropped because each hands Composer a way to install the +//! PRISTINE upstream code: +//! +//! * `source`: when the dist download fails (a checksum mismatch, an expired +//! grant token, a patch-server outage) Composer 1 and Composer 2 before 2.10 +//! print "Now trying to download from source" and install the upstream git +//! commit, and `--prefer-source` / `preferred-install: source` always do. +//! Composer writes `source` right before `dist`, but a key-sorted or +//! hand-edited lock can place it anywhere in the entry — before `name` +//! included — so the scan starts at the entry object's own `{`. +//! * `dist.mirrors`: every mirror serves the unpatched archive under the +//! upstream reference, and a `preferred` mirror is tried before the hosted +//! url (then fails the sha1 pin, or falls back to `source`). +//! +//! Byte-for-byte twin of depscan's TS +//! `app/src/patches/registry-rewrite/composer-lock-members.ts`; the shared +//! goldens under `tests/fixtures/redirect/composer/` pin the parity. +//! Every delimiter the scanner looks for is ASCII, so byte offsets are always +//! char boundaries. + +use std::ops::RangeInclusive; + +use serde_json::Value; + +use super::{FileEdit, RewriteWarning}; + +/// One `"key": value` member of a JSON object, by byte offset. `key` is the +/// raw text between the key's quotes; `value_end` is inclusive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Member { + pub key: String, + pub key_start: usize, + pub value_start: usize, + pub value_end: usize, +} + +/// What to do with the entry's top-level `source` member. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum SourcePlan { + /// The entry has no top-level `source`. + None, + /// Delete this inclusive byte range (the member plus one adjoining comma). + Remove(RangeInclusive), + /// A `source` that is not an object is left alone (and warned about). + Kept, +} + +fn is_json_whitespace(b: u8) -> bool { + matches!(b, b' ' | b'\t' | b'\n' | b'\r') +} + +fn skip_whitespace(bytes: &[u8], from: usize, limit: usize) -> usize { + let mut i = from; + while i < limit && is_json_whitespace(bytes[i]) { + i += 1; + } + i +} + +/// `bytes[index]` is a `"` preceded by an even number of backslashes. +fn is_unescaped_quote(bytes: &[u8], index: usize) -> bool { + if bytes[index] != b'"' { + return false; + } + let backslashes = bytes[..index] + .iter() + .rev() + .take_while(|&&b| b == b'\\') + .count(); + backslashes % 2 == 0 +} + +/// Offset of the `"` closing the string opened at `open_quote`. +fn string_end(bytes: &[u8], open_quote: usize, limit: usize) -> Option { + let mut escaped = false; + for (i, &b) in bytes.iter().enumerate().take(limit).skip(open_quote + 1) { + if escaped { + escaped = false; + } else if b == b'\\' { + escaped = true; + } else if b == b'"' { + return Some(i); + } + } + None +} + +/// Offset of the bracket closing the object or array opened at `open`. +fn container_end(bytes: &[u8], open: usize, limit: usize) -> Option { + let mut depth = 0usize; + let mut i = open; + while i < limit { + match bytes[i] { + b'"' => { + i = string_end(bytes, i, limit)? + 1; + continue; + } + b'{' | b'[' => depth += 1, + b'}' | b']' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + i += 1; + } + None +} + +/// Inclusive end of the JSON value starting at `start`. +fn value_end_at(bytes: &[u8], start: usize, limit: usize) -> Option { + match bytes[start] { + b'"' => string_end(bytes, start, limit), + b'{' | b'[' => container_end(bytes, start, limit), + _ => { + let mut i = start; + while i < limit { + let b = bytes[i]; + if b == b',' || b == b'}' || b == b']' || is_json_whitespace(b) { + break; + } + i += 1; + } + (i > start).then(|| i - 1) + } + } +} + +/// Offset of the `{` opening the object whose member key starts at +/// `name_key_start`, found by a string-aware backward walk at depth 0 (a +/// `"` is a delimiter when preceded by an even number of backslashes). +pub(super) fn entry_object_start(text: &str, name_key_start: usize) -> Option { + let bytes = text.as_bytes(); + let mut depth = 0usize; + let mut i = name_key_start; + while i > 0 { + i -= 1; + if is_unescaped_quote(bytes, i) { + let mut open = i; + loop { + if open == 0 { + return None; + } + open -= 1; + if is_unescaped_quote(bytes, open) { + break; + } + } + i = open; + continue; + } + match bytes[i] { + b'}' | b']' => depth += 1, + b'{' if depth == 0 => return Some(i), + b'[' if depth == 0 => return None, + b'{' | b'[' => depth -= 1, + _ => {} + } + } + None +} + +/// The members of the object spanning `object_open` (`{`) to `object_end` +/// (`}`), in document order. Scanning stops at the first token that is not a +/// well-formed member. +pub(super) fn top_level_members(text: &str, object_open: usize, object_end: usize) -> Vec { + let bytes = text.as_bytes(); + let object_end = object_end.min(bytes.len()); + let mut members = Vec::new(); + let mut i = object_open + 1; + while i < object_end { + while i < object_end && (is_json_whitespace(bytes[i]) || bytes[i] == b',') { + i += 1; + } + if i >= object_end || bytes[i] != b'"' { + break; + } + let key_start = i; + let Some(key_end) = string_end(bytes, key_start, object_end) else { + break; + }; + i = skip_whitespace(bytes, key_end + 1, object_end); + if i >= object_end || bytes[i] != b':' { + break; + } + let value_start = skip_whitespace(bytes, i + 1, object_end); + if value_start >= object_end { + break; + } + let Some(value_end) = value_end_at(bytes, value_start, object_end) else { + break; + }; + members.push(Member { + key: text[key_start + 1..key_end].to_string(), + key_start, + value_start, + value_end, + }); + i = value_end + 1; + } + members +} + +/// The inclusive span that deletes `members[index]` and one adjoining comma: +/// through the whitespace before the next key when a comma follows, +/// otherwise from the comma after the previous member's value. +pub(super) fn member_removal_range( + text: &str, + members: &[Member], + index: usize, +) -> Option> { + let bytes = text.as_bytes(); + let member = members.get(index)?; + let after_value = skip_whitespace(bytes, member.value_end + 1, bytes.len()); + if let Some(next) = members.get(index + 1) { + if bytes.get(after_value) == Some(&b',') { + return Some(member.key_start..=next.key_start - 1); + } + } + let Some(previous) = index.checked_sub(1).and_then(|p| members.get(p)) else { + return Some(member.key_start..=member.value_end); + }; + let after_previous = skip_whitespace(bytes, previous.value_end + 1, member.key_start); + let start = if bytes.get(after_previous) == Some(&b',') { + after_previous + } else { + previous.value_end + 1 + }; + Some(start..=member.value_end) +} + +/// Plan the drop of the top-level `source` member of the entry object +/// spanning `object_open` to `entry_end`. +pub(super) fn plan_source_drop(content: &str, object_open: usize, entry_end: usize) -> SourcePlan { + let members = top_level_members(content, object_open, entry_end); + let Some(index) = members.iter().position(|m| m.key == "source") else { + return SourcePlan::None; + }; + if content.as_bytes()[members[index].value_start] != b'{' { + return SourcePlan::Kept; + } + match member_removal_range(content, &members, index) { + Some(range) => SourcePlan::Remove(range), + None => SourcePlan::None, + } +} + +/// `block` (a whole `"dist": {…}` member) without its top-level `mirrors`; +/// `None` when it has none. +pub(super) fn strip_dist_mirrors(block: &str) -> Option { + let open = block.find('{')?; + let close = block.rfind('}')?; + if close <= open { + return None; + } + let members = top_level_members(block, open, close); + let index = members.iter().position(|m| m.key == "mirrors")?; + let range = member_removal_range(block, &members, index)?; + Some(format!( + "{}{}", + &block[..*range.start()], + &block[*range.end() + 1..] + )) +} + +/// Byte offsets of one located composer.lock package entry: `entry_start` is +/// its `"name"` key and `entry_end` the `}` closing it; `dist_start` is the +/// `"dist"` key and `dist_end` the `}` closing the dist object. +#[derive(Debug, Clone, Copy)] +pub(super) struct DistSpan { + pub entry_start: usize, + pub entry_end: usize, + pub dist_start: usize, + pub dist_end: usize, +} + +/// Splice the redirected dist (or, when `rewritten_dist` is `None` because +/// the dist is already redirected, the current one) into the entry, dropping +/// the entry's top-level `source` and the dist's `mirrors`. The recorded edit +/// spans the dist block AND the removed `source` member, so the ledger's +/// fragment revert restores both byte-for-byte. `None` — no edit, no ledger +/// growth — when nothing changes (an idempotent re-run over a healed lock). +pub(super) fn apply_dist_edit( + content: &mut String, + span: DistSpan, + rewritten_dist: Option<&str>, + composer_name: &str, + warnings: &mut Vec, +) -> Option { + let DistSpan { + entry_start, + entry_end, + dist_start, + dist_end, + } = span; + let mut dist = rewritten_dist + .map(str::to_string) + .unwrap_or_else(|| content[dist_start..=dist_end].to_string()); + if let Some(stripped) = strip_dist_mirrors(&dist) { + dist = stripped; + warnings.push(RewriteWarning { + code: "redirect_composer_dist_mirrors_removed".into(), + detail: format!( + "{composer_name}'s dist mirrors were removed; they serve the unpatched \ + archive and a preferred mirror would fail the sha1 check" + ), + }); + } + let plan = match entry_object_start(content, entry_start) { + Some(object_open) => plan_source_drop(content, object_open, entry_end), + None => SourcePlan::None, + }; + if plan == SourcePlan::Kept { + warnings.push(RewriteWarning { + code: "redirect_composer_source_kept".into(), + detail: format!( + "{composer_name}'s source is not an object and was left in place; a failed \ + hosted download may fall back to it" + ), + }); + } + let removal = match plan { + SourcePlan::Remove(range) => Some(range), + SourcePlan::None | SourcePlan::Kept => None, + }; + let (span_start, span_end) = match &removal { + Some(r) => (dist_start.min(*r.start()), dist_end.max(*r.end())), + None => (dist_start, dist_end), + }; + let original = content[span_start..=span_end].to_string(); + let mut pieces: Vec<(usize, usize, &str)> = vec![(dist_start, dist_end, dist.as_str())]; + if let Some(r) = &removal { + pieces.push((*r.start(), *r.end(), "")); + } + pieces.sort_by(|a, b| b.0.cmp(&a.0)); + let mut replacement = original.clone(); + for (start, end, text) in pieces { + replacement.replace_range(start - span_start..=end - span_start, text); + } + if replacement == original { + return None; + } + content.replace_range(span_start..=span_end, &replacement); + Some(FileEdit { + path: "composer.lock".into(), + kind: "redirect_composer_dist".into(), + action: "rewritten".into(), + key: Some(composer_name.to_string()), + original: Some(Value::String(original)), + new: Some(Value::String(replacement)), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keys(members: &[Member]) -> Vec<&str> { + members.iter().map(|m| m.key.as_str()).collect() + } + + #[test] + fn members_are_found_around_nested_and_escaped_values() { + let text = + r#"{"a": "x\"}", "b": {"c": [1, {"d": "]"}]}, "e": true, "f": -1.5e3, "g": null}"#; + let members = top_level_members(text, 0, text.len() - 1); + assert_eq!(keys(&members), vec!["a", "b", "e", "f", "g"]); + let b = &members[1]; + assert_eq!( + &text[b.value_start..=b.value_end], + r#"{"c": [1, {"d": "]"}]}"# + ); + let f = &members[3]; + assert_eq!(&text[f.value_start..=f.value_end], "-1.5e3"); + } + + #[test] + fn member_scan_stops_at_a_malformed_member() { + let text = r#"{"a": 1, oops, "b": 2}"#; + assert_eq!(keys(&top_level_members(text, 0, text.len() - 1)), vec!["a"]); + } + + #[test] + fn entry_object_start_walks_back_over_members_placed_before_name() { + let text = r#"[{"source": {"url": "a{b\"c"}, "k": ["x"], "name": "p/q"}]"#; + let name = text.find("\"name\"").unwrap(); + assert_eq!(entry_object_start(text, name), Some(1)); + } + + #[test] + fn entry_object_start_is_none_when_the_key_sits_in_an_array() { + let text = r#"["name", "x"]"#; + assert_eq!(entry_object_start(text, 1), None); + } + + #[test] + fn removal_takes_the_following_comma_or_else_the_preceding_one() { + let text = "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3\n}"; + let members = top_level_members(text, 0, text.len() - 1); + let first = member_removal_range(text, &members, 0).unwrap(); + let mut out = text.to_string(); + out.replace_range(first, ""); + assert_eq!(out, "{\n \"b\": 2,\n \"c\": 3\n}"); + let last = member_removal_range(text, &members, 2).unwrap(); + let mut out = text.to_string(); + out.replace_range(last, ""); + assert_eq!(out, "{\n \"a\": 1,\n \"b\": 2\n}"); + let only = r#"{"a": 1}"#; + let members = top_level_members(only, 0, only.len() - 1); + assert_eq!(member_removal_range(only, &members, 0), Some(1..=6)); + } + + #[test] + fn a_non_object_source_is_kept() { + let text = r#"{"name": "p/q", "source": "git", "dist": {}}"#; + assert_eq!(plan_source_drop(text, 0, text.len() - 1), SourcePlan::Kept); + let text = r#"{"name": "p/q", "dist": {}}"#; + assert_eq!(plan_source_drop(text, 0, text.len() - 1), SourcePlan::None); + } + + #[test] + fn a_nested_source_is_not_top_level() { + let text = r#"{"name": "p/q", "extra": {"source": {"a": 1}}}"#; + assert_eq!(plan_source_drop(text, 0, text.len() - 1), SourcePlan::None); + } + + #[test] + fn mirrors_are_stripped_only_at_the_dist_top_level() { + let block = "\"dist\": {\n \"url\": \"u\",\n \"mirrors\": [{\"url\": \"m\"}]\n}"; + assert_eq!( + strip_dist_mirrors(block).as_deref(), + Some("\"dist\": {\n \"url\": \"u\"\n}") + ); + let nested = "\"dist\": {\"url\": \"u\", \"x\": {\"mirrors\": []}}"; + assert_eq!(strip_dist_mirrors(nested), None); + } + + fn span_of(content: &str) -> DistSpan { + let entry_start = content.find("\"name\"").unwrap(); + let entry_end = content.rfind('}').unwrap(); + let dist_start = content.find("\"dist\": {").unwrap(); + let dist_end = dist_start + content[dist_start..].find('}').unwrap(); + DistSpan { + entry_start, + entry_end, + dist_start, + dist_end, + } + } + + #[test] + fn source_after_dist_is_removed_and_the_edit_spans_both() { + let mut content = + "[{\"name\": \"p/q\", \"dist\": {\"url\": \"a\"}, \"source\": {\"url\": \"g\"}}]" + .to_string(); + let span = span_of(&content); + let mut warnings = Vec::new(); + let edit = apply_dist_edit( + &mut content, + span, + Some("\"dist\": {\"url\": \"b\"}"), + "p/q", + &mut warnings, + ) + .unwrap(); + assert!(warnings.is_empty()); + assert_eq!(content, "[{\"name\": \"p/q\", \"dist\": {\"url\": \"b\"}}]"); + assert_eq!( + edit.original, + Some(Value::String( + "\"dist\": {\"url\": \"a\"}, \"source\": {\"url\": \"g\"}".into() + )) + ); + assert_eq!( + edit.new, + Some(Value::String("\"dist\": {\"url\": \"b\"}".into())) + ); + } + + #[test] + fn an_unchanged_span_records_no_edit() { + let mut content = "[{\"name\": \"p/q\", \"dist\": {\"url\": \"a\"}}]".to_string(); + let span = span_of(&content); + let mut warnings = Vec::new(); + assert!(apply_dist_edit(&mut content, span, None, "p/q", &mut warnings).is_none()); + assert!(warnings.is_empty()); + assert_eq!(content, "[{\"name\": \"p/q\", \"dist\": {\"url\": \"a\"}}]"); + } + + #[test] + fn warnings_order_mirrors_before_source_kept() { + let mut content = "[{\"name\": \"p/q\", \"source\": \"git\", \"dist\": {\"url\": \"a\", \ + \"mirrors\": []}}]" + .to_string(); + let dist_start = content.find("\"dist\": {").unwrap(); + let span = DistSpan { + entry_start: content.find("\"name\"").unwrap(), + entry_end: content.len() - 2, + dist_start, + dist_end: content.len() - 3, + }; + let mut warnings = Vec::new(); + apply_dist_edit(&mut content, span, None, "p/q", &mut warnings).unwrap(); + let codes: Vec<&str> = warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + vec![ + "redirect_composer_dist_mirrors_removed", + "redirect_composer_source_kept" + ] + ); + assert_eq!( + content, + "[{\"name\": \"p/q\", \"source\": \"git\", \"dist\": {\"url\": \"a\"}}]" + ); + } + + /// Every composer golden's `expected/` lock is a fixed point: a re-run + /// over what the rewriter wrote records no edit and no warning. + #[test] + fn every_composer_golden_output_is_a_fixed_point() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/redirect/composer/composer-lock"); + let mut checked = 0; + for case in std::fs::read_dir(&root).unwrap() { + let case = case.unwrap().path(); + let expected = case.join("expected/composer.lock"); + if !expected.is_file() { + continue; + } + let overrides: Vec = serde_json::from_str( + &std::fs::read_to_string(case.join("overrides.json")).unwrap(), + ) + .unwrap(); + let mut files = std::collections::BTreeMap::new(); + files.insert( + "composer.lock".to_string(), + std::fs::read_to_string(&expected).unwrap(), + ); + let again = super::super::rewrite_registry_redirect(&files, &overrides); + assert!( + again.files.is_empty() && again.edits.is_empty() && again.warnings.is_empty(), + "{}: re-run over expected/ must be a no-op: {:?} {:?}", + case.display(), + again.edits, + again.warnings + ); + checked += 1; + } + assert!(checked >= 8, "only {checked} composer goldens checked"); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 47fad79d..d786d9a1 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -4127,6 +4127,8 @@ fn rewrite_uv_lock( } } +mod composer_source; + // ── composer.lock ──────────────────────────────────────────────────────────── /// Whether `text` points at `artifact_url` in any spelling a rewritten file may /// carry: the raw url every rewriter emits — composer.lock included, since @@ -4260,6 +4262,7 @@ static COMPOSER_DIST_SHASUM_RE: LazyLock = LazyLock::new(|| { /// dist block's IMMEDIATE predecessor (only `,` + whitespace between them) — /// the layout composer itself always writes (`source` then `dist`). /// `None` when the entry has no source object there. +#[allow(dead_code)] fn composer_source_before_dist( content: &str, entry_start: usize, @@ -4357,13 +4360,12 @@ fn rewrite_composer_lock( continue; }; let block = content[dist_start..=dist_end].to_string(); - // Already redirected (either slash spelling): recording an edit whose - // `original` IS the hosted url would grow the ledger on every re-run - // and poison a future revert. - if artifact_url_present(&block, &dep.artifact_url) && block.contains(&sha1) { - continue; - } - if !block.contains("\"url\": \"") { + // Already redirected (either slash spelling): only the source/mirrors + // heal applies, so a re-run over a healed lock records no edit and + // the ledger never grows. + let already_redirected = + artifact_url_present(&block, &dep.artifact_url) && block.contains(&sha1); + if !already_redirected && !block.contains("\"url\": \"") { result.warnings.push(RewriteWarning { code: "redirect_composer_no_dist_url".into(), detail: format!("{composer_name}'s dist block has no url to redirect"), @@ -4384,50 +4386,25 @@ fn rewrite_composer_lock( } else { append_composer_shasum(&rewritten, &sha1) }; - // Drop the entry's `source` (the vendored backend does the same): - // when the dist download fails — checksum mismatch, an expired grant - // token, a patch-server outage — composer 1 and composer 2 before its - // source-fallback cutoff (2.2 LTS included) print "Now trying to - // download from source" and silently install the PRISTINE upstream - // commit from git, and `--prefer-source` / `preferred-install: - // source` always does. With the source gone the hosted archive is - // the only way to install the package, so a failed fetch fails the - // install instead of shipping the vulnerable code. The edit then - // spans `"source": {…},\n"dist": {…}`, so the ledger's - // fragment revert puts both blocks back byte-for-byte. - let (edit_start, original) = - match composer_source_before_dist(&content, entry_start, dist_start) { - Some(source_start) => (source_start, content[source_start..=dist_end].to_string()), - None => { - if content[entry_start..=entry_end].contains("\"source\": {") { - result.warnings.push(RewriteWarning { - code: "redirect_composer_source_kept".into(), - detail: format!( - "{composer_name}'s source block does not directly precede its \ - dist and was left in place; a failed hosted download may fall \ - back to it" - ), - }); - } - (dist_start, block.clone()) - } - }; - if rewritten != original { - content = format!( - "{}{}{}", - &content[..edit_start], - rewritten, - &content[dist_end + 1..] - ); + let rewritten = (!already_redirected).then_some(rewritten); + // Drops the entry's `source` wherever it sits and the dist's + // `mirrors` (see `composer_source`); the edit spans both, so the + // ledger's fragment revert restores them byte-for-byte. + let span = composer_source::DistSpan { + entry_start, + entry_end, + dist_start, + dist_end, + }; + if let Some(edit) = composer_source::apply_dist_edit( + &mut content, + span, + rewritten.as_deref(), + &composer_name, + &mut result.warnings, + ) { changed = true; - result.edits.push(FileEdit { - path: "composer.lock".into(), - kind: "redirect_composer_dist".into(), - action: "rewritten".into(), - key: Some(composer_name), - original: Some(Value::String(original)), - new: Some(Value::String(rewritten)), - }); + result.edits.push(edit); } } if changed { @@ -12836,11 +12813,12 @@ snapshots: ); } - /// A hand-ordered entry whose `source` does NOT directly precede its - /// `dist` keeps the source (the fragment edit cannot span it losslessly) - /// and says so; the dist is still redirected and pinned. + /// A hand-ordered entry whose `source` sits AFTER its `dist` still loses + /// the source (a failed hosted download could otherwise fall back to the + /// pristine upstream), with no warning; the one edit spans the dist + /// through the source so the fragment revert restores both. #[test] - fn composer_non_adjacent_source_is_kept_with_a_warning() { + fn composer_non_adjacent_source_is_removed() { let lock = composer_lock_with( " \"dist\": { @@ -12856,24 +12834,94 @@ snapshots: }", ); let r = composer_result(&lock, "1.0.0"); - assert_eq!(warning_codes(&r), vec!["redirect_composer_source_kept"]); + assert!(r.warnings.is_empty(), "no warnings: {:?}", r.warnings); let out = r .files .get("composer.lock") .expect("the dist is redirected"); let doc: Value = serde_json::from_str(out).expect("valid JSON"); assert_eq!(doc["packages"][0]["dist"]["url"], COMPOSER_ARTIFACT_URL); - assert!(doc["packages"][0].get("source").is_some()); + assert!(doc["packages"][0].get("source").is_none(), "{out}"); + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); let edit = &r.edits[0]; let (original, new) = ( edit.original.as_ref().and_then(Value::as_str).unwrap(), edit.new.as_ref().and_then(Value::as_str).unwrap(), ); + assert!(original.starts_with("\"dist\": {"), "{original}"); + assert!( + original.trim_end().ends_with('}') && original.contains("acme/target.git"), + "the edit spans the dist through the source: {original}" + ); + assert!(!new.contains("\"source\""), "{new}"); assert_eq!( out.replacen(new, original, 1), lock, "revert restores the lock" ); + let mut again = BTreeMap::new(); + again.insert("composer.lock".to_string(), out.clone()); + let second = rewrite_registry_redirect(&again, &[composer_override("1.0.0")]); + assert!( + second.files.is_empty() && second.edits.is_empty() && second.warnings.is_empty(), + "re-run must be a no-op: {:?} {:?}", + second.edits, + second.warnings + ); + } + + /// A lock an older rewriter redirected but left its `source` (or dist + /// `mirrors`) in is healed: one edit drops both, and the healed lock + /// is then a no-op. + #[test] + fn composer_already_redirected_entry_is_healed() { + let lock = composer_lock_with(&format!( + " + \"source\": {{ + \"type\": \"git\", + \"url\": \"https://github.com/acme/target.git\", + \"reference\": \"cafe\" + }}, + \"dist\": {{ + \"type\": \"zip\", + \"url\": \"{COMPOSER_ARTIFACT_URL}\", + \"reference\": \"cafe\", + \"shasum\": \"{COMPOSER_SHA1}\", + \"mirrors\": [ + {{ + \"url\": \"https://mirror.example/%package%.%type%\", + \"preferred\": true + }} + ] + }}" + )); + let r = composer_result(&lock, "1.0.0"); + assert_eq!( + warning_codes(&r), + vec!["redirect_composer_dist_mirrors_removed"] + ); + let out = r.files.get("composer.lock").expect("the entry is healed"); + let doc: Value = serde_json::from_str(out).expect("valid JSON"); + let target = &doc["packages"][0]; + assert!(target.get("source").is_none(), "{target}"); + assert!(target["dist"].get("mirrors").is_none(), "{target}"); + assert_eq!(target["dist"]["url"], COMPOSER_ARTIFACT_URL); + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); + let edit = &r.edits[0]; + let (original, new) = ( + edit.original.as_ref().and_then(Value::as_str).unwrap(), + edit.new.as_ref().and_then(Value::as_str).unwrap(), + ); + assert_eq!(out.replacen(new, original, 1), lock); + let mut again = BTreeMap::new(); + again.insert("composer.lock".to_string(), out.clone()); + let second = rewrite_registry_redirect(&again, &[composer_override("1.0.0")]); + assert!( + second.files.is_empty() && second.edits.is_empty() && second.warnings.is_empty(), + "re-run over the healed lock must be a no-op: {:?} {:?}", + second.edits, + second.warnings + ); } /// pnpm lockfileVersion 6 embeds resolved peers in the `packages:` key diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 985972bd..fc26bd30 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -1,12 +1,13 @@ //! Composer vendor backend: lock-only `dist` surgery pointing at a committed //! patched copy. //! -//! Spike-verified mechanism (composer 2.10 — `spikes/PHASE0-FINDINGS.txt`): +//! Mechanism (verified against real Composer 1.10 through 2.10 — see +//! `docs/testing/composer-compatibility.md`): //! edit ONLY `composer.lock`. `composer.json` is never touched, and the lock's //! `content-hash` covers composer.json alone, so the surgery triggers no //! "lock file out of date" warning. The package's lock entry is rewritten to: //! -//! * `dist` → `{"type": "path", "url": "", "reference": null}` +//! * `dist` → `{"type": "path", "url": "", "reference": ""}` //! (replaced IN ITS ORIGINAL SLOT so the entry's key order is stable); //! * `source` REMOVED entirely — left in place, `--prefer-source` could //! git-clone the unpatched upstream; with it removed the spike confirmed @@ -14,7 +15,10 @@ //! * `"transport-options": {"symlink": false}` inserted right after `dist` — //! LOAD-BEARING: composer's default path-repo strategy symlinks, and a //! symlink into `.socket/vendor/` would defeat the real-copy guarantee. -//! `symlink: false` forces the 'Mirroring' (copy) strategy. +//! `symlink: false` forces the 'Mirroring' (copy) strategy, whose file +//! finder skips whatever the copy's `.gitignore` / `.hgignore` / +//! `.gitattributes` `export-ignore` rules match — so those rules are +//! neutralized in the copy first (`mirror_filters`). //! //! Lock names are matched CASE-INSENSITIVELY (locks are normally lowercase, //! but hand-written mixed-case locks exist and install fine) while the dist @@ -57,6 +61,8 @@ use super::state::{ }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; +mod mirror_filters; + /// Project-relative lockfile this backend wires. const COMPOSER_LOCK: &str = "composer.lock"; @@ -167,8 +173,12 @@ pub async fn vendor_composer( // verbatim pre-vendor original, and re-recording here would clobber it. if entry_is_wired(&lock[section][idx], ©_rel) { if copy_matches_after_hashes(©_dir, &record.files).await { + let mut warnings = Vec::new(); + if !dry_run { + mirror_filters::heal_or_warn(©_dir, record, &pkg, &mut warnings).await; + } let result = already_patched_result(purl, ©_dir, &record.files); - return done(result, None, Vec::new()); + return done(result, None, warnings); } // Wired but the committed copy is missing/stale: rebuild the // ARTIFACT only. The lock is already correct and the first run's @@ -215,6 +225,7 @@ pub async fn vendor_composer( } } }; + mirror_filters::heal_or_warn(©_dir, record, &pkg, &mut warnings).await; warnings.push(VendorWarning::new( "vendor_artifact_rebuilt", format!( @@ -281,6 +292,13 @@ pub async fn vendor_composer( } } }; + if let Err(detail) = + mirror_filters::neutralize_or_conflict(©_dir, record, &pkg, &mut warnings).await + { + let _ = remove_tree(&uuid_dir).await; + prune_empty_vendor_dirs(©_dir).await; + return refused("vendor_composer_mirror_filter_conflict", detail); + } // ── lock rewrite ───────────────────────────────────────────────────── let original_entry = lock[section][idx].clone(); @@ -3670,4 +3688,151 @@ mod tests { lock_before ); } + + /// The vendored copy ships filter files Composer's path mirror honours: + /// they are neutralized before the lock is wired, and warned about. + #[tokio::test] + async fn fresh_vendor_neutralizes_mirror_filters() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + tokio::fs::write(installed.join(".gitignore"), "/src\n") + .await + .unwrap(); + tokio::fs::write(installed.join(".gitattributes"), "/src export-ignore\n") + .await + .unwrap(); + let (result, entry, warnings) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_some()); + assert!( + warnings + .iter() + .any(|w| w.code == "vendor_composer_mirror_filters_neutralized"), + "{warnings:?}" + ); + let copy = root.join(copy_rel()); + assert_eq!(tokio::fs::read(copy.join(".gitignore")).await.unwrap(), b""); + assert_eq!( + tokio::fs::read(copy.join(".gitattributes")).await.unwrap(), + b"" + ); + assert_eq!( + tokio::fs::read(installed.join(".gitignore")).await.unwrap(), + b"/src\n", + "the installed tree is never touched" + ); + } + + /// A patch that itself rewrites a filter file needing a change cannot be + /// made mirror-safe: the fresh vendor refuses and leaves nothing behind. + #[tokio::test] + async fn fresh_vendor_refuses_a_patched_filter_file() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, mut record) = fixture(&lock).await; + let root = dir.path(); + let attrs = b"/tests export-ignore\n"; + tokio::fs::write(installed.join(".gitattributes"), attrs) + .await + .unwrap(); + let hash = compute_git_sha256_from_bytes(attrs); + tokio::fs::write(blobs.join(&hash), attrs).await.unwrap(); + record.files.insert( + ".gitattributes".to_string(), + PatchFileInfo { + before_hash: hash.clone(), + after_hash: hash, + }, + ); + let lock_before = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + let (code, detail) = + unwrap_refused(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert_eq!(code, "vendor_composer_mirror_filter_conflict", "{detail}"); + assert!(detail.contains(".gitattributes"), "{detail}"); + assert!(!root.join(".socket/vendor/composer").exists()); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + lock_before + ); + } + + /// A copy vendored by an older CLI (filter files intact) is healed by the + /// idempotent re-run without touching the lock or the patched file. + #[tokio::test] + async fn idempotent_rerun_heals_a_legacy_copy() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + let copy = root.join(copy_rel()); + tokio::fs::write(copy.join(".hgignore"), "src\n") + .await + .unwrap(); + let lock_bytes = tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(); + + let (_, dry_entry, dry_warnings) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, true).await); + assert!( + dry_entry.is_none() && dry_warnings.is_empty(), + "{dry_warnings:?}" + ); + assert_eq!( + tokio::fs::read(copy.join(".hgignore")).await.unwrap(), + b"src\n" + ); + + let (result, entry, warnings) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success); + assert!(entry.is_none(), "the hot path never re-records"); + assert_eq!( + warnings.iter().map(|w| w.code).collect::>(), + vec!["vendor_composer_mirror_filters_neutralized"] + ); + assert_eq!(tokio::fs::read(copy.join(".hgignore")).await.unwrap(), b""); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + lock_bytes + ); + let (_, _, again) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!( + again.is_empty(), + "healed copy: nothing left to warn {again:?}" + ); + } + + /// The artifact-only rebuild of a wired package neutralizes the rebuilt + /// copy's filters too. + #[tokio::test] + async fn artifact_rebuild_neutralizes_mirror_filters() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + crate::patch::copy_tree::remove_tree(&root.join(copy_rel())) + .await + .unwrap(); + tokio::fs::write(installed.join(".gitignore"), "/src\n") + .await + .unwrap(); + let (result, _, warnings) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success, "{:?}", result.error); + let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect(); + assert_eq!( + codes, + vec![ + "vendor_composer_mirror_filters_neutralized", + "vendor_artifact_rebuilt" + ] + ); + assert_eq!( + tokio::fs::read(root.join(copy_rel()).join(".gitignore")) + .await + .unwrap(), + b"" + ); + } } diff --git a/crates/socket-patch-core/src/vendor/composer_lock/mirror_filters.rs b/crates/socket-patch-core/src/vendor/composer_lock/mirror_filters.rs new file mode 100644 index 00000000..bde68641 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/composer_lock/mirror_filters.rs @@ -0,0 +1,350 @@ +//! Composer's path-repository mirror silently skips files of the vendored +//! copy, so neutralize the filters it applies before the copy is wired. +//! +//! `transport-options.symlink: false` makes `composer install` MIRROR the +//! copy into `vendor//` through `ArchivableFilesFinder`, the +//! same finder `composer archive` uses. It applies, at the copy root: +//! +//! * `GitExcludeFilter` over `.gitignore` — Composer 1.x through 2.1.x; +//! * `GitExcludeFilter` over `.gitattributes` `export-ignore` rules — every +//! version (`-export-ignore` negations from 2.x); +//! * `HgExcludeFilter` over `.hgignore` — Composer 1.x. +//! +//! A package shipping any of those (its own dist often does) therefore lands +//! in `vendor/` MISSING the matched files: a patched file among them installs +//! as nothing, or a file the package needs at runtime vanishes. The copy's +//! filter files are rewritten so that every Composer version mirrors every +//! file: `.gitignore` / `.hgignore` are truncated to empty (the file set is +//! unchanged, so versions that ignore them are unaffected) and each +//! `.gitattributes` line carrying an `export-ignore` / `-export-ignore` +//! attribute is dropped, every other line and its line ending kept. +//! +//! A filter file the patch itself rewrites cannot be neutralized without +//! breaking its `afterHash`; that is a [`MirrorFilterError::PatchedFilterFile`] +//! conflict, raised only when the file would actually need a change. + +use std::io; +use std::path::Path; + +use crate::manifest::schema::PatchRecord; +use crate::utils::fs::atomic_write_bytes_preserving_mode; + +use crate::vendor::VendorWarning; + +const GITIGNORE: &str = ".gitignore"; +const HGIGNORE: &str = ".hgignore"; +const GITATTRIBUTES: &str = ".gitattributes"; + +/// What [`neutralize_mirror_filters`] changed in the copy. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(super) struct MirrorFilterReport { + pub gitignore_neutralized: bool, + pub hgignore_neutralized: bool, + pub export_ignore_rules_removed: usize, +} + +impl MirrorFilterReport { + pub fn changed(&self) -> bool { + self.gitignore_neutralized + || self.hgignore_neutralized + || self.export_ignore_rules_removed > 0 + } +} + +#[derive(Debug)] +pub(super) enum MirrorFilterError { + /// The patch rewrites this filter file, and it would need neutralizing. + PatchedFilterFile(&'static str), + Io(io::Error), +} + +impl std::fmt::Display for MirrorFilterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PatchedFilterFile(file) => write!( + f, + "the patch rewrites the package's {file}, whose rules make Composer's path \ + mirror skip files of the vendored copy; neutralizing them would break the \ + patched file's hash" + ), + Self::Io(e) => write!(f, "could not neutralize the copy's mirror filters: {e}"), + } + } +} + +/// A `.gitattributes` line whose attributes include `export-ignore` or +/// `-export-ignore` (the first whitespace token is the path pattern). +fn is_export_ignore_rule(line: &str) -> bool { + line.split_whitespace() + .skip(1) + .any(|attr| attr == "export-ignore" || attr == "-export-ignore") +} + +/// `text` without its export-ignore rules, and how many were dropped. +fn strip_export_ignore(text: &str) -> (String, usize) { + let mut out = String::with_capacity(text.len()); + let mut removed = 0; + for line in text.split_inclusive('\n') { + if is_export_ignore_rule(line) { + removed += 1; + } else { + out.push_str(line); + } + } + (out, removed) +} + +/// The neutralized bytes for one filter file, `None` when it is already +/// neutral (absent, or nothing to drop). A symlink is always replaced by an +/// empty regular file: its target may sit outside the copy and is never +/// read. +async fn plan(path: &Path, file: &'static str) -> io::Result, usize)>> { + let meta = match tokio::fs::symlink_metadata(path).await { + Ok(meta) => meta, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + if meta.file_type().is_symlink() { + return Ok(Some((Vec::new(), 0))); + } + if !meta.is_file() { + return Ok(None); + } + let bytes = tokio::fs::read(path).await?; + if file != GITATTRIBUTES { + return Ok((!bytes.is_empty()).then(|| (Vec::new(), 0))); + } + let text = String::from_utf8_lossy(&bytes); + let (stripped, removed) = strip_export_ignore(&text); + Ok((removed > 0).then(|| (stripped.into_bytes(), removed))) +} + +/// Rewrite the copy's `.gitignore`, `.hgignore` and `.gitattributes` so +/// Composer's path mirror copies every file (see the module doc). +/// Idempotent: a second run changes nothing. +pub(super) async fn neutralize_mirror_filters( + copy_dir: &Path, + patched_files: &[String], +) -> Result { + let mut planned = Vec::new(); + for file in [GITIGNORE, HGIGNORE, GITATTRIBUTES] { + let path = copy_dir.join(file); + if let Some(change) = plan(&path, file).await.map_err(MirrorFilterError::Io)? { + if patched_files.iter().any(|p| p == file) { + return Err(MirrorFilterError::PatchedFilterFile(file)); + } + planned.push((file, path, change)); + } + } + let mut report = MirrorFilterReport::default(); + for (file, path, (bytes, removed)) in planned { + atomic_write_bytes_preserving_mode(&path, &bytes) + .await + .map_err(MirrorFilterError::Io)?; + match file { + GITIGNORE => report.gitignore_neutralized = true, + HGIGNORE => report.hgignore_neutralized = true, + _ => report.export_ignore_rules_removed += removed.max(1), + } + } + Ok(report) +} + +fn patched_keys(record: &PatchRecord) -> Vec { + record.files.keys().cloned().collect() +} + +fn neutralized_warning(pkg: &str, report: &MirrorFilterReport) -> VendorWarning { + let mut what = Vec::new(); + if report.gitignore_neutralized { + what.push(".gitignore".to_string()); + } + if report.hgignore_neutralized { + what.push(".hgignore".to_string()); + } + if report.export_ignore_rules_removed > 0 { + what.push(format!( + ".gitattributes export-ignore ({} rule{})", + report.export_ignore_rules_removed, + if report.export_ignore_rules_removed == 1 { + "" + } else { + "s" + } + )); + } + VendorWarning::new( + "vendor_composer_mirror_filters_neutralized", + format!( + "{pkg}: Composer's path mirror would have skipped files matched by the vendored \ + copy's {}; those rules were neutralized in the vendored copy (commit it)", + what.join(", ") + ), + ) +} + +/// Fresh-vendor gate: neutralize, warn on change; `Err(detail)` when the +/// copy cannot be made mirror-safe (the caller unwinds and refuses). +pub(super) async fn neutralize_or_conflict( + copy_dir: &Path, + record: &PatchRecord, + pkg: &str, + warnings: &mut Vec, +) -> Result<(), String> { + match neutralize_mirror_filters(copy_dir, &patched_keys(record)).await { + Ok(report) => { + if report.changed() { + warnings.push(neutralized_warning(pkg, &report)); + } + Ok(()) + } + Err(e) => Err(format!("{pkg}: {e}")), + } +} + +/// Already-wired paths (artifact rebuild, idempotent re-run): the lock stays +/// wired whatever happens, so a conflict is only warned about. Heals copies +/// vendored by a CLI that predates the neutralization. +pub(super) async fn heal_or_warn( + copy_dir: &Path, + record: &PatchRecord, + pkg: &str, + warnings: &mut Vec, +) { + if let Err(detail) = neutralize_or_conflict(copy_dir, record, pkg, warnings).await { + warnings.push(VendorWarning::new( + "vendor_composer_mirror_filter_conflict", + format!("{detail}; `composer install` may not mirror every file of the copy"), + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn write(dir: &Path, rel: &str, text: &str) { + tokio::fs::write(dir.join(rel), text).await.unwrap(); + } + + async fn read(dir: &Path, rel: &str) -> String { + tokio::fs::read_to_string(dir.join(rel)).await.unwrap() + } + + #[tokio::test] + async fn nothing_present_changes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let report = neutralize_mirror_filters(dir.path(), &[]).await.unwrap(); + assert!(!report.changed()); + assert!(!dir.path().join(GITIGNORE).exists()); + } + + #[tokio::test] + async fn ignore_files_are_truncated() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), GITIGNORE, "/src\n*.php\n").await; + write(dir.path(), HGIGNORE, "syntax: glob\nsrc\n").await; + let report = neutralize_mirror_filters(dir.path(), &[]).await.unwrap(); + assert!(report.gitignore_neutralized && report.hgignore_neutralized); + assert_eq!(read(dir.path(), GITIGNORE).await, ""); + assert_eq!(read(dir.path(), HGIGNORE).await, ""); + } + + #[tokio::test] + async fn export_ignore_rules_are_dropped_and_crlf_kept() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + GITATTRIBUTES, + "* text=auto\r\n/tests export-ignore\r\n/src/Keep.php -export-ignore\r\n\ + *.php diff=php\r\n/docs\texport-ignore", + ) + .await; + let report = neutralize_mirror_filters(dir.path(), &[]).await.unwrap(); + assert_eq!(report.export_ignore_rules_removed, 3); + assert_eq!( + read(dir.path(), GITATTRIBUTES).await, + "* text=auto\r\n*.php diff=php\r\n" + ); + } + + #[tokio::test] + async fn attributes_without_export_ignore_are_left_alone() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + GITATTRIBUTES, + "* text=auto\nexport-ignore diff\n", + ) + .await; + write(dir.path(), GITIGNORE, "").await; + let report = neutralize_mirror_filters(dir.path(), &[]).await.unwrap(); + assert!(!report.changed()); + assert_eq!( + read(dir.path(), GITATTRIBUTES).await, + "* text=auto\nexport-ignore diff\n" + ); + } + + #[tokio::test] + async fn a_patched_filter_file_that_needs_a_change_conflicts() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), GITATTRIBUTES, "/tests export-ignore\n").await; + write(dir.path(), GITIGNORE, "/build\n").await; + let err = neutralize_mirror_filters(dir.path(), &[GITATTRIBUTES.to_string()]) + .await + .unwrap_err(); + assert!(matches!( + err, + MirrorFilterError::PatchedFilterFile(GITATTRIBUTES) + )); + assert_eq!( + read(dir.path(), GITIGNORE).await, + "/build\n", + "nothing written" + ); + let neutral = tempfile::tempdir().unwrap(); + write(neutral.path(), GITATTRIBUTES, "* text=auto\n").await; + let report = neutralize_mirror_filters(neutral.path(), &[GITATTRIBUTES.to_string()]) + .await + .unwrap(); + assert!( + !report.changed(), + "a patched but neutral file is no conflict" + ); + } + + #[tokio::test] + async fn neutralization_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), GITIGNORE, "/src\n").await; + write( + dir.path(), + GITATTRIBUTES, + "/tests export-ignore\n* text=auto\n", + ) + .await; + assert!(neutralize_mirror_filters(dir.path(), &[]) + .await + .unwrap() + .changed()); + let again = neutralize_mirror_filters(dir.path(), &[]).await.unwrap(); + assert!(!again.changed(), "{again:?}"); + assert_eq!(read(dir.path(), GITATTRIBUTES).await, "* text=auto\n"); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_symlinked_filter_file_is_replaced_not_followed() { + let outside = tempfile::tempdir().unwrap(); + write(outside.path(), "target", "/src\n").await; + let dir = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path().join("target"), dir.path().join(GITIGNORE)) + .unwrap(); + let report = neutralize_mirror_filters(dir.path(), &[]).await.unwrap(); + assert!(report.gitignore_neutralized); + let meta = std::fs::symlink_metadata(dir.path().join(GITIGNORE)).unwrap(); + assert!(meta.is_file()); + assert_eq!(read(outside.path(), "target").await, "/src\n"); + } +} diff --git a/crates/socket-patch-core/src/vex/discover/composer.rs b/crates/socket-patch-core/src/vex/discover/composer.rs index 9a4b37a0..860f99a1 100644 --- a/crates/socket-patch-core/src/vex/discover/composer.rs +++ b/crates/socket-patch-core/src/vex/discover/composer.rs @@ -810,4 +810,80 @@ mod tests { )], ); } + + /// A `v`-tagged release (`v3.5.1` in the lock) is found whether the leaf + /// carries the bare purl version or the tag, and under every relative + /// spelling composer accepts for a path url (`file:`, `file:./`). + #[tokio::test] + async fn vendored_v_tagged_and_file_prefixed_spellings_are_accepted() { + let wired = |name: &str, version: &str, uuid: &str, url: String| { + Value::Object(crate::vendor::composer_lock::rewrite_lock_entry( + registry_entry(name, version).as_object().unwrap(), + &url, + uuid, + )) + }; + let bare_leaf = wired( + "symfony/deprecation-contracts", + "v3.5.1", + UUID_A, + format!(".socket/vendor/composer/{UUID_A}/symfony/deprecation-contracts@3.5.1"), + ); + let tag_leaf = wired( + "symfony/polyfill-php80", + "v1.31.0", + UUID_B, + format!("file:./.socket/vendor/composer/{UUID_B}/symfony/polyfill-php80@v1.31.0"), + ); + let file_only = wired( + "psr/log", + "3.0.2", + UUID_B, + format!("file:.socket/vendor/composer/{UUID_B}/psr/log@3.0.2"), + ); + let p = Project::new(); + p.write( + "composer.lock", + lock(json!([bare_leaf, tag_leaf]), json!([file_only])), + ); + let out = run(&p).await; + assert_refs( + &out, + &[ + ( + "pkg:composer/symfony/deprecation-contracts@3.5.1", + UUID_A, + WiringMode::Vendored, + ), + ( + "pkg:composer/symfony/polyfill-php80@1.31.0", + UUID_B, + WiringMode::Vendored, + ), + ("pkg:composer/psr/log@3.0.2", UUID_B, WiringMode::Vendored), + ], + ); + assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics); + } + + /// A vendored entry that still carries its upstream `source` (a lock + /// hand-edited after vendoring) is still the committed copy: composer's + /// default `--prefer-dist` installs the path dist. + #[tokio::test] + async fn vendored_entry_with_a_leftover_source_is_a_ref() { + let mut entry = vendored_entry("psr/log", "3.0.2", UUID_B); + entry["source"] = json!({ + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5" + }); + let p = Project::new(); + p.write("composer.lock", lock(json!([entry]), json!([]))); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:composer/psr/log@3.0.2", UUID_B, WiringMode::Vendored)], + ); + assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics); + } } diff --git a/crates/socket-patch-core/src/vex/discover/testing/golden.rs b/crates/socket-patch-core/src/vex/discover/testing/golden.rs index 1f84a93d..e518a6a7 100644 --- a/crates/socket-patch-core/src/vex/discover/testing/golden.rs +++ b/crates/socket-patch-core/src/vex/discover/testing/golden.rs @@ -306,6 +306,20 @@ fn corpus() -> Vec { out.push((name, staged.path().to_path_buf(), Some(staged))); } } + // Vendored composer projects: each immediate child is one + // project root; the committed copies under its `.socket/vendor/` + // (which hold files, a `composer.json` included) are payload. + "composer-vendored" => { + let mut cases: Vec = std::fs::read_dir(&top) + .expect("read composer-vendored") + .map(|e| e.expect("composer-vendored entry").path()) + .filter(|p| p.is_dir()) + .collect(); + cases.sort(); + for case in cases { + out.push((rel(&root, &case), case, None)); + } + } // Real-package-manager captures: every directory holding a // file other than a README is one project. _ => { diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/basic/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json b/crates/socket-patch-core/tests/fixtures/composer-vendored/basic/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json new file mode 100644 index 00000000..bb3d40d3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/basic/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json @@ -0,0 +1,4 @@ +{ + "name": "psr/log", + "type": "library" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/basic/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php b/crates/socket-patch-core/tests/fixtures/composer-vendored/basic/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php new file mode 100644 index 00000000..118597db --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/basic/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php @@ -0,0 +1,2 @@ +=8.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + } + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/dev/.socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/acme/tooling@dev-main/composer.json b/crates/socket-patch-core/tests/fixtures/composer-vendored/dev/.socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/acme/tooling@dev-main/composer.json new file mode 100644 index 00000000..5deec94b --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/dev/.socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/acme/tooling@dev-main/composer.json @@ -0,0 +1,4 @@ +{ + "name": "acme/tooling", + "type": "library" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/dev/.socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/acme/tooling@dev-main/src/Patched.php b/crates/socket-patch-core/tests/fixtures/composer-vendored/dev/.socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/acme/tooling@dev-main/src/Patched.php new file mode 100644 index 00000000..118597db --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/dev/.socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/acme/tooling@dev-main/src/Patched.php @@ -0,0 +1,2 @@ +=8.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Acme\\": "src" + } + } + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json b/crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json new file mode 100644 index 00000000..bb3d40d3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json @@ -0,0 +1,4 @@ +{ + "name": "psr/log", + "type": "library" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php b/crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php new file mode 100644 index 00000000..118597db --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/outside-anchor/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php @@ -0,0 +1,2 @@ +=8.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + } + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json b/crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json new file mode 100644 index 00000000..bb3d40d3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json @@ -0,0 +1,4 @@ +{ + "name": "psr/log", + "type": "library" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php b/crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php new file mode 100644 index 00000000..118597db --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/reference-mismatch/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php @@ -0,0 +1,2 @@ +=8.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + } + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json b/crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json new file mode 100644 index 00000000..bb3d40d3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/composer.json @@ -0,0 +1,4 @@ +{ + "name": "psr/log", + "type": "library" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php b/crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php new file mode 100644 index 00000000..118597db --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/source-leftover/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2/src/Patched.php @@ -0,0 +1,2 @@ +=8.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + } + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/socket-patch.vendor.json b/crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/socket-patch.vendor.json new file mode 100644 index 00000000..aaa12449 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/socket-patch.vendor.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 1, + "purl": "pkg:composer/symfony/deprecation-contracts@3.5.1", + "patchUuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "ecosystem": "composer", + "vulnerabilities": [], + "vendoredAt": "2026-09-26T00:00:00Z" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/symfony/deprecation-contracts@3.5.1/composer.json b/crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/symfony/deprecation-contracts@3.5.1/composer.json new file mode 100644 index 00000000..3c60358e --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/symfony/deprecation-contracts@3.5.1/composer.json @@ -0,0 +1,4 @@ +{ + "name": "symfony/deprecation-contracts", + "type": "library" +} diff --git a/crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/symfony/deprecation-contracts@3.5.1/src/Patched.php b/crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/symfony/deprecation-contracts@3.5.1/src/Patched.php new file mode 100644 index 00000000..118597db --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-vendored/v-tagged/.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/symfony/deprecation-contracts@3.5.1/src/Patched.php @@ -0,0 +1,2 @@ +=8.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Acme\\": "src" + } + } + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected-edits.json new file mode 100644 index 00000000..4f2c2feb --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"source\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/Seldaek/monolog.git\",\n \"reference\": \"abc123def456\"\n },\n \"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }", + "new": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected/composer.lock new file mode 100644 index 00000000..67324378 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/expected/composer.lock @@ -0,0 +1,34 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/input/composer.lock new file mode 100644 index 00000000..24860e04 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/input/composer.lock @@ -0,0 +1,39 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/already-redirected-source-kept/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/expected-warnings.json new file mode 100644 index 00000000..2f4fdf05 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_composer_no_dist" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/input/composer.lock new file mode 100644 index 00000000..2e63d686 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/input/composer.lock @@ -0,0 +1,39 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + }, + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-before-name/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected-edits.json new file mode 100644 index 00000000..71f847a4 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"source\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/Seldaek/monolog.git\",\n \"reference\": \"abc123def456\"\n },\n \"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://api.github.com/repos/Seldaek/monolog/zipball/abc123\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"\",\n \"mirrors\": [\n {\n \"url\": \"https://mirror.example/dists/%package%/%version%/%reference%.%type%\",\n \"preferred\": true\n }\n ]\n }", + "new": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected-warnings.json new file mode 100644 index 00000000..d0b9a5ea --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_composer_dist_mirrors_removed" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected/composer.lock new file mode 100644 index 00000000..67324378 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/expected/composer.lock @@ -0,0 +1,34 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/input/composer.lock new file mode 100644 index 00000000..d580ca25 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/input/composer.lock @@ -0,0 +1,45 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "", + "mirrors": [ + { + "url": "https://mirror.example/dists/%package%/%version%/%reference%.%type%", + "preferred": true + } + ] + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/expected-warnings.json new file mode 100644 index 00000000..a65ab6f2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_composer_no_lockfile" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/input/composer.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/input/composer.json new file mode 100644 index 00000000..83dda537 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/input/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "monolog/monolog": "2.0.0" + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/no-lockfile/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected-edits.json new file mode 100644 index 00000000..8d3d217c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://api.github.com/repos/Seldaek/monolog/zipball/abc123\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"\"\n },\n \"source\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/Seldaek/monolog.git\",\n \"reference\": \"abc123def456\"\n },\n ", + "new": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n },\n " + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected/composer.lock new file mode 100644 index 00000000..cd788376 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/expected/composer.lock @@ -0,0 +1,37 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + }, + "require": { + "php": ">=7.2" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/input/composer.lock new file mode 100644 index 00000000..f39d2b83 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/input/composer.lock @@ -0,0 +1,42 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + }, + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "require": { + "php": ">=7.2" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-after-dist/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected-edits.json new file mode 100644 index 00000000..78fcdca2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"source\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/Seldaek/monolog.git\",\n \"reference\": \"abc123def456\"\n },\n \"name\": \"monolog/monolog\",\n \"version\": \"2.0.0\",\n \"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://api.github.com/repos/Seldaek/monolog/zipball/abc123\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"\"\n }", + "new": "\"name\": \"monolog/monolog\",\n \"version\": \"2.0.0\",\n \"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected/composer.lock new file mode 100644 index 00000000..67324378 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/expected/composer.lock @@ -0,0 +1,34 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/input/composer.lock new file mode 100644 index 00000000..1f768f08 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/input/composer.lock @@ -0,0 +1,39 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-before-name/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected-edits.json new file mode 100644 index 00000000..8165c850 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"source\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/Seldaek/monolog.git\",\n \"reference\": \"abc123def456\"\n },\n \"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://api.github.com/repos/Seldaek/monolog/zipball/abc123\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"\"\n }", + "new": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected/composer.lock new file mode 100644 index 00000000..2d67aaca --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/expected/composer.lock @@ -0,0 +1,40 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + }, + "extra": { + "source": { + "type": "git", + "url": "https://example.com/not-top-level.git" + } + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/input/composer.lock new file mode 100644 index 00000000..3f1c91c7 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/input/composer.lock @@ -0,0 +1,45 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + }, + "extra": { + "source": { + "type": "git", + "url": "https://example.com/not-top-level.git" + } + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-in-extra-untouched/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected-edits.json new file mode 100644 index 00000000..93866604 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://api.github.com/repos/Seldaek/monolog/zipball/abc123\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"\"\n },\n \"source\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/Seldaek/monolog.git\",\n \"reference\": \"abc123def456\"\n }", + "new": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected/composer.lock new file mode 100644 index 00000000..67324378 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/expected/composer.lock @@ -0,0 +1,34 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/input/composer.lock new file mode 100644 index 00000000..2b7feb56 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/input/composer.lock @@ -0,0 +1,39 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + }, + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-last-key/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected-edits.json new file mode 100644 index 00000000..756ca79a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"source\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/Seldaek/monolog.git\",\n \"reference\": \"abc123def456\"\n },\n \"require\": {\n \"php\": \">=7.2\"\n },\n \"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://api.github.com/repos/Seldaek/monolog/zipball/abc123\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"\"\n }", + "new": "\"require\": {\n \"php\": \">=7.2\"\n },\n \"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected-warnings.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected-warnings.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected/composer.lock new file mode 100644 index 00000000..a0d251f9 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/expected/composer.lock @@ -0,0 +1,37 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "require": { + "php": ">=7.2" + }, + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/input/composer.lock new file mode 100644 index 00000000..60484c48 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/input/composer.lock @@ -0,0 +1,42 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "require": { + "php": ">=7.2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/source-not-adjacent/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/composer-vendored.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/composer-vendored.json new file mode 100644 index 00000000..d654d0c7 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/composer-vendored.json @@ -0,0 +1,174 @@ +{ + "composer-vendored/basic": { + "refs": [ + { + "purl": "pkg:composer/psr/log@3.0.2", + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "mode": "vendored", + "source_file": "composer.lock", + "artifact_rel": ".socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2", + "locked_integrity": null, + "integrity_required": false, + "url": null, + "lockfile_basis_ok": false + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "mode": "vendored", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "vendored", + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "purl": "pkg:composer/psr/log@3.0.2", + "artifact_rel": ".socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2" + } + ] + }, + "composer-vendored/dev": { + "refs": [ + { + "purl": "pkg:composer/acme/tooling@dev-main", + "uuid": "8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f", + "mode": "vendored", + "source_file": "composer.lock", + "artifact_rel": ".socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/acme/tooling@dev-main", + "locked_integrity": null, + "integrity_required": false, + "url": null, + "lockfile_basis_ok": false + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f", + "mode": "vendored", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "vendored", + "uuid": "8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f", + "purl": "pkg:composer/acme/tooling@dev-main", + "artifact_rel": ".socket/vendor/composer/8c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f/acme/tooling@dev-main" + } + ] + }, + "composer-vendored/outside-anchor": { + "refs": [], + "diagnostics": [ + { + "code": "patched_ref_invalid", + "file": "composer.lock", + "detail": "composer.lock: packages entry \"psr/log\" is wired to \"../.socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2\", which is not a .socket/vendor/composer///@ path inside this project" + } + ], + "recognized": [ + { + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "mode": "vendored", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "composer-vendored/reference-mismatch": { + "refs": [], + "diagnostics": [ + { + "code": "patched_ref_invalid", + "file": "composer.lock", + "detail": "composer.lock: pkg:composer/psr/log@3.0.2's vendored dist.reference Some(\"f16e1d5863e37f8d8c2a01719f5b34baa2b714d3\") does not carry the patch uuid 5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f its path names" + } + ], + "recognized": [ + { + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "mode": "vendored", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "composer-vendored/source-leftover": { + "refs": [ + { + "purl": "pkg:composer/psr/log@3.0.2", + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "mode": "vendored", + "source_file": "composer.lock", + "artifact_rel": ".socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2", + "locked_integrity": null, + "integrity_required": false, + "url": null, + "lockfile_basis_ok": false + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "mode": "vendored", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "vendored", + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "purl": "pkg:composer/psr/log@3.0.2", + "artifact_rel": ".socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/psr/log@3.0.2" + } + ] + }, + "composer-vendored/v-tagged": { + "refs": [ + { + "purl": "pkg:composer/symfony/deprecation-contracts@3.5.1", + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "mode": "vendored", + "source_file": "composer.lock", + "artifact_rel": ".socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/symfony/deprecation-contracts@3.5.1", + "locked_integrity": null, + "integrity_required": false, + "url": null, + "lockfile_basis_ok": false + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "mode": "vendored", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "vendored", + "uuid": "5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f", + "purl": "pkg:composer/symfony/deprecation-contracts@3.5.1", + "artifact_rel": ".socket/vendor/composer/5f3c8a2e-7b41-4d6a-9e0c-1a2b3c4d5e6f/symfony/deprecation-contracts@3.5.1" + } + ] + } +} diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json index 393e8ca9..cbad408b 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json @@ -1,4 +1,78 @@ { + "redirect/composer/composer-lock/already-redirected-source-kept/expected": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, + "redirect/composer/composer-lock/already-redirected-source-kept/input": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, "redirect/composer/composer-lock/basic/expected": { "refs": [ { @@ -44,6 +118,59 @@ "elsewhere": [], "live_claims": [] }, + "redirect/composer/composer-lock/dist-before-name/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "redirect/composer/composer-lock/dist-mirrors/expected": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, + "redirect/composer/composer-lock/dist-mirrors/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/composer/composer-lock/escaped-slash-lock/expected": { "refs": [ { @@ -134,6 +261,14 @@ "elsewhere": [], "live_claims": [] }, + "redirect/composer/composer-lock/no-lockfile/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/composer/composer-lock/no-shasum-key/expected": { "refs": [ { @@ -224,6 +359,51 @@ "elsewhere": [], "live_claims": [] }, + "redirect/composer/composer-lock/source-after-dist/expected": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, + "redirect/composer/composer-lock/source-after-dist/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/composer/composer-lock/source-and-dist/expected": { "refs": [ { @@ -269,6 +449,186 @@ "elsewhere": [], "live_claims": [] }, + "redirect/composer/composer-lock/source-before-name/expected": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, + "redirect/composer/composer-lock/source-before-name/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "redirect/composer/composer-lock/source-in-extra-untouched/expected": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, + "redirect/composer/composer-lock/source-in-extra-untouched/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "redirect/composer/composer-lock/source-last-key/expected": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, + "redirect/composer/composer-lock/source-last-key/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "redirect/composer/composer-lock/source-not-adjacent/expected": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, + "redirect/composer/composer-lock/source-not-adjacent/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/composer/composer-lock/source-only-bystander/input": { "refs": [], "diagnostics": [], diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 4038cf42..6fe487ec 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -21,7 +21,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [golang-hosted.md](design/golang-hosted.md). Paid tier stays ❌ ([golang-hosted-no-go.md](design/golang-hosted-no-go.md)); `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | | NuGet (`nuget`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | -| Composer (`composer`) | ✅ post-install script events | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite | +| Composer (`composer`) | ✅ post-install script events | ✅ `composer.lock` `dist: path` rewrite | ✅ `composer.lock` dist url + shasum rewrite; the entry's `source` and `dist.mirrors` are removed. See [composer-compatibility.md](testing/composer-compatibility.md) | | Deno (`deno`) | ✅ apply-only — no install hook (`setup` reports `no_files`); declare in `setup.manual` for VEX coverage | ❌ refused (`vendor_unsupported_ecosystem`) | ❌ not supported | > **Maven / NuGet sidecar caveat**: Maven and NuGet are fully enabled in every mode (the diff --git a/docs/testing/composer-compatibility.md b/docs/testing/composer-compatibility.md new file mode 100644 index 00000000..63965faf --- /dev/null +++ b/docs/testing/composer-compatibility.md @@ -0,0 +1,142 @@ +# Composer compatibility + +`socket-patch` supports hosted and vendored Composer patches on every Composer +release from 1.10 through 2.10. Both modes edit `composer.lock` only: +`composer.json` and the lock's `content-hash` (which covers `composer.json` +alone) are never touched, so the edit raises no "lock file is not up to date" +warning. The real-Composer capstones install from the rewritten lock and +compare the installed bytes with the patch; rewriting alone is not an +installation result. + +See the [ecosystem matrix](../ecosystems.md#mode--ecosystem-matrix) for other +package managers and `crates/socket-patch-cli/CLI_CONTRACT.md` for the wiring +contract. + +## What each mode writes + +| Mode | Lock entry after the rewrite | +| --- | --- | +| Vendored (`vendor`, `scan`/`get --mode vendored`) | `dist` → `{"type": "path", "url": ".socket/vendor/composer///@", "reference": ""}` in the original slot, `"transport-options": {"symlink": false}` after it, `source` removed. The patched copy is committed under `.socket/vendor/composer/`. | +| Hosted (`scan --mode hosted`) | `dist.type` → `zip`, `dist.url` → the hosted archive, `dist.shasum` → its sha1 (inserted when the lock had none). The entry's top-level `source` is removed wherever it sits in the entry, and `dist.mirrors` is removed (`redirect_composer_dist_mirrors_removed`). A `source` that is not an object is left and warned about (`redirect_composer_source_kept`). | + +The lock's `version` spelling is never rewritten (`v6.4.1` stays `v6.4.1`). +Patch coordinates are matched by Composer release identity, so a lock +`3.0.2`, `v3.0.2` and a patch purl `3.0.2.0` name the same release. + +Both modes are idempotent: a re-run over a rewritten lock records no edit. +A hosted re-run over a lock rewritten by an older CLI or by the GitHub app +(redirected `dist` with `source` or `mirrors` still present) heals it with one +edit. `vendor --revert` and the hosted revert restore the recorded fragments +byte for byte. + +## Test matrix + +`.github/workflows/composer-compatibility.yml` runs, for each cell, the +vendored (`e2e_vendor_composer_build`) and hosted +(`e2e_redirect_composer_build`) real-Composer capstones plus the hermetic +`composer::` VEX cells, against a checksum-pinned `composer.phar`: + +| OS | Composer (PHP) | +| --- | --- | +| Ubuntu | 1.10.28 (8.1), 2.0.14 (8.0), 2.1.14 (8.1), 2.2.30 (8.1, 8.3), 2.5.8 (8.2), 2.8.12 (8.4), 2.9.8 (8.4), 2.10.3 (8.5) | +| Windows | 1.10.28 (8.1), 2.2.30 (8.3), 2.9.8 (8.4), 2.10.3 (8.5) | +| macOS | 1.10.28 (8.1), 2.2.30 (8.3), 2.10.3 (8.5) | +| Docker (Debian 12, PHP 8.2) | 2.2.30, 2.10.3 (`docker_e2e_vendor_composer`) | + +The capstones pin psr/log 3.0.2 (PHP ≥ 8.0) and, for the `v`-tagged cells, +symfony/deprecation-contracts `v3.5.1` (`v2.5.4` below PHP 8.1). Run one cell +locally with: + +```text +SOCKET_PATCH_PHP_BIN= SOCKET_PATCH_COMPOSER_PHAR= \ +SOCKET_PATCH_COMPOSER_E2E_REQUIRED=1 SOCKET_PATCH_COMPOSER_E2E_VERSION= \ + cargo test -p socket-patch-cli --test e2e_vendor_composer_build \ + --test e2e_redirect_composer_build -- --ignored --nocapture +``` + +Composer 1.10.28 cannot download on PHP 8.5 (`stream_context_create()` fatal); +pair it with PHP 8.4 or older. The official `composer:1` Docker images ship PHP +8.5 and are unusable for that reason. + +## Why `source` is removed: the source fallback + +When a dist download fails (a checksum mismatch, an expired grant token, an +outage) Composer may install the package from its `source` instead — the +pristine upstream git commit. + +| Composer | Failed dist download with `source` present | +| --- | --- | +| 1.x, 2.0 – 2.9 | "Now trying to download from source": exit 0, **pristine** code installed | +| 2.10+ | "Source fallback is disabled": exit 1, nothing installed (`source-fallback` defaults to false) | + +With `source` removed the dist is the only way to install the package, so a +failed fetch fails the install on every version. The +`composer_hosted_keep_source_control_documents_fallback` capstone pins this +table. + +`--prefer-source`, `config.preferred-install: "source"` (or a pattern that +resolves to `source` for the package), and, on Composer 1, a `dev-*` version +with no preference, install from `source` even when the dist is fine. A lock +entry that still carries `source` in those setups installs the pristine code +deterministically, which is why the rewriters remove it (only a non-object +`source` is left in place, with a warning). + +`dist.mirrors` (written for repositories that advertise dist mirrors, such as +Private Packagist) is tried before `dist.url` when `preferred`; every mirror +serves the unpatched archive, so the hosted rewrite removes it. + +## Reinstalling over an existing `vendor/` + +| Composer | `composer install` after the lock was rewritten | +| --- | --- | +| 2.x | Reinstalls the package (its dist or source reference changed): patched | +| 1.x | Leaves an installed stable package as it is: **pristine**. Remove `vendor//` first, then run `composer install` | + +A fresh checkout installs the patched bytes on every version. `vendor`, +`scan --mode vendored` and `get --mode vendored` print both instructions after +a run that leaves composer packages vendored; `scan --mode hosted` names them in +its next steps. Nothing else prints a Composer reinstall hint. + +## Vendored copies and Composer's mirror filters + +`transport-options.symlink: false` makes Composer MIRROR the vendored copy into +`vendor/` through the finder `composer archive` uses. That finder skips files +matched by the copy's: + +| Filter file | Composer versions that apply it | +| --- | --- | +| `.gitignore` | 1.x – 2.1.x | +| `.gitattributes` `export-ignore` / `-export-ignore` | all | +| `.hgignore` | 1.x | + +A package shipping one of them would install with files missing, a patched +file included. The vendored copy's `.gitignore` and `.hgignore` are therefore +truncated to empty and its `.gitattributes` `export-ignore` lines dropped +(`vendor_composer_mirror_filters_neutralized`). Only these three files in the +copy change; the installed `vendor/` tree and the patched files are never +touched. A patch that itself rewrites a filter file needing a change is +refused (`vendor_composer_mirror_filter_conflict`), because neutralizing it +would break the patched file's hash. A re-run over a copy vendored by an older +CLI heals it (commit the change). + +## Security blocking in 2.9 and 2.10 + +Composer 2.9 blocks `update`/`require` of versions with security advisories by +default (`audit.block-insecure`); 2.10 adds the `policy` configuration and +blocks known malware at install time. A lock that pins a vulnerable version +installs, but re-resolving it (for example to reproduce a patched fixture) needs +`config.audit.block-insecure: false` (2.9), `config.policy.advisories.block: +false` (2.10) or `--no-security-blocking`. + +## What undoes the wiring + +`composer update `, `composer update --lock` and a full `composer update` +re-resolve the entry from the repositories on every tested version: the lock +goes back to the registry dist (and `source`), so the patch is silently +dropped. Re-run `socket-patch scan` afterwards. + +## Not covered + +- `vendor/composer/installed.json` and `COMPOSER=.json` renamed locks + are not read by `vex`; only the root `composer.lock` is. +- Nested Composer projects are not discovered (discovery is cwd-only). From 98189c0209b59dc773adc64851e722abf4736d80 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 15:00:50 -0400 Subject: [PATCH 04/13] Keep CRLF composer.lock line endings when vendoring Vendoring a Composer patch (vendor, scan --mode vendored, get --mode vendored) rewrote a CRLF composer.lock, as a Windows or core.autocrlf checkout has, with LF line endings. The commit diff covered every line instead of the patched entry, and vendor --revert also wrote LF, so it did not restore the original bytes. Both writes now keep the line endings of the lock they replace, as the hosted mode already did. A CRLF lock now round-trips byte for byte through vendor and vendor --revert. Unit tests and a binary e2e cover vendor, scan --vendor and get --mode vendored on a CRLF lock. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_vendor_composer_crlf.rs | 334 ++++++++++++++++++ .../src/vendor/composer_lock.rs | 82 ++++- docs/testing/composer-compatibility.md | 3 + 3 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 crates/socket-patch-cli/tests/e2e_vendor_composer_crlf.rs diff --git a/crates/socket-patch-cli/tests/e2e_vendor_composer_crlf.rs b/crates/socket-patch-cli/tests/e2e_vendor_composer_crlf.rs new file mode 100644 index 00000000..719a580f --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_vendor_composer_crlf.rs @@ -0,0 +1,334 @@ +//! Vendored composer against a CRLF `composer.lock` (a Windows checkout, +//! `core.autocrlf=true`, or a lock committed with CRLF). +//! +//! Every vendored front door — `vendor` over a staged manifest, `scan +//! --vendor`, and `get --mode vendored` — must write the wired lock +//! back in CRLF (so the diff is the one entry, not every line), an in-sync +//! re-run must leave it byte-identical, and `vendor --revert` must restore +//! the pre-vendor bytes exactly. Each test runs the built binary; the +//! discovery/view routes are a wiremock API, so no composer and no network. + +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const UUID: &str = "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b"; +const PURL: &str = "pkg:composer/psr/log@3.0.2"; +const GHSA: &str = "GHSA-crlf-aaaa-bbbb"; +const FILE: &str = "src/LoggerInterface.php"; +const ORIGINAL: &[u8] = b" String { + r#"{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "type": "library" + } + ], + "packages-dev": [], + "plugin-api-version": "2.6.0" +} +"# + .replace('\n', "\r\n") +} + +fn write_project(root: &Path) { + std::fs::write( + root.join("composer.json"), + "{\r\n \"require\": {\r\n \"psr/log\": \"^3.0\"\r\n }\r\n}\r\n", + ) + .unwrap(); + let installed = root.join("vendor/composer"); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write( + installed.join("installed.json"), + r#"{ "packages": [ { "name": "psr/log", "version": "3.0.2", "version_normalized": "3.0.2.0" } ] } +"#, + ) + .unwrap(); + let pkg = root.join("vendor/psr/log"); + std::fs::create_dir_all(pkg.join("src")).unwrap(); + std::fs::write(pkg.join(FILE), ORIGINAL).unwrap(); + std::fs::write(root.join("composer.lock"), crlf_lock()).unwrap(); +} + +fn vulnerabilities() -> Value { + json!({ GHSA: { + "cves": ["CVE-2026-0005"], + "summary": "composer crlf fixture", + "severity": "high", + "description": "d" + }}) +} + +/// `.socket/manifest.json` + the after-hash blob, so `vendor` runs offline. +fn stage_manifest(root: &Path) { + let after = compute_git_sha256_from_bytes(PATCHED); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let manifest = json!({ "patches": { PURL: { + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { FILE: { + "beforeHash": compute_git_sha256_from_bytes(ORIGINAL), + "afterHash": after, + }}, + "vulnerabilities": vulnerabilities(), + "description": "x", "license": "MIT", "tier": "free", + }}}); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(after), PATCHED).unwrap(); +} + +async fn mount_api(server: &MockServer) { + let blob = base64::engine::general_purpose::STANDARD.encode(PATCHED); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": ["CVE-2026-0005"], "ghsaIds": [GHSA], "severity": "high", + "title": "composer crlf fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + format!("package/{FILE}"): { + "beforeHash": compute_git_sha256_from_bytes(ORIGINAL), + "afterHash": compute_git_sha256_from_bytes(PATCHED), + "blobContent": blob, + } + }, + "vulnerabilities": vulnerabilities(), + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; +} + +fn cli() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG" { + cmd.env_remove(&key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + cmd +} + +fn run_json(root: &Path, args: &[&str]) -> (i32, Value) { + let out = cli() + .args(args) + .args(["--json", "--cwd", root.to_str().unwrap()]) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "{args:?} --json must emit JSON: {e}\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code().unwrap_or(-1), env) +} + +fn api_args(api_url: &str) -> [&str; 7] { + [ + "--yes", + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ] +} + +/// No bare `\n` and no stray `\r`: every line break is a CRLF. +fn assert_all_crlf(text: &str, what: &str) { + let crlf = text.matches("\r\n").count(); + assert!(crlf > 0, "{what} has no line breaks:\n{text}"); + assert_eq!( + crlf, + text.matches('\n').count(), + "{what} must keep CRLF line endings:\n{text:?}" + ); + assert_eq!(crlf, text.matches('\r').count(), "{what}: {text:?}"); +} + +/// The lock after vendoring: CRLF, wired to the copy, and nothing but the +/// psr/log entry changed. +fn assert_wired_crlf(root: &Path) -> String { + let text = std::fs::read_to_string(root.join("composer.lock")).unwrap(); + assert_all_crlf(&text, "vendored composer.lock"); + let lock: Value = serde_json::from_str(&text).unwrap(); + let entry = &lock["packages"][0]; + assert_eq!(entry["dist"]["type"], "path", "{lock:#}"); + assert_eq!( + entry["dist"]["url"], + format!(".socket/vendor/composer/{UUID}/psr/log@3.0.2").as_str() + ); + assert_eq!(entry["dist"]["reference"], UUID); + assert!(entry.get("source").is_none(), "{lock:#}"); + let before: Value = serde_json::from_str(&crlf_lock()).unwrap(); + for key in [ + "_readme", + "content-hash", + "packages-dev", + "plugin-api-version", + ] { + assert_eq!(lock[key], before[key], "{key} untouched"); + } + text +} + +/// A re-run leaves the lock byte-identical, then `vendor --revert` restores +/// the original CRLF bytes and composer.json stays untouched throughout. +fn assert_rerun_and_revert(root: &Path, rerun: &[&str], vendored: &str) { + let (code, env) = run_json(root, rerun); + assert_eq!(code, 0, "re-vendor must succeed: {env:#}"); + assert_eq!( + std::fs::read_to_string(root.join("composer.lock")).unwrap(), + vendored, + "an in-sync re-vendor leaves composer.lock byte-identical" + ); + + let (code, env) = run_json(root, &["vendor", "--revert", "--offline"]); + assert_eq!(code, 0, "vendor --revert must succeed: {env:#}"); + assert_eq!( + std::fs::read_to_string(root.join("composer.lock")).unwrap(), + crlf_lock(), + "vendor --revert must restore the CRLF composer.lock byte-for-byte" + ); + assert_eq!( + std::fs::read(root.join("composer.json")).unwrap(), + b"{\r\n \"require\": {\r\n \"psr/log\": \"^3.0\"\r\n }\r\n}\r\n", + "composer.json is never touched" + ); + assert!( + !root.join(".socket/vendor/composer").exists(), + "revert removes the vendored copy" + ); +} + +#[test] +fn vendor_offline_keeps_a_crlf_lock_and_reverts_it_byte_identically() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_project(root); + stage_manifest(root); + + let (code, env) = run_json(root, &["vendor", "--offline"]); + assert_eq!(code, 0, "vendor must succeed: {env:#}"); + let vendored = assert_wired_crlf(root); + assert_eq!( + std::fs::read( + root.join(format!(".socket/vendor/composer/{UUID}/psr/log@3.0.2")) + .join(FILE) + ) + .unwrap(), + PATCHED + ); + assert_rerun_and_revert(root, &["vendor", "--offline"], &vendored); +} + +#[tokio::test] +async fn scan_vendor_keeps_a_crlf_lock_and_reverts_it_byte_identically() { + let server = MockServer::start().await; + mount_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_project(root); + let uri = server.uri(); + let mut args = vec!["scan", "--vendor", "--vendor-source", "build"]; + args.extend(api_args(&uri)); + + let (code, env) = run_json(root, &args); + assert_eq!(code, 0, "scan --vendor must succeed: {env:#}"); + assert_eq!(env["vendor"]["summary"]["applied"], 1, "{env:#}"); + let vendored = assert_wired_crlf(root); + assert_rerun_and_revert(root, &args, &vendored); +} + +#[tokio::test] +async fn get_vendored_keeps_a_crlf_lock_and_reverts_it_byte_identically() { + let server = MockServer::start().await; + mount_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_project(root); + let uri = server.uri(); + let mut args = vec![ + "get", + UUID, + "--mode", + "vendored", + "--vendor-source", + "build", + ]; + args.extend(api_args(&uri)); + + let (code, env) = run_json(root, &args); + assert_eq!(code, 0, "get --mode vendored must succeed: {env:#}"); + let vendored = assert_wired_crlf(root); + assert_rerun_and_revert(root, &args, &vendored); +} diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index fc26bd30..b150cb5a 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -44,6 +44,7 @@ use crate::patch::copy_tree::{fresh_copy, remove_tree}; use crate::patch::path_safety::{is_safe_multi_segment, is_safe_single_segment}; use crate::utils::composer_version::composer_versions_equivalent; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; +use crate::utils::line_endings::LineEndings; use crate::utils::purl::{build_composer_purl, parse_composer_purl}; use crate::utils::socket_dir::remove_tree_and_prune; @@ -323,7 +324,7 @@ pub async fn vendor_composer( .is_some_and(|p| p.eco == "composer"); let rewritten = rewrite_lock_entry(original_obj, ©_rel, &record.uuid); lock[section][idx] = Value::Object(rewritten.clone()); - let write_result = match composer_json_bytes(&lock) { + let write_result = match composer_lock_bytes(&lock, &lock_text) { Ok(bytes) => atomic_write_bytes_preserving_mode(&lock_path, &bytes).await, Err(e) => Err(e), }; @@ -845,6 +846,19 @@ fn composer_json_bytes(value: &Value) -> std::io::Result> { serialize_json(value, " ") } +/// [`composer_json_bytes`] in the line endings of `current`, the lock text +/// being replaced: a CRLF lock (a Windows checkout) is written back CRLF, +/// so vendor then `--revert` is byte-identical. serde_json escapes every +/// newline inside a string, so each raw `\n` is a line break. +fn composer_lock_bytes(value: &Value, current: &str) -> std::io::Result> { + let bytes = composer_json_bytes(value)?; + if LineEndings::of(current) != LineEndings::Crlf { + return Ok(bytes); + } + let lf = String::from_utf8(bytes).map_err(std::io::Error::other)?; + Ok(LineEndings::Crlf.restore(&lf).into_owned().into_bytes()) +} + /// The `
:` keys this entry can actually put back: /// a recognized wiring kind, a well-formed key, and a recorded `original`. fn restorable_keys(entry: &VendorEntry) -> HashSet { @@ -935,7 +949,7 @@ async fn restore_lock_entry( if !dry_run { lock[section][idx] = original; - let bytes = composer_json_bytes(&lock).map_err(|e| e.to_string())?; + let bytes = composer_lock_bytes(&lock, &lock_text).map_err(|e| e.to_string())?; atomic_write_bytes_preserving_mode(lock_path, &bytes) .await .map_err(|e| format!("failed to write composer.lock: {e}"))?; @@ -1608,6 +1622,70 @@ mod tests { ); } + #[tokio::test] + async fn test_crlf_lock_stays_crlf_and_reverts_byte_identical() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + let lf = String::from_utf8(composer_json_bytes(&lock).unwrap()).unwrap(); + let crlf_bytes = lf.replace('\n', "\r\n").into_bytes(); + tokio::fs::write(root.join(COMPOSER_LOCK), &crlf_bytes) + .await + .unwrap(); + + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success, "{:?}", result.error); + let entry = entry.unwrap(); + let vendored = tokio::fs::read_to_string(root.join(COMPOSER_LOCK)) + .await + .unwrap(); + assert_eq!( + LineEndings::of(&vendored), + LineEndings::Crlf, + "{vendored:?}" + ); + let wired: Value = serde_json::from_str(&vendored).unwrap(); + assert_eq!(wired["packages"][0]["dist"]["type"], "path"); + + let (rerun, _, _) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(rerun.success, "{:?}", rerun.error); + assert_eq!( + tokio::fs::read_to_string(root.join(COMPOSER_LOCK)) + .await + .unwrap(), + vendored, + "an in-sync re-vendor leaves the CRLF lock untouched" + ); + + let outcome = revert_composer(&entry, root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + crlf_bytes, + "CRLF lock restored byte-identically" + ); + } + + #[test] + fn composer_lock_bytes_follows_the_replaced_text() { + let lock = lock_value("psr/log", "3.0.2", false); + let lf = composer_json_bytes(&lock).unwrap(); + let crlf = String::from_utf8(lf.clone()) + .unwrap() + .replace('\n', "\r\n") + .into_bytes(); + assert_eq!(composer_lock_bytes(&lock, "{\r\n}\r\n").unwrap(), crlf); + assert_eq!(composer_lock_bytes(&lock, "{\n}\n").unwrap(), lf); + assert_eq!(composer_lock_bytes(&lock, "{}").unwrap(), lf); + assert_eq!( + composer_lock_bytes(&lock, "{\r\n\n}").unwrap(), + lf, + "mixed endings have no single style to restore" + ); + } + #[tokio::test] async fn test_revert_drift_warning() { let lock = lock_value("psr/log", "3.0.2", false); diff --git a/docs/testing/composer-compatibility.md b/docs/testing/composer-compatibility.md index 63965faf..9347d8e7 100644 --- a/docs/testing/composer-compatibility.md +++ b/docs/testing/composer-compatibility.md @@ -28,6 +28,9 @@ A hosted re-run over a lock rewritten by an older CLI or by the GitHub app (redirected `dist` with `source` or `mirrors` still present) heals it with one edit. `vendor --revert` and the hosted revert restore the recorded fragments byte for byte. +Both modes keep a CRLF `composer.lock` (a Windows or `core.autocrlf` +checkout) in CRLF, so the diff is the patched entry alone and the revert +restores the original bytes. ## Test matrix From 2d1410cd1af6b9b144cde31d0c84816e01330f13 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 15:54:20 -0400 Subject: [PATCH 05/13] Match SBOM-padded Composer date versions Socket's SBOM pads every numeric Composer version to four parts, so a date release locked as 20231001 reaches the CLI as 20231001.0.0.0. Composer rejects that spelling (a 6+ digit major is only valid as a date), so the version key fell back to the raw text and never matched the lock: apply, vendor, hosted redirect and VEX reported the patch as not found for its own package. A 2-4 part numeric spelling Composer rejects is now normalized with its trailing .0 parts dropped. The shared vector file, copied from depscan, gains the padded date cases checked against Composer 2.10.3. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/utils/composer_version.rs | 60 ++++++++++++++++--- .../fixtures/composer-version-vectors.json | 60 +++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-core/src/utils/composer_version.rs b/crates/socket-patch-core/src/utils/composer_version.rs index 22b39545..77c3c55e 100644 --- a/crates/socket-patch-core/src/utils/composer_version.rs +++ b/crates/socket-patch-core/src/utils/composer_version.rs @@ -12,9 +12,12 @@ //! release by (Packagist keeps `version_normalized` unique per package). It is //! checked against real Composer 2.10.3 by the shared vector file //! `tests/fixtures/composer-version-vectors.json`, which depscan's -//! `composerPatchIdentityVersion` twin asserts byte for byte. A spelling -//! Composer rejects keys as itself minus one leading `v`, and is equivalent -//! only to other rejected spellings with the same key. +//! `composerPatchIdentityVersion` twin asserts byte for byte. A 2-4 part +//! numeric spelling Composer rejects (the SBOM's four-part padding of a date +//! version, `20231001.0.0.0`) is normalized with its trailing `.0` parts +//! dropped. Any other spelling Composer rejects keys as itself minus one +//! leading `v`, and is equivalent only to other rejected spellings with the +//! same key. //! //! Only comparisons go through this module. Stored spellings (manifest keys, //! vendored leaf directories, ledger keys, crawler purls) are unchanged. @@ -189,10 +192,37 @@ fn strip_leading_v(version: &str) -> &str { } } -/// The identity key of a composer version: [`composer_version_normalize`], -/// else the spelling minus one leading `v`. +/// [`composer_version_normalize`], or for a 2-4 part numeric spelling +/// Composer rejects, the normalized form with its trailing `.0` parts dropped. +/// SBOM rows pad every numeric version to four parts, which Composer rejects +/// for a 6+ digit (date) major (`20231001` → `20231001.0.0.0`). +fn identity_normalize(version: &str) -> Option { + if let Some(normalized) = composer_version_normalize(version) { + return Some(normalized); + } + let stripped = strip_leading_v(version); + let parts: Vec<&str> = stripped.split('.').collect(); + if !(2..=4).contains(&parts.len()) + || !parts + .iter() + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) + { + return None; + } + let keep = parts + .iter() + .rposition(|part| *part != "0") + .map_or(1, |index| index + 1); + if keep == parts.len() { + return None; + } + composer_version_normalize(&parts[..keep].join(".")) +} + +/// The identity key of a composer version: its normalized form (see +/// [`identity_normalize`]), else the spelling minus one leading `v`. pub fn composer_version_key(version: &str) -> String { - composer_version_normalize(version).unwrap_or_else(|| strip_leading_v(version).to_string()) + identity_normalize(version).unwrap_or_else(|| strip_leading_v(version).to_string()) } /// Whether two composer version spellings name the same release @@ -200,7 +230,7 @@ pub fn composer_version_key(version: &str) -> String { /// Composer rejects matches only another rejected spelling with the same /// `v`-stripped text. pub fn composer_versions_equivalent(a: &str, b: &str) -> bool { - match (composer_version_normalize(a), composer_version_normalize(b)) { + match (identity_normalize(a), identity_normalize(b)) { (Some(left), Some(right)) => left == right, (None, None) => strip_leading_v(a) == strip_leading_v(b), _ => false, @@ -319,6 +349,22 @@ mod tests { assert!(failures.is_empty(), "{}", failures.join("\n")); } + #[test] + fn sbom_padded_date_versions_match_their_lock_spelling() { + assert_eq!(composer_version_key("20231001.0.0.0"), "20231001"); + assert!(composer_versions_equivalent("20231001", "20231001.0.0.0")); + assert!(composer_versions_equivalent("123456.1", "123456.1.0.0")); + assert!(!composer_versions_equivalent( + "20231001.0", + "20231001.0.0.0" + )); + assert!(!composer_versions_equivalent("1.2.3.4.5", "1.2.3.4.5.0")); + assert_eq!( + composer_purl_identity("pkg:composer/acme/dated@20231001.0.0.0"), + composer_purl_identity("pkg:composer/acme/dated@20231001"), + ); + } + #[test] fn purl_identity_bridges_padding_prefix_case_and_encoding() { let want = Some("pkg:composer/psr/log@3.0.2.0".to_string()); diff --git a/crates/socket-patch-core/tests/fixtures/composer-version-vectors.json b/crates/socket-patch-core/tests/fixtures/composer-version-vectors.json index d9e8bd57..31ff999b 100644 --- a/crates/socket-patch-core/tests/fixtures/composer-version-vectors.json +++ b/crates/socket-patch-core/tests/fixtures/composer-version-vectors.json @@ -447,6 +447,41 @@ "composerNormalized": "20100102", "identity": "20100102" }, + { + "input": "20231001", + "composerNormalized": "20231001", + "identity": "20231001" + }, + { + "input": "v20231001", + "composerNormalized": "20231001", + "identity": "20231001" + }, + { + "input": "20231001.0", + "composerNormalized": "20231001.0", + "identity": "20231001.0" + }, + { + "input": "20231001.0.0.0", + "composerNormalized": null, + "identity": "20231001" + }, + { + "input": "20231001.1.0.0", + "composerNormalized": null, + "identity": "20231001.1" + }, + { + "input": "123456.1.0.0", + "composerNormalized": null, + "identity": "123456.1" + }, + { + "input": "1234567.0.0.0", + "composerNormalized": null, + "identity": "1234567" + }, { "input": "2010-01-02", "composerNormalized": "2010.01.02", @@ -953,6 +988,31 @@ "left": "", "right": "0", "equivalent": false + }, + { + "left": "20231001", + "right": "20231001.0.0.0", + "equivalent": true + }, + { + "left": "v20231001", + "right": "20231001.0.0.0", + "equivalent": true + }, + { + "left": "123456.1", + "right": "123456.1.0.0", + "equivalent": true + }, + { + "left": "20231001.0", + "right": "20231001.0.0.0", + "equivalent": false + }, + { + "left": "20231001.1.0.0", + "right": "20231001.0.0.0", + "equivalent": false } ] } From a6979fe0b97d7ca56d0f9bfbfac5e5c4e2e7d2ad Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 18:15:55 -0400 Subject: [PATCH 06/13] Strip Composer dist mirrors before redirecting A dist that lists mirrors before its url had the mirrors' url rewritten instead of the dist's own, and a dist whose only url is a mirror is now refused with redirect_composer_no_dist_url. Also drop the dead composer_source_before_dist helper. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/patch/redirect/composer_source.rs | 25 ++++++----- .../src/patch/redirect/mod.rs | 27 ++++------- .../expected-edits.json | 10 +++++ .../expected-warnings.json | 3 ++ .../expected/composer.lock | 34 ++++++++++++++ .../input/composer.lock | 45 +++++++++++++++++++ .../dist-mirrors-before-url/overrides.json | 14 ++++++ 7 files changed, 127 insertions(+), 31 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected-edits.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected-warnings.json create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/input/composer.lock create mode 100644 crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/overrides.json diff --git a/crates/socket-patch-core/src/patch/redirect/composer_source.rs b/crates/socket-patch-core/src/patch/redirect/composer_source.rs index a5f3e3d0..8c89f13c 100644 --- a/crates/socket-patch-core/src/patch/redirect/composer_source.rs +++ b/crates/socket-patch-core/src/patch/redirect/composer_source.rs @@ -32,7 +32,7 @@ use super::{FileEdit, RewriteWarning}; /// One `"key": value` member of a JSON object, by byte offset. `key` is the /// raw text between the key's quotes; `value_end` is inclusive. #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct Member { +pub(crate) struct Member { pub key: String, pub key_start: usize, pub value_start: usize, @@ -115,7 +115,7 @@ fn container_end(bytes: &[u8], open: usize, limit: usize) -> Option { } /// Inclusive end of the JSON value starting at `start`. -fn value_end_at(bytes: &[u8], start: usize, limit: usize) -> Option { +pub(crate) fn value_end_at(bytes: &[u8], start: usize, limit: usize) -> Option { match bytes[start] { b'"' => string_end(bytes, start, limit), b'{' | b'[' => container_end(bytes, start, limit), @@ -170,7 +170,7 @@ pub(super) fn entry_object_start(text: &str, name_key_start: usize) -> Option Vec { +pub(crate) fn top_level_members(text: &str, object_open: usize, object_end: usize) -> Vec { let bytes = text.as_bytes(); let object_end = object_end.min(bytes.len()); let mut members = Vec::new(); @@ -283,10 +283,12 @@ pub(super) struct DistSpan { /// Splice the redirected dist (or, when `rewritten_dist` is `None` because /// the dist is already redirected, the current one) into the entry, dropping -/// the entry's top-level `source` and the dist's `mirrors`. The recorded edit -/// spans the dist block AND the removed `source` member, so the ledger's -/// fragment revert restores both byte-for-byte. `None` — no edit, no ledger -/// growth — when nothing changes (an idempotent re-run over a healed lock). +/// the entry's top-level `source` and the dist's `mirrors` (warned about when +/// the lock's dist had them, whether or not `rewritten_dist` still does). +/// The recorded edit spans the dist block AND the removed `source` member, +/// so the ledger's fragment revert restores both byte-for-byte. `None` — no +/// edit, no ledger growth — when nothing changes (an idempotent re-run over +/// a healed lock). pub(super) fn apply_dist_edit( content: &mut String, span: DistSpan, @@ -300,11 +302,10 @@ pub(super) fn apply_dist_edit( dist_start, dist_end, } = span; - let mut dist = rewritten_dist - .map(str::to_string) - .unwrap_or_else(|| content[dist_start..=dist_end].to_string()); - if let Some(stripped) = strip_dist_mirrors(&dist) { - dist = stripped; + let current = &content[dist_start..=dist_end]; + let spliced = rewritten_dist.unwrap_or(current); + let dist = strip_dist_mirrors(spliced).unwrap_or_else(|| spliced.to_string()); + if strip_dist_mirrors(current).is_some() { warnings.push(RewriteWarning { code: "redirect_composer_dist_mirrors_removed".into(), detail: format!( diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d786d9a1..a04c8a1a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -4127,7 +4127,7 @@ fn rewrite_uv_lock( } } -mod composer_source; +pub(crate) mod composer_source; // ── composer.lock ──────────────────────────────────────────────────────────── /// Whether `text` points at `artifact_url` in any spelling a rewritten file may @@ -4258,23 +4258,6 @@ static COMPOSER_DIST_SHASUM_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"("shasum": ")[^"]*(")"#).expect("static dist shasum regex is valid") }); -/// Byte offset of the entry's `"source": {` key when that object is the -/// dist block's IMMEDIATE predecessor (only `,` + whitespace between them) — -/// the layout composer itself always writes (`source` then `dist`). -/// `None` when the entry has no source object there. -#[allow(dead_code)] -fn composer_source_before_dist( - content: &str, - entry_start: usize, - dist_start: usize, -) -> Option { - const SOURCE_KEY: &str = "\"source\": {"; - let source_start = entry_start + content[entry_start..dist_start].rfind(SOURCE_KEY)?; - let source_end = json_object_end_from(content, source_start + SOURCE_KEY.len())?; - (source_end < dist_start && content[source_end + 1..dist_start].trim() == ",") - .then_some(source_start) -} - fn rewrite_composer_lock( files: &BTreeMap, overrides: &[DepOverride], @@ -4359,7 +4342,13 @@ fn rewrite_composer_lock( }); continue; }; - let block = content[dist_start..=dist_end].to_string(); + // The dist's own members only: a `mirrors` entry listed before the + // dist `url` would otherwise take the redirected url and then be + // dropped with the mirrors, leaving the upstream url pinned to the + // patched sha1. + let current = &content[dist_start..=dist_end]; + let block = + composer_source::strip_dist_mirrors(current).unwrap_or_else(|| current.to_string()); // Already redirected (either slash spelling): only the source/mirrors // heal applies, so a re-run over a healed lock records no edit and // the ledger never grows. diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected-edits.json new file mode 100644 index 00000000..41bdfbf9 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"source\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/Seldaek/monolog.git\",\n \"reference\": \"abc123def456\"\n },\n \"dist\": {\n \"mirrors\": [\n {\n \"url\": \"https://mirror.example/dists/%package%/%version%/%reference%.%type%\",\n \"preferred\": true\n }\n ],\n \"type\": \"zip\",\n \"url\": \"https://api.github.com/repos/Seldaek/monolog/zipball/abc123\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"\"\n }", + "new": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected-warnings.json new file mode 100644 index 00000000..d0b9a5ea --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_composer_dist_mirrors_removed" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected/composer.lock new file mode 100644 index 00000000..67324378 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/expected/composer.lock @@ -0,0 +1,34 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/input/composer.lock new file mode 100644 index 00000000..6509f857 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/input/composer.lock @@ -0,0 +1,45 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "abc123def456" + }, + "dist": { + "mirrors": [ + { + "url": "https://mirror.example/dists/%package%/%version%/%reference%.%type%", + "preferred": true + } + ], + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/overrides.json new file mode 100644 index 00000000..a83719b2 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/dist-mirrors-before-url/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] From f317fe40d6d8b5bcfd5668d155dbdb48cce5f461 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 18:15:55 -0400 Subject: [PATCH 07/13] Edit only the vendored entry in composer.lock Vendoring rewrote the whole composer.lock through serde_json, so a lock with \/ escapes, \uXXXX escapes or mixed line endings did not revert byte for byte. Splice only the patched entry's text, keeping the lock's indentation, line endings and escaping. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/composer_lock.rs | 106 +++++++- .../src/vendor/composer_lock/lock_text.rs | 240 ++++++++++++++++++ 2 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 crates/socket-patch-core/src/vendor/composer_lock/lock_text.rs diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index b150cb5a..736baf77 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -28,9 +28,13 @@ //! patch purl the bare `6.4.1` or padded `3.0.2.0`) but the lock's own //! `version` string is never rewritten. //! -//! Serialization mirrors composer's own writer: 4-space indent -//! (`JSON_PRETTY_PRINT`) + trailing newline; serde_json does not escape `/` -//! (matching `JSON_UNESCAPED_SLASHES`). +//! Only the rewritten entry's text is replaced (`lock_text`), rendered in +//! the indentation, line terminator and escaping of the text it replaces, so +//! every other byte of the lock is untouched and `--revert` restores it +//! byte for byte. A lock whose entry is not on lines of its own (compact +//! JSON) is re-serialized whole the way composer writes it: 4-space indent +//! (`JSON_PRETTY_PRINT`) + trailing newline, `/` unescaped +//! (`JSON_UNESCAPED_SLASHES`). use std::collections::HashSet; use std::path::Path; @@ -62,6 +66,7 @@ use super::state::{ }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; +mod lock_text; mod mirror_filters; /// Project-relative lockfile this backend wires. @@ -324,7 +329,7 @@ pub async fn vendor_composer( .is_some_and(|p| p.eco == "composer"); let rewritten = rewrite_lock_entry(original_obj, ©_rel, &record.uuid); lock[section][idx] = Value::Object(rewritten.clone()); - let write_result = match composer_lock_bytes(&lock, &lock_text) { + let write_result = match lock_bytes_with_entry(&lock, &lock_text, section, idx) { Ok(bytes) => atomic_write_bytes_preserving_mode(&lock_path, &bytes).await, Err(e) => Err(e), }; @@ -859,6 +864,22 @@ fn composer_lock_bytes(value: &Value, current: &str) -> std::io::Result> Ok(LineEndings::Crlf.restore(&lf).into_owned().into_bytes()) } +/// The lock bytes after `lock[section][idx]` (already set in `lock`) +/// replaced the entry at that position of `current`, the text on disk: a +/// splice of that entry alone, or the whole document when the entry cannot +/// be located on its own lines. +fn lock_bytes_with_entry( + lock: &Value, + current: &str, + section: &str, + idx: usize, +) -> std::io::Result> { + match lock_text::replace_entry(current, section, idx, &lock[section][idx]) { + Some(text) => Ok(text.into_bytes()), + None => composer_lock_bytes(lock, current), + } +} + /// The `
:` keys this entry can actually put back: /// a recognized wiring kind, a well-formed key, and a recorded `original`. fn restorable_keys(entry: &VendorEntry) -> HashSet { @@ -949,7 +970,8 @@ async fn restore_lock_entry( if !dry_run { lock[section][idx] = original; - let bytes = composer_lock_bytes(&lock, &lock_text).map_err(|e| e.to_string())?; + let bytes = + lock_bytes_with_entry(&lock, &lock_text, section, idx).map_err(|e| e.to_string())?; atomic_write_bytes_preserving_mode(lock_path, &bytes) .await .map_err(|e| format!("failed to write composer.lock: {e}"))?; @@ -1668,6 +1690,80 @@ mod tests { ); } + /// Vendor then `--revert` on `lock_bytes`, asserting the revert + /// restores them byte for byte; returns the vendored lock text. + async fn vendor_and_revert_round_trip(lock: &Value, lock_bytes: &[u8]) -> String { + let (dir, blobs, installed, record) = fixture(lock).await; + let root = dir.path(); + tokio::fs::write(root.join(COMPOSER_LOCK), lock_bytes) + .await + .unwrap(); + let (result, entry, _w) = + unwrap_done(run_vendor(root, &blobs, &installed, &record, PURL, false).await); + assert!(result.success, "{:?}", result.error); + let vendored = tokio::fs::read_to_string(root.join(COMPOSER_LOCK)) + .await + .unwrap(); + let wired: Value = serde_json::from_str(&vendored).unwrap(); + assert_eq!(wired["packages"][0]["dist"]["type"], "path"); + let outcome = revert_composer(&entry.unwrap(), root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read(root.join(COMPOSER_LOCK)).await.unwrap(), + lock_bytes, + "lock restored byte-identically" + ); + vendored + } + + /// A lock that spells slashes `\/` (older writers) keeps that spelling + /// on every line, the vendored entry's included, and reverts + /// byte-identically. + #[tokio::test] + async fn escaped_slash_lock_keeps_its_escaping_and_reverts_byte_identical() { + let mut lock = lock_value("psr/log", "3.0.2", false); + lock["packages"] + .as_array_mut() + .unwrap() + .push(psr_log_entry("monolog/monolog", "2.9.1")); + let text = String::from_utf8(composer_json_bytes(&lock).unwrap()).unwrap(); + let escaped = text.replace('/', "\\/"); + let vendored = vendor_and_revert_round_trip(&lock, escaped.as_bytes()).await; + assert!( + !vendored.replace("\\/", "").contains('/'), + "every slash stays escaped: {vendored}" + ); + assert!( + vendored.contains(&format!( + "\".socket\\/vendor\\/composer\\/{UUID}\\/psr\\/log@3.0.2\"" + )), + "{vendored}" + ); + let bystander = &escaped[escaped.find("\"name\": \"monolog").unwrap()..]; + assert!( + vendored.ends_with(bystander), + "untouched entries keep their bytes" + ); + } + + /// A lock mixing CRLF and LF keeps every line outside the vendored + /// entry as it was, and reverts byte-identically. + #[tokio::test] + async fn mixed_line_ending_lock_keeps_untouched_lines_and_reverts_byte_identical() { + let lock = lock_value("psr/log", "3.0.2", false); + let text = String::from_utf8(composer_json_bytes(&lock).unwrap()).unwrap(); + let packages = text.find("\"packages\"").unwrap(); + let mixed = format!( + "{}{}", + text[..packages].replace('\n', "\r\n"), + &text[packages..] + ); + assert_eq!(LineEndings::of(&mixed), LineEndings::Mixed); + let vendored = vendor_and_revert_round_trip(&lock, mixed.as_bytes()).await; + assert!(vendored.starts_with(&mixed[..packages]), "{vendored:?}"); + assert_eq!(LineEndings::of(&vendored), LineEndings::Mixed); + } + #[test] fn composer_lock_bytes_follows_the_replaced_text() { let lock = lock_value("psr/log", "3.0.2", false); diff --git a/crates/socket-patch-core/src/vendor/composer_lock/lock_text.rs b/crates/socket-patch-core/src/vendor/composer_lock/lock_text.rs new file mode 100644 index 00000000..9922d4fa --- /dev/null +++ b/crates/socket-patch-core/src/vendor/composer_lock/lock_text.rs @@ -0,0 +1,240 @@ +//! Text-level replacement of one composer.lock package entry. +//! +//! Vendoring and `--revert` each change ONE entry, so they splice that +//! entry's text and leave every other byte of the lock alone. A whole-lock +//! re-serialization rewrote what serde_json spells differently from the +//! lock's writer: `\/`-escaped slashes and `\u00e9`-escaped characters +//! (older or non-Composer writers), and the line endings of a lock that +//! mixes CRLF and LF. The spliced entry is rendered in the style of the +//! text it replaces: its indentation, its line terminator, and the lock's +//! slash and unicode escaping, so vendor then `--revert` restores a +//! Composer-shaped entry byte for byte. + +use serde_json::Value; + +use crate::patch::redirect::composer_source::{top_level_members, value_end_at}; +use crate::utils::line_endings::{majority_terminator, LineEndings}; +use crate::vendor::common::{detect_indent, serialize_json}; + +/// `text` with `lock[section][index]` replaced by `entry`, or `None` when +/// the entry cannot be located as its own indented line run (the caller +/// then re-serializes the whole document). +pub(super) fn replace_entry( + text: &str, + section: &str, + index: usize, + entry: &Value, +) -> Option { + let (start, end) = entry_span(text, section, index)?; + let line_start = text[..start].rfind('\n').map_or(0, |i| i + 1); + let base = &text[line_start..start]; + if !base.bytes().all(|b| b == b' ' || b == b'\t') { + return None; + } + let current = &text[start..=end]; + let eol = match LineEndings::of(current) { + LineEndings::Crlf => "\r\n", + LineEndings::Lf => "\n", + LineEndings::Mixed => majority_terminator(current), + LineEndings::None => majority_terminator(text), + }; + let mut rendered = serialize_json(entry, &indent_unit(text, base)).ok()?; + rendered.pop(); + let mut rendered = String::from_utf8(rendered).ok()?; + if escapes_slashes(text) { + rendered = rendered.replace('/', "\\/"); + } + if escapes_unicode(text) { + rendered = escape_non_ascii(&rendered); + } + // serde_json escapes every newline inside a string, so each `\n` it + // emits is a line break of the entry. + let rendered = rendered.replace('\n', &format!("{eol}{base}")); + Some(format!("{}{rendered}{}", &text[..start], &text[end + 1..])) +} + +/// Byte span (inclusive) of `lock[section][index]`, counting every array +/// element as the parsed `Value` does. A repeated `section` key resolves to +/// its last occurrence, the one serde_json keeps. +fn entry_span(text: &str, section: &str, index: usize) -> Option<(usize, usize)> { + let bytes = text.as_bytes(); + let open = text.find(|c: char| !c.is_ascii_whitespace())?; + if bytes[open] != b'{' { + return None; + } + let close = value_end_at(bytes, open, bytes.len())?; + let member = top_level_members(text, open, close) + .into_iter() + .rfind(|m| m.key == section)?; + if bytes[member.value_start] != b'[' { + return None; + } + let array_end = member.value_end; + let mut i = member.value_start + 1; + let mut position = 0; + loop { + while i < array_end && (bytes[i].is_ascii_whitespace() || bytes[i] == b',') { + i += 1; + } + if i >= array_end { + return None; + } + let element_end = value_end_at(bytes, i, array_end)?; + if position == index { + return Some((i, element_end)); + } + position += 1; + i = element_end + 1; + } +} + +/// The indent unit of an entry at depth 2 whose own line starts with +/// `base`: half of it when it is one unit repeated (Composer's 8 spaces → +/// 4), else the lock's first indent. +fn indent_unit(text: &str, base: &str) -> String { + let half = base.len() / 2; + if half > 0 && base.len() % 2 == 0 && base[..half] == base[half..] { + base[..half].to_string() + } else { + detect_indent(text) + } +} + +/// Backslash count immediately before `index`, odd when the byte there is +/// escaped. +fn escaped(bytes: &[u8], index: usize) -> bool { + bytes[..index] + .iter() + .rev() + .take_while(|&&b| b == b'\\') + .count() + % 2 + == 1 +} + +/// Whether the lock spells every `/` as `\/` (and has at least one). +fn escapes_slashes(text: &str) -> bool { + let bytes = text.as_bytes(); + let mut any = false; + for (i, &b) in bytes.iter().enumerate() { + if b == b'/' { + if !escaped(bytes, i) { + return false; + } + any = true; + } + } + any +} + +/// Whether the lock is pure ASCII and spells some non-ASCII character as a +/// `\uXXXX` escape: PHP's `json_encode` without `JSON_UNESCAPED_UNICODE`. +fn escapes_unicode(text: &str) -> bool { + if !text.is_ascii() { + return false; + } + let bytes = text.as_bytes(); + text.match_indices("\\u").any(|(i, _)| { + !escaped(bytes, i) + && text + .get(i + 2..i + 6) + .and_then(|hex| u16::from_str_radix(hex, 16).ok()) + .is_some_and(|unit| unit >= 0x80) + }) +} + +/// Every non-ASCII character as PHP writes it: lowercase `\uXXXX`, with a +/// surrogate pair above the BMP. Non-ASCII only occurs inside strings in +/// serde_json output. +fn escape_non_ascii(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + if c.is_ascii() { + out.push(c); + continue; + } + let mut units = [0u16; 2]; + for unit in c.encode_utf16(&mut units) { + out.push_str(&format!("\\u{unit:04x}")); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn lock_text(entries: &Value) -> String { + let doc = json!({ + "_readme": ["This file locks the dependencies"], + "content-hash": "abc", + "packages": entries, + "packages-dev": [] + }); + String::from_utf8(serialize_json(&doc, " ").unwrap()).unwrap() + } + + #[test] + fn a_canonical_entry_round_trips_byte_for_byte() { + let entries = json!([ + {"name": "a/a", "version": "1.0.0", "dist": {"type": "zip", "url": "https://x/a"}}, + {"name": "b/b", "version": "2.0.0", "require": {}} + ]); + let text = lock_text(&entries); + for index in 0..2 { + assert_eq!( + replace_entry(&text, "packages", index, &entries[index]).unwrap(), + text + ); + } + let replaced = replace_entry(&text, "packages", 1, &json!({"name": "c/c"})).unwrap(); + let mut expected: Value = serde_json::from_str(&text).unwrap(); + expected["packages"][1] = json!({"name": "c/c"}); + assert_eq!(replaced, lock_text(&expected["packages"])); + assert!(replace_entry(&text, "packages", 2, &json!({})).is_none()); + assert!(replace_entry(&text, "packages-dev", 0, &json!({})).is_none()); + assert!(replace_entry(&text, "missing", 0, &json!({})).is_none()); + } + + #[test] + fn the_replaced_entry_keeps_the_lock_escaping_and_line_endings() { + let entry = json!({"name": "a/a", "authors": [{"name": "Zoë 🎉"}], "url": "https://x/a"}); + let text = lock_text(&json!([entry])); + let escaped = escape_non_ascii(&text.replace('/', "\\/")); + assert!(escaped.contains("Zo\\u00eb \\ud83c\\udf89"), "{escaped}"); + assert!(escapes_slashes(&escaped) && escapes_unicode(&escaped)); + assert_eq!( + replace_entry(&escaped, "packages", 0, &entry).unwrap(), + escaped + ); + + let crlf = text.replace('\n', "\r\n"); + assert_eq!(replace_entry(&crlf, "packages", 0, &entry).unwrap(), crlf); + + // A lock mixing both: only the entry's own lines are rendered, in + // the entry's own terminator. + let first_line = text.find('\n').unwrap(); + let mixed = format!("{}\r\n{}", &text[..first_line], &text[first_line + 1..]); + assert_eq!(LineEndings::of(&mixed), LineEndings::Mixed); + assert_eq!(replace_entry(&mixed, "packages", 0, &entry).unwrap(), mixed); + } + + #[test] + fn detection_ignores_escaped_backslashes() { + assert!(!escapes_slashes(r#"{"a": "x\\/y"}"#)); + assert!(escapes_slashes(r#"{"a": "x\\\/y"}"#)); + assert!(!escapes_slashes(r#"{"a": "x"}"#)); + assert!(!escapes_unicode(r#"{"a": "\\u00e9"}"#)); + assert!(!escapes_unicode(r#"{"a": "\u0041"}"#)); + assert!(!escapes_unicode("{\"a\": \"\\u00e9 é\"}")); + } + + #[test] + fn a_compact_lock_is_left_to_the_caller() { + let text = r#"{"packages": [{"name": "a/a"}]}"#; + assert!(replace_entry(text, "packages", 0, &json!({"name": "b/b"})).is_none()); + assert!(replace_entry("[]", "packages", 0, &json!({})).is_none()); + } +} From 2d7333ee6f5b74eba98f64975dc34ea9f7a929c5 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 18:15:55 -0400 Subject: [PATCH 08/13] Match vendored Composer ownership by release Vendored ownership checks compared composer purls as exact strings, so a ledger keyed pkg:composer/psr/log@3.0.2.0 never covered the lock's @3.0.2 and apply, rollback, gc and scan treated a live vendored patch as foreign or dead. Match composer purls by release identity. The vendor hint now reads composer.lock only when this run wired composer, and never blocks on a special file. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/apply.rs | 12 +- .../socket-patch-cli/src/commands/rollback.rs | 7 +- .../src/commands/scan/discovery.rs | 49 ++++++- .../socket-patch-cli/src/commands/scan/gc.rs | 5 +- .../socket-patch-cli/src/commands/scan/mod.rs | 108 ++++++++++++++-- .../src/commands/scan/vendor_flow.rs | 8 +- .../socket-patch-cli/src/commands/vendor.rs | 20 ++- .../tests/covgap_commands_vendor.rs | 55 ++++++++ crates/socket-patch-core/src/vendor/mod.rs | 4 +- crates/socket-patch-core/src/vendor/state.rs | 120 ++++++++++++++++-- .../socket-patch-core/src/vex/discover/mod.rs | 16 ++- 11 files changed, 359 insertions(+), 45 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 3854401f..ae1def25 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -17,6 +17,7 @@ use socket_patch_core::patch::redirect::golang_local::{ use socket_patch_core::telemetry::{track_patch_applied, track_patch_apply_failed}; use socket_patch_core::utils::purl::parse_golang_purl; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::vendor::purl_keys_cover; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -246,11 +247,10 @@ async fn mismatch_blob_gaps( .iter() .filter(|(key, _)| *key == purl || strip_purl_qualifiers(key) == stripped) .collect(); - if vendored_purls.contains(purl.as_str()) - || vendored_purls.contains(stripped) + if purl_keys_cover(vendored_purls, purl) || records .iter() - .any(|(key, _)| vendored_purls.contains(key.as_str())) + .any(|(key, _)| purl_keys_cover(vendored_purls, key)) { continue; } @@ -1294,8 +1294,7 @@ fn synthesize_vendor_owned_results( target_manifest_purls: &HashSet, vendored_purls: &HashSet, ) -> (Vec, HashSet, HashSet) { - let is_vendored = - |p: &str| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)); + let is_vendored = |p: &str| purl_keys_cover(vendored_purls, p); let mut results: Vec = Vec::new(); let mut matched: HashSet = HashSet::new(); let mut vendored_targets: Vec = target_manifest_purls @@ -1711,8 +1710,7 @@ async fn apply_patches_inner( // release-variant manifest keys (pypi `?artifact_id=`…) hit too; // unreadable state degrades to "nothing vendored" (fail-open). let vendored_purls = socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await; - let is_vendored = - |p: &str| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p)); + let is_vendored = |p: &str| purl_keys_cover(&vendored_purls, p); let (mut results, mut matched_manifest_purls, vendored_bases) = synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls); diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 4a3aefe7..25297528 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -16,7 +16,9 @@ use socket_patch_core::patch::rollback::{ }; use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers}; -use socket_patch_core::vendor::{save_state, RevertOpts, VendorState, VendorWarning}; +use socket_patch_core::vendor::{ + purl_keys_cover, save_state, RevertOpts, VendorState, VendorWarning, +}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -2243,8 +2245,7 @@ pub(crate) async fn rollback_patches_inner( // `vendor --revert` undoes it wholesale. Matching mirrors apply's // ledger-key / base-purl / qualifier-stripped triple; the caller // degrades unreadable state to "nothing vendored". - let is_vendored = - |p: &str| vendored_keys.contains(p) || vendored_keys.contains(strip_purl_qualifiers(p)); + let is_vendored = |p: &str| purl_keys_cover(vendored_keys, p); let (vendored_targets, patches_to_rollback): (Vec<_>, Vec<_>) = patches_to_rollback .into_iter() .partition(|p| is_vendored(&p.purl)); diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index d3876120..c3e601b3 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -7,7 +7,7 @@ use socket_patch_core::api::types::{ BatchPackagePatches, BatchPatchInfo, PatchResponse, PatchSearchResult, }; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; -use socket_patch_core::utils::composer_version::purl_identity_key; +use socket_patch_core::utils::composer_version::{composer_purl_identity, purl_identity_key}; use socket_patch_core::utils::purl::{normalize_purl, purl_eq, strip_purl_qualifiers}; use socket_patch_core::vendor::lock_inventory::LockfileEntry; use socket_patch_core::vendor::VendorState; @@ -186,14 +186,14 @@ pub(super) async fn vendored_ledger_supplement( // contract-documented recovery convention — see `vendor::path`). Err(_) => vendored_purls_from_artifacts(common).await, }; - let crawled_norm: HashSet = crawled - .iter() - .map(|p| normalize_purl(&p.purl).into_owned()) - .collect(); + // Composer by release identity: a ledger `@3.0.2.0` is the crawled + // `@3.0.2`, not a second package to supplement. + let key = |p: &str| composer_purl_identity(p).unwrap_or_else(|| normalize_purl(p).into_owned()); + let crawled_norm: HashSet = crawled.iter().map(|p| key(&p.purl)).collect(); let mut seen: HashSet = HashSet::new(); let mut out = Vec::new(); for base in &base_purls { - let norm = normalize_purl(base).into_owned(); + let norm = key(base); if crawled_norm.contains(&norm) || !seen.insert(norm) { continue; } @@ -1154,6 +1154,43 @@ mod tests { vendored_ledger_supplement(&args, crawled, &state).await } + /// A ledger entry vendored as `@3.0.2.0` is the crawled composer + /// `@3.0.2`, not a second package to add to the scan. + #[tokio::test] + async fn ledger_supplement_matches_composer_by_release_identity() { + let tmp = tempfile::tempdir().unwrap(); + let mut state = VendorState::new(); + let entry: socket_patch_core::vendor::VendorEntry = + serde_json::from_value(serde_json::json!({ + "ecosystem": "composer", + "basePurl": "pkg:composer/psr/log@3.0.2.0", + "uuid": VENDORED_UUID, + "artifact": {"path": format!(".socket/vendor/composer/{VENDORED_UUID}/psr/log@3.0.2.0"), "sha256": ""}, + "wiring": [], + })) + .unwrap(); + state + .entries + .insert("pkg:composer/psr/log@3.0.2.0".to_string(), entry); + let crawled = crawled_from_purl("pkg:composer/psr/log@3.0.2", tmp.path()).unwrap(); + let args = GlobalArgs { + cwd: tmp.path().to_path_buf(), + ..GlobalArgs::default() + }; + let out = vendored_ledger_supplement(&args, &[crawled], &Ok(state.clone())).await; + assert!( + out.is_empty(), + "{:?}", + out.iter().map(|p| &p.purl).collect::>() + ); + + let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await; + assert_eq!( + out.iter().map(|p| p.purl.as_str()).collect::>(), + vec!["pkg:composer/psr/log@3.0.2.0"] + ); + } + #[tokio::test] async fn corrupt_ledger_recovers_vendored_purls_from_committed_artifacts() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 66f02bbb..6c2aab9b 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -7,7 +7,7 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::utils::composer_version::purl_identity_key; use socket_patch_core::utils::purl::strip_purl_qualifiers; -use socket_patch_core::vendor::VENDOR_STATE_REL; +use socket_patch_core::vendor::{purl_keys_cover, VENDOR_STATE_REL}; use std::collections::HashSet; use std::path::Path; use std::time::Duration; @@ -505,8 +505,7 @@ fn detect_prunable( .keys() .filter(|p| { !scanned_bases.contains(&purl_identity_key(p)) - && !vendored.contains(p.as_str()) - && !vendored.contains(strip_purl_qualifiers(p)) + && !purl_keys_cover(vendored, p) && crate::ecosystem_dispatch::crawl_covers_purl(p.as_str()) }) .cloned() diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 0e727c38..3ab1ee0d 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -16,8 +16,9 @@ use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::telemetry::{track_patch_scan_failed, track_patch_scanned}; +use socket_patch_core::utils::composer_version::purl_identity_key; use socket_patch_core::utils::purl::{normalize_purl, purl_name_version, strip_purl_qualifiers}; -use socket_patch_core::vendor::VendorState; +use socket_patch_core::vendor::{purl_keys_cover, VendorState}; use socket_patch_core::vex::discover::{LedgerLiveness, WiringMode}; use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; @@ -601,11 +602,8 @@ fn partition_agent_selection( vendored: &HashSet, lockfile_only: &LockfileSupplement, ) -> AgentSelection { - let (kept, vendored_records) = partition_skipped_selected( - selected, - |p| vendored.contains(p) || vendored.contains(strip_purl_qualifiers(p)), - "vendored", - ); + let (kept, vendored_records) = + partition_skipped_selected(selected, |p| purl_keys_cover(vendored, p), "vendored"); let (kept, not_installed_records) = partition_skipped_selected( kept, |p| lockfile_only_contains(&lockfile_only.purls, p), @@ -1259,9 +1257,11 @@ pub(super) async fn hosted_wiring_retained_purls( return Vec::new(); } let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + // By release identity: a record keyed `@3.0.2.0` names the scanned + // composer `@3.0.2`. let scanned: std::collections::BTreeSet = scanned_purls .into_iter() - .map(|p| canon(p.as_ref())) + .map(|p| purl_identity_key(p.as_ref())) .collect(); // Cheap no-I/O gate: only ledger records naming a scanned purl can ever // prove live wiring, so when none do (a zero/filtered discovery, or a @@ -1269,8 +1269,8 @@ pub(super) async fn hosted_wiring_retained_purls( let candidates: Vec<(String, &str)> = redirect .records .iter() + .filter(|(key, _)| scanned.contains(&purl_identity_key(key))) .map(|(key, record)| (canon(key), record.uuid.as_str())) - .filter(|(purl, _)| scanned.contains(purl)) .collect(); if candidates.is_empty() { return Vec::new(); @@ -3205,6 +3205,98 @@ mod tests { assert_eq!(retained, vec![purl.to_string()]); } + /// A composer package vendored under the padded `@3.0.2.0` spelling is + /// still vendor-owned when a later scan's batch echoes `@3.0.2`: it is + /// skipped as vendored, never applied in place. + #[test] + fn agent_selection_partitions_composer_vendored_by_release_identity() { + let result = |purl: &str| PatchSearchResult { + uuid: TAKEOVER_UUID.to_string(), + purl: purl.to_string(), + published_at: String::new(), + description: String::new(), + license: "MIT".to_string(), + tier: "free".to_string(), + vulnerabilities: HashMap::new(), + }; + let mut state = VendorState::new(); + let entry: socket_patch_core::vendor::VendorEntry = serde_json::from_value( + serde_json::json!({ + "ecosystem": "composer", + "basePurl": "pkg:composer/psr/log@3.0.2.0", + "uuid": TAKEOVER_UUID, + "artifact": {"path": format!(".socket/vendor/composer/{TAKEOVER_UUID}/psr/log@3.0.2.0"), "sha256": ""}, + "wiring": [], + }), + ) + .unwrap(); + state + .entries + .insert("pkg:composer/psr/log@3.0.2.0".to_string(), entry); + let split = partition_agent_selection( + vec![ + result("pkg:composer/psr/log@3.0.2"), + result("pkg:composer/psr/log@3.0.3"), + ], + &state.purl_keys(), + &LockfileSupplement::default(), + ); + assert_eq!(split.vendored_purls, vec!["pkg:composer/psr/log@3.0.2"]); + assert_eq!( + split + .kept + .iter() + .map(|p| p.purl.as_str()) + .collect::>(), + vec!["pkg:composer/psr/log@3.0.3"] + ); + } + + /// A hosted record keyed by the padded `@3.0.2.0` spelling still names + /// the scanned composer `@3.0.2` whose live lock the patch server wires. + #[tokio::test] + async fn hosted_retained_probe_matches_composer_by_release_identity() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let padded = "pkg:composer/psr/log@3.0.2.0"; + write_redirect_ledger_with_edit(root, &[padded]).await; + let lock = serde_json::json!({ + "packages": [{ + "name": "psr/log", + "version": "3.0.2", + "dist": { + "type": "zip", + "url": format!("https://patch.socket.dev/patch/composer/psr/log/3.0.2/11111111-1111-1111-1111-111111111111/{TAKEOVER_UUID}/log-3.0.2.zip"), + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "0123456789abcdef0123456789abcdef01234567" + } + }], + "packages-dev": [] + }); + tokio::fs::write( + root.join("composer.lock"), + serde_json::to_string_pretty(&lock).unwrap(), + ) + .await + .unwrap(); + let ledger = load_ledger(root).await; + for scanned in ["pkg:composer/psr/log@3.0.2", "pkg:composer/psr/log@v3.0.2"] { + let scanned: HashSet = [scanned.to_string()].into_iter().collect(); + assert_eq!( + hosted_wiring_retained_purls(&common_at(root), ledger.as_ref(), &scanned).await, + vec![padded.to_string()] + ); + } + let other: HashSet = ["pkg:composer/psr/log@3.0.3".to_string()] + .into_iter() + .collect(); + assert!( + hosted_wiring_retained_purls(&common_at(root), ledger.as_ref(), &other) + .await + .is_empty() + ); + } + #[tokio::test] async fn hosted_retained_probe_is_silent_without_live_records_or_wiring() { let purl = "pkg:npm/minimist@1.2.2"; diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 662bc2ee..3e438d33 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -22,6 +22,7 @@ use socket_patch_core::api::types::{BatchPackagePatches, PatchResponse, PatchSea use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::telemetry::track_patch_vendor_failed; +use socket_patch_core::utils::composer_version::composer_purls_equivalent; use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::vendor::{load_state, lookup_entry, save_state, VendorState}; use std::collections::{HashMap, HashSet}; @@ -395,7 +396,12 @@ async fn migrate_legacy_manifest_records( let keys: Vec = manifest .patches .keys() - .filter(|k| *k == &key || *k == purl || strip_purl_qualifiers(k) == base) + .filter(|k| { + *k == &key + || *k == purl + || strip_purl_qualifiers(k) == base + || composer_purls_equivalent(k, base) + }) .cloned() .collect(); for k in keys { diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 29405c02..ef05877d 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -25,6 +25,7 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{verify_file_patch, PatchSources}; use socket_patch_core::telemetry::{track_patch_vendor_failed, track_patch_vendored}; +use socket_patch_core::utils::composer_version::composer_purls_equivalent; use socket_patch_core::utils::purl::{canonical_purl, normalize_purl, strip_purl_qualifiers}; use socket_patch_core::utils::socket_dir::remove_tree_and_prune; use socket_patch_core::vendor::{ @@ -1922,6 +1923,8 @@ pub(crate) async fn vendor_records( if let Some(entry) = entry { if let Some(flavor) = entry.flavor.as_deref() { wired_flavors.insert(flavor.to_string()); + } else if entry.ecosystem == "composer" { + wired_flavors.insert("composer".to_string()); } has_errors |= persist_vendor_entry( common, env, &mut state, candidate, entry, detached, record, @@ -2022,7 +2025,16 @@ pub(crate) async fn vendor_records( until reinstalled." ); } - if let Ok(lock) = std::fs::read_to_string(common.cwd.join("composer.lock")) { + let composer_lock = wired_flavors + .contains("composer") + .then(|| { + socket_patch_core::utils::fs::read_regular_to_string_sync( + &common.cwd.join("composer.lock"), + ) + .ok() + }) + .flatten(); + if let Some(lock) = composer_lock { let packages = super::composer_hints::vendored_composer_packages(&lock); super::composer_hints::vendored_reinstall_hints(&packages) .iter() @@ -2458,7 +2470,11 @@ pub(crate) async fn run_vendor_gc( let dropped: Vec = m .patches .keys() - .filter(|k| *k == &purl || strip_purl_qualifiers(k) == base) + .filter(|k| { + *k == &purl + || strip_purl_qualifiers(k) == base + || composer_purls_equivalent(k, &base) + }) .cloned() .collect(); for k in dropped { diff --git a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs index 5785b504..3eec89d3 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs @@ -622,6 +622,61 @@ fn human_vendor_prints_summary_committables_and_reinstall_hint() { ); } +/// An npm-only run never opens the project's composer.lock for the +/// Composer reinstall hint: a FIFO planted there would block the open(2) +/// after the lock and ledger are written, and a lock naming vendored +/// composer copies this run did not wire gets no composer hint. +#[cfg(unix)] +#[test] +fn npm_only_human_vendor_does_not_read_composer_lock() { + use std::time::{Duration, Instant}; + let fx = npm_fixture(); + let fifo = fx.root().join("composer.lock"); + assert!(Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap() + .success()); + let mut child = Command::new(env!("CARGO_BIN_EXE_socket-patch")) + .args(["vendor", "--offline", "--cwd", fx.root().to_str().unwrap()]) + .current_dir(fx.root()) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env_remove("SOCKET_OFFLINE") + .env_remove("SOCKET_API_URL") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn socket-patch binary"); + let started = Instant::now(); + while child.try_wait().unwrap().is_none() { + if started.elapsed() > Duration::from_secs(60) { + let _ = child.kill(); + panic!("vendor blocked opening the composer.lock FIFO"); + } + std::thread::sleep(Duration::from_millis(25)); + } + let out = child.wait_with_output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(out.status.code(), Some(0), "{stdout}"); + assert!(stdout.contains("Vendored 1 package."), "{stdout}"); + + let fx = npm_fixture(); + std::fs::write( + fx.root().join("composer.lock"), + json!({"packages": [{"name": "psr/log", "version": "3.0.2", "dist": { + "type": "path", + "url": format!(".socket/vendor/composer/{UUID}/psr/log@3.0.2"), + "reference": UUID}}]}) + .to_string(), + ) + .unwrap(); + let (code, stdout, stderr) = human_vendor(&fx, &[]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(stdout.contains("Run `npm install`"), "{stdout}"); + assert!(!stdout.contains("composer install"), "{stdout}"); +} + /// Human `--dry-run`: the `Would vendor` verb and NO commit/reinstall /// hints (nothing was written). A dry-run success is translated to a /// `Verified` event (counted under `summary.verified`, not `applied`); the diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 0421c09a..efb1fa1f 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -104,8 +104,8 @@ pub(crate) use pypi_lock::restore_document as restore_python_document; pub(crate) use npm_common::is_safe_npm_name; pub use pypi_requirements::requirements_include_names; pub use state::{ - carry_forward_wiring, load_state, lookup_entry, save_state, VendorEntry, VendorState, - VENDOR_STATE_REL, + carry_forward_wiring, load_state, lookup_entry, purl_keys_cover, save_state, VendorEntry, + VendorState, VENDOR_STATE_REL, }; // The hosted→vendored takeover refuses a berry project the backend would // refuse BEFORE it reverts the hosted redirect. diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index 6dd6f56c..a8e921c6 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -30,6 +30,7 @@ use serde::{Deserialize, Serialize}; use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; +use crate::utils::composer_version::{composer_purl_identity, composer_purls_equivalent}; use crate::utils::fs::{atomic_write_bytes, read_regular_to_bytes}; use crate::utils::purl::{patch_matches, strip_purl_qualifiers}; use crate::utils::serde::serialize_sorted; @@ -285,12 +286,15 @@ impl VendorEntry { } /// Does this entry, stored under ledger `key`, own the manifest purl - /// `purl`? The ledger-key / qualifier-stripped-key / base-purl triple — - /// the per-entry form of the set [`VendorState::purl_keys`] flattens. + /// `purl`? The ledger-key / qualifier-stripped-key / base-purl triple, + /// plus composer release identity (`@3.0.2` owns `@3.0.2.0`) — the + /// per-entry form of the set [`VendorState::purl_keys`] flattens. pub fn covers_purl(&self, key: &str, purl: &str) -> bool { key == purl || strip_purl_qualifiers(key) == strip_purl_qualifiers(purl) || self.base_purl == strip_purl_qualifiers(purl) + || composer_purls_equivalent(key, purl) + || composer_purls_equivalent(&self.base_purl, purl) } } @@ -312,24 +316,39 @@ impl VendorState { /// Every purl spelling under which this ledger's entries are /// addressable: each entry's map key (the manifest purl, possibly - /// qualified), its resolved base purl, and the qualifier-stripped key. - /// The one derivation behind every whole-set vendor-ownership match - /// (apply / rollback / remove / scan prune); [`super::vendored_purl_keys`] - /// is its load-then-derive convenience. + /// qualified), its resolved base purl, the qualifier-stripped key, and + /// for composer the release identity of both + /// ([`composer_purl_identity`]). The one derivation behind every + /// whole-set vendor-ownership match (apply / rollback / remove / scan + /// prune); match against it with [`purl_keys_cover`]. + /// [`super::vendored_purl_keys`] is its load-then-derive convenience. pub fn purl_keys(&self) -> HashSet { self.entries .iter() .flat_map(|(key, entry)| { [ - key.clone(), - entry.base_purl.clone(), - strip_purl_qualifiers(key).to_string(), + Some(key.clone()), + Some(entry.base_purl.clone()), + Some(strip_purl_qualifiers(key).to_string()), + composer_purl_identity(key), + composer_purl_identity(&entry.base_purl), ] }) + .flatten() .collect() } } +/// Whether `purl` is vendor-owned according to `keys`, a +/// [`VendorState::purl_keys`] set: by its own spelling, its +/// qualifier-stripped base, or (composer) its release identity, so a scan +/// that sees `@3.0.2` still finds the entry vendored as `@3.0.2.0`. +pub fn purl_keys_cover(keys: &HashSet, purl: &str) -> bool { + keys.contains(purl) + || keys.contains(strip_purl_qualifiers(purl)) + || composer_purl_identity(purl).is_some_and(|identity| keys.contains(&identity)) +} + impl Default for VendorState { fn default() -> Self { Self::new() @@ -496,7 +515,9 @@ fn binary_snapshot_identity_matches(a: &serde_json::Value, b: &serde_json::Value /// The ledger entry addressable as `purl`: the exact map key first, then /// any entry whose resolved `base_purl` equals it (a qualified manifest -/// key resolves to the entry recorded under the base PURL). +/// key resolves to the entry recorded under the base PURL), then, for a +/// composer purl, the entry of the same release in another version +/// spelling (the smallest such key, so the pick is deterministic). pub fn lookup_entry<'a>( entries: &'a HashMap, purl: &str, @@ -512,6 +533,15 @@ pub fn lookup_entry_kv<'a>( entries .get_key_value(purl) .or_else(|| entries.iter().find(|(_, e)| e.base_purl == purl)) + .or_else(|| { + entries + .iter() + .filter(|(key, e)| { + composer_purls_equivalent(key, purl) + || composer_purls_equivalent(&e.base_purl, purl) + }) + .min_by(|(a, _), (b, _)| a.cmp(b)) + }) } fn state_path(project_root: &Path) -> PathBuf { @@ -873,6 +903,76 @@ mod tests { assert!(!entry.covers_purl(key, "pkg:npm/other@1.0.0")); } + fn composer_entry(base_purl: &str) -> VendorEntry { + let mut entry = sample_entry(); + entry.ecosystem = "composer".into(); + entry.base_purl = base_purl.into(); + entry + } + + /// Composer ownership is by release identity: an entry vendored as + /// `@3.0.2.0` owns the `@3.0.2` / `@v3.0.2` a later scan sees, and the + /// reverse; another release or another ecosystem's spelling games do not. + #[test] + fn composer_ownership_matches_every_version_spelling() { + let padded = "pkg:composer/psr/log@3.0.2.0"; + let entry = composer_entry(padded); + for purl in [ + "pkg:composer/psr/log@3.0.2", + "pkg:composer/Psr/Log@v3.0.2", + padded, + ] { + assert!(entry.covers_purl(padded, purl), "{purl}"); + } + assert!(!entry.covers_purl(padded, "pkg:composer/psr/log@3.0.3")); + let bare = composer_entry("pkg:composer/psr/log@3.0.2"); + assert!(bare.covers_purl("pkg:composer/psr/log@3.0.2", padded)); + + let mut state = VendorState::new(); + state.entries.insert(padded.into(), entry); + let keys = state.purl_keys(); + assert!(purl_keys_cover(&keys, "pkg:composer/psr/log@3.0.2")); + assert!(purl_keys_cover(&keys, "pkg:composer/psr/log@v3.0.2")); + assert!(purl_keys_cover(&keys, padded)); + assert!(!purl_keys_cover(&keys, "pkg:composer/psr/log@3.0.3")); + assert!(!purl_keys_cover(&keys, "pkg:npm/psr/log@3.0.2")); + + // Non-composer ownership keeps exact spelling semantics. + let mut npm = VendorState::new(); + npm.entries + .insert("pkg:npm/lodash@4.17.21".into(), sample_entry()); + let npm_keys = npm.purl_keys(); + assert!(purl_keys_cover(&npm_keys, "pkg:npm/lodash@4.17.21?x=y")); + assert!(!purl_keys_cover(&npm_keys, "pkg:npm/lodash@4.17.21.0")); + } + + /// `lookup_entry_kv` finds a composer entry filed under another spelling + /// of the same release, preferring an exact key and picking the smallest + /// equivalent key when several exist. + #[test] + fn lookup_finds_a_composer_entry_by_release_identity() { + let mut entries = HashMap::new(); + entries.insert( + "pkg:composer/psr/log@3.0.2.0".to_string(), + composer_entry("pkg:composer/psr/log@3.0.2.0"), + ); + let (key, _) = lookup_entry_kv(&entries, "pkg:composer/psr/log@3.0.2").unwrap(); + assert_eq!(key, "pkg:composer/psr/log@3.0.2.0"); + assert!(lookup_entry(&entries, "pkg:composer/psr/log@3.0.3").is_none()); + + entries.insert( + "pkg:composer/psr/log@v3.0.2".to_string(), + composer_entry("pkg:composer/psr/log@v3.0.2"), + ); + let (key, _) = lookup_entry_kv(&entries, "pkg:composer/psr/log@v3.0.2").unwrap(); + assert_eq!(key, "pkg:composer/psr/log@v3.0.2", "an exact key wins"); + let (key, _) = lookup_entry_kv(&entries, "pkg:composer/psr/log@3.0.2").unwrap(); + assert_eq!( + key, "pkg:composer/psr/log@3.0.2.0", + "smallest equivalent key" + ); + } + #[tokio::test] async fn round_trip_and_determinism() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index fe3090b0..d226c58d 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -511,7 +511,7 @@ impl Discovery { let key = canonical_base_purl(purl); self.refs .iter() - .any(|r| r.uuid == uuid && r.mode == mode && r.purl == key) + .any(|r| r.uuid == uuid && r.mode == mode && same_package(&r.purl, &key)) } /// Whether some file discovery read mentions patch `uuid` as a `mode` @@ -648,7 +648,7 @@ impl Discovery { self.refs.iter().any(|r| { r.uuid == uuid && r.mode == WiringMode::Vendored - && r.purl == key + && same_package(&r.purl, &key) && r.artifact_rel.as_deref() == Some(artifact) }) }) @@ -1417,6 +1417,14 @@ pub fn canonical_base_purl(purl: &str) -> String { } } +/// Whether a ref's [`canonical_base_purl`] and `key` (another canonical +/// base) name the same package release: equal, or for composer the same +/// release in another version spelling (a ledger's `@3.0.2.0` is the lock's +/// `@3.0.2`). +fn same_package(ref_purl: &str, key: &str) -> bool { + ref_purl == key || crate::utils::composer_version::composer_purls_equivalent(ref_purl, key) +} + /// [`canonical_base_purl`] for a ref about to be pushed, plus shape checks: /// a known ecosystem type and a non-empty name and version (the version /// after the LAST `@`, containing no `/`). @@ -1445,7 +1453,9 @@ impl Discovery { /// `mode`, whichever patch it names. pub fn wires_package(&self, purl: &str, mode: WiringMode) -> bool { let key = canonical_base_purl(purl); - self.refs.iter().any(|r| r.mode == mode && r.purl == key) + self.refs + .iter() + .any(|r| r.mode == mode && same_package(&r.purl, &key)) } /// Liveness of a VENDOR-ledger entry — the ONE rule every reader of the From 1ae6acd4ad40e925ab74fda3a7f5412f5b01819f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 18:15:56 -0400 Subject: [PATCH 09/13] Do not report source-installed Composer as fixed Composer installs from source first when preferred-install resolves to source for the package, or when it is auto with a dev version on a Composer 1 or 2.0 lock. A leftover source entry then means pristine bytes, so VEX no longer reports those entries as fixed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/lock_inventory/composer.rs | 3 + .../src/vex/discover/composer.rs | 322 ++++++++++++++++-- 2 files changed, 302 insertions(+), 23 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs b/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs index 5223de1e..68e91cbd 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs @@ -29,6 +29,8 @@ pub(crate) struct ComposerLockPackage<'a> { /// The `dist` object: what composer's default `--prefer-dist` install /// consumes, and the block both backends rewrite. pub(crate) dist: Option<&'a Value>, + /// The `source` member, whatever its shape. + pub(crate) source: Option<&'a Value>, } impl ComposerLockPackage<'_> { @@ -74,6 +76,7 @@ pub(crate) fn composer_lock_packages(doc: &Value) -> Vec name: pkg.get("name").and_then(Value::as_str), version: pkg.get("version").and_then(Value::as_str), dist: pkg.get("dist"), + source: pkg.get("source"), }); } } diff --git a/crates/socket-patch-core/src/vex/discover/composer.rs b/crates/socket-patch-core/src/vex/discover/composer.rs index 860f99a1..a55131d1 100644 --- a/crates/socket-patch-core/src/vex/discover/composer.rs +++ b/crates/socket-patch-core/src/vex/discover/composer.rs @@ -6,14 +6,17 @@ //! The lock is parsed as JSON, so the `\/`-escaped slashes older composer //! versions write (fixture `escaped-slash-lock`; the hosted rewriter's //! [`crate::patch::redirect::artifact_url_present`] accepts both spellings) -//! arrive unescaped. Each entry's identity is its own `name` (lowercased by +//! arrive unescaped. Each entry's purl is its own `name` (lowercased by //! [`composer_purl`], the way packagist canonicalizes and both backends //! match — fixture `mixed-case-v-prefix`) and its `version` through //! composer's leading-`v` normalization //! ([`crate::crawlers::composer_crawler::normalize_version`]: locks carry the -//! pretty `v6.4.1`, purls the bare `6.4.1`). Only `dist` is read: it is what -//! composer's default `--prefer-dist` install consumes and the only block -//! either backend rewrites. +//! pretty `v6.4.1`, purls the bare `6.4.1`). A vendored leaf matches it by +//! release identity ([`composer_purls_equivalent`]: a leaf keyed by the +//! patch's padded `3.0.2.0` is the lock's `3.0.2`). The rewritten `dist` is +//! what composer's default install consumes and the only block either +//! backend rewrites, so it is what a ref is read from — unless composer +//! installs the entry from its `source` instead (below). //! //! * **Hosted** (`patch::redirect::rewrite_composer_lock`): `dist.url` on //! the patch server → [`DiscoverCtx::hosted_uuid`] (last canonical-uuid @@ -28,11 +31,12 @@ //! upstream commit and is not checked. A `path`-type dist never downloads //! its url, so a Socket url there wires nothing and is diagnosed. The //! rewriter also drops the entry's git `source` (fixture -//! `source-and-dist`): composer 1 and 2.2 LTS fall back to it when the -//! hosted dist fails, installing the pristine upstream commit. A `source` -//! is NOT a reason to reject the ref (locks redirected before that fix -//! still carry one); an installed tree that came from the fallback is -//! pristine and fails hash verification (`not_applied`). +//! `source-and-dist`): Composer 1 and Composer 2 before 2.10 fall back to +//! it when the hosted dist fails, installing the pristine upstream commit. +//! A leftover `source` (locks redirected before that fix still carry one) +//! rejects the ref only when composer installs from it outright (below); +//! an installed tree that came from the fallback is pristine and fails +//! hash verification (`not_applied`). //! * **Vendored** (`vendor::composer_lock::rewrite_lock_entry`): `dist: //! {"type": "path", "url": ".socket/vendor/composer///@", //! "reference": ""}` with `source` removed and @@ -44,11 +48,22 @@ //! (the backend keys the dir `/@`, lowercase), //! and `reference` equal to the path's uuid (the backend has written the //! uuid there since vendoring shipped; composer carries it verbatim into -//! `vendor/composer/installed.json`). `transport-options` and a leftover -//! `source` are NOT required — composer still consumes the vendored bytes -//! without them (symlinked instead of mirrored; `--prefer-source` aside). -//! A path dist pins no content hash, so `locked_integrity` is `None`; the -//! committed artifact is hashed instead. +//! `vendor/composer/installed.json`). `transport-options` is NOT required — +//! composer still consumes the vendored bytes without it (symlinked +//! instead of mirrored). A path dist pins no content hash, so +//! `locked_integrity` is `None`; the committed artifact is hashed instead. +//! +//! **Source installs.** Composer tries an entry's `source` before its `dist` +//! when the root `composer.json`'s `config.preferred-install` resolves to +//! `source` for the package (a string, or the first matching pattern of a +//! per-package map), or when it is `auto` — the default through Composer +//! 2.0, read from the lock's `plugin-api-version` — and the version is a dev +//! version. A Socket-wired entry that still has a `source` (a lock +//! hand-edited after vendoring, or redirected before the rewriter dropped +//! it) then installs the pristine upstream commit, so it is diagnosed and +//! yields no ref: the same veto depscan's SBOM applies +//! (`composerInstallsFromSource`). `--prefer-source` / `--prefer-dist` and +//! global config are install-time choices the project cannot record. //! //! Out of scope: `vendor/composer/installed.json` (an install artifact, not a //! root lock — the installed tree is verified by the crawler instead) and @@ -61,12 +76,112 @@ use super::{ DiscoverCtx, Discovery, LocateOpts, PatchedRef, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, }; use crate::crawlers::composer_crawler::normalize_version; -use crate::utils::composer_version::composer_purls_equivalent; +use crate::utils::composer_version::{composer_purls_equivalent, composer_version_normalize}; use crate::vendor::lock_inventory::{composer_lock_packages, ComposerLockPackage}; /// The lock both backends rewrite (root-relative). const COMPOSER_LOCK: &str = "composer.lock"; +/// The manifest beside it, read for `config.preferred-install`. +const COMPOSER_JSON: &str = "composer.json"; + +/// What decides whether `composer install` takes an entry from its `source` +/// (see the module doc): the root composer.json's +/// `config.preferred-install` and the lock's `plugin-api-version`. +struct InstallPreference { + preferred_install: Option, + plugin_api_version: Option, +} + +impl InstallPreference { + /// Read quietly: composer.json is not a lock, and a missing or + /// unparseable one leaves composer's default. + async fn load(ctx: &DiscoverCtx<'_>, lock: &Value) -> Self { + let preferred_install = + crate::utils::fs::read_regular_to_string(&ctx.root.join(COMPOSER_JSON)) + .await + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|manifest| manifest.get("config")?.get("preferred-install").cloned()); + Self { + preferred_install, + plugin_api_version: lock + .get("plugin-api-version") + .and_then(Value::as_str) + .map(str::to_string), + } + } + + /// Composer's `preferred-install` default: `auto` through 2.0, `dist` + /// from 2.1. A lock without `plugin-api-version` was written by 1.x. + fn default_preference(&self) -> &'static str { + match self.plugin_api_version.as_deref() { + None => "auto", + Some(v) if v.starts_with("1.") || v.starts_with("2.0.") => "auto", + Some(_) => "dist", + } + } + + /// Whether composer installs `entry` from its `source` before trying + /// its `dist` (`DownloadManager::getAvailableSources`). + fn installs_from_source(&self, entry: &ComposerLockPackage<'_>) -> bool { + if !has_source(entry.source) { + return false; + } + let is_dev = entry.version.is_some_and(is_dev_version); + let fallback = Value::from(self.default_preference()); + match self.preferred_install.as_ref().unwrap_or(&fallback) { + Value::Object(patterns) => { + // Composer's Config::merge folds the string default in as a + // trailing `*`, which every name matches. + let name = entry.name.unwrap_or(""); + let value = patterns + .iter() + .filter(|(pattern, _)| pattern.as_str() != "*") + .find(|(pattern, _)| preference_pattern_matches(pattern, name)) + .map(|(_, value)| value) + .or_else(|| patterns.get("*")) + .unwrap_or(&fallback); + !(value == "dist" || (!is_dev && value == "auto")) + } + Value::String(preference) if preference == "source" => true, + // PHP's loose `switch` sends `true` to the 'dist' case. + Value::String(preference) if preference == "dist" => false, + Value::Bool(true) => false, + _ => is_dev, + } + } +} + +/// An entry `source` composer can install from: an object whose `type` is +/// set (PHP truthiness: `''` and `'0'` name none). +fn has_source(source: Option<&Value>) -> bool { + match source + .and_then(Value::as_object) + .and_then(|s| s.get("type")) + { + None => false, + Some(Value::String(kind)) => !kind.is_empty() && kind != "0", + Some(_) => true, + } +} + +/// A `dev-*` branch or `*-dev` version (a `#` suffix ignored). +fn is_dev_version(version: &str) -> bool { + let mut normalized = composer_version_normalize(version).unwrap_or_else(|| version.to_string()); + if let Some(hash) = normalized.find('#').filter(|&i| i + 1 < normalized.len()) { + normalized.truncate(hash); + } + normalized.starts_with("dev-") || normalized.ends_with("-dev") +} + +/// Composer's `preferred-install` pattern match: `*` is any run, the rest is +/// literal, the whole name must match, case-insensitively. +fn preference_pattern_matches(pattern: &str, name: &str) -> bool { + let source = regex::escape(pattern).replace(r"\*", ".*"); + regex::Regex::new(&format!("(?i)^{source}$")).is_ok_and(|re| re.is_match(name)) +} + pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let file = COMPOSER_LOCK; let Some(bytes) = ctx.read_bytes(file, out).await else { @@ -87,11 +202,12 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { ); return; } + let preference = InstallPreference::load(ctx, &doc).await; // The inventory's own walk: `packages` then `packages-dev` (a missing // or non-array section — composer writes `"packages-dev": []`, older / // hand-trimmed locks may omit it — is simply empty). for entry in composer_lock_packages(&doc) { - entry_ref(ctx, file, &entry, out); + entry_ref(ctx, file, &entry, &preference, out); } } @@ -102,6 +218,7 @@ fn entry_ref( ctx: &DiscoverCtx<'_>, file: &str, entry: &ComposerLockPackage<'_>, + preference: &InstallPreference, out: &mut Discovery, ) { let section = entry.section; @@ -154,6 +271,18 @@ fn entry_ref( ); return; }; + if preference.installs_from_source(entry) { + out.diag( + DIAG_REF_INVALID, + file, + format!( + "{file}: {purl} is Socket-wired in its dist, but its lock entry keeps a `source` \ + that composer installs from instead (preferred-install), so the installed \ + bytes are the unpatched upstream" + ), + ); + return; + } if let Some(vref) = vendored { // The leaf carries the patch purl's spelling (`@3.0.2.0`), the lock @@ -866,19 +995,166 @@ mod tests { assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics); } - /// A vendored entry that still carries its upstream `source` (a lock - /// hand-edited after vendoring) is still the committed copy: composer's - /// default `--prefer-dist` installs the path dist. - #[tokio::test] - async fn vendored_entry_with_a_leftover_source_is_a_ref() { - let mut entry = vendored_entry("psr/log", "3.0.2", UUID_B); + fn with_source(mut entry: Value) -> Value { entry["source"] = json!({ "type": "git", "url": "https://github.com/php-fig/log.git", "reference": "f16e1d5" }); + entry + } + + fn composer_json(preferred_install: Value) -> String { + serde_json::to_string_pretty(&json!({ + "require": {"psr/log": "^3.0"}, + "config": {"preferred-install": preferred_install} + })) + .unwrap() + } + + fn assert_source_veto(out: &Discovery) { + assert!(out.refs.is_empty(), "{:?}", out.refs); + assert_eq!(out.diagnostics.len(), 1, "{:?}", out.diagnostics); + assert_eq!(out.diagnostics[0].code, DIAG_REF_INVALID); + assert!( + out.diagnostics[0].detail.contains("installs from instead"), + "{:?}", + out.diagnostics[0] + ); + } + + /// A vendored entry that still carries its upstream `source` (a lock + /// hand-edited after vendoring) is still the committed copy under + /// composer's default install, which takes the path dist. + #[tokio::test] + async fn vendored_entry_with_a_leftover_source_is_a_ref() { + let entry = with_source(vendored_entry("psr/log", "3.0.2", UUID_B)); + for manifest in [ + None, + Some(composer_json(json!("dist"))), + Some(composer_json(json!("auto"))), + ] { + let p = Project::new(); + p.write("composer.lock", lock(json!([entry.clone()]), json!([]))); + if let Some(manifest) = &manifest { + p.write("composer.json", manifest); + } + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:composer/psr/log@3.0.2", UUID_B, WiringMode::Vendored)], + ); + assert!( + out.diagnostics.is_empty(), + "{manifest:?}: {:?}", + out.diagnostics + ); + } + } + + /// `preferred-install: source` makes composer git-clone the pristine + /// upstream commit from a leftover `source`: neither a vendored nor a + /// hosted entry is a ref then, and the run says why. + #[tokio::test] + async fn a_leftover_source_composer_installs_from_is_not_a_ref() { + let hosted = with_source(hosted_entry( + "psr/log", + "3.0.2", + &format!("https://patch.socket.dev/patch/composer/psr/log/3.0.2/{TOKEN}/{UUID_B}/log-3.0.2.zip"), + Some(SHA1), + )); + let vendored = with_source(vendored_entry("psr/log", "3.0.2", UUID_B)); + for entry in [vendored, hosted] { + for preference in [ + json!("source"), + json!({"psr/*": "source", "*": "dist"}), + json!({"*": "source"}), + ] { + let p = Project::new(); + p.write("composer.lock", lock(json!([entry.clone()]), json!([]))); + p.write("composer.json", composer_json(preference)); + assert_source_veto(&run(&p).await); + } + } + } + + /// Composer's per-package map: the first matching non-`*` pattern + /// decides, case-insensitively; the `*` entry (or composer's default) + /// covers the rest. + #[tokio::test] + async fn preferred_install_patterns_pick_the_first_match() { + let entry = with_source(vendored_entry("psr/log", "3.0.2", UUID_B)); + for (preference, vetoed) in [ + (json!({"PSR/*": "source"}), true), + (json!({"psr/log": "dist", "psr/*": "source"}), false), + (json!({"monolog/*": "source"}), false), + (json!({"monolog/*": "source", "*": "source"}), true), + ] { + let p = Project::new(); + p.write("composer.lock", lock(json!([entry.clone()]), json!([]))); + p.write("composer.json", composer_json(preference.clone())); + let out = run(&p).await; + assert_eq!(out.refs.is_empty(), vetoed, "{preference}: {:?}", out.refs); + } + } + + /// `auto` installs a dev version from source. It is the default on a + /// Composer 1 lock (no `plugin-api-version`) and on 2.0; from 2.1 the + /// default is `dist`. + #[tokio::test] + async fn auto_installs_dev_versions_from_source() { + let dev = with_source(vendored_entry("psr/log", "dev-main", UUID_B)); + let stable = with_source(vendored_entry("psr/log", "3.0.2", UUID_B)); + let lock_with_api = |entry: &Value, api: Option<&str>| { + let mut doc: Value = + serde_json::from_str(&lock(json!([entry.clone()]), json!([]))).unwrap(); + match api { + Some(api) => doc["plugin-api-version"] = json!(api), + None => { + doc.as_object_mut().unwrap().remove("plugin-api-version"); + } + } + serde_json::to_string_pretty(&doc).unwrap() + }; + for (entry, api, manifest, vetoed) in [ + (&dev, None, None, true), + (&dev, Some("2.0.0"), None, true), + (&dev, Some("2.6.0"), None, false), + (&dev, Some("2.6.0"), Some(json!("auto")), true), + (&stable, None, None, false), + (&stable, Some("2.6.0"), Some(json!("auto")), false), + ] { + let p = Project::new(); + p.write("composer.lock", lock_with_api(entry, api)); + if let Some(preference) = &manifest { + p.write("composer.json", composer_json(preference.clone())); + } + let out = run(&p).await; + assert_eq!( + out.refs.is_empty(), + vetoed, + "{api:?} {manifest:?}: {:?}", + out.refs + ); + if vetoed { + assert_source_veto(&out); + } + } + } + + /// No `source` left, nothing to install from: `preferred-install: + /// source` falls back to the dist, which is the patch. + #[tokio::test] + async fn source_preference_without_a_source_keeps_the_ref() { let p = Project::new(); - p.write("composer.lock", lock(json!([entry]), json!([]))); + p.write( + "composer.lock", + lock( + json!([vendored_entry("psr/log", "3.0.2", UUID_B)]), + json!([]), + ), + ); + p.write("composer.json", composer_json(json!("source"))); let out = run(&p).await; assert_refs( &out, From 2ce588abd3c05e4a055c965f575e61d9f586d041 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 18:15:56 -0400 Subject: [PATCH 10/13] Name the vendor dir in Composer reinstall hints After a hosted redirect that removed no source entry, Composer 2 keeps an existing vendor/ copy, so the hint now names the vendor directory to remove on every Composer version. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/composer_hints.rs | 106 ++++++++++++++++-- .../src/commands/scan/hosted.rs | 23 ++-- 2 files changed, 108 insertions(+), 21 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/composer_hints.rs b/crates/socket-patch-cli/src/commands/composer_hints.rs index e4b00260..a6c3375f 100644 --- a/crates/socket-patch-cli/src/commands/composer_hints.rs +++ b/crates/socket-patch-cli/src/commands/composer_hints.rs @@ -2,11 +2,15 @@ //! //! Both modes rewire `composer.lock` only: the installed `vendor/` tree keeps //! the unpatched bytes until Composer reinstalls the package. Composer 2 -//! reinstalls a locked package whose dist changed on the next `composer -//! install`; Composer 1 does not, so its `vendor//` must be -//! removed first. +//! reinstalls a locked package on the next `composer install` only when its +//! version, dist reference or source reference changed. Vendoring sets the +//! dist reference to the patch uuid; a hosted redirect keeps it and drops +//! the entry's `source`, so an entry that had no `source` changes nothing +//! Composer compares. Composer 1 never reinstalls a changed package. In both +//! cases `vendor//` must be removed first. -use serde_json::Value; +use serde_json::{Map, Value}; +use socket_patch_core::patch::redirect::FileEdit; const VENDOR_PREFIX: &str = ".socket/vendor/composer/"; @@ -59,12 +63,47 @@ pub(crate) fn vendored_reinstall_hints(packages: &[String]) -> Vec { } /// The reinstall example for a hosted run's next steps when it rewrote -/// `composer.lock`. -pub(crate) fn hosted_reinstall_hint(files: &[String]) -> Option<&'static str> { - files.iter().any(|f| f == "composer.lock").then_some( - " (e.g. `composer install`; on Composer 1 first remove the patched packages' \ - vendor// directories)", - ) +/// `composer.lock`. `edits` are the run's rewrite edits: a redirected entry +/// whose edit removed no `source` must have its vendor dir removed on every +/// Composer version. +pub(crate) fn hosted_reinstall_hint(files: &[String], edits: &[FileEdit]) -> Option { + if !files.iter().any(|f| f == "composer.lock") { + return None; + } + let mut unchanged: Vec = edits + .iter() + .filter(|e| e.path == "composer.lock" && e.kind == "redirect_composer_dist") + .filter(|e| !removes_source(e)) + .filter_map(|e| Some(format!("vendor/{}", e.key.as_deref()?.to_lowercase()))) + .collect(); + unchanged.sort_unstable(); + unchanged.dedup(); + if unchanged.is_empty() { + return Some( + " (e.g. `composer install`; on Composer 1 first remove the patched packages' \ + vendor// directories)" + .to_string(), + ); + } + Some(format!( + " (e.g. `composer install`; first remove {} — Composer does not reinstall a package \ + whose lock entry has no `source` when only its dist url changes — and on Composer 1 \ + every other patched package's vendor// directory)", + unchanged.join(", ") + )) +} + +/// Whether a composer dist edit dropped the entry's `source` object, which +/// changes the source reference Composer 2 compares. The recorded fragments +/// are runs of whole entry members, so each parses as an object's body. +fn removes_source(edit: &FileEdit) -> bool { + let members = |fragment: Option<&Value>| -> Option> { + serde_json::from_str(&format!("{{{}}}", fragment?.as_str()?)).ok() + }; + let had = members(edit.original.as_ref()) + .is_some_and(|m| m.get("source").is_some_and(Value::is_object)); + let has = members(edit.new.as_ref()).is_none_or(|m| m.contains_key("source")); + had && !has } #[cfg(test)] @@ -124,8 +163,51 @@ mod tests { #[test] fn hosted_hint_needs_a_rewritten_composer_lock() { - assert!(hosted_reinstall_hint(&["package-lock.json".to_string()]).is_none()); - let hint = hosted_reinstall_hint(&["composer.lock".to_string()]).unwrap(); + assert!(hosted_reinstall_hint(&["package-lock.json".to_string()], &[]).is_none()); + let hint = hosted_reinstall_hint(&["composer.lock".to_string()], &[]).unwrap(); assert!(hint.contains("composer install") && hint.contains("Composer 1")); } + + fn dist_edit(key: &str, original: &str, new: &str) -> FileEdit { + FileEdit { + path: "composer.lock".into(), + kind: "redirect_composer_dist".into(), + action: "rewritten".into(), + key: Some(key.into()), + original: Some(Value::String(original.into())), + new: Some(Value::String(new.into())), + } + } + + const OLD_DIST: &str = + r#""dist": {"type": "zip", "url": "https://x/a.zip", "reference": "abc"}"#; + const NEW_DIST: &str = r#""dist": {"type": "zip", "url": "https://patch/a.zip", "reference": "abc", "shasum": "1111111111111111111111111111111111111111"}"#; + + /// Composer 2 reinstalls a redirected entry because dropping its + /// `source` changes the source reference; a dist-only entry changes + /// nothing it compares, so its vendor dir is named for every version. + #[test] + fn hosted_hint_names_dist_only_entries_for_every_composer() { + let files = ["composer.lock".to_string()]; + let with_source = dist_edit( + "psr/log", + &format!(r#""source": {{"type": "git", "url": "u", "reference": "abc"}}, {OLD_DIST}"#), + NEW_DIST, + ); + let source_after_extra = dist_edit( + "psr/log", + &format!(r#"{OLD_DIST}, "extra": {{"source": {{}}}}, "source": {{"type": "git"}}"#), + &format!(r#"{NEW_DIST}, "extra": {{"source": {{}}}}"#), + ); + for edits in [vec![with_source.clone()], vec![source_after_extra]] { + let hint = hosted_reinstall_hint(&files, &edits).unwrap(); + assert!(hint.contains("on Composer 1 first remove"), "{hint}"); + } + + let dist_only = dist_edit("Acme/Lib", OLD_DIST, NEW_DIST); + let hint = hosted_reinstall_hint(&files, &[with_source, dist_only]).unwrap(); + assert!(hint.contains("first remove vendor/acme/lib —"), "{hint}"); + assert!(!hint.contains("vendor/psr/log"), "{hint}"); + assert!(hint.contains("on Composer 1"), "{hint}"); + } } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 63b63750..a2fa55a5 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -2960,9 +2960,12 @@ pub(crate) async fn run_redirect_selected( ); } if !common.dry_run { - for line in - format_next_steps(&human_files, ledger_written, !takeover_migrated.is_empty()) - { + for line in format_next_steps( + &human_files, + &rewrite.edits, + ledger_written, + !takeover_migrated.is_empty(), + ) { println!("{line}"); } } @@ -3263,6 +3266,7 @@ fn join_names(names: &[String], max: usize) -> String { /// `.socket/vendor/` directory is named instead of the redirect ledger. fn format_next_steps( files: &[String], + edits: &[socket_patch_core::patch::redirect::FileEdit], ledger_written: bool, vendored_removed: bool, ) -> Vec { @@ -3286,9 +3290,9 @@ fn format_next_steps( .iter() .any(|f| f == "package-lock.json" || f == "npm-shrinkwrap.json"); let hint = if npm { - " (e.g. `npm ci`)" + " (e.g. `npm ci`)".to_string() } else { - crate::commands::composer_hints::hosted_reinstall_hint(files).unwrap_or("") + crate::commands::composer_hints::hosted_reinstall_hint(files, edits).unwrap_or_default() }; vec![ format!("Commit {} to keep the redirect.", join_names(&commit, 6)), @@ -4795,9 +4799,9 @@ mod tests { #[test] fn next_steps_name_the_ledger_files_and_reinstall() { - assert!(format_next_steps(&[], true, false).is_empty()); + assert!(format_next_steps(&[], &[], true, false).is_empty()); assert_eq!( - format_next_steps(&["package-lock.json".to_string()], true, false), + format_next_steps(&["package-lock.json".to_string()], &[], true, false), vec![ "Commit .socket/vendor/redirect-state.json and package-lock.json to keep the \ redirect." @@ -4812,6 +4816,7 @@ mod tests { "pnpm-lock.yaml".to_string(), "pnpm-workspace.yaml".to_string(), ], + &[], false, false, ); @@ -4825,12 +4830,12 @@ mod tests { #[test] fn next_steps_after_a_takeover_name_the_removed_vendored_state() { assert_eq!( - format_next_steps(&["package-lock.json".to_string()], true, true)[0], + format_next_steps(&["package-lock.json".to_string()], &[], true, true)[0], "Commit .socket/vendor/ (the redirect ledger, plus the removed vendored ledger \ entries and artifacts) and package-lock.json to keep the redirect." ); assert_eq!( - format_next_steps(&["pnpm-lock.yaml".to_string()], false, true)[0], + format_next_steps(&["pnpm-lock.yaml".to_string()], &[], false, true)[0], "Commit .socket/vendor/ (the removed vendored ledger entries and artifacts) and \ pnpm-lock.yaml to keep the redirect." ); From fb22dfa17b00cf2a7f2a77be58e88e795ae92ad0 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 18:15:57 -0400 Subject: [PATCH 11/13] Run Composer compatibility on every related change The composer compatibility workflow now also runs for vendor, patch, manifest, lockfile and command changes. Docker composer tests fail instead of skipping when SOCKET_PATCH_DOCKER_E2E_REQUIRED=1, and the source-fallback test disables autocrlf so Windows compares LF bytes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/composer-compatibility.yml | 18 +++++++--- .../tests/composer_e2e_common/mod.rs | 7 +++- .../tests/docker_vendor_common/mod.rs | 33 ++++++++++++++----- .../tests/docker_vendor_common_selftest.rs | 12 ++++++- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/.github/workflows/composer-compatibility.yml b/.github/workflows/composer-compatibility.yml index 6f39099c..abaeaa3a 100644 --- a/.github/workflows/composer-compatibility.yml +++ b/.github/workflows/composer-compatibility.yml @@ -24,15 +24,23 @@ on: paths: - '.github/workflows/composer-compatibility.yml' - 'tests/docker/Dockerfile.composer' - - 'crates/socket-patch-core/src/vendor/composer_lock.rs' - - 'crates/socket-patch-core/src/vendor/composer_lock/**' - - 'crates/socket-patch-core/src/vendor/lock_inventory/composer.rs' - - 'crates/socket-patch-core/src/patch/redirect/**' + - 'Cargo.lock' + - 'Cargo.toml' + - 'crates/*/Cargo.toml' + - 'crates/socket-patch-core/src/vendor/**' + - 'crates/socket-patch-core/src/patch/**' + - 'crates/socket-patch-core/src/manifest/**' + - 'crates/socket-patch-core/tests/fixtures/redirect/composer/**' + - 'crates/socket-patch-core/tests/fixtures/composer-version-vectors.json' - 'crates/socket-patch-core/src/crawlers/composer_crawler.rs' - 'crates/socket-patch-core/src/utils/composer*.rs' - 'crates/socket-patch-core/src/utils/purl.rs' - 'crates/socket-patch-core/src/vex/**' - 'crates/socket-patch-cli/src/commands/vendor*' + - 'crates/socket-patch-cli/src/commands/get*.rs' + - 'crates/socket-patch-cli/src/commands/apply.rs' + - 'crates/socket-patch-cli/src/commands/rollback.rs' + - 'crates/socket-patch-cli/src/commands/remove.rs' - 'crates/socket-patch-cli/src/commands/composer_hints.rs' - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/vex*.rs' @@ -197,4 +205,6 @@ jobs: load: true - name: Run the vendored composer Docker capstone + env: + SOCKET_PATCH_DOCKER_E2E_REQUIRED: '1' run: cargo test -p socket-patch-cli --features docker-e2e --test docker_e2e_vendor_composer diff --git a/crates/socket-patch-cli/tests/composer_e2e_common/mod.rs b/crates/socket-patch-cli/tests/composer_e2e_common/mod.rs index 99e5474c..69cd155b 100644 --- a/crates/socket-patch-cli/tests/composer_e2e_common/mod.rs +++ b/crates/socket-patch-cli/tests/composer_e2e_common/mod.rs @@ -147,7 +147,9 @@ pub fn composer_major(suite: &str) -> Option { /// Run `composer ` in `cwd` with a PRIVATE home + cache (the host's /// composer state must neither leak in nor be polluted). The home's /// `config.json` disables `secure-http`: the hosted capstone's patch server -/// is a plain-http loopback wiremock. +/// is a plain-http loopback wiremock. Git runs with `core.autocrlf=false`: +/// Git for Windows' system default converts a source install's checkout to +/// CRLF, so its bytes would never equal the upstream archive's. pub fn composer(cwd: &Path, args: &[&str], home: &Path, cache: &Path) -> Output { std::fs::create_dir_all(home).unwrap(); std::fs::create_dir_all(cache).unwrap(); @@ -163,6 +165,9 @@ pub fn composer(cwd: &Path, args: &[&str], home: &Path, cache: &Path) -> Output super::cache_env::isolate(&mut cmd); cmd.env("COMPOSER_HOME", home) .env("COMPOSER_CACHE_DIR", cache) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "core.autocrlf") + .env("GIT_CONFIG_VALUE_0", "false") .output() .expect("failed to run composer") } diff --git a/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs b/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs index 6e5637da..2dccf0c9 100644 --- a/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs +++ b/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs @@ -58,20 +58,35 @@ pub fn cov_docker_args() -> Vec { /// reports `ok`. Build locally with /// `docker build -f tests/docker/Dockerfile. -t .` /// (after `Dockerfile.base` → `socket-patch-test-base:latest`). +/// +/// With `SOCKET_PATCH_DOCKER_E2E_REQUIRED=1` (a CI job that built the image +/// itself) a missing docker or image fails the test instead, so a tag +/// mismatch cannot pass the job with zero assertions. #[must_use] pub fn skip_if_no_image(image: &str) -> bool { - let Ok(out) = Command::new("docker") + skip_or_require_image( + image, + std::env::var("SOCKET_PATCH_DOCKER_E2E_REQUIRED").is_ok_and(|v| v == "1"), + ) +} + +/// [`skip_if_no_image`] with the required switch passed in. +#[must_use] +pub fn skip_or_require_image(image: &str, required: bool) -> bool { + let reason = match Command::new("docker") .args(["image", "inspect", image]) .output() - else { - eprintln!("skipping: `docker` not on PATH"); - return true; + { + Err(_) => "`docker` not on PATH".to_string(), + Ok(out) if !out.status.success() => format!("docker image `{image}` not present"), + Ok(_) => return false, }; - if !out.status.success() { - eprintln!("skipping: docker image `{image}` not present"); - return true; - } - false + assert!( + !required, + "{reason}; SOCKET_PATCH_DOCKER_E2E_REQUIRED=1 forbids skipping" + ); + eprintln!("skipping: {reason}"); + true } fn docker_run(image: &str, host_dir: &Path, script: &str, extra: &[&str]) -> Output { diff --git a/crates/socket-patch-cli/tests/docker_vendor_common_selftest.rs b/crates/socket-patch-cli/tests/docker_vendor_common_selftest.rs index 729f7783..8c47f9f0 100644 --- a/crates/socket-patch-cli/tests/docker_vendor_common_selftest.rs +++ b/crates/socket-patch-cli/tests/docker_vendor_common_selftest.rs @@ -18,7 +18,7 @@ use sha2::{Digest, Sha256}; #[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common; -use docker_vendor_common::{bash_prelude, stage_patch_fn}; +use docker_vendor_common::{bash_prelude, skip_or_require_image, stage_patch_fn}; /// The docker images always have coreutils `sha256sum`; macOS dev hosts may /// only have perl `shasum`, so shim it for these local runs. @@ -164,3 +164,13 @@ fn stage_patch_records_git_blob_sha256_and_stages_blob() { let blob = dir.path().join(".socket/blobs").join(git_sha256(after)); assert_eq!(std::fs::read(&blob).unwrap(), after, "staged blob bytes"); } + +/// A required docker suite fails on a missing image instead of skipping; +/// an optional one skips. +#[test] +fn a_required_docker_suite_fails_on_a_missing_image() { + let image = "socket-patch-test-does-not-exist:never"; + assert!(skip_or_require_image(image, false)); + let required = std::panic::catch_unwind(|| skip_or_require_image(image, true)); + assert!(required.is_err(), "a required suite must not skip"); +} From 0e3d9ce4b40d773d052fa8ee154e324c1c216058 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 18:19:09 -0400 Subject: [PATCH 12/13] Satisfy clippy in the Composer lock splice Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/vendor/composer_lock/lock_text.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/vendor/composer_lock/lock_text.rs b/crates/socket-patch-core/src/vendor/composer_lock/lock_text.rs index 9922d4fa..1a62b4c4 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock/lock_text.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock/lock_text.rs @@ -93,7 +93,7 @@ fn entry_span(text: &str, section: &str, index: usize) -> Option<(usize, usize)> /// 4), else the lock's first indent. fn indent_unit(text: &str, base: &str) -> String { let half = base.len() / 2; - if half > 0 && base.len() % 2 == 0 && base[..half] == base[half..] { + if half > 0 && base.len().is_multiple_of(2) && base[..half] == base[half..] { base[..half].to_string() } else { detect_indent(text) From 791848dfeb7e9ec45aa67b86c804848655729d8c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 26 Sep 2026 18:30:19 -0400 Subject: [PATCH 13/13] Read composer.json through the VEX discovery context The source-install veto read composer.json directly, which the VEX discovery rules forbid: every extractor reads through DiscoverCtx so unreadable files are diagnosed and Socket identities are recognized. Also add the golden entries for the dist-mirrors-before-url fixture. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vex/discover/composer.rs | 18 ++++---- .../redirect-composer.json | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/composer.rs b/crates/socket-patch-core/src/vex/discover/composer.rs index a55131d1..b3e928a7 100644 --- a/crates/socket-patch-core/src/vex/discover/composer.rs +++ b/crates/socket-patch-core/src/vex/discover/composer.rs @@ -94,15 +94,13 @@ struct InstallPreference { } impl InstallPreference { - /// Read quietly: composer.json is not a lock, and a missing or - /// unparseable one leaves composer's default. - async fn load(ctx: &DiscoverCtx<'_>, lock: &Value) -> Self { - let preferred_install = - crate::utils::fs::read_regular_to_string(&ctx.root.join(COMPOSER_JSON)) - .await - .ok() - .and_then(|text| serde_json::from_str::(&text).ok()) - .and_then(|manifest| manifest.get("config")?.get("preferred-install").cloned()); + /// A missing or unparseable composer.json leaves composer's default. + async fn load(ctx: &DiscoverCtx<'_>, lock: &Value, out: &mut Discovery) -> Self { + let preferred_install = ctx + .read_text(COMPOSER_JSON, out) + .await + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|manifest| manifest.get("config")?.get("preferred-install").cloned()); Self { preferred_install, plugin_api_version: lock @@ -202,7 +200,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { ); return; } - let preference = InstallPreference::load(ctx, &doc).await; + let preference = InstallPreference::load(ctx, &doc, out).await; // The inventory's own walk: `packages` then `packages-dev` (a missing // or non-array section — composer writes `"packages-dev": []`, older / // hand-trimmed locks may omit it — is simply empty). diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json index cbad408b..d512b5fe 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json @@ -126,6 +126,51 @@ "elsewhere": [], "live_claims": [] }, + "redirect/composer/composer-lock/dist-mirrors-before-url/expected": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, + "redirect/composer/composer-lock/dist-mirrors-before-url/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/composer/composer-lock/dist-mirrors/expected": { "refs": [ {