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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ limits, and required install commands.

### Fixed

- Gem hosted and vendored modes wire only the manifest Bundler loads. A `gems.rb`
twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer
leads to an edit of an ignored `Gemfile` that reports success and attests an
unpatched gem; unsupported layouts are refused before any write (#341, #390).
- **npm dependencies installed from git, a URL or `file:` are no longer
reported patched.** npm installs such a dependency from the dependent's
spec (`github:user/repo`, `https://…/x.tgz`, `file:…`) and ignores the
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2351,6 +2351,21 @@ pub(crate) async fn vendor_records_reusing(
// restore, raised HERE instead — the same `failed` event,
// code and detail, in the dry run and the wet run alike —
// so the hosted wiring stays untouched.
// The gem backend's manifest refusal, likewise raised before
// the restore (a hosted `gems.rb` project cannot vendor).
if candidate.starts_with("pkg:gem/") {
if let Some((code, detail)) =
socket_patch_core::vendor::gem::gem_manifest_refusal(&common.cwd).await
{
has_errors = true;
env.record(
PatchEvent::new(PatchAction::Failed, candidate.clone())
.with_error(code, detail.clone()),
);
report_vendor_failure(common, candidate, &detail);
continue;
}
}
if candidate.starts_with("pkg:npm/") {
let refusal = berry_takeover_refusal
.get_or_init(|| {
Expand Down
163 changes: 162 additions & 1 deletion crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,13 +400,19 @@ enum Driver {
/// selector (v4.0). No `--vex` (get has none); the uuid path needs only
/// the view + reference mocks and is exempt from installed narrowing.
GetUuid,
/// [`Driver::ScanVex`] on a dual-boot project whose `.bundle/config`
/// sets `BUNDLE_GEMFILE: "Gemfile.next"` (#390): bundler loads
/// `Gemfile.next`, so the run must redirect nothing and attest nothing.
/// The fixture asserts that contract itself and yields `None`.
ScanVexDualBoot,
}

impl Driver {
fn label(self) -> &'static str {
match self {
Driver::ScanVex => "scan --mode hosted",
Driver::GetUuid => "get <uuid> --mode hosted",
Driver::ScanVexDualBoot => "scan --mode hosted (BUNDLE_GEMFILE=Gemfile.next)",
}
}
}
Expand Down Expand Up @@ -754,8 +760,22 @@ async fn redirect_scanned_project(
// --vex (get has none), get's envelope with the nested `redirect`.
let api = server.uri();
let proj_str = proj.to_str().expect("utf8 tmp path");
if driver == Driver::ScanVexDualBoot {
// The next-Rails dual boot: a `Gemfile.next` pair that bundler loads
// through the committed `.bundle/config`.
std::fs::copy(proj.join(gemfile_name), proj.join("Gemfile.next")).unwrap();
std::fs::copy(proj.join(lock_name), proj.join("Gemfile.next.lock")).unwrap();
let args = bundler.config_local_args("gemfile", "Gemfile.next");
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let cfg = bundle(&proj, &args);
assert!(
cfg.status.success(),
"bundle config set --local gemfile failed:\n{}",
String::from_utf8_lossy(&cfg.stderr)
);
}
let argv: Vec<&str> = match driver {
Driver::ScanVex => vec![
Driver::ScanVex | Driver::ScanVexDualBoot => vec![
"scan",
"--mode",
"hosted",
Expand Down Expand Up @@ -792,6 +812,19 @@ async fn redirect_scanned_project(
],
};
let (code, stdout, stderr) = run_socket(&proj, &argv);
if driver == Driver::ScanVexDualBoot {
let env: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("not JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}"));
// `--vex` with nothing to attest is an error: the run must not
// look like a successful, attested patch.
assert_ne!(code, 0, "nothing was patched or attested: {env}");
assert_eq!(
env["error"]["code"], "manifest_not_found",
"envelope: {env}"
);
assert_dual_boot_redirects_nothing(&env, &proj, &pristine_gemfile, &pristine_lock);
return None;
}
assert_eq!(
code,
0,
Expand Down Expand Up @@ -865,6 +898,7 @@ async fn redirect_scanned_project(
"in-run hosted VEX is attested from this run's fetched record, not hash-verified: {env}"
);
}
Driver::ScanVexDualBoot => unreachable!("asserted and returned above"),
Driver::GetUuid => {
// get's hosted envelope (CLI_CONTRACT.md "get --mode and
// installed narrowing"): `found` counts the resolved patch;
Expand Down Expand Up @@ -918,6 +952,52 @@ async fn redirect_scanned_project(
})
}

/// #390's contract on a `BUNDLE_GEMFILE: Gemfile.next` project: the hosted
/// scan names the setting, rewrites neither the `Gemfile` pair (which
/// bundler ignores) nor `Gemfile.next`, and its in-run VEX attests nothing.
/// Then the real bundler, loading `Gemfile.next`, resolves the upstream gem
/// (nothing pretends otherwise).
fn assert_dual_boot_redirects_nothing(
env: &serde_json::Value,
proj: &Path,
pristine_gemfile: &[u8],
pristine_lock: &[u8],
) {
let warning_codes: Vec<&str> = env["redirect"]["warnings"]
.as_array()
.map(|a| a.iter().filter_map(|w| w["code"].as_str()).collect())
.unwrap_or_default();
assert!(
warning_codes.contains(&"redirect_gem_bundle_gemfile_unsupported"),
"the BUNDLE_GEMFILE refusal must be reported: {env}"
);
assert!(
!warning_codes.contains(&"redirect_gem_no_gemfile"),
"the refusal names its real cause, not a missing Gemfile: {env}"
);
assert_eq!(
env["redirect"]["redirected"], 0,
"nothing redirected: {env}"
);
assert!(
env["vex"]["statements"].as_u64().unwrap_or(0) == 0,
"no in-run attestation for a gem bundler installs unpatched: {env}"
);
for (file, want) in [
("Gemfile", pristine_gemfile),
("Gemfile.lock", pristine_lock),
("Gemfile.next", pristine_gemfile),
("Gemfile.next.lock", pristine_lock),
] {
assert_eq!(
std::fs::read(proj.join(file)).unwrap(),
want,
"{file} must be byte-untouched"
);
}
assert_no_redirect_ledger(proj);
}

/// v5 hosted mode never writes `.socket/vendor/redirect-state.json`.
fn assert_no_redirect_ledger(proj: &Path) {
assert!(
Expand Down Expand Up @@ -1388,6 +1468,87 @@ async fn gem_hosted_gems_rb_spelling_redirects_and_installs() {
manifestless_vex_matrix(&fx, &fresh).await;
}

/// #341 follow-through: a CHECKSUMS-converged hosted `gems.rb` pin is a
/// live hosted pin, so `get --mode vendored` takes it over. Vendored mode
/// cannot wire `gems.rb`, and the refusal must come before the takeover
/// reverts the pin.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \
the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"]
async fn gem_hosted_gems_rb_pin_survives_a_refused_vendored_takeover() {
let Some(fx) = redirect_scanned_project(
"gems.rb takeover",
Spelling::GemsRb,
true,
true,
None,
Driver::ScanVex,
)
.await
else {
return;
};
vendor_takeover_keeps_the_hosted_gems_rb_pin(&fx);
}

/// A hosted→vendored takeover of a `gems.rb` project: vendored mode cannot
/// wire `gems.rb`, so `get --mode vendored` must refuse BEFORE it restores the hosted
/// pin's upstream entry, or the gem ends up unpatched in both modes.
fn vendor_takeover_keeps_the_hosted_gems_rb_pin(fx: &RedirectFixture) {
let before: Vec<Vec<u8>> = ["gems.rb", "gems.locked"]
.iter()
.map(|f| std::fs::read(fx.proj.join(f)).unwrap())
.collect();
let proj = fx.proj.to_str().expect("utf8 tmp path");
let api = fx._server.uri();
let (code, stdout, stderr) = run_socket(
&fx.proj,
&[
"get", UUID, "--mode", "vendored", "--json", "--yes", "--cwd", proj, "--api-url",
&api, "--org", ORG, "--api-token", "fake",
// The mock serves the patch registry: its origin is the one a
// hosted pin is trusted on.
"--patch-server-url", &api,
],
);
assert_ne!(code, 0, "vendor must refuse.\nstdout:\n{stdout}\nstderr:\n{stderr}");
assert!(
stdout.contains("gemfile_not_loaded"),
"the manifest refusal names its cause:\n{stdout}"
);
assert!(
!stdout.contains("vendor_takeover_reverted_redirect"),
"the hosted pin must not be reverted first:\n{stdout}"
);
for (file, before) in ["gems.rb", "gems.locked"].iter().zip(before) {
assert_eq!(
std::fs::read(fx.proj.join(file)).unwrap(),
before,
"{file} keeps its hosted wiring"
);
}
}

/// #390: bundler's `BUNDLE_GEMFILE` (here a committed `.bundle/config`
/// naming `Gemfile.next`, the dual-boot layout) picks the manifest it
/// loads. The hosted scan used to rewrite the ignored `Gemfile`, report
/// success and attest the patch; it must redirect and attest nothing.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \
the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"]
async fn gem_hosted_bundle_gemfile_dual_boot_redirects_nothing() {
let fx = redirect_scanned_project(
"dual-boot",
Spelling::Gemfile,
false,
true,
None,
Driver::ScanVexDualBoot,
)
.await;
assert!(fx.is_none(), "the dual-boot driver asserts in place");
}

/// The compact-index DEPENDENCY contract, pinned from the red side: a patch
/// registry whose `/info` omits the gem's runtime deps (production's
/// HISTORICAL behavior until the 2026-08-18 republish fixed the served index)
Expand Down
Loading
Loading