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
24 changes: 19 additions & 5 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,11 @@ pub struct DownloadParams {
/// `--strict` forwarded to the nested apply (a beforeHash mismatch
/// fails instead of warn-and-overwrite).
pub strict: bool,
/// `--ecosystems` forwarded to the nested apply. Without this the
/// nested apply ran UNSCOPED over the whole manifest, so
/// `scan --ecosystems gem --sync` could mutate other ecosystems'
/// packages the user had explicitly filtered out.
pub ecosystems: Option<Vec<String>>,
/// Persist downloaded blob content into `.socket/blobs` (the apply
/// flows need it for later hook/rollback runs). Vendor flows pass
/// `false`: their patch content is staged in memory and the committed
Expand Down Expand Up @@ -1098,6 +1103,7 @@ async fn run_nested_apply(
download_mode: String,
strict: bool,
api: socket_patch_core::api::client::ApiClientEnvOverrides,
ecosystems: Option<Vec<String>>,
) -> bool {
// Apply re-resolves a relative manifest path against ITS `--cwd`
// (`resolved_manifest_path`), but ours is already cwd-resolved —
Expand All @@ -1119,6 +1125,11 @@ async fn run_nested_apply(
api_token: api.api_token,
org: api.org_slug,
proxy_url: api.proxy_url,
// Scope the nested apply like the caller was scoped: leaving
// this at the default `None` made `scan --ecosystems gem --sync`
// apply the WHOLE manifest, mutating other ecosystems' packages
// the user filtered out.
ecosystems,
..crate::args::GlobalArgs::default()
},
force: false,
Expand Down Expand Up @@ -1232,10 +1243,7 @@ pub async fn download_and_apply_patches(
// status/exit code degrade and it is never auto-applied.
if files.is_empty() {
if !params.json && !params.silent {
eprintln!(
" [fail] {} (patch has no applicable files)",
patch.purl
);
eprintln!(" [fail] {} (patch has no applicable files)", patch.purl);
}
downloaded_patches.push(serde_json::json!({
"purl": patch.purl,
Expand Down Expand Up @@ -1393,6 +1401,7 @@ pub async fn download_and_apply_patches(
params.download_mode.clone(),
params.strict,
resolved_api_overrides(params),
params.ecosystems.clone(),
)
.await;
}
Expand Down Expand Up @@ -1824,6 +1833,7 @@ pub async fn run(args: GetArgs) -> i32 {
api_overrides: args.common.api_client_overrides(),
all_releases: args.all_releases,
strict: args.common.strict,
ecosystems: args.common.ecosystems.clone(),
persist_blobs: true,
};

Expand Down Expand Up @@ -2034,6 +2044,7 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 {
args.common.download_mode.clone(),
args.common.strict,
args.common.api_client_overrides(),
args.common.ecosystems.clone(),
)
.await;
}
Expand Down Expand Up @@ -3106,7 +3117,10 @@ mod tests {
// record — the guardrail-triggering condition the download/apply
// flows now count as failed rather than applied.
let mut broken = HashMap::new();
broken.insert("src/lib.rs".to_string(), file_resp(Some(&"e".repeat(64)), None));
broken.insert(
"src/lib.rs".to_string(),
file_resp(Some(&"e".repeat(64)), None),
);
let broken_patch = patch_with_files(broken);
assert!(
files_for_manifest(&broken_patch).is_empty(),
Expand Down
17 changes: 17 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,15 @@ async fn embed_vex_into_json(
if vex_args.vex.is_none() || base_code != 0 {
return base_code;
}
// A dry run is a non-mutating preview: generating here would verify the
// deliberately untouched tree (failing outright on a not-yet-vendored
// project) and write an attestation file to disk. The marker keeps the
// request visible to JSON consumers instead of silently dropping it
// (same shape as the vendor JSON arm's early return).
if common.dry_run {
result["vex"] = serde_json::json!({ "skipped": true, "reason": "dry_run" });
return base_code;
}
let params = vex_args.to_build_params();
match generate_vex_from_manifest_path(common, &params, manifest_path).await {
Ok(summary) => {
Expand Down Expand Up @@ -314,6 +323,13 @@ async fn embed_vex_human(
if vex_args.vex.is_none() || base_code != 0 {
return base_code;
}
// Dry-run twin of the JSON guard above: no generation, no file write.
if common.dry_run {
if !common.silent {
println!("[dry-run] VEX generation skipped. No attestation written.");
}
return base_code;
}
let params = vex_args.to_build_params();
match generate_vex_from_manifest_path(common, &params, manifest_path).await {
Ok(summary) => {
Expand Down Expand Up @@ -411,6 +427,7 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) -
api_overrides: args.common.api_client_overrides(),
all_releases: args.all_releases,
strict: args.common.strict,
ecosystems: args.common.ecosystems.clone(),
persist_blobs: args.mode != Some(ScanMode::Vendored),
}
}
Expand Down
17 changes: 13 additions & 4 deletions crates/socket-patch-cli/src/commands/scan/vendor_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,9 @@ async fn run_scan_vendor_step(
}

/// The `scan --vendor` JSON path: discovery → (dry-run preview | download
/// → GC → vendor engine) → embedded VEX → print `result` → exit code.
/// → GC → vendor engine → embedded VEX) → print `result` → exit code.
/// The dry-run arm skips the VEX embed (emitting a `vex.skipped` marker
/// instead): a dry run vendors nothing, so there is no state to attest.
///
/// Extracted from `run` (and called through `Box::pin`) so its sizeable
/// temporaries get their own poll frame, entered only when `--vendor` is
Expand Down Expand Up @@ -285,10 +287,17 @@ async fn run_vendor_json_path(
)
.await;
}
let final_code =
embed_vex_into_json(&args.common, &args.vex, manifest_path, 0, result).await;
// Embedded VEX is skipped on a dry run (apply.rs's precedent):
// nothing was vendored, so there is no just-vendored state to
// attest — generating here would verify the deliberately untouched
// tree (failing outright on a not-yet-vendored project) and write
// an attestation file during --dry-run. The marker keeps the
// request visible to JSON consumers instead of silently dropping it.
if args.vex.vex.is_some() {
result["vex"] = serde_json::json!({ "skipped": true, "reason": "dry_run" });
}
println!("{}", serde_json::to_string_pretty(&result).unwrap());
return final_code;
return 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dry-run VEX skip is JSON-only

Medium Severity

The new dry-run VEX skip only covers the vendor JSON path. Interactive scan --vendor --dry-run --vex still calls embed_vex_human, so it can exit 1 on a not-yet-vendored project or write an attestation during --dry-run—the same failure mode this PR claimed to close. apply already skips VEX on dry-run for both output modes.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 10c0079. Configure here.

}

// 1) Download phase. Manifest mode reuses the `--apply`
Expand Down
112 changes: 101 additions & 11 deletions crates/socket-patch-cli/tests/docker_e2e_gem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
//! - `gem_global_install_full_apply_chain` — `gem install` without
//! --install-dir, installs to the system gem directory; socket-patch
//! scans + applies with `--global`.
//!
//! The fixture serves the TRUE git-blob sha256 of the installed
//! `lib/colorize.rb` as `beforeHash` (computed once by a probe container
//! from the real upstream artifact — see [`upstream_before_hash`]), so
//! both apply paths run gated, without `--force`: `scan --sync`'s own
//! nested apply must patch the file in the same run, and the explicit
//! `apply` (against restored pristine bytes) must pass the variant gate.
//! With the old all-zeros placeholder the nested apply failed invisibly
//! and only the `--force` escape hatch was ever exercised.

#![cfg(feature = "docker-e2e")]

Expand Down Expand Up @@ -69,6 +78,52 @@ fn plain_sha256(content: &[u8]) -> String {
hex::encode(hasher.finalize())
}

/// Probe: install colorize 1.1.0 from the real registry and emit the
/// git-blob sha256 of the exact `lib/colorize.rb` bytes `gem install`
/// lays down — the value the fixture must serve as `beforeHash` for the
/// default (no `--force`) apply path to pass the variant gate.
const BEFORE_HASH_PROBE_SCRIPT: &str = r#"#!/usr/bin/env bash
set -uo pipefail
gem install --no-document --install-dir /tmp/probe colorize -v 1.1.0 > /tmp/install.log 2>&1 || {
cat /tmp/install.log >&2; exit 1
}
F=/tmp/probe/gems/colorize-1.1.0/lib/colorize.rb
[ -f "$F" ] || { echo "FAIL: $F missing" >&2; exit 1; }
{ printf 'blob %d\0' "$(wc -c < "$F")"; cat "$F"; } | sha256sum | cut -d' ' -f1
"#;

/// True git-blob sha256 of the colorize-1.1.0 `lib/colorize.rb` that
/// `gem install` produces, computed once per process by
/// [`BEFORE_HASH_PROBE_SCRIPT`] in a probe container. Serving this as the
/// fixture's `beforeHash` — instead of an all-zeros placeholder — is what
/// lets the gated apply paths run: with the placeholder, `scan --sync`'s
/// nested apply hit the variant gate and failed invisibly, so the chain
/// only ever proved the `--force` path.
fn upstream_before_hash() -> String {
static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
HASH.get_or_init(|| {
let out = run_container(BEFORE_HASH_PROBE_SCRIPT);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"beforeHash probe container failed:\nstdout=\n{stdout}\nstderr=\n{stderr}"
);
stdout
.lines()
.rev()
.map(str::trim)
.find(|l| l.len() == 64 && l.bytes().all(|b| b.is_ascii_hexdigit()))
.unwrap_or_else(|| {
panic!(
"beforeHash probe emitted no 64-hex git-blob sha256:\nstdout=\n{stdout}\nstderr=\n{stderr}"
)
})
.to_string()
})
.clone()
}

/// Shared verification block for both scripts. Expects `GEM_FILE`,
/// `EXPECTED_SHA`, and `APPLY_EXIT` to be set, plus the JSON captured in
/// `/tmp/scan.json` and `/tmp/apply.json`.
Expand Down Expand Up @@ -123,7 +178,7 @@ exit 0
"#
}

async fn make_mock_server(after_hash: &str) -> MockServer {
async fn make_mock_server(before_hash: &str, after_hash: &str) -> MockServer {
let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock");
let server = MockServer::builder().listener(listener).start().await;

Expand Down Expand Up @@ -169,9 +224,12 @@ async fn make_mock_server(after_hash: &str) -> MockServer {
"publishedAt": "2024-01-01T00:00:00Z",
"files": {
// gem uses `package/<rel>` (npm-style) — apply strips
// the prefix and joins with the gem dir.
// the prefix and joins with the gem dir. beforeHash is
// the TRUE git-blob sha256 of the installed upstream
// file so the default (gated, no --force) apply path is
// what the chain exercises.
"package/lib/colorize.rb": {
"beforeHash": "0000000000000000000000000000000000000000000000000000000000000000",
"beforeHash": before_hash,
"afterHash": after_hash,
"blobContent": blob_b64,
}
Expand Down Expand Up @@ -213,6 +271,9 @@ gem install --no-document --install-dir "$INSTALL_DIR" colorize -v 1.1.0 > /tmp/
GEM_FILE="$INSTALL_DIR/gems/colorize-1.1.0/lib/colorize.rb"
[ -f "$GEM_FILE" ] || {{ echo "FAIL: $GEM_FILE missing" >&2; exit 1; }}
echo "Installed to: $GEM_FILE" >&2
# Keep a pristine copy: the explicit apply below is exercised against it
# after scan --sync's own nested apply has already patched the live file.
cp "$GEM_FILE" /tmp/pristine.rb

# Pre-seed setup.manual so the agent-mode VEX leg keeps the gem patch through
# property 7 (this project isn't `socket-patch setup`-configured; agent patches
Expand All @@ -229,7 +290,19 @@ socket-patch scan --json --sync --yes \
--ecosystems gem > /tmp/scan.json 2>/tmp/sync.err
cat /tmp/sync.err >&2

socket-patch apply --json --force --offline --ecosystems gem > /tmp/apply.json 2>/tmp/apply.err
# The fixture serves the TRUE beforeHash, so scan --sync's own nested apply
# (which never uses --force) must pass the variant gate and patch the file
# in the same run — with an all-zeros placeholder this failed invisibly.
grep -q 'SOCKET-PATCH-E2E-MARKER' "$GEM_FILE" || {{
echo "FAIL: scan --sync's nested apply left $GEM_FILE unpatched" >&2
cat /tmp/scan.json >&2; head -3 "$GEM_FILE" >&2; exit 1; }}

# Restore the pristine file so the explicit apply below exercises the
# default (gated, no --force) path end to end instead of short-circuiting
# on an already-patched file.
cp /tmp/pristine.rb "$GEM_FILE"

socket-patch apply --json --offline --ecosystems gem > /tmp/apply.json 2>/tmp/apply.err
APPLY_EXIT=$?
cat /tmp/apply.err >&2

Expand Down Expand Up @@ -276,6 +349,9 @@ GEM_DIR=$(gem env gemdir)
GEM_FILE="$GEM_DIR/gems/colorize-1.1.0/lib/colorize.rb"
[ -f "$GEM_FILE" ] || {{ echo "FAIL: $GEM_FILE missing" >&2; exit 1; }}
echo "Global-installed at: $GEM_FILE" >&2
# Keep a pristine copy: the explicit apply below is exercised against it
# after scan --sync's own nested apply has already patched the live file.
cp "$GEM_FILE" /tmp/pristine.rb

mkdir -p /workspace/proj && cd /workspace/proj

Expand All @@ -285,7 +361,19 @@ socket-patch scan --json --sync --yes --global \
--ecosystems gem > /tmp/scan.json 2>/tmp/sync.err
cat /tmp/sync.err >&2

socket-patch apply --json --force --offline --global --ecosystems gem > /tmp/apply.json 2>/tmp/apply.err
# The fixture serves the TRUE beforeHash, so scan --sync's own nested apply
# (which never uses --force) must pass the variant gate and patch the file
# in the same run — with an all-zeros placeholder this failed invisibly.
grep -q 'SOCKET-PATCH-E2E-MARKER' "$GEM_FILE" || {{
echo "FAIL: scan --sync's nested apply left $GEM_FILE unpatched" >&2
cat /tmp/scan.json >&2; head -3 "$GEM_FILE" >&2; exit 1; }}

# Restore the pristine file so the explicit apply below exercises the
# default (gated, no --force) path end to end instead of short-circuiting
# on an already-patched file.
cp /tmp/pristine.rb "$GEM_FILE"

socket-patch apply --json --offline --global --ecosystems gem > /tmp/apply.json 2>/tmp/apply.err
APPLY_EXIT=$?
cat /tmp/apply.err >&2
{verify}"#
Expand Down Expand Up @@ -390,12 +478,13 @@ async fn assert_api_path_exercised(server: &MockServer) {

#[tokio::test]
async fn gem_local_install_full_apply_chain() {
let after_hash = git_sha256(PATCHED_RB);
let server = make_mock_server(&after_hash).await;
let api_url = format!("http://host.docker.internal:{}", server.address().port());
if skip_if_no_image() {
return;
}
let before_hash = upstream_before_hash();
let after_hash = git_sha256(PATCHED_RB);
let server = make_mock_server(&before_hash, &after_hash).await;
let api_url = format!("http://host.docker.internal:{}", server.address().port());
let expected_sha = plain_sha256(PATCHED_RB);
let out = run_container(&local_script(&api_url, &expected_sha));
let stdout = String::from_utf8_lossy(&out.stdout);
Expand All @@ -418,12 +507,13 @@ async fn gem_local_install_full_apply_chain() {

#[tokio::test]
async fn gem_global_install_full_apply_chain() {
let after_hash = git_sha256(PATCHED_RB);
let server = make_mock_server(&after_hash).await;
let api_url = format!("http://host.docker.internal:{}", server.address().port());
if skip_if_no_image() {
return;
}
let before_hash = upstream_before_hash();
let after_hash = git_sha256(PATCHED_RB);
let server = make_mock_server(&before_hash, &after_hash).await;
let api_url = format!("http://host.docker.internal:{}", server.address().port());
let expected_sha = plain_sha256(PATCHED_RB);
let out = run_container(&global_script(&api_url, &expected_sha));
let stdout = String::from_utf8_lossy(&out.stdout);
Expand Down
11 changes: 8 additions & 3 deletions crates/socket-patch-cli/tests/e2e_gem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) {
fn bundle_run(cwd: &Path, args: &[&str]) {
let mut cmd = Command::new("bundle");
cmd.args(args).current_dir(cwd);
// Bundler 4 removed `bundle install --path`; BUNDLE_PATH is honored by
// bundler 2 through 4 and keeps the vendor/bundle/ruby/*/gems layout
// `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");
cache_env::isolate(&mut cmd);
let out = cmd.output().expect("failed to run bundle");
assert!(
Expand Down Expand Up @@ -449,7 +454,7 @@ fn test_gem_full_lifecycle() {

// -- Setup: create project and install activestorage@5.2.0 ----------------
write_gemfile(cwd);
bundle_run(cwd, &["install", "--path", "vendor/bundle"]);
bundle_run(cwd, &["install"]);

let gem_dir = find_gem_dir(cwd);

Expand Down Expand Up @@ -540,7 +545,7 @@ fn test_gem_dry_run() {
let cwd = dir.path();

write_gemfile(cwd);
bundle_run(cwd, &["install", "--path", "vendor/bundle"]);
bundle_run(cwd, &["install"]);

let gem_dir = find_gem_dir(cwd);

Expand Down Expand Up @@ -579,7 +584,7 @@ fn test_gem_save_only() {
let cwd = dir.path();

write_gemfile(cwd);
bundle_run(cwd, &["install", "--path", "vendor/bundle"]);
bundle_run(cwd, &["install"]);

let gem_dir = find_gem_dir(cwd);

Expand Down
Loading
Loading