From 5c2960d33182228814bf8b445e47fde4f1d745a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:22:23 +0000 Subject: [PATCH 1/5] Start fix for #1808 Assisted-by: Claude Code:claude-opus-5-5 From 2cdd4808ddd03e0353c70cb216c6f38d0bfb0f6b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:33:28 +0000 Subject: [PATCH 2/5] Vendor gems whose Gemfile.lock uses CRLF A Gemfile.lock checked out with Windows line endings (Git for Windows autocrlf, or eol=crlf) made vendored gem mode fail with "Gemfile.lock has no GEM section", and the hosted-to-vendored eject dry run said the eject would succeed while the real run failed and rolled back. The lock is now edited and reverted in LF form and written back with its own CRLF endings, so the vendored pair edit, the in-sync re-run and --revert all work byte for byte. A lock that mixes CRLF and LF fails closed with a clear message. The eject and takeover dry runs now report a backend failure over the restored files, so they agree with the wet run. Fixes #1808 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/vendor.rs | 13 +- .../tests/e2e_redirect_gem_build.rs | 186 ++++++++++++++++++ .../tests/e2e_vendor_gem_build.rs | 96 +++++++++ crates/socket-patch-core/src/vendor/gem.rs | 182 ++++++++++++++++- 4 files changed, 473 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 46488779b..b6cbb11dc 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -249,7 +249,7 @@ impl TakeoverUndo { } /// The dry-run twin of the wet takeover's rollback: the vendored backend's -/// refusal over the project as `restore` would leave it, or `None` when it +/// refusal or failure over the project as `restore` would leave it, or `None` when it /// would vendor (or the restored project cannot be previewed). The /// restored text is staged in a throwaway group commit that is never /// committed, so nothing reaches the disk: a restore touching a file the @@ -312,6 +312,17 @@ async fn takeover_dry_refusal( Some(VendorOutcome::Refused { code, detail }) if !refusal_is_benign(code) => { Some((code, detail)) } + // An edit that fails over the restored text (a lock shape the + // backend cannot splice, #1808) fails the wet run, which rolls the + // restore back: the preview reports the same failure. + Some(VendorOutcome::Done { + result, warnings, .. + }) if !result.success && !warnings.iter().any(|w| vendor::is_unserved(w.code)) => Some(( + "apply_failed", + result + .error + .unwrap_or_else(|| "the vendored backend failed".to_string()), + )), _ => None, } } 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..0192c140f 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,192 @@ 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. +#[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}" + ); + 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:?}" + ); + } + } + } +} + +/// #1808, the dry/wet parity half: a lock the vendored edit cannot write +/// back (CRLF and LF mixed) fails the wet `vendor` eject, which rolls the +/// restore back, so the dry run must report that failure too instead of +/// previewing `eject_planned`. Neither run leaves the hosted pair changed. +#[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_mixed_eol_lock_fails_the_eject_dry_and_wet() { + let Some(fx) = redirect_scanned_project( + "hosted mixed-eol 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) + ); + let lock_path = fx.proj.join("Gemfile.lock"); + let lf = std::fs::read_to_string(&lock_path).unwrap(); + // Only the trailing BUNDLED WITH lines go CRLF: the hosted restore + // still finds its entry, and the vendored edit has no single ending. + let at = lf.find("BUNDLED WITH").expect("BUNDLED WITH"); + let mixed = format!("{}{}", &lf[..at], lf[at..].replace('\n', "\r\n")); + std::fs::write(&lock_path, &mixed).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![ + "vendor", + "--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!("vendor eject, mixed-eol lock (dry_run={dry_run})"); + let (code, stdout, stderr) = + run_socket_env(&fx.proj, &argv, &[("SOCKET_RUBYGEMS_URL", &upstream)]); + assert_eq!(code, 1, "{label}:\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("apply_failed") && stdout.contains("mixes CRLF and LF"), + "{label}: names the failure:\n{stdout}" + ); + assert!( + !stdout.contains("eject_planned"), + "{label}: no eject is planned:\n{stdout}" + ); + for (file, before) in ["Gemfile", "Gemfile.lock"].iter().zip(before) { + assert_eq!( + std::fs::read(fx.proj.join(file)).unwrap(), + before, + "{label}: {file} keeps its hosted wiring" + ); + } + } +} + /// 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 8acc9c297..2407f54a3 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -2106,3 +2106,99 @@ 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(); + 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"); + 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"], true); + assert!( + ci.status.success(), + "frozen install of the CRLF pair:\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, + "frozen install leaves the CRLF lock byte-identical" + ); + + 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 713cba07d..1c2217473 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -62,6 +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::{to_lf, LineEndings}; use crate::utils::purl::{build_gem_purl, parse_gem_purl, purl_qualifier}; use super::common::{ @@ -130,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 @@ -234,6 +240,7 @@ pub async fn gem_vendor_target_preflight( .await .ok()?, }; + let lock_text = to_lf(&lock_text); plan_gemfile_edit(&gemfile_text, &name, &version, ©_rel) .and_then(|plan| refuse_append_of_direct_dependency(plan, &lock_text, &name)) .and_then(|plan| { @@ -385,6 +392,13 @@ async fn gem_prelude( )); } }; + // A mixed lock stays as read: `gem_edits` refuses it, and its + // already-wired checks see what is on disk. + let lock_eol = LineEndings::of(&lock_text); + let lock_text = match lock_eol { + LineEndings::Crlf => to_lf(&lock_text).into_owned(), + _ => lock_text, + }; let local_stub = { let spec_src = installed_path @@ -466,6 +480,7 @@ async fn gem_prelude( gemfile_text, lock_path, lock_text, + lock_eol, lock_wired, copy_ok, }) @@ -508,7 +523,14 @@ fn gem_edits( // ── Gemfile.lock edit (pure text surgery, computed before any write) ── // A lock-shape failure therefore costs no download / copy / patch and no // Gemfile write. - let lock_edit = match edit_lock(&prelude.lock_text, name, version, &prelude.copy_rel) { + let lock_edit = match prelude.lock_eol { + LineEndings::Mixed => Err( + "Gemfile.lock mixes CRLF and LF line endings, so there is no one line ending to write the edit back in (re-run `bundle lock` or normalize the file first)" + .to_string(), + ), + _ => edit_lock(&prelude.lock_text, name, version, &prelude.copy_rel), + }; + let lock_edit = match lock_edit { Ok(edit) => edit, Err(e) => { return Err(Box::new(done( @@ -583,6 +605,7 @@ pub async fn vendor_gem<'a>( gemfile_text, lock_path, lock_text: _, + lock_eol, lock_wired, copy_ok, } = &prelude; @@ -731,7 +754,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 — @@ -2222,6 +2246,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, } @@ -2249,9 +2276,17 @@ impl<'a> RevertFiles<'a> { Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => return Err(format!("unreadable {name}: {e}")), }; + let eol = match (&text, name == GEMFILE_LOCK) { + (Some(t), true) if LineEndings::of(t) == LineEndings::Crlf => LineEndings::Crlf, + _ => LineEndings::Lf, + }; *slot = Some(RevertFile { read: text.clone(), - text, + text: text.map(|t| match eol { + LineEndings::Crlf => to_lf(&t).into_owned(), + _ => t, + }), + eol, changed: false, }); } @@ -2268,11 +2303,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 { @@ -4543,6 +4580,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 From d2b9a7695388b548a36fc3654870ae973069b95f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:45:07 +0000 Subject: [PATCH 3/5] Keep the eject preview scope; document CRLF locks Drop the generic eject dry-run preview change: the hosted restore already refuses a lock that mixes CRLF and LF before the vendored edit runs, so there was no reachable case to test it with. The CRLF eject test shows the dry and wet runs now agree. Note in docs/ecosystems.md that a CRLF Gemfile.lock is vendored and reverted in its own line ending and a mixed one fails closed. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/vendor.rs | 13 +-- .../tests/e2e_redirect_gem_build.rs | 82 ------------------- docs/ecosystems.md | 2 +- 3 files changed, 2 insertions(+), 95 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index b6cbb11dc..46488779b 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -249,7 +249,7 @@ impl TakeoverUndo { } /// The dry-run twin of the wet takeover's rollback: the vendored backend's -/// refusal or failure over the project as `restore` would leave it, or `None` when it +/// refusal over the project as `restore` would leave it, or `None` when it /// would vendor (or the restored project cannot be previewed). The /// restored text is staged in a throwaway group commit that is never /// committed, so nothing reaches the disk: a restore touching a file the @@ -312,17 +312,6 @@ async fn takeover_dry_refusal( Some(VendorOutcome::Refused { code, detail }) if !refusal_is_benign(code) => { Some((code, detail)) } - // An edit that fails over the restored text (a lock shape the - // backend cannot splice, #1808) fails the wet run, which rolls the - // restore back: the preview reports the same failure. - Some(VendorOutcome::Done { - result, warnings, .. - }) if !result.success && !warnings.iter().any(|w| vendor::is_unserved(w.code)) => Some(( - "apply_failed", - result - .error - .unwrap_or_else(|| "the vendored backend failed".to_string()), - )), _ => None, } } 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 0192c140f..75a1bd5e3 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -3133,88 +3133,6 @@ async fn gem_hosted_pin_with_a_crlf_lock_ejects_cleanly() { } } -/// #1808, the dry/wet parity half: a lock the vendored edit cannot write -/// back (CRLF and LF mixed) fails the wet `vendor` eject, which rolls the -/// restore back, so the dry run must report that failure too instead of -/// previewing `eject_planned`. Neither run leaves the hosted pair changed. -#[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_mixed_eol_lock_fails_the_eject_dry_and_wet() { - let Some(fx) = redirect_scanned_project( - "hosted mixed-eol 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) - ); - let lock_path = fx.proj.join("Gemfile.lock"); - let lf = std::fs::read_to_string(&lock_path).unwrap(); - // Only the trailing BUNDLED WITH lines go CRLF: the hosted restore - // still finds its entry, and the vendored edit has no single ending. - let at = lf.find("BUNDLED WITH").expect("BUNDLED WITH"); - let mixed = format!("{}{}", &lf[..at], lf[at..].replace('\n', "\r\n")); - std::fs::write(&lock_path, &mixed).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![ - "vendor", - "--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!("vendor eject, mixed-eol lock (dry_run={dry_run})"); - let (code, stdout, stderr) = - run_socket_env(&fx.proj, &argv, &[("SOCKET_RUBYGEMS_URL", &upstream)]); - assert_eq!(code, 1, "{label}:\nstdout:\n{stdout}\nstderr:\n{stderr}"); - assert!( - stdout.contains("apply_failed") && stdout.contains("mixes CRLF and LF"), - "{label}: names the failure:\n{stdout}" - ); - assert!( - !stdout.contains("eject_planned"), - "{label}: no eject is planned:\n{stdout}" - ); - for (file, before) in ["Gemfile", "Gemfile.lock"].iter().zip(before) { - assert_eq!( - std::fs::read(fx.proj.join(file)).unwrap(), - before, - "{label}: {file} keeps its hosted wiring" - ); - } - } -} - /// 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/docs/ecosystems.md b/docs/ecosystems.md index feb2028eb..f1d04dacf 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`) | From d1d60bb9c77767175c6bef9ee2af5bcb53b71a6b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:12:33 +0000 Subject: [PATCH 4/5] Refuse mixed-ending locks first, share one helper A Gemfile.lock that mixes CRLF and LF now fails before any Gemfile check, so a user fixing a Gemfile declaration is not sent round twice. The forward edit and the revert read the lock through one helper, so they always agree on which locks are folded to LF. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_gem_build.rs | 1 + crates/socket-patch-core/src/vendor/gem.rs | 59 +++++++++++-------- 2 files changed, 36 insertions(+), 24 deletions(-) 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 75a1bd5e3..afd79b7ea 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -3035,6 +3035,7 @@ async fn gem_hosted_pin_previews_and_runs_a_clean_eject() { /// 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. +/// (The Gemfile's spliced line still takes LF, a gap #815 tracks.) #[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"] diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 1c2217473..f70f913a2 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -240,7 +240,6 @@ pub async fn gem_vendor_target_preflight( .await .ok()?, }; - let lock_text = to_lf(&lock_text); plan_gemfile_edit(&gemfile_text, &name, &version, ©_rel) .and_then(|plan| refuse_append_of_direct_dependency(plan, &lock_text, &name)) .and_then(|plan| { @@ -392,13 +391,7 @@ async fn gem_prelude( )); } }; - // A mixed lock stays as read: `gem_edits` refuses it, and its - // already-wired checks see what is on disk. - let lock_eol = LineEndings::of(&lock_text); - let lock_text = match lock_eol { - LineEndings::Crlf => to_lf(&lock_text).into_owned(), - _ => lock_text, - }; + let (lock_eol, lock_text) = lock_lf_form(lock_text); let local_stub = { let spec_src = installed_path @@ -494,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)) @@ -523,14 +529,7 @@ fn gem_edits( // ── Gemfile.lock edit (pure text surgery, computed before any write) ── // A lock-shape failure therefore costs no download / copy / patch and no // Gemfile write. - let lock_edit = match prelude.lock_eol { - LineEndings::Mixed => Err( - "Gemfile.lock mixes CRLF and LF line endings, so there is no one line ending to write the edit back in (re-run `bundle lock` or normalize the file first)" - .to_string(), - ), - _ => edit_lock(&prelude.lock_text, name, version, &prelude.copy_rel), - }; - let lock_edit = match lock_edit { + let lock_edit = match edit_lock(&prelude.lock_text, name, version, &prelude.copy_rel) { Ok(edit) => edit, Err(e) => { return Err(Box::new(done( @@ -1770,6 +1769,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 @@ -2276,16 +2286,17 @@ impl<'a> RevertFiles<'a> { Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => return Err(format!("unreadable {name}: {e}")), }; - let eol = match (&text, name == GEMFILE_LOCK) { - (Some(t), true) if LineEndings::of(t) == LineEndings::Crlf => LineEndings::Crlf, - _ => LineEndings::Lf, + 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(), - text: text.map(|t| match eol { - LineEndings::Crlf => to_lf(&t).into_owned(), - _ => t, - }), + read, + text, eol, changed: false, }); From e38787013a4dcfaac43cf290634ae53a21db5221 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:34:27 +0000 Subject: [PATCH 5/5] Compare the CRLF e2e frozen leg with bundler's own Bundler 4.0.21 refuses a frozen install of any CRLF Gemfile.lock, even one socket-patch never edited, so the new CRLF vendor test failed on that CI leg. The test now checks the pristine CRLF lock first and only asserts a frozen install when bundler accepts the pristine one. It still checks that bundler takes the vendored lock unchanged. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vendor_gem_build.rs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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 2407f54a3..8851209ad 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -2125,6 +2125,15 @@ fn gem_vendor_crlf_lock_fresh_checkout_and_revert() { 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]; @@ -2163,22 +2172,28 @@ fn gem_vendor_crlf_lock_fresh_checkout_and_revert() { // 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"], true); + let ci = bundle(&fresh, &["install"], frozen_crlf_ok); assert!( ci.status.success(), - "frozen install of the CRLF pair:\nstdout:\n{}\nstderr:\n{}", + "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, - "frozen install leaves the CRLF lock byte-identical" + "bundler accepts the wired CRLF lock as is" ); let (code, stdout, stderr) = vendor(&[]);