diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index 330f3cd17..8500a5afc 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -3029,6 +3029,118 @@ async fn gem_hosted_pin_previews_and_runs_a_clean_eject() { } } +/// #1808: a hosted gem whose `Gemfile.lock` (or whole pair) is CRLF, as a +/// Git for Windows `autocrlf` checkout leaves it, ejects to vendored mode +/// like an LF one. The `vendor` eject and the `scan --mode vendored` +/// takeover agree dry and wet: both exit 0, and the wet run wires the +/// vendored copy with the lock still CRLF on every line instead of failing +/// `apply_failed` ("Gemfile.lock has no GEM section") and rolling back. +/// A CRLF Gemfile keeps CRLF on every line too (#1821). +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17); \ + the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_pin_with_a_crlf_lock_ejects_cleanly() { + for (command, crlf_files) in [ + ("vendor", &["Gemfile.lock"][..]), + ("vendor", &["Gemfile", "Gemfile.lock"][..]), + ("scan", &["Gemfile", "Gemfile.lock"][..]), + ] { + let Some(fx) = redirect_scanned_project( + "hosted crlf eject", + Spelling::Gemfile, + false, + true, + None, + Driver::ScanVex, + ) + .await + else { + return; + }; + let install = bundle(&fx.proj, &["install"]); + assert!( + install.status.success(), + "converging install:\n{}", + String::from_utf8_lossy(&install.stderr) + ); + for file in crlf_files { + let path = fx.proj.join(file); + let text = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, text.replace('\n', "\r\n")).unwrap(); + } + mount_vendored_grant(&fx).await; + let proj = fx.proj.to_str().expect("utf8 tmp path"); + let api = fx._server.uri(); + let upstream = format!("{api}/upstream"); + for dry_run in [true, false] { + let before: Vec> = ["Gemfile", "Gemfile.lock"] + .iter() + .map(|f| std::fs::read(fx.proj.join(f)).unwrap()) + .collect(); + let mut argv = vec![command]; + if command == "scan" { + argv.extend(["--mode", "vendored"]); + } + argv.extend([ + "--json", + "--yes", + "--cwd", + proj, + "--api-url", + &api, + "--org", + ORG, + "--api-token", + "fake", + "--patch-server-url", + &api, + ]); + if dry_run { + argv.push("--dry-run"); + } + let label = format!("{command} eject, CRLF {crlf_files:?} (dry_run={dry_run})"); + let (code, stdout, stderr) = + run_socket_env(&fx.proj, &argv, &[("SOCKET_RUBYGEMS_URL", &upstream)]); + assert_eq!(code, 0, "{label}:\nstdout:\n{stdout}\nstderr:\n{stderr}"); + for bad in ["apply_failed", "eject_rolled_back", "no GEM section"] { + assert!(!stdout.contains(bad), "{label}: {bad}:\n{stdout}"); + } + if dry_run { + for (file, before) in ["Gemfile", "Gemfile.lock"].iter().zip(before) { + assert_eq!( + std::fs::read(fx.proj.join(file)).unwrap(), + before, + "{label}: a dry run writes nothing to {file}" + ); + } + } else { + let gemfile = std::fs::read_to_string(fx.proj.join("Gemfile")).unwrap(); + assert!( + gemfile.contains("path: \".socket/vendor/gem/"), + "{label}: the Gemfile now points at the vendored copy:\n{gemfile}" + ); + if crlf_files.contains(&"Gemfile") { + assert_eq!( + gemfile.matches('\n').count(), + gemfile.matches("\r\n").count(), + "{label}: every line of the Gemfile keeps CRLF:\n{gemfile:?}" + ); + } + let lock = std::fs::read_to_string(fx.proj.join("Gemfile.lock")).unwrap(); + assert!( + lock.contains("PATH\r\n remote: .socket/vendor/gem/"), + "{label}: the lock wires the vendored copy:\n{lock:?}" + ); + assert_eq!( + lock.matches('\n').count(), + lock.matches("\r\n").count(), + "{label}: every line of the lock keeps CRLF:\n{lock:?}" + ); + } + } + } +} + /// One refused `vendor` eject of the group-block fixture (#1761): dry or /// wet, the eject is refused whole with `gemfile_declaration_not_editable` /// and `eject_refused` (exit 1), and the hosted `Gemfile` / diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs index 261c20c94..6872c38e5 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -2187,3 +2187,114 @@ fn gem_vendor_gemspec_dev_dependency_refuses_or_installs_frozen() { "revert restores Gemfile.lock byte-identical" ); } + +/// #1808: a CRLF `Gemfile.lock` (a Git for Windows `autocrlf` checkout, or +/// `eol=crlf`; bundler keeps CRLF on purpose) vendors like an LF one. The +/// dry run and the wet run both succeed, the wired lock keeps CRLF on every +/// line, a frozen fresh-checkout install accepts it byte-stably, a re-run +/// is byte-stable, and `--revert` hands back the CRLF lock byte for byte. +#[test] +#[ignore = "host capstone: shells out to a real bundler >= 1.17; the unpinned `test` job \ + skips it, the e2e job runs it with a pinned toolchain via --ignored"] +fn gem_vendor_crlf_lock_fresh_checkout_and_revert() { + let Some((tmp, proj, _bundler, _purl)) = staged_rack_project("crlf lock") else { + return; + }; + let lock_path = proj.join("Gemfile.lock"); + let gemfile_path = proj.join("Gemfile"); + let lf = std::fs::read_to_string(&lock_path).unwrap(); + let crlf = lf.replace('\n', "\r\n"); + std::fs::write(&lock_path, &crlf).unwrap(); + let gemfile_before = std::fs::read(&gemfile_path).unwrap(); + // Some bundlers (4.0.21) refuse a frozen install of any CRLF lock, even + // one socket-patch never touched; the frozen leg below then has nothing + // to compare against. + let pristine = tmp.path().join("pristine"); + std::fs::create_dir_all(&pristine).unwrap(); + std::fs::copy(&gemfile_path, pristine.join("Gemfile")).unwrap(); + std::fs::copy(&lock_path, pristine.join("Gemfile.lock")).unwrap(); + copy_dir_recursive(&proj.join(".bundle"), &pristine.join(".bundle")); + let frozen_crlf_ok = bundle(&pristine, &["install"], true).status.success(); + let cwd = proj.to_str().unwrap(); + let vendor = |extra: &[&str]| { + let mut args = vec!["vendor", "--json", "--offline", "--cwd", cwd]; + args.extend_from_slice(extra); + run_socket(&proj, &args) + }; + + let (code, stdout, stderr) = vendor(&["--dry-run"]); + assert_eq!(code, 0, "dry run:\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(!stdout.contains("apply_failed"), "dry run:\n{stdout}"); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + crlf, + "a dry run writes nothing" + ); + + let (code, stdout, stderr) = vendor(&[]); + assert_eq!(code, 0, "vendor:\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_json_envelope(&stdout); + assert_eq!(env["summary"]["applied"], 1, "{env}"); + let wired = std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!( + wired.matches('\n').count(), + wired.matches("\r\n").count(), + "every line of the wired lock keeps CRLF:\n{wired:?}" + ); + let copy_rel = format!(".socket/vendor/gem/{UUID}/"); + assert!( + wired.starts_with(&format!("PATH\r\n remote: {copy_rel}")), + "the PATH section is spliced in CRLF:\n{wired:?}" + ); + assert!( + wired.contains(&format!("\r\n {DEP} (= ")), + "the DEPENDENCIES pin is spliced in CRLF:\n{wired:?}" + ); + + // Bundler reads the CRLF pair as wired and keeps it byte-identical. + let fresh = tmp.path().join("fresh"); + if !frozen_crlf_ok { + println!( + "SKIP frozen leg of e2e_vendor_gem_build (crlf lock): this bundler refuses a \ + frozen install of the pristine CRLF lock too" + ); + } + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(&gemfile_path, fresh.join("Gemfile")).unwrap(); + std::fs::copy(&lock_path, fresh.join("Gemfile.lock")).unwrap(); + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + copy_dir_recursive(&proj.join(".bundle"), &fresh.join(".bundle")); + let ci = bundle(&fresh, &["install"], frozen_crlf_ok); + assert!( + ci.status.success(), + "install of the CRLF pair (frozen: {frozen_crlf_ok}):\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + assert_eq!( + std::fs::read_to_string(fresh.join("Gemfile.lock")).unwrap(), + wired, + "bundler accepts the wired CRLF lock as is" + ); + + let (code, stdout, stderr) = vendor(&[]); + assert_eq!(code, 0, "re-run:\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + wired, + "a re-run takes the in-sync path and leaves the lock alone" + ); + + let (code, stdout, stderr) = run_socket(&proj, &["vendor", "--revert", "--json", "--cwd", cwd]); + assert_eq!(code, 0, "revert:\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + !stdout.contains("drifted"), + "a clean revert reports no drift:\n{stdout}" + ); + assert_eq!(std::fs::read(&gemfile_path).unwrap(), gemfile_before); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + crlf, + "revert restores the CRLF lock byte for byte" + ); +} diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 0037f8d23..ea6482536 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -62,7 +62,7 @@ use crate::patch::copy_tree::remove_tree; use crate::patch::path_safety::is_safe_single_segment; use crate::patch::redirect::gem_line_tail_blocks_edit; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; -use crate::utils::line_endings; +use crate::utils::line_endings::{self, to_lf, LineEndings}; use crate::utils::purl::{build_gem_purl, parse_gem_purl, purl_qualifier}; use super::common::{ @@ -131,7 +131,12 @@ struct GemPrelude { gemfile_path: PathBuf, gemfile_text: String, lock_path: PathBuf, + /// The lock in LF form; [`GemPrelude::lock_eol`] spells an edit back. lock_text: String, + /// The lock's own line ending (#1808): a CRLF lock (a Git for Windows + /// `autocrlf` checkout; bundler keeps CRLF) is edited in LF form and + /// written back in CRLF. + lock_eol: LineEndings, /// Gemfile and Gemfile.lock already wire this uuid's copy. lock_wired: bool, /// ...and the committed copy is intact (the in-sync hot path, which @@ -386,6 +391,7 @@ async fn gem_prelude( )); } }; + let (lock_eol, lock_text) = lock_lf_form(lock_text); let local_stub = { let spec_src = installed_path @@ -467,6 +473,7 @@ async fn gem_prelude( gemfile_text, lock_path, lock_text, + lock_eol, lock_wired, copy_ok, }) @@ -480,6 +487,19 @@ fn gem_edits( prelude: &GemPrelude, ) -> Result<(GemfilePlan, LockEdit), Box> { let (name, version) = (prelude.name.as_str(), prelude.version.as_str()); + // ── a lock the edit cannot be written back into (#1808) ────────────── + // Checked first: no Gemfile fix makes such a lock editable. + if prelude.lock_eol == LineEndings::Mixed { + return Err(Box::new(done( + failed_result( + purl, + &prelude.copy_dir, + "failed to edit Gemfile.lock: it mixes CRLF and LF line endings, so there is no one line ending to write the edit back in (normalize the file, e.g. re-run `bundle lock`, first)".to_string(), + ), + None, + Vec::new(), + ))); + } // ── Gemfile edit plan (refusals before any write) ──────────────────── let plan = match plan_gemfile_edit(&prelude.gemfile_text, name, version, &prelude.copy_rel) .and_then(|plan| refuse_append_of_direct_dependency(plan, &prelude.lock_text, name)) @@ -584,6 +604,7 @@ pub async fn vendor_gem<'a>( gemfile_text, lock_path, lock_text: _, + lock_eol, lock_wired, copy_ok, } = &prelude; @@ -732,7 +753,8 @@ pub async fn vendor_gem<'a>( } // ── Gemfile.lock write (a failure here unwinds the Gemfile) ────────── - if let Err(e) = atomic_write_bytes_preserving_mode(lock_path, lock_edit.text.as_bytes()).await { + let new_lock = lock_eol.restore(&lock_edit.text); + if let Err(e) = atomic_write_bytes_preserving_mode(lock_path, new_lock.as_bytes()).await { let mut detail = format!("failed to write Gemfile.lock: {e}"); // Unwind: a Gemfile pointing at a path the lock doesn't agree with // is exactly the half-wired state the pair edit exists to prevent — @@ -1761,6 +1783,17 @@ fn gemspec_listing(project_root: &Path, dir: &str) -> crate::formats::gem::gemsp Ok(out) } +/// `text` (a Gemfile.lock as read) in the form the lock surgery reads, and +/// the line ending an edit of it is written back in (#1808): a CRLF lock is +/// folded to LF and restored as CRLF. Any other lock stays as read — a +/// mixed one has no single ending to restore, so [`gem_edits`] refuses it. +fn lock_lf_form(text: String) -> (LineEndings, String) { + match LineEndings::of(&text) { + LineEndings::Crlf => (LineEndings::Crlf, to_lf(&text).into_owned()), + eol => (eol, text), + } +} + /// Produce the pair-edited lock text (see the module doc for the canonical /// form). Pure string surgery on exact line spans — every byte not /// deliberately changed is preserved, which is what keeps the result @@ -2237,6 +2270,9 @@ struct RevertFile { text: Option, /// The bytes as read, to put back if a later write fails. read: Option, + /// The line ending `text` is written back in: a CRLF lock is reverted + /// in LF form, like [`edit_lock`] edits it (#1808). + eol: LineEndings, changed: bool, } @@ -2264,9 +2300,18 @@ impl<'a> RevertFiles<'a> { Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => return Err(format!("unreadable {name}: {e}")), }; + let read = text.clone(); + let (eol, text) = match text { + Some(t) if name == GEMFILE_LOCK => { + let (eol, t) = lock_lf_form(t); + (eol, Some(t)) + } + other => (LineEndings::Lf, other), + }; *slot = Some(RevertFile { - read: text.clone(), + read, text, + eol, changed: false, }); } @@ -2283,11 +2328,13 @@ impl<'a> RevertFiles<'a> { let Some(RevertFile { text: Some(text), read, + eol, changed: true, }) = file else { continue; }; + let text = eol.restore(text); if let Err(e) = atomic_write_bytes_preserving_mode(&self.root.join(name), text.as_bytes()).await { @@ -4747,6 +4794,145 @@ mod tests { ); } + /// #1808: a CRLF lock (a Git for Windows `autocrlf` checkout; bundler + /// keeps CRLF) is edited in its own line ending — the dry run and the + /// wet run both succeed — and a revert hands it back byte for byte. + #[tokio::test] + async fn crlf_lock_vendors_in_crlf_and_reverts_byte_exact() { + let cases = [ + (GEMFILE_DIRECT, LOCK_DIRECT, Some(expected_lock_direct())), + (GEMFILE_TRANSITIVE, LOCK_TRANSITIVE, None), + ]; + for (gemfile, lf_lock, expected_lf) in cases { + let crlf = lf_lock.replace('\n', "\r\n"); + let (_tmp, root, installed, blobs, record) = fixture(gemfile, &crlf).await; + + let (dry, _, _) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, true).await); + assert!(dry.success, "dry run: {:?}", dry.error); + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor: {:?}", result.error); + let wired = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(); + assert_eq!( + wired.matches('\n').count(), + wired.matches("\r\n").count(), + "every line keeps CRLF: {wired:?}" + ); + if let Some(expected) = expected_lf { + assert_eq!(wired, expected.replace('\n', "\r\n")); + } + // The ledger records bare lines, as for an LF lock. + let entry = entry.unwrap(); + let lk = entry + .wiring + .iter() + .find(|w| w.kind == LOCK_WIRING_KIND) + .unwrap(); + assert!( + !lk.new.as_ref().unwrap().to_string().contains("\\r"), + "{:?}", + lk.new + ); + + // The in-sync hot path recognises the CRLF wiring. + let (rerun, again, _) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(rerun.success, "{:?}", rerun.error); + assert!(again.is_none(), "hot path must not re-record"); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + wired + ); + + let outcome = revert_gem(&entry, &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!( + !outcome + .warnings + .iter() + .any(|w| w.code == "vendor_lock_entry_drifted"), + "{:?}", + outcome.warnings + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + gemfile + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + crlf, + "lock byte-identical to the CRLF fixture" + ); + } + } + + /// #1808: a CRLF bundler >= 2.6 lock — the CHECKSUMS rewrite and its + /// revert keep CRLF too. + #[tokio::test] + async fn crlf_checksums_lock_vendors_and_reverts_byte_exact() { + let crlf = SPIKE_LOCK_CHECKSUMS_BEFORE.replace('\n', "\r\n"); + let (_tmp, root, installed, blobs, record) = + fixture_318(SPIKE_GEMFILE_CHECKSUMS, &crlf).await; + let (result, entry, _w) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "{:?}", result.error); + let wired = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(); + assert!( + wired.contains("\r\nCHECKSUMS\r\n rack (3.1.8)\r\n"), + "{wired:?}" + ); + assert_eq!(wired.matches('\n').count(), wired.matches("\r\n").count()); + + let (rerun, again, _) = + unwrap_done(run_vendor_318(&root, &blobs, &installed, &record, false).await); + assert!(rerun.success, "{:?}", rerun.error); + assert!(again.is_none(), "hot path must not re-record"); + + let outcome = revert_gem(&entry.unwrap(), &root, false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + crlf + ); + } + + /// A lock mixing CRLF and LF has no one line ending to write the edit + /// back in: vendor fails closed (dry run too) and writes nothing. + #[tokio::test] + async fn mixed_line_ending_lock_fails_closed() { + let mixed = LOCK_DIRECT.replacen('\n', "\r\n", 3); + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, &mixed).await; + for dry_run in [true, false] { + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, dry_run).await); + assert!(!result.success, "dry_run={dry_run}"); + assert!(entry.is_none()); + let err = result.error.unwrap_or_default(); + assert!(err.contains("mixes CRLF and LF"), "{err}"); + } + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + mixed + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_DIRECT + ); + } + #[tokio::test] async fn test_checksums_already_bare_records_nothing() { // Spike `bare-checksum-registry-gem/before`: a registry-sourced lock diff --git a/docs/ecosystems.md b/docs/ecosystems.md index baa0e0892..ea59c4dc2 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -17,7 +17,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | npm (`npm`) — pnpm / yarn / berry / bun / vlt | ✅ any install layout, vlt's `node_modules/.vlt` store included (every store copy, copy-on-write) | ✅ seven lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)), vlt `vlt-lock.json` lockfileVersion 0/1 (patched package directories for direct dependencies of the root or a workspace member; transitive targets refused — see [vlt notes](#npm-vlt-notes)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun, vlt (`vlt-lock.json` without `lockfileVersion`, 0 or 1) — pnpm, berry, bun and vlt carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) and [vlt notes](#npm-vlt-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ in place | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; Pipenv 2023.7.9+ exports the vendored wheel without a hash, so `pipenv requirements --hash` output fails pip's hash-checking mode — `pypi_pipenv_hash_export_unsupported`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); needs cargo 1.68+ (sparse registries) — a project whose `rust-version` or `rust-toolchain[.toml]` declares an older cargo is refused (`redirect_cargo_old_cargo`), use `--mode vendored`; direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; a crate the user overrides with `[patch]` (path, git or another registry) is refused too, the override is left alone; a `cargo vendor` or `cargo local-registry` project (crates.io replaced by an on-disk source in the project's, a parent directory's or `$CARGO_HOME`'s `.cargo/config*`) is refused, use agent mode; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | -| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` twin, which bundler ≥ 2 loads instead, or a `BUNDLE_GEMFILE`-configured manifest makes vendoring refuse with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` or `Gemfile` + `Gemfile.lock`, whichever the project holds (a `Gemfile` + `gems.rb` twin is refused with `redirect_gem_twin_manifest_ambiguous`: bundler 1.x loads the `Gemfile`, bundler ≥ 2 loads `gems.rb`, and nothing in the project says which bundler installs it; bundler 4's `BUNDLE_LOCKFILE` (environment, app config or global config) naming any other lock is refused with `redirect_gem_bundle_lockfile_unsupported`, as is vendoring with `gemfile_not_loaded`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from `.bundle/config` (which outranks the environment, as in bundler), the environment, or the global `~/.bundle/config` / `$BUNDLE_USER_CONFIG` (lowest, as in bundler) is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); a Bundler all-source, exact-source or hostname mirror in app config or the scan environment can capture the patch registry, so the redirect is refused with `redirect_gem_mirror_overrides_source` without printing mirror URLs (scope mirrors to `mirror.https://rubygems.org`; user-global config and mirrors set only in a later install environment are not inspected); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | +| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (a CRLF `Gemfile.lock`, as a `core.autocrlf` checkout leaves it, is edited and reverted in its own line ending, and a lock mixing CRLF and LF fails closed; `Gemfile` spelling only — a `gems.rb` twin, which bundler ≥ 2 loads instead, or a `BUNDLE_GEMFILE`-configured manifest makes vendoring refuse with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` or `Gemfile` + `Gemfile.lock`, whichever the project holds (a `Gemfile` + `gems.rb` twin is refused with `redirect_gem_twin_manifest_ambiguous`: bundler 1.x loads the `Gemfile`, bundler ≥ 2 loads `gems.rb`, and nothing in the project says which bundler installs it; bundler 4's `BUNDLE_LOCKFILE` (environment, app config or global config) naming any other lock is refused with `redirect_gem_bundle_lockfile_unsupported`, as is vendoring with `gemfile_not_loaded`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from `.bundle/config` (which outranks the environment, as in bundler), the environment, or the global `~/.bundle/config` / `$BUNDLE_USER_CONFIG` (lowest, as in bundler) is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); a Bundler all-source, exact-source or hostname mirror in app config or the scan environment can capture the patch registry, so the redirect is refused with `redirect_gem_mirror_overrides_source` without printing mirror URLs (scope mirrors to `mirror.https://rubygems.org`; user-global config and mirrors set only in a later install environment are not inspected); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | 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 [Go notes](#go-directory-replaces-and-gosum). Paid hosted patches are unsupported; `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) — Maven and Gradle | ✅ in place in every copy the build consumes: each `~/.m2` copy it reads and each Gradle `files-2.1` copy; `~/.m2` `.sha1`/`.md5` sidecars are rewritten, Gradle copies get advisories; jar-member records swap in the patch service's whole jar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) and [Gradle](#gradle) | ✅ suffixed Maven repository (`-socket.` pin + `.mvn/maven.config` + fallback file repository) for every pom root, single-module or reactor, or Gradle 6.8+ same-GAV repository with settings wiring and SHA-256 checks (a root with both `pom.xml` and a Gradle build wires both); see [JVM vendoring](design/maven-vendoring.md) and [Gradle](#gradle) | ✅ fail-closed by a Socket-only `-socket.` suffix: pom projects get a pinned `` (`${property}` versions are refused); Gradle 6.8+ builds get an owned settings script, lock-entry rewrites and a resolution tripwire — see [Maven & NuGet caveats](#maven--nuget-caveats) and [Gradle](#gradle) | | sbt / Mill / scala-cli (`maven`) | ✅ Coursier caches (sbt 1.3+, sbt 2, Mill, scala-cli) and Ivy caches (sbt 0.13–1.2, `useCoursier := false`) patched in place, Coursier checksum sidecars resynced — see [Scala build tools](#scala-build-tools-sbt-mill-scala-cli) | ✅ sbt 0.13.18+: generated `socket-patch-vendor.sbt` over the committed suffixed `.socket/vendor/maven2` tree; scala-cli directory builds: owned `socket-patch.scala` + same-GAV `.socket/vendor/coursier` tree (Linux / macOS); Mill: not wired (agent or hosted guidance) | ✅ sbt 0.13.18+: one generated `socket-patch.sbt`, gated on sbt's own `sbt update` records; Mill / scala-cli: paste-able snippets only (`redirect_mill_manual_snippet`, `redirect_scala_cli_manual_snippet`) |