diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 863dbcbca..ae3685c7c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -747,7 +747,7 @@ to **six flavors**. | npm / vlt (`vlt-lock.json`, lockfileVersion 0 or 1 — A0 locks without a version and every other version refuse `vendor_lockfile_version_unsupported`; flavor `vlt`) | patched package **directory** `.socket/vendor/npm//[@scope/]-/node_modules//` (the extra `node_modules/` level lets a package `require()` its own name), its `package.json` without `devDependencies`, plus `/.gitignore` (re-includes the payload against the project's ignores, ignores vlt's links inside it) and `/.gitattributes` (`-text`) | direct dependencies of the root or a workspace member only: the lock node becomes a `file` node for the directory, its importer edges and outgoing edges are re-keyed, and each importer's `package.json` spec becomes `file:`; every moved entry lands where vlt's serializer puts it. A node whose only extra is one peer context (`ṗ:N`, `peer.N`, `peer.<16 hex>`: from vlt 1.0.8 a root dependency with resolved peers, from rc.15 a workspace member's) becomes a `file` node without the extra, as vlt writes `file:` dependencies, keeping its peer edges; revert restores the extra-bearing DepID. Refused before any write: transitive targets (`vendor_vlt_transitive_unsupported`), two or more instances of one `name@version` or a modifier extra, importer `peer` edges, foreign registries, a git, remote-tarball or local-directory node of the same package name (vlt records no version for it), a package `vlt build` would build in place (`vendor_vlt_build_scripts_unsupported`), a name declared in several dependency fields (`vendor_lock_entry_unsupported`), a spec that disagrees with the lock (`vendor_vlt_lock_out_of_sync`), a payload git would ignore (`vendor_artifact_gitignored`), a purl vendored under another flavor (`vendor_flavor_changed`); era-A locks warn `vendor_vlt_legacy_lockfile`; an optional dependency (or any dependency node_modules still links to its installed upstream copy) gets `vendor_vlt_reinstall_required` | fresh checkout, `vlt ci` with cold caches: the patched bytes load and `vlt-lock.json` stays byte-identical, also through a warm and a cold `vlt install --frozen-lockfile` (checked on 1.2.0, 1.0.10, 1.0.4, 1.0.0-rc.32 and 1.0.0-rc.14, and on every release by `docs/testing/vlt-compatibility.md`); no-op installs, `vlt install `, `uninstall` and `vlt update` keep the direct dependency vendored. `vendor --revert` restores the registry node, edges and specs, keeping what vlt re-laid since, and refuses on drift | | 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 cargo 1.45 or newer (`--offline` from an empty CARGO_HOME is enough there); older cargo fails closed whatever the index state, and a project that does not pin cargo ≥ 1.45 (`rust-version` or toolchain file) gets the `cargo_multi_version_old_cargo` warning. 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 `/@/`; 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` | +| 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 `//` first (the hint names the project's resolved vendor dir: `COMPOSER_VENDOR_DIR`, else `config.vendor-dir`, else `vendor`). `composer update ` reverts it. See `docs/testing/composer-compatibility.md` | | gem | gem dir `-/` + gemspec materialized from `specifications/` | **Gemfile + Gemfile.lock pair**: the `gem` line gains `path:` (or a managed block for transitive deps); the lock's spec block moves GEM→PATH and the DEPENDENCIES entry becomes ` (= )!`, in bundler's exact canonical form | `bundle install` (normal **and** `BUNDLE_FROZEN=true`), byte-stable lock. Lock-only edits are a silent unpatch — hence the mandatory pair | | pypi / uv (uv.lock) | rebuilt wheel (canonical PEP 427 filename; RECORD regenerated) | `[tool.uv.sources] = {path}` in pyproject + surgical uv.lock rewrite; transitive deps via `[tool.uv] override-dependencies` | `uv sync --locked` / `--frozen --offline` (hash-verified, byte-stable lock) | | pypi / poetry (poetry.lock: legacy `[metadata.hashes]`, lock 1.0/1.1 `[metadata.files]`, 2.x `files`) | (rebuilt wheel) | lock-only: the target `[[package]]` gets `[package.source] type="file"` (+ `reference = ""` on the 0.12/1.0 layouts, which read it unconditionally) and the single `{file, hash: sha256-of-our-wheel}` entry in whichever table the generation keeps it. pyproject + `metadata.content-hash` untouched; CRLF locks keep their line endings. A lock written by Poetry < 1.4 emits `pypi_poetry_integrity_unverified` (that installer verifies no local hashes and skips an already-installed version) | `poetry check --lock && poetry sync`, cold cache (hash fail-closed from Poetry 1.4; byte-stable lock) — see `docs/testing/poetry-compatibility.md` | diff --git a/crates/socket-patch-cli/src/commands/composer_hints.rs b/crates/socket-patch-cli/src/commands/composer_hints.rs index a6c3375f1..32012cda7 100644 --- a/crates/socket-patch-cli/src/commands/composer_hints.rs +++ b/crates/socket-patch-cli/src/commands/composer_hints.rs @@ -7,11 +7,40 @@ //! 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. +//! cases `//` must be removed first, where +//! `` is the directory Composer installs into +//! (`COMPOSER_VENDOR_DIR`, else `config.vendor-dir`, else `vendor`): a hint +//! naming `vendor/` in a project that relocated it sends the user to delete +//! a path that does not exist, and `composer install` then leaves the +//! package unpatched (#658). + +use std::path::Path; use serde_json::{Map, Value}; use socket_patch_core::patch::redirect::FileEdit; +/// The project's Composer vendor directory as the hints print it: relative +/// to `cwd` with `/` separators when it lies inside the project (`vendor`, +/// `lib`, `deps/php`), else the absolute path `COMPOSER_VENDOR_DIR` named. +/// A `config.vendor-dir` the crawler refuses (absolute, or climbing out of +/// the project) prints as the `` placeholder rather than a +/// directory that would be wrong. +pub(crate) fn vendor_dir_label(cwd: &Path) -> String { + let Some(dir) = + socket_patch_core::crawlers::composer_crawler::resolve_local_vendor_dir_sync(cwd) + else { + return "".to_string(); + }; + match dir.strip_prefix(cwd) { + Ok(rel) if !rel.as_os_str().is_empty() => rel + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"), + _ => dir.display().to_string(), + } +} + const VENDOR_PREFIX: &str = ".socket/vendor/composer/"; /// The `/` of every composer.lock package (`packages` and @@ -45,15 +74,21 @@ pub(crate) fn vendored_composer_packages(lock_text: &str) -> Vec { /// 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 { +/// `vendor_dir` is the [`vendor_dir_label`] the package dirs are under. +pub(crate) fn vendored_reinstall_hints(packages: &[String], vendor_dir: &str) -> Vec { if packages.is_empty() { return Vec::new(); } - let dirs: Vec = packages.iter().map(|p| format!("vendor/{p}")).collect(); + let dirs: Vec = packages + .iter() + .map(|p| format!("{vendor_dir}/{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!( + "Run `composer install` to update {vendor_dir}/ — vendoring rewires composer.lock \ + only, so the installed {vendor_dir}/ tree keeps the unpatched bytes until Composer \ + reinstalls it." + ), format!( "Composer 1 does not reinstall a locked package whose dist changed: remove {} \ first, then run `composer install`.", @@ -65,8 +100,13 @@ pub(crate) fn vendored_reinstall_hints(packages: &[String]) -> Vec { /// 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 { +/// Composer version. `vendor_dir` is the [`vendor_dir_label`] the package +/// dirs are under. +pub(crate) fn hosted_reinstall_hint( + files: &[String], + edits: &[FileEdit], + vendor_dir: &str, +) -> Option { if !files.iter().any(|f| f == "composer.lock") { return None; } @@ -74,21 +114,20 @@ pub(crate) fn hosted_reinstall_hint(files: &[String], edits: &[FileEdit]) -> Opt .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()))) + .filter_map(|e| Some(format!("{vendor_dir}/{}", e.key.as_deref()?.to_lowercase()))) .collect(); unchanged.sort_unstable(); unchanged.dedup(); if unchanged.is_empty() { - return Some( + return Some(format!( " (e.g. `composer install`; on Composer 1 first remove the patched packages' \ - vendor// directories)" - .to_string(), - ); + {vendor_dir}// directories)" + )); } 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)", + every other patched package's {vendor_dir}// directory)", unchanged.join(", ") )) } @@ -146,8 +185,8 @@ mod tests { #[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!(vendored_reinstall_hints(&[], "vendor").is_empty()); + let hints = vendored_reinstall_hints(&["a/b".to_string(), "c/d".to_string()], "vendor"); assert_eq!(hints.len(), 2); assert!( hints[0].starts_with("Run `composer install`"), @@ -163,8 +202,8 @@ mod tests { #[test] fn hosted_hint_needs_a_rewritten_composer_lock() { - assert!(hosted_reinstall_hint(&["package-lock.json".to_string()], &[]).is_none()); - let hint = hosted_reinstall_hint(&["composer.lock".to_string()], &[]).unwrap(); + assert!(hosted_reinstall_hint(&["package-lock.json".to_string()], &[], "vendor").is_none()); + let hint = hosted_reinstall_hint(&["composer.lock".to_string()], &[], "vendor").unwrap(); assert!(hint.contains("composer install") && hint.contains("Composer 1")); } @@ -200,14 +239,70 @@ mod tests { &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(); + let hint = hosted_reinstall_hint(&files, &edits, "vendor").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(); + let hint = + hosted_reinstall_hint(&files, &[with_source.clone(), dist_only.clone()], "vendor") + .unwrap(); assert!(hint.contains("first remove vendor/acme/lib —"), "{hint}"); assert!(!hint.contains("vendor/psr/log"), "{hint}"); assert!(hint.contains("on Composer 1"), "{hint}"); + + // A relocated vendor dir is the one named (#658). + let hint = hosted_reinstall_hint(&files, &[with_source, dist_only], "lib").unwrap(); + assert!(hint.contains("first remove lib/acme/lib —"), "{hint}"); + assert!( + hint.contains("patched package's lib//"), + "{hint}" + ); + assert!(!hint.contains("vendor/"), "{hint}"); + let hint = hosted_reinstall_hint(&files, &[], "lib").unwrap(); + assert!( + hint.contains("remove the patched packages' lib//"), + "{hint}" + ); + } + + /// #658: with `config.vendor-dir: lib` the vendored hints name + /// `lib//`, the directory Composer installed into. + #[test] + fn vendored_hints_name_a_relocated_vendor_dir() { + let hints = vendored_reinstall_hints(&["acme/tool".to_string()], "lib"); + assert!(hints[0].contains("update lib/"), "{}", hints[0]); + assert!( + hints[1].contains("remove lib/acme/tool first"), + "{}", + hints[1] + ); + assert!(!hints.join("\n").contains("vendor/"), "{hints:?}"); + } + + /// The label follows Composer's own resolution: `config.vendor-dir` + /// (normalized, `/`-separated) else `vendor`; a refused value prints a + /// placeholder instead of a wrong path. `COMPOSER_VENDOR_DIR` is left + /// unset by the test environment, as everywhere in this crate. + #[test] + fn vendor_dir_label_follows_config_vendor_dir() { + let label = |json: Option<&str>| { + let dir = tempfile::tempdir().unwrap(); + if let Some(json) = json { + std::fs::write(dir.path().join("composer.json"), json).unwrap(); + } + vendor_dir_label(dir.path()) + }; + assert_eq!(label(None), "vendor"); + assert_eq!(label(Some("{}")), "vendor"); + assert_eq!(label(Some(r#"{"config":{"vendor-dir":"lib"}}"#)), "lib"); + assert_eq!( + label(Some(r#"{"config":{"vendor-dir":"./deps\\php/"}}"#)), + "deps/php" + ); + assert_eq!( + label(Some(r#"{"config":{"vendor-dir":"../outside"}}"#)), + "" + ); } } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 9bbe8c1c1..09b3cc27f 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1744,7 +1744,12 @@ pub(crate) async fn run_redirect_selected( let mut next_steps = if common.dry_run { Vec::new() } else { - format_next_steps(&human_files, &rewrite.edits, !takeover_migrated.is_empty()) + format_next_steps( + &common.cwd, + &human_files, + &rewrite.edits, + !takeover_migrated.is_empty(), + ) }; next_steps.extend(deferred_steps); for line in next_steps { @@ -2156,8 +2161,10 @@ fn join_names(names: &[String], max: usize) -> String { /// rewritten files, reinstall so the installed tree picks up the patched /// artifacts, then verify with `vex`. After a vendored→hosted takeover /// (`vendored_removed`) the commit also has to carry the deleted vendored -/// ledger entries and artifacts. +/// ledger entries and artifacts. `cwd` locates the project's Composer +/// vendor directory for the composer reinstall hint. fn format_next_steps( + cwd: &std::path::Path, files: &[String], edits: &[socket_patch_core::patch::redirect::FileEdit], vendored_removed: bool, @@ -2180,7 +2187,9 @@ fn format_next_steps( } else if let Some(sbt) = socket_patch_core::patch::redirect::sbt::next_step_hint(files) { sbt.to_string() } else { - crate::commands::composer_hints::hosted_reinstall_hint(files, edits).unwrap_or_default() + let vendor_dir = crate::commands::composer_hints::vendor_dir_label(cwd); + crate::commands::composer_hints::hosted_reinstall_hint(files, edits, &vendor_dir) + .unwrap_or_default() }; let mut extra = Vec::new(); if files @@ -4329,9 +4338,14 @@ mod tests { #[test] fn next_steps_name_the_rewritten_files_and_reinstall() { - assert!(format_next_steps(&[], &[], false).is_empty()); + assert!(format_next_steps(std::path::Path::new("."), &[], &[], false).is_empty()); assert_eq!( - format_next_steps(&["package-lock.json".to_string()], &[], false), + format_next_steps( + std::path::Path::new("."), + &["package-lock.json".to_string()], + &[], + false + ), vec![ "Next steps:".to_string(), " 1. Commit package-lock.json to keep the hosted patches.".to_string(), @@ -4342,6 +4356,7 @@ mod tests { ] ); let steps = format_next_steps( + std::path::Path::new("."), &[ "pnpm-lock.yaml".to_string(), "pnpm-workspace.yaml".to_string(), @@ -4358,22 +4373,35 @@ mod tests { #[test] fn next_steps_add_the_vlt_ci_line_only_for_a_rewritten_vlt_lock() { - let steps = format_next_steps(&["vlt-lock.json".to_string()], &[], false); + let steps = format_next_steps( + std::path::Path::new("."), + &["vlt-lock.json".to_string()], + &[], + false, + ); assert_eq!( steps.last().map(String::as_str), Some(" 3. vlt: commit vlt-lock.json; CI should run `vlt ci`.") ); - assert!( - !format_next_steps(&["package-lock.json".to_string()], &[], false) - .iter() - .any(|s| s.contains("vlt:")) - ); + assert!(!format_next_steps( + std::path::Path::new("."), + &["package-lock.json".to_string()], + &[], + false + ) + .iter() + .any(|s| s.contains("vlt:"))); } #[test] fn next_steps_after_a_takeover_name_the_removed_vendored_state() { assert_eq!( - format_next_steps(&["pnpm-lock.yaml".to_string()], &[], true)[1], + format_next_steps( + std::path::Path::new("."), + &["pnpm-lock.yaml".to_string()], + &[], + true + )[1], " 1. Commit .socket/vendor/ (the removed vendored ledger entries and artifacts) and \ pnpm-lock.yaml to keep the hosted patches." ); diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 29bcad9cd..66afbdd8c 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -3858,7 +3858,11 @@ fn print_vendor_closing( &common.cwd.join("composer.lock"), ) { let packages = super::composer_hints::vendored_composer_packages(&lock); - extra.extend(super::composer_hints::vendored_reinstall_hints(&packages)); + let vendor_dir = super::composer_hints::vendor_dir_label(&common.cwd); + extra.extend(super::composer_hints::vendored_reinstall_hints( + &packages, + &vendor_dir, + )); } } for line in crate::ui::next_steps(&commit, &reinstall, &extra) { diff --git a/crates/socket-patch-cli/tests/e2e_composer_vendor_dir_hints.rs b/crates/socket-patch-cli/tests/e2e_composer_vendor_dir_hints.rs new file mode 100644 index 000000000..be3ac2c23 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_composer_vendor_dir_hints.rs @@ -0,0 +1,148 @@ +//! The Composer reinstall hints `vendor` prints must name the directory +//! Composer actually installed into (#658). +//! +//! A project with `config.vendor-dir: lib` installs `psr/log` under +//! `lib/psr/log`. After vendoring, the "Composer 1 does not reinstall … +//! remove first" hint used to say `vendor/psr/log`, a path that does +//! not exist: following it, `composer install` reports "Nothing to +//! install" and the installed package stays unpatched. Runs the built +//! binary over a fabricated install; no composer and no network. + +#[path = "common/mod.rs"] +mod common; +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::path::Path; + +use serde_json::json; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + +const UUID: &str = "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d"; +const PURL: &str = "pkg:composer/psr/log@3.0.2"; +const FILE: &str = "src/LoggerInterface.php"; +const ORIGINAL: &[u8] = b") { + let mut manifest = json!({ "require": { "psr/log": "^3.0" } }); + if let Some(dir) = vendor_dir { + manifest["config"] = json!({ "vendor-dir": dir }); + } + std::fs::write( + root.join("composer.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + let vendor = root.join(vendor_dir.unwrap_or("vendor")); + std::fs::create_dir_all(vendor.join("composer")).unwrap(); + std::fs::write( + vendor.join("composer/installed.json"), + r#"{ "packages": [ { "name": "psr/log", "version": "3.0.2", "version_normalized": "3.0.2.0" } ] } +"#, + ) + .unwrap(); + let pkg = vendor.join("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).unwrap(); + + 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": { "GHSA-vdir-aaaa-bbbb": { + "cves": ["CVE-2026-0658"], "summary": "s", "severity": "high", "description": "d" + }}, + "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(); +} + +/// Run `vendor --offline` in human mode; returns (exit code, stdout). +fn vendor(root: &Path) -> (i32, String, String) { + let mut cmd = common::hermetic::binary_command(); + cmd.env_remove("COMPOSER_VENDOR_DIR"); + let args = ["vendor", "--offline"]; + let _fixture = prebuilt_common::prepare_command(&mut cmd, root, &args, &[]); + let out = cmd + .args(["--cwd", root.to_str().unwrap()]) + .output() + .expect("run socket-patch"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +#[test] +fn vendored_reinstall_hint_names_a_relocated_vendor_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_project(root, Some("lib")); + let (code, stdout, stderr) = vendor(root); + assert_eq!( + code, 0, + "vendor failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stdout.contains("remove lib/psr/log first"), + "the Composer 1 hint must name the installed dir lib/psr/log:\n{stdout}" + ); + assert!( + stdout.contains("Run `composer install` to update lib/"), + "{stdout}" + ); + assert!( + !stdout.contains("vendor/psr/log"), + "the hint names a directory that does not exist:\n{stdout}" + ); +} + +#[test] +fn vendored_reinstall_hint_keeps_the_default_vendor_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_project(root, None); + let (code, stdout, stderr) = vendor(root); + assert_eq!( + code, 0, + "vendor failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stdout.contains("remove vendor/psr/log first"), "{stdout}"); +} diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 9a67ea99a..9629233ff 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -500,6 +500,25 @@ fn parse_config_vendor_dir(composer_json: &str) -> Option { /// environment, the same trust level as `CARGO_HOME` / `NUGET_PACKAGES` / /// `MAVEN_REPO_LOCAL`, which are all honored verbatim. async fn resolve_local_vendor_dir(cwd: &Path) -> Option { + let manifest = crate::utils::fs::read_regular_to_string(&cwd.join("composer.json")) + .await + .ok(); + vendor_dir_from(cwd, manifest.as_deref()) +} + +/// The blocking twin of [`resolve_local_vendor_dir`], for the human +/// next-step hints that name the installed package directory a user has +/// to remove before `composer install` (#658): those are formatted on a +/// synchronous path after the run, and must name the directory Composer +/// actually installed into, not a hardcoded `vendor/`. +pub fn resolve_local_vendor_dir_sync(cwd: &Path) -> Option { + let manifest = crate::utils::fs::read_regular_to_string_sync(&cwd.join("composer.json")).ok(); + vendor_dir_from(cwd, manifest.as_deref()) +} + +/// The shared resolution of the two entry points above, given the +/// composer.json text (`None` when it is missing or unreadable). +fn vendor_dir_from(cwd: &Path, composer_json: Option<&str>) -> Option { // A set-but-empty value counts as unset (twin of the MAVEN_REPO_LOCAL // and NUGET_PACKAGES rules): honoring `""` would resolve the vendor // tree to the project root itself. @@ -513,7 +532,7 @@ async fn resolve_local_vendor_dir(cwd: &Path) -> Option { return Some(cwd.join(from_env)); } - match read_config_vendor_dir(&cwd.join("composer.json")).await { + match composer_json.and_then(parse_config_vendor_dir) { Some(configured) => normalize_config_vendor_dir(&configured) .filter(|normalized| path_safety::is_safe_multi_segment(normalized)) .map(|normalized| cwd.join(normalized)), @@ -534,17 +553,6 @@ fn normalize_config_vendor_dir(raw: &str) -> Option { crate::utils::relpath::resolve_rel("", raw, 0).filter(|segments| !segments.is_empty()) } -/// Read `config.vendor-dir` from a composer.json on disk. Read with -/// [`crate::utils::fs::read_regular_to_string`] for the same reason -/// installed.json is: the manifest belongs to the untrusted project, and -/// a FIFO planted at that path would wedge a plain read forever. -async fn read_config_vendor_dir(manifest_path: &Path) -> Option { - let content = crate::utils::fs::read_regular_to_string(manifest_path) - .await - .ok()?; - parse_config_vendor_dir(&content) -} - /// The directory an installed.json `install-path` may not escape. /// /// `install-path` legitimately points OUTSIDE the vendor tree — that is diff --git a/docs/testing/composer-compatibility.md b/docs/testing/composer-compatibility.md index afaa6f30d..a28d99203 100644 --- a/docs/testing/composer-compatibility.md +++ b/docs/testing/composer-compatibility.md @@ -93,12 +93,15 @@ serves the unpatched archive, so the hosted rewrite removes it. | 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` | +| 1.x | Leaves an installed stable package as it is: **pristine**. Remove `//` 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. +its next steps. Nothing else prints a Composer reinstall hint. The directory +the hints name is the one Composer installed into: `COMPOSER_VENDOR_DIR`, else +`config.vendor-dir`, else `vendor` (a project with `"vendor-dir": "lib"` is told +to remove `lib//`). ## Vendored copies and Composer's mirror filters