Skip to content
Open
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
60 changes: 60 additions & 0 deletions crates/socket-patch-core/src/utils/purl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<type>/<name>@<version>` purls: strip
/// `?qualifiers`/`#subpath` FIRST (a qualifier value can itself embed an
/// `@`, e.g. a `git@github.com` source URL), require `prefix`, then split
Expand Down Expand Up @@ -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
Expand Down
109 changes: 102 additions & 7 deletions crates/socket-patch-core/src/vendor/gem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -173,18 +173,50 @@ pub async fn vendor_gem(
);
}

// Platform-suffixed installs (`<name>-<version>-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 `<name>-<version>-<platform>`
// 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
// `<name>-<version>`. 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 `<name>-<version>-<suffix>` 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)"
),
);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
/// `<name>-<version>`). 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
Expand Down
Loading