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
114 changes: 114 additions & 0 deletions crates/socket-patch-cli/tests/e2e_gem_native_ext_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,120 @@ fn native_patch_that_does_not_build_is_put_back() {
assert_eq!(walk_files(&ext_dir), built_before);
}

/// An `extconf.rb` that, like nokogiri's (which activates `mini_portile2`),
/// loads a helper gem installed beside the copy in the same home.
const EXTCONF_WITH_HELPER: &str = "gem \"sp_helper\"\nrequire \"sp_helper\"\n\
require \"mkmf\"\ncreate_makefile(\"sp_native/sp_native\")\n";

/// Build the pure-Ruby `sp_helper` gem in `scratch` (outside the project)
/// and install it into `home`.
fn install_helper_gem(scratch: &Path, home: &Path) {
let src = scratch.join("sp_helper-src");
let user_home = scratch.join("gem-user-home");
std::fs::create_dir_all(src.join("lib")).unwrap();
std::fs::write(src.join("lib/sp_helper.rb"), "module SpHelper; end\n").unwrap();
std::fs::write(
src.join("sp_helper.gemspec"),
"Gem::Specification.new do |s|\n s.name = \"sp_helper\"\n s.version = \"1.0.0\"\n \
s.summary = \"t\"\n s.authors = [\"t\"]\n s.files = [\"lib/sp_helper.rb\"]\nend\n",
)
.unwrap();
for (args, dir) in [
(vec!["build", "sp_helper.gemspec"], src.clone()),
(
vec![
"install",
"--local",
"--no-document",
"--install-dir",
home.to_str().unwrap(),
"sp_helper-1.0.0.gem",
],
src.clone(),
),
] {
let out = Command::new("gem")
.args(&args)
.current_dir(&dir)
.env("HOME", &user_home)
.output()
.unwrap();
assert!(
out.status.success(),
"gem {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}

/// A crawl root spelled relative (`--cwd proj`, or the default `.`) used
/// to reach the build as a relative spec path and `GEM_PATH`, which Ruby
/// resolves from the project dir and `extconf.rb` from the ext dir: the
/// build could not load a gem from the copy's own home, so nokogiri
/// (`mini_portile2`) failed with `native_extension_build_failed`.
#[test]
#[ignore = "host capstone: compiles a C extension with the real ruby/gem toolchain; \
the unpinned `test` job skips it, the pinned-Ruby e2e leg runs it via --ignored"]
fn native_patch_builds_from_a_relative_crawl_root() {
let Some(abi) = toolchain() else {
return;
};
let patched = c_source(2);
let fx = Fixture::new(&abi, &patched);
install_helper_gem(fx._tmp.path(), &fx.home);
let socket = fx.proj.join(".socket");
let entries = [
("ext/sp_native/extconf.rb", EXTCONF, EXTCONF_WITH_HELPER),
(C_KEY, &c_source(1) as &str, &patched as &str),
];
let hashes: Vec<(String, String)> = entries
.iter()
.map(|(_, b, a)| (git_sha256(b.as_bytes()), git_sha256(a.as_bytes())))
.collect();
let files: Vec<PatchEntry<'_>> = entries
.iter()
.zip(&hashes)
.map(|((key, _, _), (b, a))| PatchEntry {
file_name: key,
before_hash: b,
after_hash: a,
})
.collect();
// `rollback` drops the record and frees its blobs, so this runs twice.
let record = || {
write_minimal_manifest(&socket, PURL, UUID, &files);
for ((_, b, a), (bh, ah)) in entries.iter().zip(&hashes) {
write_blob(&socket, bh, b.as_bytes());
write_blob(&socket, ah, a.as_bytes());
}
};
record();

// `--cwd proj` from the project's parent.
let parent = fx.proj.parent().unwrap();
let (code, stdout, stderr) = common::run(parent, &["apply", "--cwd", "proj", "--json"]);
assert_eq!(code, 0, "apply failed:\n{stdout}\n{stderr}");
assert_eq!(event_action(&stdout), "applied", "{stdout}");
assert_eq!(fx.value(), "2", "Ruby must load the rebuilt extension");

let (code, stdout, stderr) = common::run(parent, &["rollback", "--cwd", "proj", "--json"]);
assert_eq!(code, 0, "rollback failed:\n{stdout}\n{stderr}");
assert_eq!(fx.value(), "1");

// The default `.` crawl root, run from inside the project.
record();
let (code, stdout, stderr) = fx.run(&["apply", "--json"]);
assert_eq!(code, 0, "apply failed:\n{stdout}\n{stderr}");
assert_eq!(event_action(&stdout), "applied", "{stdout}");
assert_eq!(fx.value(), "2", "Ruby must load the rebuilt extension");
let (code, stdout, stderr) = fx.run(&["apply", "--check"]);
assert_eq!(code, 0, "a rebuilt patch checks clean:\n{stdout}\n{stderr}");
let (code, stdout, stderr) = fx.run(&["rollback", "--json"]);
assert_eq!(code, 0, "rollback failed:\n{stdout}\n{stderr}");
assert_eq!(fx.value(), "1", "the original build is back");
assert!(!fx.marker().exists());
}

/// `(relative path, bytes)` of every file under `dir`.
fn walk_files(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut out = Vec::new();
Expand Down
31 changes: 30 additions & 1 deletion crates/socket-patch-core/src/patch/gem_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,19 @@ impl GemCopy {
/// The copy at `pkg_path`, when it sits in a RubyGems `gems/` dir.
/// Whether its spec exists is checked by the caller.
fn of(pkg_path: &Path) -> Option<GemCopy> {
// Absolute, because a build runs Ruby from the project dir and
// `extconf.rb` from the ext dir: a relative crawl root (`--cwd .`,
// `--cwd proj`) would leave the spec path and `GEM_PATH` pointing
// nowhere, and an `extconf.rb` that loads `mini_portile2` (nokogiri)
// from the copy's own home would fail to build. `..` is dropped
// lexically, as Ruby's `File.expand_path` drops it from the spec
// path, so both name the same gem dir. A rooted path is kept as is.
let pkg_path = if pkg_path.has_root() {
pkg_path.to_path_buf()
} else {
crate::utils::relpath::absolute_lexically(pkg_path)
};
let pkg_path = pkg_path.as_path();
let full_name = pkg_path.file_name()?.to_str()?.to_string();
let gems = pkg_path.parent()?;
if gems.file_name()? != "gems" {
Expand Down Expand Up @@ -802,7 +815,7 @@ struct BackupInfo {
/// [`BuildMarker::extension_dir`] for `ext_dir` (one of the copy's
/// installed extension dirs, [`find_ruby`]): relative to `home`, compared
/// canonically too (Ruby and the crawler may spell the same dir
/// differently: a relative crawl root, `/tmp` vs `/private/tmp`), so the
/// differently through a symlink: `/tmp` vs `/private/tmp`), so the
/// record survives the project moving.
fn ext_dir_label(home: &Path, ext_dir: &Path) -> String {
let canonical_rel = || {
Expand Down Expand Up @@ -1631,6 +1644,22 @@ mod tests {
assert!(GemCopy::of(Path::new("/h/bundler/gems2/json-abc")).is_none());
}

/// A relative crawl root reaches Ruby (run from the project dir) and
/// `extconf.rb` (run from the ext dir) as absolute paths, `..` dropped
/// the way Ruby's `File.expand_path` drops it.
#[test]
fn gem_copy_of_a_relative_path_is_absolute() {
let copy = GemCopy::of(Path::new("proj/../vendor/ruby/3.4.0/gems/json-2.10.1")).unwrap();
let expected = std::env::current_dir().unwrap().join("vendor/ruby/3.4.0");
assert_eq!(copy.home, expected);
assert!(copy.spec_path.is_absolute());
assert!(copy.gem_dir.is_absolute());
assert!(!copy
.gem_dir
.components()
.any(|c| c == std::path::Component::ParentDir));
}

#[test]
fn gem_name_strips_version_and_platform() {
assert_eq!(gem_name_of("json-2.10.1"), "json");
Expand Down
Loading