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..abaeaa3a --- /dev/null +++ b/.github/workflows/composer-compatibility.yml @@ -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 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/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/composer_hints.rs b/crates/socket-patch-cli/src/commands/composer_hints.rs new file mode 100644 index 00000000..a6c3375f --- /dev/null +++ b/crates/socket-patch-cli/src/commands/composer_hints.rs @@ -0,0 +1,213 @@ +//! 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 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::{Map, Value}; +use socket_patch_core::patch::redirect::FileEdit; + +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`. `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)] +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")); + } + + 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/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/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 c4fb8fde..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,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::{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; 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 @@ -181,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; } @@ -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 @@ -1149,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 58eee602..6c2aab9b 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -5,8 +5,9 @@ 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::vendor::VENDOR_STATE_REL; +use socket_patch_core::utils::composer_version::purl_identity_key; +use socket_patch_core::utils::purl::strip_purl_qualifiers; +use socket_patch_core::vendor::{purl_keys_cover, VENDOR_STATE_REL}; use std::collections::HashSet; use std::path::Path; use std::time::Duration; @@ -497,14 +498,14 @@ 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)) - && !vendored.contains(p.as_str()) - && !vendored.contains(strip_purl_qualifiers(p)) + !scanned_bases.contains(&purl_identity_key(p)) + && !purl_keys_cover(vendored, p) && crate::ecosystem_dispatch::crawl_covers_purl(p.as_str()) }) .cloned() @@ -638,6 +639,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/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 2c218c53..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 { @@ -3285,7 +3289,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`)".to_string() + } else { + crate::commands::composer_hints::hosted_reinstall_hint(files, edits).unwrap_or_default() + }; vec![ format!("Commit {} to keep the redirect.", join_names(&commit, 6)), format!( @@ -4791,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." @@ -4808,6 +4816,7 @@ mod tests { "pnpm-lock.yaml".to_string(), "pnpm-workspace.yaml".to_string(), ], + &[], false, false, ); @@ -4821,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." ); 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 04cd4227..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,6 +2025,21 @@ pub(crate) async fn vendor_records( until reinstalled." ); } + 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() + .for_each(|hint| println!("{hint}")); + } } } @@ -2452,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/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/composer_e2e_common/mod.rs b/crates/socket-patch-cli/tests/composer_e2e_common/mod.rs index c10ef195..69cd155b 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() ); } } @@ -111,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(); @@ -119,7 +157,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") @@ -127,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/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-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/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"); +} 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-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-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/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/composer_source.rs b/crates/socket-patch-core/src/patch/redirect/composer_source.rs new file mode 100644 index 00000000..8c89f13c --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/composer_source.rs @@ -0,0 +1,560 @@ +//! 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(crate) 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`. +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), + _ => { + 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(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(); + 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` (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, + rewritten_dist: Option<&str>, + composer_name: &str, + warnings: &mut Vec, +) -> Option { + let DistSpan { + entry_start, + entry_end, + dist_start, + dist_end, + } = span; + 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!( + "{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 ccfcadac..a04c8a1a 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; @@ -4127,6 +4127,8 @@ fn rewrite_uv_lock( } } +pub(crate) 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 @@ -4197,9 +4199,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 +4218,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()); @@ -4256,22 +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. -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], @@ -4356,14 +4342,19 @@ 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\": \"") { + // 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. + 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 +4375,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 +12802,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 +12823,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/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..77c3c55e --- /dev/null +++ b/crates/socket-patch-core/src/utils/composer_version.rs @@ -0,0 +1,435 @@ +//! 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 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. +//! +//! 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 + } +} + +/// [`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 { + identity_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 (identity_normalize(a), identity_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 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()); + 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..736baf77 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,18 +15,26 @@ //! * `"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 //! 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 `/` -//! (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; @@ -33,12 +42,13 @@ 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::line_endings::LineEndings; use crate::utils::purl::{build_composer_purl, parse_composer_purl}; use crate::utils::socket_dir::remove_tree_and_prune; @@ -56,6 +66,9 @@ use super::state::{ }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; +mod lock_text; +mod mirror_filters; + /// Project-relative lockfile this backend wires. const COMPOSER_LOCK: &str = "composer.lock"; @@ -166,8 +179,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 @@ -214,6 +231,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!( @@ -280,6 +298,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(); @@ -304,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_json_bytes(&lock) { + 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), }; @@ -738,15 +763,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)) } @@ -826,6 +851,35 @@ 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 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 { @@ -916,7 +970,8 @@ 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 = + 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}"))?; @@ -1589,6 +1644,144 @@ 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" + ); + } + + /// 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); + 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); @@ -2746,6 +2939,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. @@ -3628,4 +3862,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/lock_text.rs b/crates/socket-patch-core/src/vendor/composer_lock/lock_text.rs new file mode 100644 index 00000000..1a62b4c4 --- /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().is_multiple_of(2) && 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()); + } +} 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/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/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/composer.rs b/crates/socket-patch-core/src/vex/discover/composer.rs index 8562e74b..b3e928a7 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,11 +76,110 @@ 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, 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 { + /// 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 + .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 { @@ -86,11 +200,12 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { ); return; } + 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). for entry in composer_lock_packages(&doc) { - entry_ref(ctx, file, &entry, out); + entry_ref(ctx, file, &entry, &preference, out); } } @@ -101,6 +216,7 @@ fn entry_ref( ctx: &DiscoverCtx<'_>, file: &str, entry: &ComposerLockPackage<'_>, + preference: &InstallPreference, out: &mut Discovery, ) { let section = entry.section; @@ -153,10 +269,25 @@ 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 { - 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 +586,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] @@ -759,4 +937,227 @@ 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); + } + + 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!([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, + &[("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/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 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/composer-version-vectors.json b/crates/socket-patch-core/tests/fixtures/composer-version-vectors.json new file mode 100644 index 00000000..31ff999b --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/composer-version-vectors.json @@ -0,0 +1,1018 @@ +{ + "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": "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", + "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 + }, + { + "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 + } + ] +} 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-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" + } + } +] 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/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/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 bdace21a..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 @@ -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,104 @@ "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-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": [ + { + "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 +306,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": [ { @@ -179,6 +359,96 @@ "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-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": [ { @@ -224,6 +494,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..9347d8e7 --- /dev/null +++ b/docs/testing/composer-compatibility.md @@ -0,0 +1,145 @@ +# 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. +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 + +`.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). 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} "