diff --git a/crates/socket-patch-core/src/utils/purl.rs b/crates/socket-patch-core/src/utils/purl.rs index bb2b41ca..3021738f 100644 --- a/crates/socket-patch-core/src/utils/purl.rs +++ b/crates/socket-patch-core/src/utils/purl.rs @@ -108,6 +108,27 @@ pub fn purl_eq(a: &str, b: &str) -> bool { normalize_purl(a) == normalize_purl(b) } +/// Extract the value of a single PURL qualifier (`?key=value&…`), if present. +/// +/// The PURL grammar places qualifiers after the base as `?k1=v1&k2=v2`, +/// optionally followed by a `#subpath`. Keys are matched case-insensitively +/// (per the PURL spec); the value is returned verbatim (callers +/// percent-decode if they need to). A malformed `k` with no `=` is skipped, +/// not fatal. Returns `None` when the purl has no qualifier string or none +/// of them match `key`. +/// +/// e.g. `purl_qualifier("pkg:gem/nokogiri@1.16.5?platform=java", "platform")` +/// -> `Some("java")`. +pub fn purl_qualifier<'a>(purl: &'a str, key: &str) -> Option<&'a str> { + let after_q = purl.split_once('?')?.1; + // Qualifiers end at the optional `#subpath`. + let quals = after_q.split('#').next().unwrap_or(after_q); + quals.split('&').find_map(|pair| { + let (k, v) = pair.split_once('=')?; + k.eq_ignore_ascii_case(key).then_some(v) + }) +} + /// Shared split for `pkg:/@` purls: strip /// `?qualifiers`/`#subpath` FIRST (a qualifier value can itself embed an /// `@`, e.g. a `git@github.com` source URL), require `prefix`, then split @@ -642,6 +663,45 @@ mod tests { ); } + #[test] + fn test_purl_qualifier() { + // Single qualifier. + assert_eq!( + purl_qualifier("pkg:gem/nokogiri@1.16.5?platform=java", "platform"), + Some("java") + ); + // Portable default and bare purl. + assert_eq!( + purl_qualifier("pkg:gem/activestorage@7.0.2.2?platform=ruby", "platform"), + Some("ruby") + ); + assert_eq!( + purl_qualifier("pkg:gem/activestorage@7.0.2.2", "platform"), + None + ); + // Case-insensitive key match; value returned verbatim. + assert_eq!( + purl_qualifier("pkg:gem/nokogiri@1.16.5?Platform=x86_64-linux", "platform"), + Some("x86_64-linux") + ); + // Picks the right one out of several, and stops at `#subpath`. + assert_eq!( + purl_qualifier("pkg:gem/x@1?arch=arm&platform=x64-mingw32#lib", "platform"), + Some("x64-mingw32") + ); + assert_eq!( + purl_qualifier("pkg:gem/x@1?platform=ruby#lib/x.rb", "platform"), + Some("ruby") + ); + // Missing key among present qualifiers, and a malformed pair is + // skipped rather than aborting the scan. + assert_eq!(purl_qualifier("pkg:gem/x@1?arch=arm", "platform"), None); + assert_eq!( + purl_qualifier("pkg:gem/x@1?bogus&platform=java", "platform"), + Some("java") + ); + } + #[test] fn test_parse_maven_qualifier_with_embedded_at() { // groupId/artifactId split must survive an `@` buried in a diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 3f3ee42b..448f081d 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -57,7 +57,7 @@ use crate::patch::copy_tree::{fresh_copy, remove_tree}; use crate::patch::path_safety::is_safe_single_segment; use crate::patch::redirect::gem_line_trailing_options; use crate::utils::fs::atomic_write_bytes_preserving_mode; -use crate::utils::purl::{build_gem_purl, parse_gem_purl}; +use crate::utils::purl::{build_gem_purl, parse_gem_purl, purl_qualifier}; use super::common::{ already_patched_result, copy_matches_after_hashes, done, refused, service_offline_conflict, @@ -173,18 +173,50 @@ pub async fn vendor_gem( ); } - // Platform-suffixed installs (`--x86_64-linux`) ship - // precompiled artifacts that are machine-specific — committing one would - // break every other platform, so they are refused, not guessed at. + // Platform-specific (precompiled) gem builds ship machine-specific + // artifacts — committing one would break every other platform — so they + // are refused, not guessed at. Two independent signals decide this: + // + // 1. The purl's own `?platform=` qualifier (the AUTHORITATIVE production + // key). RubyGems' default portable platform is `ruby`; a bare purl + // (no qualifier) is likewise the portable build. Only a *native* + // platform value (`x86_64-linux`, `arm64-darwin`, `java`, + // `x64-mingw32`, …) is refused. + // 2. Defense in depth: the resolved install dir's own name. A + // locally-installed native variant is `--` + // even when the manifest purl looked portable (the crawler strips the + // suffix to the base purl, so a `?platform=ruby` lookup can still land + // on a native install dir). + // + // The old gate tested only `dir_name != leaf`, which spuriously refused + // EVERY pure-ruby gem fetched via the registry auto-fetch ladder: that + // path stages the pristine `.gem` into a private tempdir named literally + // `gem` (see registry_fetch::fetch_gem), so `dir_name` was `gem`, never + // `-`. Gating on the platform (not the staging dir name) + // lets `?platform=ruby` and bare purls vendor while still refusing true + // native builds by either signal. + if let Some(platform) = purl_qualifier(purl, "platform") { + if !platform.is_empty() && !platform.eq_ignore_ascii_case("ruby") { + return refused( + "platform_gem_unsupported", + format!( + "`{name}@{version}` is a platform-specific gem build (`platform={platform}`); precompiled platform gems cannot be vendored portably" + ), + ); + } + } let dir_name = installed_dir .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); - if dir_name != leaf { + // A dir named `--` is a precompiled platform + // install; the auto-fetch staging dir (`gem`) is NOT such a suffix and + // passes. + if dir_name != leaf && dir_name.starts_with(&format!("{leaf}-")) { return refused( "platform_gem_unsupported", format!( - "installed dir `{dir_name}` does not equal `{leaf}` (platform-specific gem builds cannot be vendored portably)" + "installed dir `{dir_name}` is a platform-suffixed build of `{leaf}` (platform-specific gem builds cannot be vendored portably)" ), ); } @@ -1665,10 +1697,22 @@ mod tests { installed: &Path, record: &PatchRecord, dry_run: bool, + ) -> VendorOutcome { + run_vendor_purl(PURL, root, blobs, installed, record, dry_run).await + } + + /// [`run_vendor`] with a caller-chosen purl (e.g. a `?platform=` variant). + async fn run_vendor_purl( + purl: &str, + root: &Path, + blobs: &Path, + installed: &Path, + record: &PatchRecord, + dry_run: bool, ) -> VendorOutcome { let sources = PatchSources::blobs_only(blobs); vendor_gem( - PURL, + purl, installed, root, record, @@ -1911,6 +1955,57 @@ mod tests { assert!(!root.join(".socket").exists()); } + /// A native `?platform=` qualifier (e.g. `x86_64-linux`) is refused as a + /// platform-specific build EVEN when the resolved install dir is the clean + /// portable leaf — the purl qualifier is the authoritative signal. + #[tokio::test] + async fn test_refuses_native_platform_qualifier() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + // installed dir is the pristine `rack-3.2.6` leaf; only the purl says + // this is a native build. + let purl = "pkg:gem/rack@3.2.6?platform=x86_64-linux"; + let (code, detail) = + unwrap_refused(run_vendor_purl(purl, &root, &blobs, &installed, &record, false).await); + assert_eq!(code, "platform_gem_unsupported"); + assert!( + detail.contains("x86_64-linux"), + "refusal names the offending platform: {detail}" + ); + assert!(!root.join(".socket").exists(), "refusal must write nothing"); + } + + /// Regression: a pure-ruby gem fetched via the registry auto-fetch ladder + /// is staged into a private tempdir named literally `gem` (NOT + /// `-`). The old gate refused every such gem with + /// `platform_gem_unsupported` because `dir_name != leaf`. With the purl's + /// `?platform=ruby` (the portable default) the vendor must now SUCCEED — + /// the staging dir name is not a platform signal. + #[tokio::test] + async fn test_platform_ruby_gem_from_autofetch_staging_dir_vendors() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + // Rename the install dir to `gem`, mirroring registry_fetch::fetch_gem's + // staging leaf. The sibling `specifications/rack-3.2.6.gemspec` (needed + // by the local build) is derived from installed_dir.parent().parent(), + // so keeping the dir under the same gem_home preserves it. + let staged = installed.parent().unwrap().join("gem"); + tokio::fs::rename(&installed, &staged).await.unwrap(); + + let purl = "pkg:gem/rack@3.2.6?platform=ruby"; + let (result, _entry, _w) = + unwrap_done(run_vendor_purl(purl, &root, &blobs, &staged, &record, false).await); + assert!( + result.success, + "pure-ruby (?platform=ruby) gem from an auto-fetch `gem` staging dir must vendor: {:?}", + result.error + ); + // The patched copy landed under the leaf, not the staging dir name. + let copy = root.join(copy_rel()); + assert_eq!( + tokio::fs::read(copy.join("lib/rack.rb")).await.unwrap(), + PATCHED + ); + } + #[tokio::test] async fn test_refuses_unparseable_declaration() { // (a) indented inside a group block