Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
Expand Down
210 changes: 210 additions & 0 deletions .github/workflows/composer-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
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'
- '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'
- '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
env:
SOCKET_PATCH_DOCKER_E2E_REQUIRED: '1'
run: cargo test -p socket-patch-cli --features docker-e2e --test docker_e2e_vendor_composer
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>-<version>/` (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 `<version>+socket.<uuid>` — `<core>+<meta>.socket.<uuid>` 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 `<name>-socket-<first 8 hex of the uuid>` with `package = "<name>"` (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 `<module>@<version>/` | `go.mod` `replace <module> <ver> => ./.socket/vendor/golang/<uuid>/<module>@<ver>` | `go build` with `GOPROXY=off` + empty `GOMODCACHE` (directory replaces bypass go.sum entirely; survives `go mod tidy`) |
| composer | package dir `<vendor>/<name>@<version>/` | `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 <pkg>` reverts it |
| composer | package dir `<vendor>/<name>@<version>/`; 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: "<patch-uuid>"}`, `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/<vendor>/<name>` first. `composer update <pkg>` reverts it. See `docs/testing/composer-compatibility.md` |
| gem | gem dir `<name>-<version>/` + 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 `<name> (= <ver>)!`, 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] <name> = {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` |
Expand Down
12 changes: 5 additions & 7 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1294,8 +1294,7 @@ fn synthesize_vendor_owned_results(
target_manifest_purls: &HashSet<String>,
vendored_purls: &HashSet<String>,
) -> (Vec<ApplyResult>, HashSet<String>, HashSet<String>) {
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<ApplyResult> = Vec::new();
let mut matched: HashSet<String> = HashSet::new();
let mut vendored_targets: Vec<String> = target_manifest_purls
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading