diff --git a/crates/socket-patch-cli/tests/e2e_gem_native_ext_build.rs b/crates/socket-patch-cli/tests/e2e_gem_native_ext_build.rs index 78cecd730..531574a3b 100644 --- a/crates/socket-patch-cli/tests/e2e_gem_native_ext_build.rs +++ b/crates/socket-patch-cli/tests/e2e_gem_native_ext_build.rs @@ -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> = 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)> { let mut out = Vec::new(); diff --git a/crates/socket-patch-core/src/patch/gem_ext.rs b/crates/socket-patch-core/src/patch/gem_ext.rs index a226eb24b..d37b3a6b9 100644 --- a/crates/socket-patch-core/src/patch/gem_ext.rs +++ b/crates/socket-patch-core/src/patch/gem_ext.rs @@ -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 { + // 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" { @@ -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 = || { @@ -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");