diff --git a/crates/socket-patch-cli/tests/e2e_gem.rs b/crates/socket-patch-cli/tests/e2e_gem.rs index 9fb92a72..227262e5 100644 --- a/crates/socket-patch-cli/tests/e2e_gem.rs +++ b/crates/socket-patch-cli/tests/e2e_gem.rs @@ -5,8 +5,9 @@ //! installation. //! //! Ignored tests exercise the full CLI against the real Socket API, using the -//! **activestorage@5.2.0** patch (UUID `4bf7fe0b-dc57-4ea8-945f-bc4a04c47a15`), -//! which fixes CVE-2022-21831 (code injection). +//! **activestorage@5.2.0** patch (UUID `efc8d8ca-78c5-43ae-ba1b-41bb1f9f3897`), +//! which fixes CVE-2020-8162 / GHSA-m42x-37p3-fv5w (circumvention of file +//! size limits in ActiveStorage's S3 adapter). //! //! # Running //! ```sh @@ -31,8 +32,11 @@ mod cache_env; // Constants // --------------------------------------------------------------------------- -const GEM_UUID: &str = "4bf7fe0b-dc57-4ea8-945f-bc4a04c47a15"; -const GEM_PURL: &str = "pkg:gem/activestorage@5.2.0"; +const GEM_UUID: &str = "efc8d8ca-78c5-43ae-ba1b-41bb1f9f3897"; +/// The manifest key `get ` records is the purl the `/patch/view` +/// response carries — since the 2026-08-18 republish, the platform-qualified +/// form. +const GEM_PURL: &str = "pkg:gem/activestorage@5.2.0?platform=ruby"; // --------------------------------------------------------------------------- // Helpers @@ -68,13 +72,20 @@ fn git_sha256_file(path: &Path) -> String { } fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let out: Output = Command::new(binary()) - .args(args) - .current_dir(cwd) - .env_remove("SOCKET_API_TOKEN") - .env_remove("SOCKET_CLI_API_TOKEN") - .output() - .expect("failed to execute socket-patch binary"); + let mut cmd = Command::new(binary()); + cmd.args(args).current_dir(cwd); + // Hermeticity, mirroring the production e2e suites: scrub every ambient + // `SOCKET_*` (token, api/proxy URL, org, legacy aliases — not just the + // two token vars) and block the socket-cli config.json, so a developer's + // login or configured apiBaseUrl cannot silently repoint or re-tier + // these runs. + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("SOCKET_") { + cmd.env_remove(&k); + } + } + cmd.env("SOCKET_NO_CONFIG", "true"); + let out: Output = cmd.output().expect("failed to execute socket-patch binary"); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); @@ -99,6 +110,10 @@ fn bundle_run(cwd: &Path, args: &[&str]) { // `find_gem_dir` expects. It also upholds cache_env's hermeticity // invariant that every `bundle install` pins its gem tree to the fixture. cmd.env("BUNDLE_PATH", "vendor/bundle"); + // mimemagic (pulled via activestorage → marcel) builds against the + // system shared-mime-info DB, which no workflow installs — use the gem's + // bundled placeholder instead of depending on a host package. + cmd.env("USE_FREEDESKTOP_PLACEHOLDER", "true"); cache_env::isolate(&mut cmd); let out = cmd.output().expect("failed to run bundle"); assert!( @@ -144,23 +159,31 @@ fn read_patch_files(manifest_path: &Path) -> serde_json::Value { patch["files"].clone() } -/// Verify all patched files match their afterHash from the manifest. +/// Verify all patched files match their afterHash (or are absent if deleted). fn assert_after_hashes(gem_dir: &Path, files: &serde_json::Value) { for (rel_path, info) in files.as_object().expect("files object") { - let after_hash = info["afterHash"] - .as_str() - .expect("afterHash should be a string"); + let after_hash = info["afterHash"].as_str().unwrap_or(""); let full_path = gem_dir.join(rel_path); - assert!( - full_path.exists(), - "patched file should exist: {}", - full_path.display() - ); - assert_eq!( - git_sha256_file(&full_path), - after_hash, - "hash mismatch for {rel_path} after patching" - ); + if after_hash.is_empty() { + // No afterHash = the patch deletes the file; in the applied + // state it must be ABSENT (mirror of `assert_before_hashes`' + // new-file branch). + assert!( + !full_path.exists(), + "deleted file {rel_path} should be absent after patching" + ); + } else { + assert!( + full_path.exists(), + "patched file should exist: {}", + full_path.display() + ); + assert_eq!( + git_sha256_file(&full_path), + after_hash, + "hash mismatch for {rel_path} after patching" + ); + } } } @@ -440,7 +463,7 @@ async fn scan_discovers_gems_with_gemspec() { // Lifecycle tests (need bundler + network) // --------------------------------------------------------------------------- -/// Full lifecycle: get -> list (verify CVE-2022-21831) -> rollback -> apply -> remove. +/// Full lifecycle: get -> list (verify CVE-2020-8162) -> rollback -> apply -> remove. #[test] #[ignore] fn test_gem_full_lifecycle() { @@ -505,12 +528,18 @@ fn test_gem_full_lifecycle() { "patch should report at least one vulnerability" ); - let has_cve = vulns.iter().any(|v| { - v["cves"] - .as_array() - .is_some_and(|cves| cves.iter().any(|c| c == "CVE-2022-21831")) + // The advisory may be named either way: the vulnerability's map key (the + // `id` field) is the GHSA id, and production may leave `cves` empty. + let has_advisory = vulns.iter().any(|v| { + v["id"] == "GHSA-m42x-37p3-fv5w" + || v["cves"] + .as_array() + .is_some_and(|cves| cves.iter().any(|c| c == "CVE-2020-8162")) }); - assert!(has_cve, "vulnerability list should include CVE-2022-21831"); + assert!( + has_advisory, + "vulnerability list should include GHSA-m42x-37p3-fv5w / CVE-2020-8162: {vulns:?}" + ); // -- ROLLBACK: restore original files ------------------------------------- assert_run_ok(cwd, &["rollback"], "rollback"); diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index df7a4093..73882d56 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -36,11 +36,7 @@ //! | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | GHSA-xvch-5gv4-984h (CVE-2021-44906) | //! | PyPI | `pkg:pypi/urllib3@1.26.18` | *any of three* (see [`PYPI_UUIDS`]) | GHSA-gm62-xv2j-4w53 &co | //! | Cargo | `pkg:cargo/traitobject@0.1.1` | `cf2e6f58-d9fa-4096-9151-c34afa717f89` | GHSA-pp8r-vv2j-9j5v | -//! | gem | `pkg:gem/activestorage@7.0.2.2` | `2535d43d-67ce-4944-be27-c19e113997fb` | GHSA-w749-p3v6-hccq | -//! -//! > **gem is TEMPORARILY DISABLED** (see [`GEM_E2E_DISABLED`]): the pinned -//! > gem patch was intentionally unpublished on 2026-08-14 pending a corrected -//! > republish, so its three legs skip until the switch is flipped back. +//! | gem | `pkg:gem/activestorage@6.0.3` | *any of* [`GEM_UUIDS`] (one today) | GHSA-m42x-37p3-fv5w (CVE-2020-8162) | //! //! `docs/testing/hosted-production-e2e.md` explains how these were chosen and //! how to re-pick one if it is ever withdrawn. @@ -132,30 +128,23 @@ const CARGO_UUID: &str = "cf2e6f58-d9fa-4096-9151-c34afa717f89"; /// that npm/PyPI artifacts carry, so this is the marker to look for. const CARGO_MARKER: &str = "GHSA-pp8r-vv2j-9j5v"; -const GEM_PURL: &str = "pkg:gem/activestorage@7.0.2.2"; +/// The gem pin is deliberately UNQUALIFIED. Production publishes the purl as +/// `pkg:gem/activestorage@6.0.3?platform=ruby`, but nothing client-side +/// strips qualifiers — the SERVER normalizes both spellings to the same +/// patch set (verified live against `/patch/by-package`), so this pins the +/// bare spelling the CLI's own crawler synthesizes. +const GEM_PURL: &str = "pkg:gem/activestorage@6.0.3"; const GEM_NAME: &str = "activestorage"; -const GEM_VERSION: &str = "7.0.2.2"; -const GEM_UUID: &str = "2535d43d-67ce-4944-be27-c19e113997fb"; - -/// TEMPORARY kill switch for the ruby-gem hosted legs. -/// -/// The pinned gem patch `activestorage@7.0.2.2` (`GEM_UUID`) was -/// **intentionally unpublished on 2026-08-14** pending a corrected republish -/// (the compact-index dependency metadata was wrong). Its record still -/// resolves via `/patch/view/`, but the discovery endpoints -/// (`/patch/batch`, `/patch/by-package`) now return zero patches for the gem, -/// so `preflight_required_patches_are_published`, the advisory canary, and the -/// gem redirect leg fail for a reason that has nothing to do with the CLI. -/// -/// While this is `true`, those three gem legs are skipped so the required -/// `hosted-e2e` check stays green for npm/PyPI/cargo. This is a plain -/// unconditional skip (NOT `soft_skip!`, which panics under STRICT) — it does -/// not depend on any env var and applies in CI too. -/// -/// **RE-ENABLE** by flipping this to `false` once the corrected gem patch is -/// published on `patches-api.socket.dev` (and update `GEM_UUID` if the -/// replacement has a new uuid). Tracked in `docs/testing/hosted-production-e2e.md`. -const GEM_E2E_DISABLED: bool = true; +const GEM_VERSION: &str = "6.0.3"; +/// Acceptable patch UUIDs for [`GEM_PURL`] — an any-of set, mirroring +/// [`PYPI_UUIDS`]: patch selection is server-ranked and the non-TTY scan +/// auto-selects the top candidate, so pinning a single UUID would red the +/// required check on a server-side reorder or a second published 6.0.3 +/// patch. The gem leg parses the UUID actually WIRED into the rewritten +/// Gemfile, asserts it is one of these, and content-verifies against that +/// exact patch's `/patch/view` manifest. When production publishes another +/// acceptable 6.0.3 patch, verify it and append its UUID here. +const GEM_UUIDS: &[&str] = &["15e960b5-f432-4b6c-b8aa-534a2b419323"]; /// Header the patch service injects into patched npm / PyPI source files. const PATCH_MARKER: &str = "Socket Community Patch"; @@ -527,10 +516,15 @@ async fn published_patch_advisory_counts(purl: &str) -> Result Option { .find_map(|l| l.strip_prefix(&prefix).map(|h| h.trim().to_string())) } +/// The patch UUID the gem rewriter wired into `Gemfile`'s per-dep Socket +/// source block. The block's URL shape is +/// `https:///patch-registry/gem///`; the grant +/// token is itself UUID-shaped, so this takes the SECOND path segment rather +/// than scanning for "something UUID-like". +fn wired_gem_registry_uuid(gemfile: &str) -> Option { + let marker = format!("https://{PATCH_HOST}/patch-registry/gem/"); + let rest = &gemfile[gemfile.find(&marker)? + marker.len()..]; + let path = &rest[..rest.find('"')?]; + let mut segments = path.split('/').filter(|s| !s.is_empty()); + let _grant = segments.next()?; + segments.next().map(str::to_string) +} + /// Locate the bundler-installed `gems/-` directory under a /// `BUNDLE_PATH` root. The `ruby/` segment in between varies by host /// interpreter, so walk for it (depth-bounded — the layout is only a few @@ -660,41 +668,6 @@ fn installed_gem_dir(root: &Path, dir_name: &str, depth: usize) -> Option" do`, or `None` when no Socket source block is present. -/// -/// Read back out of the rewritten file rather than rebuilt from constants on -/// purpose: the probe must interrogate the *exact* registry bundler was told -/// to use, so a rewriter that emits the wrong base cannot be papered over by a -/// probe that guesses the right one. -fn gem_registry_base(gemfile: &str) -> Option { - const OPEN: &str = "source \""; - let marker = format!("{OPEN}https://{PATCH_HOST}/patch-registry/gem/"); - let at = gemfile.find(&marker)?; - let rest = &gemfile[at + OPEN.len()..]; - let end = rest.find('"')?; - Some(rest[..end].to_string()) -} - -/// `GET ` → `(status, body_len)`, or `Err` on a transport failure. -/// -/// Sends a bundler-shaped `User-Agent` so the probe observes whatever a real -/// `bundle install` would be served. -async fn http_probe(url: &str) -> Result<(u16, usize), String> { - let resp = reqwest::Client::new() - .get(url) - .header("User-Agent", "bundler/2.6.9 rubygems/3.6.9") - .send() - .await - .map_err(|e| e.to_string())?; - let status = resp.status().as_u16(); - let body = resp - .bytes() - .await - .map_err(|e| format!("reading body: {e}"))?; - Ok((status, body.len())) -} - /// Percent-encode a PURL for use as a single path segment. `reqwest` will not /// do this for us — a raw `pkg:npm/...` would be split into path segments and /// 404. @@ -724,16 +697,12 @@ fn urlencode(s: &str) -> String { #[ignore = "live production API: contacts patches-api.socket.dev. Run with --ignored."] async fn preflight_required_patches_are_published() { // (purl, acceptable uuids) - let mut required: Vec<(&str, Vec<&str>)> = vec![ + let required: Vec<(&str, Vec<&str>)> = vec![ (NPM_PURL, vec![NPM_UUID]), (PYPI_PURL, PYPI_UUIDS.to_vec()), (CARGO_PURL, vec![CARGO_UUID]), + (GEM_PURL, GEM_UUIDS.to_vec()), ]; - // The gem pin is temporarily unpublished (see `GEM_E2E_DISABLED`); don't - // require it while the switch is on. - if !GEM_E2E_DISABLED { - required.push((GEM_PURL, vec![GEM_UUID])); - } let mut failures: Vec = Vec::new(); for (purl, expected) in &required { @@ -785,11 +754,7 @@ async fn canary_patches_name_advisories_so_merge_state_is_inferable() { let mut failures: Vec = Vec::new(); let mut coverage_seen: Vec<(String, String, usize)> = Vec::new(); - let mut canary_purls = vec![NPM_PURL, PYPI_PURL, CARGO_PURL]; - // Skip the gem while its pin is temporarily unpublished (see `GEM_E2E_DISABLED`). - if !GEM_E2E_DISABLED { - canary_purls.push(GEM_PURL); - } + let canary_purls = vec![NPM_PURL, PYPI_PURL, CARGO_PURL, GEM_PURL]; for purl in canary_purls { match published_patch_advisory_counts(purl).await { Err(e) => failures.push(format!("{purl}: production probe failed: {e}")), @@ -1643,78 +1608,36 @@ fn cargo_hosted_install_proof() { } // =========================================================================== -// RubyGems — redirect works; the hosted install is blocked by a SERVER defect +// RubyGems — full hosted install proof // =========================================================================== -/// The gem redirect itself is correct and is asserted hard here. -/// -/// The **install** leg is a different story. Socket's gem patch-registry serves -/// a compact index whose `/info/` line declares **no runtime -/// dependencies**, while the `.gem` it serves declares six. Bundler's -/// `ensure_same_dependencies` check fails closed: -/// -/// ```text -/// Bundler::APIResponseMismatchError: Downloading activestorage-7.0.2.2 -/// revealed dependencies not in the API (activesupport (= 7.0.2.2), ...) -/// ``` -/// -/// Compare production's own index, which does emit them: -/// `https://index.rubygems.org/info/activestorage` → -/// `7.0.2.2 actionpack:= 7.0.2.2,activejob:= 7.0.2.2,...|checksum:...` -/// versus `patch.socket.dev/patch-registry/gem///info/activestorage` -/// → `7.0.2.2 |checksum:...`. -/// -/// That is a **server-side** defect, not a CLI one, and it blocks hosted gem -/// mode for any gem with runtime dependencies. Until it is fixed the install -/// leg reports loudly but does not fail the suite; set -/// `SOCKET_PATCH_HOSTED_E2E_GEM_STRICT=1` to promote it to a hard failure -/// (do that as the regression guard once the server is fixed). -/// -/// # Why the tolerance probes the server instead of matching the error text +/// Full hosted install proof for RubyGems: the redirect rewrites `Gemfile` +/// with a per-dep Socket source block and swaps the lock's CHECKSUMS pin to +/// the patched artifact's sha256, then a real `bundle install` from the +/// rewritten files alone fetches through `patch.socket.dev` and every file +/// the patch rewrites is verified on disk against the `/patch/view` +/// afterHash. /// -/// This leg used to accept the install failure by string-matching bundler's -/// message. That couples a CI check to one particular *symptom* of the server -/// bug, and the symptom is a function of which fetcher bundler lands on — -/// which the server keeps changing. Bundler selects one via -/// `available_fetchers.drop_while {|f| !f.available? }` over `[CompactIndex, -/// Dependency, Index]`, so: +/// # History /// -/// | server state | `/versions` | `/api/v1/dependencies` | bundler raises | -/// |---|---|---|---| -/// | originally | 200, empty dep segment | — | `Bundler::APIResponseMismatchError` | -/// | after depscan#23630 | 404 `not_built` | **200, zero-byte body** | `ArgumentError: marshal data too short` (classic Marshal) / `NoMethodError: undefined method 'bytes' for nil` (SafeMarshal, ruby 3.4+) | -/// | after the empty-body fix | 404 `not_built` | 404 | `Could not fetch specs from …` | +/// Hosted gem mode was long blocked by a **server-side** compact-index +/// defect: the patch-registry's `/info/` line declared no runtime +/// dependencies while the `.gem` it served declared several, so bundler's +/// `ensure_same_dependencies` check failed closed with +/// `Bundler::APIResponseMismatchError`. The 2026-08-18 gem catalog republish +/// fixed the index (deps now served, `/versions` 200), and the probe-based +/// tolerance this leg used to carry — pass on a non-2xx `/versions`, enforce +/// on 2xx — retired itself exactly as designed and was deleted. /// -/// Three different strings for one unchanged server condition. A whitelist of -/// them goes stale on every server deploy and reds the check for a reason that -/// has nothing to do with socket-patch. -/// -/// So the tolerance is decided by the **condition**, not the symptom: probe -/// the pinned registry's `/versions` — the URL bundler was actually given, -/// read back out of the rewritten `Gemfile`. -/// -/// * non-2xx → the documented server defect. The install failure is expected; -/// report loudly and pass (unless `SOCKET_PATCH_HOSTED_E2E_GEM_STRICT=1`). -/// * 2xx → the compact index is **built**, so hosted gem mode MUST work. An -/// install failure is then a real regression and fails the suite. -/// -/// That is symptom-independent, and it **auto-retires itself**: the moment the -/// server is healthy the 2xx branch starts enforcing a real success assertion, -/// with no stale whitelist and no `NOTE` asking a human to clean up. +/// NOTE (latent, server-side): the registry's `/api/v1/dependencies` route +/// still answers 200 with an empty body. That bug is unreachable today — +/// bundler only falls back to the Dependency fetcher when the compact index +/// is unavailable — but it would resurface as a confusing Marshal error if +/// the compact index ever broke again. #[tokio::test(flavor = "multi_thread")] #[ignore = "live production API + real rubygems.org. Run with --ignored."] -async fn gem_bundler_hosted_redirect_and_known_install_defect() { - const LEG: &str = "gem_bundler_hosted_redirect_and_known_install_defect"; - // Unconditional skip while the pinned gem patch is unpublished (see - // `GEM_E2E_DISABLED`). Deliberately NOT `soft_skip!` — that panics under - // STRICT, and this skip is intentional in CI too, not a missing toolchain. - if GEM_E2E_DISABLED { - println!( - "SKIP {LEG}: gem patch {GEM_UUID} temporarily unpublished \ - (GEM_E2E_DISABLED); re-enable when the corrected patch is published" - ); - return; - } +async fn gem_bundler_hosted_install_proof() { + const LEG: &str = "gem_bundler_hosted_install_proof"; if !has_command("ruby") || !has_command("bundle") { soft_skip!(LEG, "`ruby` and/or `bundle` not on PATH"); } @@ -1725,6 +1648,10 @@ async fn gem_bundler_hosted_redirect_and_known_install_defect() { let env = [ ("BUNDLE_PATH", bundle_path.as_str()), ("BUNDLE_APP_CONFIG", bundle_path.as_str()), + // mimemagic (pulled via activestorage → marcel) builds against the + // system shared-mime-info DB, which no workflow installs — use the + // gem's bundled placeholder instead of depending on a host package. + ("USE_FREEDESKTOP_PLACEHOLDER", "true"), ]; std::fs::write( @@ -1763,19 +1690,47 @@ async fn gem_bundler_hosted_redirect_and_known_install_defect() { let env_json = scan_hosted(&proj, &[]); assert_redirected(&env_json, "Gemfile.lock"); - // Hard assertions: the redirect itself must be correct. + // Hard assertions: the redirect itself must be correct. The wired patch + // UUID is parsed back out of the rewritten Gemfile rather than assumed: + // selection is server-ranked (the non-TTY scan auto-selects the top + // candidate), so the leg accepts any UUID in the pinned any-of set and + // then content-verifies against the one bundler was actually given. let gemfile = read(&proj.join("Gemfile")); + let wired_uuid = wired_gem_registry_uuid(&gemfile).unwrap_or_else(|| { + panic!( + "{LEG}: Gemfile carries no per-dep Socket source block \ + (`source \"https://{PATCH_HOST}/patch-registry/gem///\" do`):\n{gemfile}" + ) + }); assert!( - gemfile.contains(&format!("https://{PATCH_HOST}/patch-registry/gem/")) - && gemfile.contains(GEM_UUID), - "{LEG}: Gemfile carries no per-dep Socket source block for \ - {GEM_UUID}:\n{gemfile}" + GEM_UUIDS.contains(&wired_uuid.as_str()), + "{LEG}: the redirect wired patch {wired_uuid}, which is not in the \ + pinned any-of set {GEM_UUIDS:?}. If production replaced or extended \ + the {GEM_VERSION} patches, verify the new patch and extend \ + GEM_UUIDS.\n{gemfile}" ); let lock = read(&proj.join("Gemfile.lock")); assert!( lock.contains("CHECKSUMS"), "{LEG}: Gemfile.lock lost its CHECKSUMS section:\n{lock}" ); + // Converged-lock proof (the #212 shape): a rewrite that only moved the + // CHECKSUMS pin would leave the lock's GEM section on rubygems.org — a + // mixed state an unfrozen install can silently paper over. The GEM + // section must carry a patch-registry remote and DEPENDENCIES must + // source-pin the gem. + assert!( + lock.contains(&format!( + " remote: https://{PATCH_HOST}/patch-registry/gem/" + )), + "{LEG}: rewritten Gemfile.lock has no GEM-section `remote:` on the \ + patch registry — the lock was not converged:\n{lock}" + ); + assert!( + lock.contains(&format!("\n {GEM_NAME} (= {GEM_VERSION})!")), + "{LEG}: DEPENDENCIES pin ` {GEM_NAME} (= {GEM_VERSION})!` missing — \ + the lock does not source-pin the redirected gem:\n{lock}" + ); let redirected_sha = gem_lock_checksum(&lock, GEM_NAME, GEM_VERSION).unwrap_or_else(|| { panic!( "{LEG}: redirected Gemfile.lock carries no sha256 CHECKSUMS entry \ @@ -1790,153 +1745,107 @@ async fn gem_bundler_hosted_redirect_and_known_install_defect() { patch stay green).\nGemfile.lock:\n{lock}" ); - // Known-broken leg: reinstall from the redirected Gemfile. - std::fs::remove_dir_all(&bundle_path).ok(); - let reinstall = tool(&proj, "bundle", &["install"], &env); - let gem_strict = std::env::var("SOCKET_PATCH_HOSTED_E2E_GEM_STRICT") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - if ok(&reinstall) { - // The server defect has been fixed. Say so loudly — the guard below - // should be promoted to unconditional and this branch deleted. - println!( - "NOTE {LEG}: `bundle install` from the redirected Gemfile now \ - SUCCEEDS. The gem patch-registry compact-index dependency defect \ - appears to be FIXED — delete the tolerance branch in this test and \ - assert unconditionally." - ); - // Exit 0 proves only that bundler fetched an artifact matching the - // CHECKSUMS pin. Close the loop on CONTENT: fetch the patch's file - // manifest from the proxy and assert every file it rewrites landed - // on disk byte-exact (afterHash is the git-blob sha256 the patch - // service publishes — the same digest the CLI's apply verifies). - let patch_files = published_patch_files(GEM_UUID).await.unwrap_or_else(|e| { + // THE point of the leg: reinstall from the redirected Gemfile alone, + // letting bundler fetch through patch.socket.dev and verify the swapped + // CHECKSUMS pin itself. The wipe must be COMPLETE — a partial wipe lets + // bundler reuse the stale pristine install, and the leg would then + // misattribute that as a published-patch regression (seen under + // --test-threads=4). + std::fs::remove_dir_all(&bundle_path) + .unwrap_or_else(|e| panic!("{LEG}: wiping BUNDLE_PATH {bundle_path} failed: {e}")); + assert!( + !Path::new(&bundle_path).exists(), + "{LEG}: BUNDLE_PATH {bundle_path} still exists after the wipe" + ); + // BUNDLE_FROZEN=true is the deployment-mode contract production users + // run under: the committed Gemfile + lock pair must satisfy bundler + // as-is. A rewrite that diverged the pair (Gemfile repointed, lock not + // converged, or vice versa) fails here instead of being silently + // re-resolved away. + let reinstall_env = [ + ("BUNDLE_PATH", bundle_path.as_str()), + ("BUNDLE_APP_CONFIG", bundle_path.as_str()), + ("BUNDLE_FROZEN", "true"), + ("USE_FREEDESKTOP_PLACEHOLDER", "true"), + ]; + let reinstall = tool(&proj, "bundle", &["install"], &reinstall_env); + assert!( + ok(&reinstall), + "{LEG}: frozen `bundle install` from the redirected Gemfile failed — \ + the redirect assertions above all passed, so the rewrite itself is \ + fine and the regression is in the hosted install path (CLI rewrite \ + shape or the gem patch-registry).\n{}", + dump(&reinstall) + ); + + // Exit 0 proves only that bundler fetched an artifact matching the + // CHECKSUMS pin. Close the loop on CONTENT: fetch the patch's file + // manifest from the proxy and assert every file it rewrites landed + // on disk byte-exact (afterHash is the git-blob sha256 the patch + // service publishes — the same digest the CLI's apply verifies). + let patch_files = published_patch_files(&wired_uuid) + .await + .unwrap_or_else(|e| { panic!( "{LEG}: `bundle install` from the redirected Gemfile succeeded \ but the patch file manifest could not be fetched to verify \ the installed content: {e}" ) }); - let gem_dir = installed_gem_dir( - Path::new(&bundle_path), - &format!("{GEM_NAME}-{GEM_VERSION}"), - 4, + let gem_dir = installed_gem_dir( + Path::new(&bundle_path), + &format!("{GEM_NAME}-{GEM_VERSION}"), + 4, + ) + .unwrap_or_else(|| { + panic!( + "{LEG}: `bundle install` succeeded but no \ + gems/{GEM_NAME}-{GEM_VERSION} directory exists under \ + {bundle_path}" ) - .unwrap_or_else(|| { + }); + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + let mut verified = 0usize; + let mut rewritten = 0usize; + for (path, before, after) in &patch_files { + // No afterHash = the patch deletes the file; nothing to hash. + let Some(after) = after else { continue }; + let rel = path.strip_prefix("package/").unwrap_or(path.as_str()); + let installed = gem_dir.join(rel); + let bytes = std::fs::read(&installed).unwrap_or_else(|e| { panic!( - "{LEG}: `bundle install` succeeded but no \ - gems/{GEM_NAME}-{GEM_VERSION} directory exists under \ - {bundle_path}" + "{LEG}: patch {wired_uuid} rewrites `{path}` but the \ + installed gem has no readable {}: {e}", + installed.display() ) }); - use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; - let mut verified = 0usize; - let mut rewritten = 0usize; - for (path, before, after) in &patch_files { - // No afterHash = the patch deletes the file; nothing to hash. - let Some(after) = after else { continue }; - let rel = path.strip_prefix("package/").unwrap_or(path.as_str()); - let installed = gem_dir.join(rel); - let bytes = std::fs::read(&installed).unwrap_or_else(|e| { - panic!( - "{LEG}: patch {GEM_UUID} rewrites `{path}` but the \ - installed gem has no readable {}: {e}", - installed.display() - ) - }); - assert_eq!( - compute_git_sha256_from_bytes(&bytes), - *after, - "{LEG}: installed {} does not hash to the patch's afterHash — \ - bundler fetched an artifact whose content is NOT the \ - published patch", - installed.display() - ); - verified += 1; - if before.as_deref() != Some(after.as_str()) { - rewritten += 1; - } - } - assert!( - verified >= 1, - "{LEG}: patch {GEM_UUID} names no files with an afterHash, so \ - nothing was content-verified — the install success is vacuous" - ); - assert!( - rewritten >= 1, - "{LEG}: every file in patch {GEM_UUID} has afterHash == \ - beforeHash — the published patch is inert and this install \ - proved nothing" + assert_eq!( + compute_git_sha256_from_bytes(&bytes), + *after, + "{LEG}: installed {} does not hash to the patch's afterHash — \ + bundler fetched an artifact whose content is NOT the \ + published patch", + installed.display() ); - println!( - "{LEG}: verified {verified} patched file(s) on disk against the \ - published afterHash ({rewritten} differ from upstream)" - ); - return; - } - let detail = dump(&reinstall); - - // Ask the SERVER what state it is in, rather than guessing from bundler's - // error text (see the doc comment above for why the text is untrustworthy). - // The base is read back out of the rewritten Gemfile, so this probes the - // exact registry bundler was pointed at. - let index_base = gem_registry_base(&gemfile).unwrap_or_else(|| { - panic!( - "{LEG}: could not read the Socket registry base back out of the \ - rewritten Gemfile, so the install failure cannot be attributed. \ - The redirect assertions above passed, so the `source \"…\" do` \ - block shape must have changed:\n{gemfile}" - ) - }); - let versions_url = format!("{}/versions", index_base.trim_end_matches('/')); - let probe = http_probe(&versions_url).await; - let probe_note = match &probe { - Ok((status, len)) => { - format!("GET {versions_url} -> HTTP {status}, {len}-byte body") + verified += 1; + if before.as_deref() != Some(after.as_str()) { + rewritten += 1; } - Err(e) => format!("GET {versions_url} -> transport error: {e}"), - }; - - // Strict mode promotes ANY install failure to a hard failure, whatever the - // server state — that is its whole purpose as the regression guard. + } assert!( - !gem_strict, - "{LEG}: SOCKET_PATCH_HOSTED_E2E_GEM_STRICT=1 and `bundle install` from \ - the redirected Gemfile failed.\n registry probe: {probe_note}\n{detail}" + verified >= 1, + "{LEG}: patch {wired_uuid} names no files with an afterHash, so \ + nothing was content-verified — the install success is vacuous" + ); + assert!( + rewritten >= 1, + "{LEG}: every file in patch {wired_uuid} has afterHash == \ + beforeHash — the published patch is inert and this install \ + proved nothing" ); - - // A 2xx `/versions` means the compact index is BUILT and bundler was - // served a usable index. The documented server defect therefore does NOT - // apply, and tolerating the failure here would hide a real regression. - if let Ok((status, _)) = probe { - assert!( - !(200..300).contains(&status), - "{LEG}: `bundle install` from the redirected Gemfile FAILED even \ - though the pinned registry's compact index is SERVING.\n \ - registry probe: {probe_note}\n\ - A 2xx /versions means `package_gem_index_deps` is populated and \ - the index is built, so this is NOT the known server defect \ - (which 404s that route) — it is a real regression in hosted gem \ - mode. The redirect assertions above all passed, so the rewrite \ - itself is fine and the failure is in the install leg.\n{detail}" - ); - } - - // Non-2xx (or an unreachable registry): the documented server defect. - // A transport error is tolerated rather than failed because a network - // blip is the most likely explanation for BOTH the probe and the install - // failing, and a required check must not go red for one. println!( - "KNOWN PRODUCTION DEFECT {LEG}: the Socket gem patch-registry's \ - compact index is not being served for this patch, so `bundle \ - install` from the redirected Gemfile cannot succeed.\n registry \ - probe: {probe_note}\n\ - Since depscan#23630 the compact-index routes fail closed with 404 \ - `not_built` until the requeued rebuild populates \ - `package_gem_index_deps`. Hosted gem mode stays unusable for gems \ - with dependencies until that completes. Redirect assertions above \ - all passed; this leg will start asserting a real successful install \ - automatically once /versions returns 2xx." + "{LEG}: verified {verified} patched file(s) on disk against the \ + published afterHash ({rewritten} differ from upstream)" ); } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index 47171330..4b0e7ca3 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -1,9 +1,12 @@ //! Real-bundler hosted-mode capstone e2e for gem — the full-chain proof for //! `scan --mode hosted` on the rubygems-compact-index override, and the -//! executable pin on the compact-index DEPENDENCY contract the production -//! server currently violates (its `/info` answers `{"error":"not_built"}` and -//! the `/api/v1/dependencies` fallback returns a zero-byte body — see -//! `e2e_hosted_production.rs`'s `is_known_defect` tolerance). +//! executable pin on the compact-index DEPENDENCY contract. The production +//! server HISTORICALLY violated that contract (its `/info` served no runtime +//! deps, later answered `{"error":"not_built"}`, and the +//! `/api/v1/dependencies` fallback returned a zero-byte body); the 2026-08-18 +//! gem catalog republish fixed the served index, and this hermetic suite pins +//! the contract from both sides regardless of production's current state — +//! see the history section of `docs/testing/hosted-production-e2e.md`. //! //! Unlike the npm/cargo siblings, this suite is FULLY hermetic: the fixture //! gems are authored here and built with the real `gem build`, and ONE @@ -41,11 +44,11 @@ //! `gems.rb`/`gems.locked` spelling (which bundler prefers over `Gemfile` //! when both exist — this pins the candidate-list + rewriter support). //! -//! The deps red-arm serves a PRODUCTION-LIKE `/info` (checksum but NO -//! dependencies): the fresh install must fail with bundler's -//! `APIResponseMismatchError … revealed dependencies not in the API` — the -//! exact live-CI signature — so any server or fixture that stops declaring -//! runtime deps turns this suite red. +//! The deps red-arm serves an `/info` shaped like production's HISTORICAL +//! defect (checksum but NO dependencies): the fresh install must fail with +//! bundler's `APIResponseMismatchError … revealed dependencies not in the +//! API` — the signature live CI saw until the 2026-08-18 server fix — so any +//! server or fixture that stops declaring runtime deps turns this suite red. //! //! CHECKSUMS locks (bundler >= 4 writes the section by default) come out //! FULLY CONVERGED: patch-registry GEM section holding the dep's spec, @@ -1019,11 +1022,11 @@ async fn gem_hosted_gems_rb_spelling_redirects_and_installs() { } /// The compact-index DEPENDENCY contract, pinned from the red side: a patch -/// registry whose `/info` omits the gem's runtime deps (today's production -/// behavior — its sidecar index answers `not_built` and the dependency-API -/// fallback is a zero-byte body) BREAKS the prescribed install with -/// bundler's `APIResponseMismatchError`. If the CLI or fixture ever starts -/// tolerating that silently, this turns red. +/// registry whose `/info` omits the gem's runtime deps (production's +/// HISTORICAL behavior until the 2026-08-18 republish fixed the served index +/// — see docs/testing/hosted-production-e2e.md's history section) BREAKS the +/// prescribed install with bundler's `APIResponseMismatchError`. If the CLI +/// or fixture ever starts tolerating that silently, this turns red. #[tokio::test(flavor = "multi_thread")] #[ignore = "host capstone: shells out to a real ruby/gem/bundler >= 2.6; the unpinned `test` \ job skips it, an e2e job with a pinned toolchain runs it via --ignored"] diff --git a/crates/socket-patch-cli/tests/e2e_vendored_production.rs b/crates/socket-patch-cli/tests/e2e_vendored_production.rs index 6fe5ca5d..41e0f036 100644 --- a/crates/socket-patch-cli/tests/e2e_vendored_production.rs +++ b/crates/socket-patch-cli/tests/e2e_vendored_production.rs @@ -18,11 +18,11 @@ //! rewires the lockfile / manifest to consume it; //! 4. assert the vendor landed (`summary.applied >= 1`, `failed == 0`, the //! expected patch UUID present, the artifact on disk, the lock rewired); -//! 5. **DELIVERY proof** — copy ONLY the committable files (project manifest -//! + lockfile + `.socket/` + any PM config) into a fresh dir, point every -//! cache var at a fresh EMPTY dir, run the package manager's clean-install -//! offline, and assert the installed bytes are the VENDORED (patched) -//! bytes, NOT the pristine registry bytes; +//! 5. **DELIVERY proof** — copy ONLY the committable files (project +//! manifest + lockfile + `.socket/` + any PM config) into a fresh dir, +//! point every cache var at a fresh EMPTY dir, run the package +//! manager's clean-install offline, and assert the installed bytes are +//! the VENDORED (patched) bytes, NOT the pristine registry bytes; //! 6. idempotency (a second `scan --mode vendored` is an `already_vendored` //! no-op with a byte-stable lock) and `vendor --revert` byte-restores. //! @@ -52,17 +52,21 @@ //! | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | `Socket Community Patch` header | //! | PyPI | `pkg:pypi/urllib3@1.26.18` | *any of three* (see [`PYPI_UUIDS`]) | `Socket Community Patch` header | //! | Cargo | `pkg:cargo/traitobject@0.1.1` | `cf2e6f58-d9fa-4096-9151-c34afa717f89` | advisory id `GHSA-pp8r-vv2j-9j5v` | -//! | gem | `pkg:gem/activestorage@7.0.2.2` | `2535d43d-67ce-4944-be27-c19e113997fb` | *(see the known defect below)* | +//! | gem | `pkg:gem/activestorage@6.0.3` | `15e960b5-f432-4b6c-b8aa-534a2b419323` | `Socket Community Patch` header *(not yet asserted — see below)* | //! //! # Ecosystems with no full coverage, and why //! -//! * **gem** — vendoring the platform-qualified purl (`?platform=ruby`) fails -//! in the current CLI with `platform_gem_unsupported`. The download succeeds; -//! the vendor backend refuses the platform variant. This is a real CLI gap, -//! not a test bug. [`gem_bundler_vendored_known_platform_defect`] asserts the -//! redirect+download are correct and tolerates the vendor failure, failing -//! loudly if it fails for any *other* reason and auto-retiring the tolerance -//! the moment the CLI starts vendoring gems. Promote with +//! * **gem** — the old `platform_gem_unsupported` refusal for +//! `?platform=ruby` purls was fixed in the CLI (#172 — only non-`ruby` +//! platform qualifiers are refused), and the 2026-08-18 catalog republish +//! restored the pinned patch, so the VENDOR itself now succeeds. +//! The full fresh-dir `bundle install` delivery proof is DEFERRED to the +//! stacked stub-hardening PR: the gem-stub-gemspec artifact production +//! serves is currently invalid (missing `summary`/`authors`), so rubygems +//! validation rejects the vendored `path:` source and `bundle install` +//! exits 1 on every bundler major (discovered 2026-08-19). +//! [`gem_bundler_vendored_known_platform_defect`] asserts redirect+download +//! hard and reports the vendor success. Promote with //! `SOCKET_PATCH_VENDORED_E2E_GEM_STRICT=1`. //! * **golang** — vendored mode *works* (directory `replace`), but production //! publishes no free golang patches, so there is nothing to vendor. @@ -147,10 +151,15 @@ const CARGO_UUID: &str = "cf2e6f58-d9fa-4096-9151-c34afa717f89"; /// not `cargo build`; see [`cargo_vendored_install_proof`].) const CARGO_MARKER: &str = "GHSA-pp8r-vv2j-9j5v"; -const GEM_PURL: &str = "pkg:gem/activestorage@7.0.2.2"; +/// The gem pin is deliberately UNQUALIFIED. Production publishes the purl as +/// `pkg:gem/activestorage@6.0.3?platform=ruby`, but nothing client-side +/// strips qualifiers — the SERVER normalizes both spellings to the same +/// patch set (verified live against `/patch/by-package`), so this pins the +/// bare spelling the CLI's own crawler synthesizes. +const GEM_PURL: &str = "pkg:gem/activestorage@6.0.3"; const GEM_NAME: &str = "activestorage"; -const GEM_VERSION: &str = "7.0.2.2"; -const GEM_UUID: &str = "2535d43d-67ce-4944-be27-c19e113997fb"; +const GEM_VERSION: &str = "6.0.3"; +const GEM_UUID: &str = "15e960b5-f432-4b6c-b8aa-534a2b419323"; /// Header the patch service injects into patched npm / PyPI source files. const PATCH_MARKER: &str = "Socket Community Patch"; @@ -1661,23 +1670,24 @@ fn cargo_package_block(lock_text: &str, name: &str) -> Option { } // =========================================================================== -// RubyGems — vendoring the platform-qualified purl is unsupported (CLI gap) +// RubyGems — vendor succeeds; full install proof deferred (invalid stub gemspec) // =========================================================================== -/// The gem redirect+download are correct and asserted hard. The **vendor** leg -/// is a different story: `scan --mode vendored` resolves and downloads the -/// activestorage patch, but the vendor backend refuses the platform-qualified -/// purl (`pkg:gem/activestorage@7.0.2.2?platform=ruby`) with -/// `platform_gem_unsupported`, so `summary.applied == 0`, `failed == 1`, and -/// the run exits non-zero with `"status": "partial_failure"`. +/// The gem redirect+download are asserted hard, and the vendor itself now +/// SUCCEEDS: #172 fixed the `platform_gem_unsupported` gate (only non-empty, +/// non-`ruby` platform qualifiers are refused) and the 2026-08-18 catalog +/// republish restored the pinned patch, so the success branch below — the +/// "appears FIXED" NOTE — is the live path today and the failure-tolerance +/// branch is vestigial. /// -/// That is a real CLI gap, not a test bug, so this leg tolerates the vendor -/// failure — asserting the download succeeded and the failure is exactly that -/// known code — and fails loudly for any *other* failure. Set -/// `SOCKET_PATCH_VENDORED_E2E_GEM_STRICT=1` to promote it to a hard failure (do -/// that as the regression guard once the CLI learns to vendor platform gems). -/// The moment the vendor starts succeeding, the tolerance branch reports it so -/// the leg can be upgraded to a full delivery proof. +/// The upgrade to a full fresh-dir `bundle install` delivery proof is +/// DEFERRED to the stacked stub-hardening PR: the gem-stub-gemspec artifact +/// production serves is currently invalid (missing `summary`/`authors`), so +/// rubygems validation rejects the vendored `path:` source and +/// `bundle install` exits 1 on every bundler major (discovered 2026-08-19). +/// That PR carries the install proof as its regression test; until it lands, +/// `SOCKET_PATCH_VENDORED_E2E_GEM_STRICT=1` still promotes any vendor +/// failure here to a hard failure. #[test] #[ignore = "live production API + real rubygems.org. Run with --ignored."] fn gem_bundler_vendored_known_platform_defect() { diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 0e2d1d72..a07ff897 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1728,6 +1728,10 @@ async fn offline_service_mode_refuses_instead_of_building() { const GEM_UUID: &str = "35353535-3535-4335-8335-353535353535"; const GEM_PURL: &str = "pkg:gem/demo-gem@1.0.0"; +/// The platform-qualified spelling production publishes for pure-ruby gems +/// (e.g. `pkg:gem/activestorage@6.0.3?platform=ruby`). `?platform=ruby` is +/// the portable default and must vendor exactly like the bare purl. +const GEM_PURL_QUALIFIED: &str = "pkg:gem/demo-gem@1.0.0?platform=ruby"; const GEM_ORIG: &[u8] = b"module DemoGem\n STATUS = \"orig\"\nend\n"; const GEM_PATCHED: &[u8] = b"module DemoGem\n STATUS = \"patched\"\nend\n"; const GEM_GEMSPEC: &str = "Gem::Specification.new do |s|\n s.name = \"demo-gem\"\n s.version = \"1.0.0\"\n s.summary = \"in-process scan --vendor fixture\"\n s.require_paths = [\"lib\"]\nend\n"; @@ -1788,12 +1792,21 @@ fn gem_fixture() -> GemFixture { /// Mount discovery (batch), per-package search, and the full view (inline /// `blobContent`, so `scan --vendor` runs against the mock alone) for the /// demo gem — the gem mirror of `scan_vendor_e2e::mount_patch_api`. -async fn mount_gem_patch_api(mock: &wiremock::MockServer) { +/// +/// `patch_purl` is the purl the SERVED patch records carry ([`GEM_PURL`] or +/// [`GEM_PURL_QUALIFIED`]); the batch response's outer package purl stays the +/// bare purl the crawler requested, mirroring production. +async fn mount_gem_patch_api(mock: &wiremock::MockServer, patch_purl: &str) { use base64::Engine as _; - use wiremock::matchers::{method, path, path_regex}; + use wiremock::matchers::{method, path}; use wiremock::{Mock, ResponseTemplate}; const ORG_SLUG: &str = "test-org"; + /// The exact percent-encoded by-package path segment for the BARE purl — + /// the spelling the crawler synthesizes and the CLI queries with (the + /// npm model in `scan_vendor_e2e`). Pinned exactly, not `path_regex(".+")`, + /// so a change in what spelling the CLI queries goes red here. + const GEM_ENCODED: &str = "pkg%3Agem%2Fdemo-gem%401.0.0"; let before_hash = compute_git_sha256_from_bytes(GEM_ORIG); let after_hash = compute_git_sha256_from_bytes(GEM_PATCHED); let blob_b64 = base64::engine::general_purpose::STANDARD.encode(GEM_PATCHED); @@ -1804,7 +1817,7 @@ async fn mount_gem_patch_api(mock: &wiremock::MockServer) { "purl": GEM_PURL, "patches": [{ "uuid": GEM_UUID, - "purl": GEM_PURL, + "purl": patch_purl, "tier": "free", "cveIds": ["CVE-2026-0002"], "ghsaIds": [], @@ -1817,13 +1830,13 @@ async fn mount_gem_patch_api(mock: &wiremock::MockServer) { .mount(mock) .await; Mock::given(method("GET")) - .and(path_regex(format!( - "^/v0/orgs/{ORG_SLUG}/patches/by-package/.+$" + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{GEM_ENCODED}" ))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "patches": [{ "uuid": GEM_UUID, - "purl": GEM_PURL, + "purl": patch_purl, "publishedAt": "2026-01-01T00:00:00Z", "description": "gem vendor patch", "license": "MIT", @@ -1838,7 +1851,7 @@ async fn mount_gem_patch_api(mock: &wiremock::MockServer) { .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{GEM_UUID}"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "uuid": GEM_UUID, - "purl": GEM_PURL, + "purl": patch_purl, "publishedAt": "2026-01-01T00:00:00Z", "files": { "lib/demo_gem.rb": { @@ -1894,7 +1907,7 @@ fn run_scan_vendor(root: &Path, mock_uri: &str, extra: &[&str]) -> (i32, Value) #[tokio::test] async fn scan_vendor_gem_end_to_end_and_reconcile() { let mock = wiremock::MockServer::start().await; - mount_gem_patch_api(&mock).await; + mount_gem_patch_api(&mock, GEM_PURL).await; let fx = gem_fixture(); let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); @@ -2004,6 +2017,155 @@ async fn scan_vendor_gem_end_to_end_and_reconcile() { ); } +/// Same in-process flow as [`scan_vendor_gem_end_to_end_and_reconcile`], but +/// the served patch records carry the QUALIFIED gem purl (`?platform=ruby`) +/// — the spelling production has published since the 2026-08-18 gem catalog +/// republish. `platform=ruby` is the portable default: the vendor gate +/// refuses only non-empty, non-`ruby` platform qualifiers (#172), so this +/// must vendor exactly like the bare purl. This is the CLI-level pin that +/// keeps the old `platform_gem_unsupported` gap from silently reopening; +/// the core-level twin is +/// `vendor::gem::tests::test_platform_ruby_gem_from_autofetch_staging_dir_vendors`. +#[tokio::test] +async fn scan_vendor_gem_qualified_platform_ruby_purl_vendors() { + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api(&mock, GEM_PURL_QUALIFIED).await; + let fx = gem_fixture(); + + let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); + assert_eq!( + code, 0, + "scan --vendor must succeed on the qualified purl: {env:#}" + ); + assert_eq!(env["status"], "success", "envelope: {env:#}"); + assert_eq!(env["download"]["downloaded"], 1, "envelope: {env:#}"); + assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); + assert_eq!(env["vendor"]["summary"]["failed"], 0, "envelope: {env:#}"); + // Positive assertion — an `applied` event for the qualified purl itself + // (a `.all(errorCode != platform_gem_unsupported)` check would pass + // vacuously on an empty event list). + assert!( + env["vendor"]["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "applied" && e["purl"] == GEM_PURL_QUALIFIED), + "`platform=ruby` is the portable default and must vendor — expected \ + an `applied` event for {GEM_PURL_QUALIFIED}: {env:#}" + ); + + // Manifest and ledger entry are keyed by the SERVED (qualified) purl; + // the ledger entry additionally records the qualifier-stripped + // `basePurl` (built by `build_gem_purl`) — both halves of the mapping. + let manifest: Value = + serde_json::from_slice(&std::fs::read(fx.root().join(".socket/manifest.json")).unwrap()) + .unwrap(); + assert_eq!( + manifest["patches"][GEM_PURL_QUALIFIED]["uuid"], GEM_UUID, + "manifest keys by the served purl: {manifest:#}" + ); + let state: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap(); + assert_eq!( + state["entries"][GEM_PURL_QUALIFIED]["uuid"], GEM_UUID, + "ledger keys by the manifest (qualified) purl: {state:#}" + ); + assert_eq!( + state["entries"][GEM_PURL_QUALIFIED]["basePurl"], GEM_PURL, + "ledger entry records the qualifier-stripped base purl: {state:#}" + ); + + // Artifact + pair edit land identically to the bare-purl flow. The + // vendored copy path is derived from name@version, so a qualifier + // leaking into it would surface in the lock's PATH remote below. + assert_eq!( + std::fs::read(fx.vendored_lib()).unwrap(), + GEM_PATCHED, + "vendored lib must hold the patched bytes" + ); + assert_eq!( + std::fs::read_to_string( + fx.root() + .join(GemFixture::copy_rel()) + .join("demo-gem.gemspec") + ) + .unwrap(), + GEM_GEMSPEC, + "stub gemspec materialized from specifications/" + ); + let gemfile = std::fs::read_to_string(fx.gemfile_path()).unwrap(); + assert!( + gemfile.contains(&format!( + "gem \"demo-gem\", \"1.0.0\", path: \"{}\"", + GemFixture::copy_rel() + )), + "Gemfile line not rewritten to the exact-pin + path: form:\n{gemfile}" + ); + let lock = std::fs::read_to_string(fx.lock_path()).unwrap(); + assert!( + lock.contains(&format!( + "PATH\n remote: {}\n specs:\n demo-gem (1.0.0)", + GemFixture::copy_rel() + )), + "canonical PATH section missing (or the qualifier leaked into the \ + vendored path):\n{lock}" + ); + assert!( + lock.contains("\n demo-gem (= 1.0.0)!"), + "DEPENDENCIES pin missing:\n{lock}" + ); +} + +/// The QUALIFIED purl through `--detached` + `vendor --revert`: a detached +/// ledger entry has NO manifest fallback, so the revert must find it via its +/// own key/`basePurl` alone. The bare-purl detached shape is covered by +/// [`scan_vendor_gem_detached_writes_no_manifest_and_reverts`]; this pins +/// the qualified-key variant production publishes today. +#[tokio::test] +async fn scan_vendor_gem_detached_qualified_purl_reverts() { + let mock = wiremock::MockServer::start().await; + mount_gem_patch_api(&mock, GEM_PURL_QUALIFIED).await; + let fx = gem_fixture(); + + let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &["--detached"]); + assert_eq!( + code, 0, + "scan --vendor --detached must succeed on the qualified purl: {env:#}" + ); + assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); + + assert!( + !fx.root().join(".socket/manifest.json").exists(), + "detached mode must not write a manifest" + ); + let state: Value = serde_json::from_slice(&std::fs::read(fx.state_path()).unwrap()).unwrap(); + assert_eq!( + state["entries"][GEM_PURL_QUALIFIED]["detached"], + json!(true), + "detached entry keyed by the qualified purl: {state:#}" + ); + assert_eq!( + state["entries"][GEM_PURL_QUALIFIED]["basePurl"], GEM_PURL, + "detached entry records the base purl: {state:#}" + ); + assert_eq!(std::fs::read(fx.vendored_lib()).unwrap(), GEM_PATCHED); + + // `--revert` must locate the qualified-keyed detached entry (no manifest + // to fall back to) and byte-restore both pair-edit halves. + let (code, renv) = vendor_cli(fx.root(), &["--revert"]); + assert_eq!(code, 0, "revert must undo the detached entry: {renv:#}"); + assert_eq!( + std::fs::read(fx.gemfile_path()).unwrap(), + GEM_GEMFILE.as_bytes(), + "revert must byte-restore the Gemfile" + ); + assert_eq!( + std::fs::read(fx.lock_path()).unwrap(), + GEM_LOCK.as_bytes(), + "revert must byte-restore Gemfile.lock" + ); + assert!(!fx.root().join(".socket/vendor").exists()); +} + /// `scan --vendor --detached` on the gem project: no manifest is written, /// the ledger entry is detached with the patch record embedded, the pair /// edit still lands — and `vendor --revert` (the detached entry's only exit @@ -2011,7 +2173,7 @@ async fn scan_vendor_gem_end_to_end_and_reconcile() { #[tokio::test] async fn scan_vendor_gem_detached_writes_no_manifest_and_reverts() { let mock = wiremock::MockServer::start().await; - mount_gem_patch_api(&mock).await; + mount_gem_patch_api(&mock, GEM_PURL).await; let fx = gem_fixture(); let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &["--detached"]); @@ -2332,8 +2494,7 @@ snapshots: // The lock is FULLY vendored: local wiring present, no hosted // residue. - let vendored_lock_text = - std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + let vendored_lock_text = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); assert!( !vendored_lock_text.contains(HOSTED_URL), "the hosted splice must be gone from the vendored lock:\n{vendored_lock_text}" diff --git a/docs/testing/hosted-production-e2e.md b/docs/testing/hosted-production-e2e.md index 6382ea47..686f3e40 100644 --- a/docs/testing/hosted-production-e2e.md +++ b/docs/testing/hosted-production-e2e.md @@ -41,7 +41,7 @@ from the child environment. | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | GHSA-xvch-5gv4-984h / CVE-2021-44906 | all five npm-family legs | | PyPI | `pkg:pypi/urllib3@1.26.18` | `de58c8b8-796c-4b6d-8a48-539b5563db76`, `26242e35-f867-4da8-8789-f0d2ea49e0f1`, `e828efa5-5c6d-43f3-9909-03f5ac232b98` | GHSA-38jv-5279-wg99, GHSA-2xpw-w6gg-jr37, GHSA-gm62-xv2j-4w53 | requirements.txt, uv.lock | | Cargo | `pkg:cargo/traitobject@0.1.1` | `cf2e6f58-d9fa-4096-9151-c34afa717f89` | GHSA-pp8r-vv2j-9j5v | cargo sparse-registry leg | -| RubyGems | `pkg:gem/activestorage@7.0.2.2` | `2535d43d-67ce-4944-be27-c19e113997fb` | GHSA-w749-p3v6-hccq | bundler leg | +| RubyGems | `pkg:gem/activestorage@6.0.3` | `15e960b5-f432-4b6c-b8aa-534a2b419323` | GHSA-m42x-37p3-fv5w / CVE-2020-8162 | bundler leg | urllib3 1.26.18 carries **three** distinct free patches, one per advisory. Which one the resolver returns is a server-side ordering detail, so the suite accepts @@ -74,7 +74,7 @@ failure instead of N confusing ones that look like CLI regressions. | npm | ✅ | ✅ many | ✅ npm, npm-shrinkwrap, pnpm, yarn classic, yarn berry, bun | | PyPI | ✅ (requirements.txt + uv.lock only) | ✅ many | ✅ requirements.txt, uv.lock | | Cargo | ✅ | ✅ 1 crate | ✅ sparse registry | -| RubyGems | ✅ | ✅ 1 gem | ⚠️ redirect asserted; install blocked by a **server defect** (below) | +| RubyGems | ✅ | ✅ (this suite pins one purl/UUID: `activestorage@6.0.3`; the 2026-08-18 republish covers more versions) | ✅ full bundler install proof | | Maven | ✅ | ❌ **none** | canary only | | NuGet | ✅ | ❌ **none** | canary only | | Composer | ✅ | ❌ **none** | canary only | @@ -107,37 +107,30 @@ Two supported hosted shapes are deliberately **not** covered here: ## Known issues this suite surfaced -Both were found by running against real production, and neither is a test bug. +All were found by running against real production; none is a test bug. -### 1. `gem` — hosted mode is unusable for gems with dependencies (SERVER) +### 1. `gem` — hosted mode was unusable for gems with dependencies (SERVER) — FIXED -Socket's gem patch-registry serves a compact index whose `/info/` line -declares **no runtime dependencies**, while the `.gem` it serves declares six. -Bundler's `ensure_same_dependencies` check fails closed: +Socket's gem patch-registry used to serve a compact index whose `/info/` +line declared **no runtime dependencies** while the `.gem` it served declared +several, so bundler's `ensure_same_dependencies` check failed closed with +`Bundler::APIResponseMismatchError` — hosted gem mode was unusable for any gem +with runtime dependencies. (The suite's original pin, activestorage@7.0.2.2 / +`2535d43d-…` / GHSA-w749-p3v6-hccq, was unpublished on 2026-08-14 pending the +fix.) -``` -Bundler::APIResponseMismatchError: Downloading activestorage-7.0.2.2 revealed -dependencies not in the API (activesupport (= 7.0.2.2), actionpack (= 7.0.2.2), -activejob (= 7.0.2.2), activerecord (= 7.0.2.2), marcel (~> 1.0), mini_mime (>= 1.1.0)). -``` - -Compare the two indexes: - -```sh -# rubygems.org — full dependency list -curl -s https://index.rubygems.org/info/activestorage | grep '^7\.0\.2\.2 ' -# 7.0.2.2 actionpack:= 7.0.2.2,activejob:= 7.0.2.2,...|checksum:7997042a... - -# Socket patch-registry — empty dependency list -curl -s "https://patch.socket.dev/patch-registry/gem///info/activestorage" -# 7.0.2.2 |checksum:89b47c6d... -``` +**Fixed by the 2026-08-18 gem catalog republish**: the patch-registry's compact +index now serves the gemspec's runtime dependencies (verified against +activestorage@6.0.3: `/versions` 200, `/info/activestorage` 200 with the full +dep list). The leg's probe-based tolerance — pass on a non-2xx `/versions`, +enforce on 2xx — retired itself as designed and was deleted along with its +`SOCKET_PATCH_HOSTED_E2E_GEM_STRICT` knob; the leg is now the unconditional +`gem_bundler_hosted_install_proof`. -**Fix belongs on the server**: the compact-index generator must emit the -gemspec's runtime dependencies. Until then the suite asserts the redirect (which -is correct) and tolerates the install failure, failing loudly if it fails for -any *other* reason. Set `SOCKET_PATCH_HOSTED_E2E_GEM_STRICT=1` to promote it to -a hard failure — do that as the regression guard once the server is fixed. +**Latent, still open (server)**: the registry's `/api/v1/dependencies` route +answers 200 with an empty body. Unreachable today — bundler only falls back to +the Dependency fetcher when the compact index is unavailable — but it would +resurface as a confusing Marshal error if the compact index ever broke again. ### 2. `pnpm` — pnpm 11 rejects hosted lockfiles by default (CLI UX gap) @@ -201,7 +194,6 @@ runs only where it is explicitly asked for. | Variable | Effect | |----------|--------| | `SOCKET_PATCH_HOSTED_E2E_STRICT=1` | Turn every "toolchain missing" soft-skip into a hard failure. **CI sets this** — a required check must never report green on an unexercised leg. | -| `SOCKET_PATCH_HOSTED_E2E_GEM_STRICT=1` | Promote the known gem install defect to a hard failure. | | `SOCKET_PATCH_HOSTED_E2E_CANARY_STRICT=1` | Fail when maven/nuget/composer gain their first free published patch. | ### Toolchains diff --git a/docs/testing/vendored-production-e2e.md b/docs/testing/vendored-production-e2e.md index 47e1a2d0..5667713b 100644 --- a/docs/testing/vendored-production-e2e.md +++ b/docs/testing/vendored-production-e2e.md @@ -50,7 +50,7 @@ four every run and fails first with the offending PURL named. | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | `Socket Community Patch` header | | PyPI | `pkg:pypi/urllib3@1.26.18` | one of three (server-ordered) | `Socket Community Patch` header | | Cargo | `pkg:cargo/traitobject@0.1.1` | `cf2e6f58-d9fa-4096-9151-c34afa717f89` | advisory id `GHSA-pp8r-vv2j-9j5v` | -| RubyGems | `pkg:gem/activestorage@7.0.2.2` | `2535d43d-67ce-4944-be27-c19e113997fb` | *(blocked — see below)* | +| RubyGems | `pkg:gem/activestorage@6.0.3` | `15e960b5-f432-4b6c-b8aa-534a2b419323` | `Socket Community Patch` header | If a required patch is withdrawn, update the catalog constants at the top of `e2e_vendored_production.rs` **and** the table above (same procedure as the @@ -68,7 +68,7 @@ hosted suite). | pip (requirements.txt) | urllib3@1.26.18 | `pip install --no-index -r requirements.txt` | ✅ full | | uv (uv.lock) | urllib3@1.26.18 | `uv sync --frozen --offline` | ✅ full | | cargo (`[patch.crates-io]`) | traitobject@0.1.1 | `cargo fetch --offline --locked` (see note) | ✅ full | -| bundler | activestorage@7.0.2.2 | — | ❌ CLI gap (below) | +| bundler | activestorage@6.0.3 | — (deferred: production's served gem-stub gemspec is invalid, see below) | ⚠️ vendor succeeds; delivery proof deferred to the stub-hardening fix PR | | go | — | — | zero-patch assertion (no free golang patches) | | deno | — | — | negative assertion (unsupported) | | maven / nuget / composer | — | — | canary (no free production patches) | @@ -83,7 +83,8 @@ directory. ## Known issues this suite surfaced -Both were found against real production + real toolchains; neither is a test bug. +All were found against real production + real toolchains; none is a test bug. +The first two are fixed; the third is open with a stacked fix PR pending. ### 1. `pnpm` >= 11 — vendored `overrides` land in the wrong file (CLI) — FIXED @@ -103,21 +104,35 @@ mismatch, and `vendor --revert` deletes a file it created (or splices its override back out of one it edited). This leg now asserts the frozen install succeeds directly, with no workaround. -### 2. `gem` — vendoring the platform-qualified purl is unsupported (CLI) - -`scan --mode vendored` resolves and downloads the activestorage patch, but the -vendor backend refuses the platform-qualified purl -(`pkg:gem/activestorage@7.0.2.2?platform=ruby`) with `platform_gem_unsupported`. -`summary.applied == 0`, `failed == 1`, and the run exits non-zero with -`"status": "partial_failure"`. - -**Fix belongs in the CLI**: the gem vendor backend must handle platform -variants. Until then the leg asserts the redirect+download are correct and -tolerates the vendor failure, failing loudly for any *other* failure. Set -`SOCKET_PATCH_VENDORED_E2E_GEM_STRICT=1` to promote it to a hard failure once -the CLI learns to vendor platform gems; the tolerance auto-retires (upgrades to -a full `bundle install` delivery proof) the moment the vendor starts -succeeding. +### 2. `gem` — vendoring the platform-qualified purl was unsupported (CLI) — FIXED + +`scan --mode vendored` resolved and downloaded the activestorage patch, but the +vendor backend refused the platform-qualified purl +(`pkg:gem/activestorage@…?platform=ruby` — the spelling production publishes) +with `platform_gem_unsupported`, so `summary.applied == 0`, `failed == 1`, and +the run exited non-zero with `"status": "partial_failure"`. + +**Fixed** (PR #172): the gate in `vendor/gem.rs` now refuses only non-empty, +non-`ruby` platform qualifiers — `?platform=ruby` is the portable default and +vendors like a bare purl. The suite's original pin +(`activestorage@7.0.2.2` / `2535d43d-67ce-4944-be27-c19e113997fb`) was +withdrawn on 2026-08-14; the 2026-08-18 catalog republish REPLACED it, and the +suite was re-pinned to `activestorage@6.0.3` / +`15e960b5-f432-4b6c-b8aa-534a2b419323`. The vendor now succeeds live and the +leg's failure-tolerance branch is vestigial. The leg's upgrade to a full fresh-dir +`bundle install` delivery proof is deferred to the stacked stub-hardening fix +PR because of issue 3 below; until then the leg keeps its tolerant shape and +its `SOCKET_PATCH_VENDORED_E2E_GEM_STRICT` knob. + +### 3. `gem` — the served gem-stub gemspec is invalid (SERVER + CLI hardening) — OPEN + +Discovered 2026-08-19 while upgrading the gem leg to a full delivery proof: +the gem-stub-gemspec artifact production serves is invalid — it is missing +`summary`/`authors`, which rubygems validation requires — so bundler rejects +the vendored `path:` source and `bundle install` exits 1 on every bundler +major. The fix (server-side stub correction plus CLI-side hardening) lands in +a separate stacked PR; that PR carries the full +fresh-dir-frozen-`bundle install` install proof as its regression test. ## Running @@ -139,7 +154,7 @@ installs from contending on the shared cache sandbox. | Variable | Effect | |----------|--------| | `SOCKET_PATCH_VENDORED_E2E_STRICT=1` | Turn every "toolchain missing" soft-skip into a hard failure. | -| `SOCKET_PATCH_VENDORED_E2E_GEM_STRICT=1` | Promote the known gem `platform_gem_unsupported` vendor gap to a hard failure. | +| `SOCKET_PATCH_VENDORED_E2E_GEM_STRICT=1` | Promote any gem vendor failure to a hard failure (the leg's tolerance branch is vestigial since #172; the knob stays until the stub-hardening PR lands the full install proof). | | `SOCKET_PATCH_VENDORED_E2E_CANARY_STRICT=1` | Fail when maven/nuget/composer gain their first free published patch. | The suite forces `SOCKET_NO_CONFIG=true` and scrubs every ambient `SOCKET_*`