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
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1271,8 +1271,10 @@ jobs:
# Ruby <= 3.3 and 1.17-2.1 need Ruby <= 3.1 (`untaint`). 2.7.2
# (no boundary of its own) is in e2e-full.
# Hosted and vendored share each era's leg (one Ruby + bundler
# setup; the run step loops over `suite`).
- {os: ubuntu-latest, suite: e2e_redirect_gem_build e2e_vendor_gem_build, ruby: '3.4', bundler: '4.0.21'}
# setup; the run step loops over `suite`), as does the agent-mode
# C-extension rebuild capstone (#1996), which needs the same Ruby
# plus the runner's C compiler.
- {os: ubuntu-latest, suite: e2e_redirect_gem_build e2e_vendor_gem_build e2e_gem_native_ext_build, ruby: '3.4', bundler: '4.0.21'}
# The live-API smoke suites (e2e_npm, e2e_pypi, e2e_gem,
# e2e_scan) are intentionally NOT in the PR matrix — their
# `#[ignore]`-gated tests hit the real public proxy at
Expand Down Expand Up @@ -2008,7 +2010,7 @@ jobs:
# composer 1.10 keeps the PHP 8.2 it has always run on here
# (composer-compatibility.yml runs 1.10.28 on 8.1).
- {os: ubuntu-latest, suite: e2e_vendor_composer_build e2e_redirect_composer_build, composer: '1', php: '8.2'}
- {os: ubuntu-latest, suite: e2e_redirect_gem_build e2e_vendor_gem_build, ruby: '3.1', bundler: '1.17.3'}
- {os: ubuntu-latest, suite: e2e_redirect_gem_build e2e_vendor_gem_build e2e_gem_native_ext_build, ruby: '3.1', bundler: '1.17.3'}
- {os: ubuntu-latest, suite: e2e_redirect_gem_build e2e_vendor_gem_build, ruby: '3.1', bundler: '2.1.4'}
- {os: ubuntu-latest, suite: e2e_redirect_gem_build e2e_vendor_gem_build, ruby: '3.1', bundler: '2.2.33'}
- {os: ubuntu-latest, suite: e2e_redirect_gem_build e2e_vendor_gem_build, ruby: '3.3', bundler: '2.5.23'}
Expand Down
43 changes: 43 additions & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,49 @@ are patched by `socket-patch apply` like every other ecosystem:
or a bare `--global-prefix` cache with no known `go install` directory) the same warning code says
so, and VEX is not withheld (#422).

### Ruby gems with native extensions in agent mode

Ruby loads the extension RubyGems compiled at install time
(`extensions/<platform>/<abi>/<gem>-<version>/`, and its copy under the gem's `lib/`), never
the `ext/` sources beside it. When a gem record changes a native-extension build input, `apply`
writes the patched sources and then rebuilds the extension from them with RubyGems' own builder
(the step `gem pristine --extensions` runs), using the Ruby that owns the install: the one in
`<prefix>/bin` of a `<prefix>/lib/ruby/gems/<abi>` home, else the `ruby` on `PATH` run from
`--cwd`. A build input is a changed `.c`, `.h`, `.cc`, `.cpp`, `.cxx`, `.hh`, `.hpp`, `.hxx`,
`.m`, `.y` or `.rl` file, an `extconf.rb`, a `depend` under `ext/`, or any file under the
directory of a build script the installed gemspec's `extensions` names (a script at the gem root:
the files beside it) (#1996). In a gem that declares no extensions, only such a source under
`ext/` is one (where a precompiled platform gem keeps the sources of the binary it ships), and
never a grammar file (`.y`, `.rl`), whose generated output the gem packs. The Ruby must build into
the gem's installed `extensions/<platform>/<abi>/` dir; a gem without one (installed by a RubyGems
older than 2.2) is refused. The rebuild runs the gem's own build scripts, as `gem install` did.

The first rebuild of a copy backs up the install-time build (extension directory and `lib/`
copies) to `.socket-patch-native-orig/` in the gem directory, and every rebuild records
`.socket-patch-native-build.json` there: the source hashes it was built from and the hash of every
artifact it produced. A record whose sources are patched and whose marker matches is
`already_patched`; sources an earlier release patched without a rebuild are rebuilt, with a
`native_extension_rebuilt` warning. `rollback` (and `remove`) puts the backed-up build back with
the sources; without a backup it rebuilds from the restored sources. Only the gem's own
extension dir under its home is ever replaced, whatever path the backup or marker names.

`--no-gem-native-rebuild` (env `SOCKET_NO_GEM_NATIVE_REBUILD`) is for a caller that compiles the
extension itself (the patches evaluation harness): the patched sources are written as before #1996,
with no Ruby run and no backup or marker, and the run succeeds with a `native_extension_not_rebuilt`
warning. `vex` and `apply --check` then omit the patch until a rebuild by `apply` records it.

| Code | Run result | Meaning |
|---|---|---|
| `native_extensions_unsupported` | refused, exit 1 | The record changes build inputs that cannot be rebuilt in place: the copy is not a RubyGems install with a `specifications/` entry (a Bundler git checkout), the gem declares no extensions (a precompiled platform gem), or no Ruby whose extension directory for the gem is the installed one was found. Nothing is written; use `scan --mode hosted`, where RubyGems compiles the patched gem. |
| `native_extension_build_failed` | refused, exit 1 | The extension did not build from the patched sources (no compiler or Ruby headers, a build error). The previous build and the original sources are put back; the error carries the build output's tail. |
| `native_extension_rebuilt` | warning | The sources were already patched (by a release before #1996) and the extension was rebuilt from them. |
| `native_extension_not_rebuilt` | warning | `--no-gem-native-rebuild`: the sources were written without a rebuild; Ruby loads the old build until the caller compiles it. |
| `native_extension_restore_failed` | rollback: that copy fails | The sources were restored, but the original compiled extension could not be put back. Run `gem pristine <gem> --extensions`. |

`vex` and `apply --check` attest such a copy only while its marker matches the record's patched
sources and every artifact it lists; otherwise the purl fails with `native_extension_not_rebuilt`.
Hosted copies are judged on their files alone, since RubyGems compiled them from the patched gem.

### sbt / Mill / scala-cli caches in agent mode

A JVM project root (any `JVM_PROJECT_MARKERS` file, `build.sbt` / `build.mill` / `build.sc` /
Expand Down
17 changes: 17 additions & 0 deletions crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,21 @@ pub struct GlobalArgs {
value_parser = parse_bool_flag,
)]
pub no_vlt_install_cleanup: bool,

/// Agent mode (`apply`, `scan`/`get --mode agent`): write a gem patch
/// that changes native-extension sources WITHOUT rebuilding the
/// extension, for a caller that compiles it itself. Ruby keeps loading
/// the old build until then, so `vex` and `apply --check` do not attest
/// those patches (`native_extension_not_rebuilt`). Other subcommands
/// accept it silently.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "no-gem-native-rebuild",
env = "SOCKET_NO_GEM_NATIVE_REBUILD",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub no_gem_native_rebuild: bool,
}

impl GlobalArgs {
Expand Down Expand Up @@ -641,6 +656,7 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[
"SOCKET_NO_TRUST_LOCKFILE_CONFIG",
"SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG",
"SOCKET_NO_VLT_INSTALL_CLEANUP",
"SOCKET_NO_GEM_NATIVE_REBUILD",
];

/// Every env var a subcommand-local flag binds (one per `env = "..."`
Expand Down Expand Up @@ -709,6 +725,7 @@ impl Default for GlobalArgs {
no_trust_lockfile_config: false,
no_npm_allow_remote_config: false,
no_vlt_install_cleanup: false,
no_gem_native_rebuild: false,
}
}
}
Expand Down
50 changes: 41 additions & 9 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use socket_patch_core::patch::apply::{
apply_package_patch, verify_file_patch, ApplyResult, MismatchPolicy, PatchSources, VerifyStatus,
};
use socket_patch_core::patch::apply_lock::LockGuard;
use socket_patch_core::patch::gem_ext;
use socket_patch_core::patch::redirect::golang_local::{
apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state,
};
Expand Down Expand Up @@ -717,6 +718,10 @@ fn describe_check_failure(reason: &str) -> &'static str {
"hash_mismatch" => "an installed copy matches neither the original nor the patched bytes",
"file_not_found" => "a patched file is missing from an installed copy",
"no_files" => "the patch record lists no files to verify",
"native_extension_not_rebuilt" => {
"the patched native-extension sources were not compiled into the extension \
Ruby loads"
}
"no_matching_variant" => {
"an installed copy matches none of the manifest's release variants \
(no matching variant found)"
Expand Down Expand Up @@ -2703,15 +2708,42 @@ async fn apply_patches_inner(
}

attempted = true;
let result = apply_package_patch(
variant_purl,
pkg_path,
&patch.files,
&sources,
args.common.dry_run,
policy,
)
.await;
// A gem patch to native-extension sources rebuilds the
// extension, or is refused with nothing written (#1996).
let result = if Ecosystem::from_purl(purl) == Some(Ecosystem::Gem) {
let applied = gem_ext::apply_gem_package_patch(
variant_purl,
pkg_path,
&patch.files,
&sources,
args.common.dry_run,
policy,
&gem_ext::RubyEnv {
cwd: args.common.cwd.clone(),
rebuild: !args.common.no_gem_native_rebuild,
},
)
.await;
// A best-effort fallback-home copy's refusal is
// its per-copy skip below, not a run warning.
if let Some((code, detail)) = applied
.warning
.filter(|_| applied.result.success || !best_effort)
{
run_warnings.push(RunWarning { code, detail });
}
applied.result
} else {
apply_package_patch(
variant_purl,
pkg_path,
&patch.files,
&sources,
args.common.dry_run,
policy,
)
.await
};

warn_mismatch_overwrites(&result, &args.common);
// A variant that reached apply is the installed distribution
Expand Down
22 changes: 20 additions & 2 deletions crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3276,14 +3276,32 @@ pub(crate) async fn rollback_patches_inner(
match try_rollback_local_go(purl, pkg_path, patch, common).await {
Some(r) => r,
None => {
rollback_package_patch(
let mut result = rollback_package_patch(
purl,
pkg_path,
&patch.files,
&blobs_path,
common.dry_run,
)
.await
.await;
// A gem whose native extension `apply` rebuilt gets its
// original compiled extension back with its sources
// (#1996).
if result.success
&& !common.dry_run
&& Ecosystem::from_purl(purl) == Some(Ecosystem::Gem)
{
use socket_patch_core::patch::gem_ext;
let env = gem_ext::RubyEnv {
cwd: common.cwd.clone(),
rebuild: !common.no_gem_native_rebuild,
};
if let Err(e) = gem_ext::restore_original_build(pkg_path, &env).await {
result.success = false;
result.error = Some(format!("{}: {e}", gem_ext::RESTORE_FAILED_CODE));
}
}
result
}
}
};
Expand Down
4 changes: 4 additions & 0 deletions crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2091,6 +2091,10 @@ fn omission_phrase(reason: &str) -> &'static str {
against"
}
"stale_install" => "the installed copy is not patched",
"native_extension_not_rebuilt" => {
"the patched native-extension sources were not compiled into the extension Ruby \
loads; run socket-patch apply to rebuild it"
}
DEPENDENCY_NOT_INSTALLED => {
"the patched package.json declares a dependency that is not installed beside \
the package"
Expand Down
11 changes: 8 additions & 3 deletions crates/socket-patch-cli/tests/cli_global_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ fn global_flag_cases() -> Vec<(&'static str, Option<&'static str>, fn(&GlobalArg
("--no-vlt-install-cleanup", None, |c| {
assert!(c.no_vlt_install_cleanup)
}),
("--no-gem-native-rebuild", None, |c| {
assert!(c.no_gem_native_rebuild)
}),
("--lock-timeout", Some("30"), |c| {
assert_eq!(c.lock_timeout, Some(30))
}),
Expand Down Expand Up @@ -233,6 +236,7 @@ fn global_flag_cases_cover_every_global_field() {
no_trust_lockfile_config: _,
no_npm_allow_remote_config: _,
no_vlt_install_cleanup: _,
no_gem_native_rebuild: _,
strict: _,
allow_patch_conflicts: _,
vendor_source: _,
Expand All @@ -241,11 +245,11 @@ fn global_flag_cases_cover_every_global_field() {
maven_config: _,
} = common;

// 27 fields ↔ 27 long-flag cases. Bump both this count and add a case when
// 28 fields ↔ 28 long-flag cases. Bump both this count and add a case when
// the destructure above forces you to add a field.
assert_eq!(
global_flag_cases().len(),
27,
28,
"every GlobalArgs field needs a long-flag case in global_flag_cases()",
);
}
Expand Down Expand Up @@ -592,7 +596,7 @@ fn bool_env_vars_reject_zero_and_falsey() {
#[serial_test::serial]
fn empty_bool_env_var_resolves_to_false_not_crash() {
// (env var, accessor) for every boolean global.
let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 13] = [
let bool_vars: [(&str, fn(&GlobalArgs) -> bool); 14] = [
("SOCKET_OFFLINE", |c| c.offline),
("SOCKET_STRICT", |c| c.strict),
("SOCKET_GLOBAL", |c| c.global),
Expand All @@ -612,6 +616,7 @@ fn empty_bool_env_var_resolves_to_false_not_crash() {
("SOCKET_NO_VLT_INSTALL_CLEANUP", |c| {
c.no_vlt_install_cleanup
}),
("SOCKET_NO_GEM_NATIVE_REBUILD", |c| c.no_gem_native_rebuild),
];

for (var, accessor) in bool_vars {
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/tests/common/clap_parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ pub fn global_defaults() -> Globals {
no_trust_lockfile_config: false,
no_npm_allow_remote_config: false,
no_vlt_install_cleanup: false,
no_gem_native_rebuild: false,
})
}

Expand Down
Loading
Loading