Skip to content

Commit 4112baf

Browse files
committed
Read Bundler's global config for gem settings
`bundle config set --global` writes cache_path, gemfile and path to ~/.bundle/config (or $BUNDLE_USER_CONFIG, $BUNDLE_USER_HOME/config, $BUNDLE_CONFIG), and Bundler honours that file below the local config and the environment. socket-patch never read it, so with a global setting: - hosted scan gave no stale-install warning for the archive Bundler installs from, and its VEX attested the unpatched gem; - hosted scan rewired Gemfile while Bundler loaded another manifest, instead of refusing with redirect_gem_bundle_gemfile_unsupported; - agent apply patched a gem copy Bundler never loads. Resolve the global file the way Bundler does and consult it as the lowest config tier for all three settings, honouring BUNDLE_IGNORE_CONFIG and Bundler's rule that a local or env path setting shadows the global one. Fixes #577 Assisted-by: Claude Code:claude-opus-5-5
1 parent 8ee9804 commit 4112baf

7 files changed

Lines changed: 673 additions & 57 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs‎

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,219 @@ async fn gem_hosted_stale_archive_at_configured_cache_path_warns_and_is_not_atte
634634
}
635635
}
636636

637+
/// #577: Bundler reads settings local → env → GLOBAL → default, and the
638+
/// global tier is the file `bundle config set --global …` writes:
639+
/// `$BUNDLE_CONFIG`, else `$BUNDLE_USER_CONFIG`, else
640+
/// `$BUNDLE_USER_HOME/config`, else `~/.bundle/config`. A global
641+
/// `cache_path` moves bundler's install-from cache exactly like a local
642+
/// one, so the stale archive there must warn and must not be attested.
643+
/// A local `cache_path` still beats the global one, and under
644+
/// `BUNDLE_IGNORE_CONFIG` neither file counts.
645+
#[tokio::test(flavor = "multi_thread")]
646+
async fn gem_hosted_stale_archive_at_global_cache_path_warns_and_is_not_attested() {
647+
let server = MockServer::start().await;
648+
mount_api(&server, None).await;
649+
let moved = "---\nBUNDLE_CACHE_PATH: \"vendor/gems\"\n";
650+
// (label, env var naming the global file or its dir, local config,
651+
// extra env, cache dir the archive sits in)
652+
for (label, global_var, local, extra, cache_dir) in [
653+
("home", "HOME", None, None, "gems"),
654+
("user-config", "BUNDLE_USER_CONFIG", None, None, "gems"),
655+
("user-home", "BUNDLE_USER_HOME", None, None, "gems"),
656+
("bundle-config", "BUNDLE_CONFIG", None, None, "gems"),
657+
// The local tier beats the global one: the global `vendor/gems`
658+
// is shadowed by the local `vendor/other`.
659+
(
660+
"local-wins",
661+
"HOME",
662+
Some("---\nBUNDLE_CACHE_PATH: \"vendor/other\"\n"),
663+
None,
664+
"other",
665+
),
666+
// Bundler skips every config file: `vendor/cache` stays in force.
667+
(
668+
"ignore-config",
669+
"HOME",
670+
None,
671+
Some(("BUNDLE_IGNORE_CONFIG", "1")),
672+
"cache",
673+
),
674+
] {
675+
let tmp = tempfile::tempdir().unwrap();
676+
let proj = tmp.path().join("proj");
677+
std::fs::create_dir_all(&proj).unwrap();
678+
write_manifest_pair(&proj);
679+
if let Some(local) = local {
680+
std::fs::create_dir_all(proj.join(".bundle")).unwrap();
681+
std::fs::write(proj.join(".bundle").join("config"), local).unwrap();
682+
}
683+
// Lay the global config down where `global_var` points bundler.
684+
let user = tmp.path().join("user");
685+
let (global_value, global_file) = match global_var {
686+
"HOME" => (user.clone(), user.join(".bundle").join("config")),
687+
"BUNDLE_USER_HOME" => (user.clone(), user.join("config")),
688+
_ => (user.join("bundle-config"), user.join("bundle-config")),
689+
};
690+
std::fs::create_dir_all(global_file.parent().unwrap()).unwrap();
691+
std::fs::write(&global_file, moved).unwrap();
692+
let archive = proj
693+
.join("vendor")
694+
.join(cache_dir)
695+
.join(format!("{DEP}-{DEP_VERSION}.gem"));
696+
std::fs::create_dir_all(archive.parent().unwrap()).unwrap();
697+
std::fs::write(&archive, b"upstream-gem-archive-bytes").unwrap();
698+
699+
let global_value = global_value.to_str().unwrap().to_string();
700+
let mut env: Vec<(&str, &str)> = vec![(global_var, &global_value)];
701+
// HOME keeps pointing somewhere empty for the non-HOME rows, so an
702+
// ambient ~/.bundle/config cannot leak in.
703+
let empty_home = tmp.path().join("empty-home");
704+
let empty_home = empty_home.to_str().unwrap().to_string();
705+
if global_var != "HOME" {
706+
env.push(("HOME", &empty_home));
707+
}
708+
env.extend(extra);
709+
710+
let vex_path = proj.join("out.vex.json");
711+
let (code, stdout, stderr) = common::run_with_env(
712+
&proj,
713+
&[
714+
"scan",
715+
"--mode",
716+
"hosted",
717+
"--json",
718+
"--yes",
719+
"--cwd",
720+
proj.to_str().unwrap(),
721+
"--api-url",
722+
&server.uri(),
723+
"--org",
724+
ORG,
725+
"--api-token",
726+
"fake",
727+
"--vex",
728+
vex_path.to_str().unwrap(),
729+
"--vex-product",
730+
"pkg:gem/app@1.0.0",
731+
],
732+
&env,
733+
);
734+
let envelope = common::parse_json_envelope(&stdout);
735+
let warnings = stale_warnings(&envelope);
736+
assert_eq!(
737+
warnings.len(),
738+
1,
739+
"{label}: the globally configured cache archive must warn: {envelope}\nstderr:\n{stderr}"
740+
);
741+
assert!(
742+
warnings[0].contains(&archive.display().to_string()),
743+
"{label}: the warning must name the archive bundler installs from: {}",
744+
warnings[0]
745+
);
746+
if let Ok(doc) = std::fs::read_to_string(&vex_path) {
747+
assert!(
748+
!doc.contains(PURL),
749+
"{label}: a stale purl must never be attested by the same run's VEX:\n{doc}"
750+
);
751+
}
752+
assert_ne!(
753+
code, 0,
754+
"{label}: an all-stale --vex run must fail, not attest.\nstdout:\n{stdout}"
755+
);
756+
}
757+
}
758+
759+
/// #577: a global `gemfile` setting (`bundle config set --global gemfile
760+
/// Gemfile.next`) makes bundler load `Gemfile.next`, exactly like the local
761+
/// one #507 covers. The hosted scan must refuse with
762+
/// `redirect_gem_bundle_gemfile_unsupported`, leave the `Gemfile` pair
763+
/// untouched, and attest nothing.
764+
#[tokio::test(flavor = "multi_thread")]
765+
async fn gem_hosted_global_gemfile_setting_is_refused() {
766+
let server = MockServer::start().await;
767+
mount_api(&server, None).await;
768+
let tmp = tempfile::tempdir().unwrap();
769+
let proj = tmp.path().join("proj");
770+
std::fs::create_dir_all(&proj).unwrap();
771+
write_manifest_pair(&proj);
772+
std::fs::write(
773+
proj.join("Gemfile.next"),
774+
format!("source \"https://rubygems.org\"\ngem \"{DEP}\", \"{DEP_VERSION}\"\n"),
775+
)
776+
.unwrap();
777+
let home = tmp.path().join("home");
778+
std::fs::create_dir_all(home.join(".bundle")).unwrap();
779+
std::fs::write(
780+
home.join(".bundle").join("config"),
781+
"---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n",
782+
)
783+
.unwrap();
784+
let pristine_gemfile = std::fs::read(proj.join("Gemfile")).unwrap();
785+
let pristine_lock = std::fs::read(proj.join("Gemfile.lock")).unwrap();
786+
787+
let vex_path = proj.join("out.vex.json");
788+
let (code, stdout, stderr) = common::run_with_env(
789+
&proj,
790+
&[
791+
"scan",
792+
"--mode",
793+
"hosted",
794+
"--json",
795+
"--yes",
796+
"--cwd",
797+
proj.to_str().unwrap(),
798+
"--api-url",
799+
&server.uri(),
800+
"--org",
801+
ORG,
802+
"--api-token",
803+
"fake",
804+
"--vex",
805+
vex_path.to_str().unwrap(),
806+
"--vex-product",
807+
"pkg:gem/app@1.0.0",
808+
],
809+
&[("HOME", home.to_str().unwrap())],
810+
);
811+
let envelope = common::parse_json_envelope(&stdout);
812+
let warnings: Vec<&serde_json::Value> = envelope["redirect"]["warnings"]
813+
.as_array()
814+
.map(|a| a.iter().collect())
815+
.unwrap_or_default();
816+
let refusal = warnings
817+
.iter()
818+
.find(|w| w["code"] == "redirect_gem_bundle_gemfile_unsupported")
819+
.unwrap_or_else(|| {
820+
panic!("the global gemfile setting must be refused: {envelope}\nstderr:\n{stderr}")
821+
});
822+
let detail = refusal["detail"].as_str().unwrap_or_default();
823+
assert!(
824+
detail.contains("global bundler config") && detail.contains("--global"),
825+
"the refusal must name the global setting and its remedy: {detail}"
826+
);
827+
assert_eq!(
828+
envelope["redirect"]["redirected"], 0,
829+
"nothing redirected: {envelope}"
830+
);
831+
assert_eq!(
832+
std::fs::read(proj.join("Gemfile")).unwrap(),
833+
pristine_gemfile,
834+
"the Gemfile bundler ignores must be byte-identical"
835+
);
836+
assert_eq!(
837+
std::fs::read(proj.join("Gemfile.lock")).unwrap(),
838+
pristine_lock,
839+
"the lock must be byte-identical"
840+
);
841+
if let Ok(doc) = std::fs::read_to_string(&vex_path) {
842+
assert!(
843+
!doc.contains(PURL),
844+
"nothing may be attested for a gem bundler installs unpatched:\n{doc}"
845+
);
846+
}
847+
assert_ne!(code, 0, "nothing was patched or attested: {envelope}");
848+
}
849+
637850
/// 7. Manifest-less VEX (no `.socket/manifest.json` — hosted never writes
638851
/// one) over the stale-install scenario, before and after the prescribed
639852
/// fix. The two post-install lock shapes are the ones REAL bundler writes

0 commit comments

Comments
 (0)