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
84 changes: 81 additions & 3 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

18 changes: 12 additions & 6 deletions crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,10 @@ pub struct GlobalArgs {
)]
pub offline: bool,

/// Treat a beforeHash mismatch as a hard error. By DEFAULT a file whose
/// on-disk content matches neither the patch's beforeHash nor its
/// afterHash is overwritten with the full verified patched content and
/// surfaced as a stderr warning (`content_mismatch_overwritten`); this
/// flag restores the fail-closed behavior. On commands that have
/// `--force`, `--force` overrides it.
/// Reject modified file content even with --allow-user-patches. Without
/// explicit consent, mismatches already fail by default. On commands that
/// have --force, --force overrides this file-content check (but never the
/// separate check for declared project patches).
#[arg(
help_heading = GLOBAL_OPTIONS,
long,
Expand All @@ -195,6 +193,13 @@ pub struct GlobalArgs {
)]
pub strict: bool,

/// Explicitly permit attempting patches on packages with project patches or
/// local modifications. Review and test the combined result; backend safety
/// refusals still apply. --yes does not approve patch composition; --force
/// approves only file-content drift.
#[arg(help_heading = GLOBAL_OPTIONS, long)]
pub allow_user_patches: bool,

/// Operate on globally-installed packages.
#[arg(
help_heading = GLOBAL_OPTIONS,
Expand Down Expand Up @@ -780,6 +785,7 @@ impl Default for GlobalArgs {
patch_server_url: None,
offline: false,
strict: false,
allow_user_patches: false,
global: false,
global_prefix: None,
json: false,
Expand Down
2 changes: 2 additions & 0 deletions crates/socket-patch-cli/src/commands/agent_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ pub struct DownloadParams {
/// `--strict` forwarded to the nested apply (a beforeHash mismatch
/// fails instead of warn-and-overwrite).
pub strict: bool,
pub allow_user_patches: bool,
/// `--ecosystems` forwarded to the nested apply, so it never touches
/// other ecosystems' packages the user filtered out.
pub ecosystems: Option<Vec<String>>,
Expand Down Expand Up @@ -1401,6 +1402,7 @@ pub(crate) fn nested_apply_args_from_params(
global: params.global,
global_prefix: params.global_prefix.clone(),
strict: params.strict,
allow_user_patches: params.allow_user_patches,
// Scope the nested apply like the caller was scoped: `None` would
// apply the WHOLE manifest, mutating other ecosystems' packages the
// user filtered out.
Expand Down
38 changes: 31 additions & 7 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,13 @@ fn mismatch_overwrite_warnings(results: &[ApplyResult], dry_run: bool) -> Vec<Ru
.collect()
}

/// The mismatch policy this run applies with: `--force` ⊃ default
/// (adds the missing-file skip), `--strict` restores fail-closed.
fn mismatch_policy(force: bool, strict: bool) -> MismatchPolicy {
/// Unknown local changes fail closed unless explicitly approved. --force is
/// existing explicit consent for file drift; declared patches have a separate
/// gate that requires --allow-user-patches even with --force.
fn mismatch_policy(force: bool, strict: bool, allow_user_patches: bool) -> MismatchPolicy {
if force {
MismatchPolicy::Force
} else if strict {
} else if strict || !allow_user_patches {
MismatchPolicy::Strict
} else {
MismatchPolicy::Warn
Expand Down Expand Up @@ -1039,7 +1040,7 @@ pub(crate) struct ApplyRunReport {
/// when `code != 0`.
pub applied: Vec<String>,
/// Non-fatal per-file warnings the caller's envelope must carry: one
/// `content_mismatch_overwritten` per file the default mismatch policy
/// `content_mismatch_overwritten` per file the explicitly approved mismatch policy
/// overwrote (#1004). A nested apply never prints JSON and is silent
/// for a JSON caller, so this is their only channel. Filled whenever
/// the apply loop ran, whatever the exit code.
Expand Down Expand Up @@ -1138,6 +1139,16 @@ pub(crate) async fn run_locked(
}
};

let user_patch_warnings =
match super::user_patches::check(&args.common, manifest.patches.keys()).await {
Ok(warnings) => warnings,
Err(error) => {
lock.release();
let code = report_apply_failure(&args, &error, &error.message, &telemetry).await;
return ApplyRunReport::run_failure(code, &error.code, error.message);
}
Comment thread
mikolalysenko marked this conversation as resolved.
};

// Package-manager layout detection. yarn-berry PnP keeps packages
// inside `.yarn/cache/*.zip` and resolves them via `.pnp.cjs` —
// the npm crawler can't reach them and rewriting zips is a
Expand Down Expand Up @@ -1230,6 +1241,15 @@ pub(crate) async fn run_locked(
// rightly ignores it). It rides the run-warning channel so it
// is never silent.
let mut run_warnings = run_warnings;
// A nested apply's caller (get, scan) already reported the
// overrides it approved; repeating them would double each line.
if args.nested.is_none() {
run_warnings.extend(
user_patch_warnings
.into_iter()
.map(|(code, detail)| RunWarning { code, detail }),
);
}
run_warnings.extend(results.iter().filter_map(|r| {
let note = r.error.as_deref().filter(|_| r.success)?;
note.contains(socket_patch_core::patch::apply::OWNERSHIP_NOT_RESTORED_MARKER)
Expand Down Expand Up @@ -2146,7 +2166,11 @@ async fn apply_patches_inner(

// Apply patches
let sources = staged.as_patch_sources();
let policy = mismatch_policy(args.force, args.common.strict);
let policy = mismatch_policy(
args.force,
args.common.strict,
args.common.allow_user_patches,
);
let mut has_errors = false;

// Group release-variant PURLs by base. PyPI (`?artifact_id=`),
Expand Down Expand Up @@ -3502,7 +3526,7 @@ mod tests {
/// `HashMap`-iteration-ordered pick let a variant describing a
/// different, NOT-installed distribution randomly match and get
/// attempted (nondeterministic spurious failures, or wrong-variant
/// content overwrites under the default mismatch policy). 64 rounds
/// content overwrites under the explicitly approved mismatch policy). 64 rounds
/// with fresh maps so the randomized per-instance iteration order is
/// actually exercised.
#[tokio::test]
Expand Down
26 changes: 26 additions & 0 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,20 @@ pub async fn run(args: GetArgs) -> i32 {
)
.await;
let selected = vec![search_result_from_response(&patch)];
let user_patch_warnings =
match super::user_patches::check(&args.common, [&patch.purl]).await {
Ok(warnings) => {
super::user_patches::warn(&args.common, &warnings);
warnings
}
Err(error) => {
return super::user_patches::refuse(
&args.common,
JsonCommand::Get,
error,
)
}
};
// Acting against the repo's socket.yml says so
// (`policy_bypassed`).
let mut uuid_warnings =
Expand All @@ -1312,6 +1326,7 @@ pub async fn run(args: GetArgs) -> i32 {
// Already on stderr from the client: JSON only, after the
// print above.
uuid_warnings.extend(org_warnings.iter().cloned());
uuid_warnings.extend(user_patch_warnings);
// Mode dispatch. All three reuse THIS fetched patch and
// this possibly-proxy-fallback client rather than
// re-fetching with a fresh one, which would re-hit the
Expand Down Expand Up @@ -1728,6 +1743,13 @@ pub async fn run(args: GetArgs) -> i32 {
} else {
selected
};
match super::user_patches::check(&args.common, selected.iter().map(|p| &p.purl)).await {
Ok(warnings) => {
super::user_patches::warn(&args.common, &warnings);
narrow_warnings.extend(warnings);
}
Err(error) => return super::user_patches::refuse(&args.common, JsonCommand::Get, error),
}
if agent_preview {
return agent_dry_run(&args, &selected, &narrow_skips, &narrow_warnings).await;
}
Expand Down Expand Up @@ -2165,6 +2187,7 @@ fn get_download_params(args: &GetArgs, save_only: bool, persist_blobs: bool) ->
silent: args.common.silent,
all_releases: args.all_releases,
strict: args.common.strict,
allow_user_patches: args.common.allow_user_patches,
ecosystems: args.common.ecosystems.clone(),
persist_blobs,
patch_server_url: args.common.patch_server_url.clone(),
Expand Down Expand Up @@ -3845,6 +3868,7 @@ mod tests {
silent: true,
all_releases: false,
strict: false,
allow_user_patches: false,
ecosystems: None,
persist_blobs: false,
patch_server_url: None,
Expand Down Expand Up @@ -4542,6 +4566,7 @@ mod tests {
silent: true,
all_releases: false,
strict: false,
allow_user_patches: false,
ecosystems: None,
// The vendor-detached posture this fn exists for.
persist_blobs: false,
Expand Down Expand Up @@ -5619,6 +5644,7 @@ mod tests {
let common = GlobalArgs {
verbose: true,
strict: true,
allow_user_patches: false,
json: true,
dry_run: true,
..GlobalArgs::default()
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -635,3 +635,4 @@ mod tests {
assert_eq!(mode, Ok(scan::ScanMode::Vendored));
}
}
pub(crate) mod user_patches;
20 changes: 20 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,26 @@ async fn run_redirect_selected_untracked(
None
};

// Re-read project patch declarations under the write lock. A patch added
// during API selection must not be lost in a takeover or lock rewrite.
if !common.allow_user_patches {
if let Err(error) =
crate::commands::user_patches::check(common, candidates.iter().map(&sel_purl)).await
{
// The shared refusal path keeps this run's envelope and lets
// telemetry go out before the JSON error.
return refuse(
common,
scan_result.take(),
&engine::Refusal {
code: error.code,
message: error.message,
},
&mut telemetry.json_error,
);
}
}

// v5 hosted mode keeps no ledger: the lockfiles are the only record of
// a redirect (vex, list, vendor and rollback read the hosted pins from
// them). This run's patch records (fetched below) feed only the
Expand Down
45 changes: 41 additions & 4 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,7 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) -
silent,
all_releases: args.all_releases,
strict: args.common.strict,
allow_user_patches: args.common.allow_user_patches,
Comment thread
mikolalysenko marked this conversation as resolved.
ecosystems: args.common.ecosystems.clone(),
persist_blobs: args.mode != Some(ScanMode::Vendored),
patch_server_url: args.common.patch_server_url.clone(),
Expand Down Expand Up @@ -1216,13 +1217,21 @@ fn is_info_note(code: &str) -> bool {
}

/// Print the run-level warnings to stderr: advisories as `Note:` (not
/// under `--silent`), everything else as `Warning:`.
/// under `--silent`), everything else as `Warning:`. User-patch skips are
/// silenced too, matching `user_patches::check`; the envelope keeps them.
fn print_layout_refusals(refusals: &[(String, String)], silent: bool) {
for (code, detail) in refusals {
if is_info_note(code) {
if !silent {
eprintln!("Note: {detail}");
}
} else if matches!(
code.as_str(),
super::user_patches::CONFLICT | super::user_patches::ALLOWED
) {
if !silent {
eprintln!("Warning: {detail}");
}
} else {
eprintln!("Warning: {detail}");
}
Expand Down Expand Up @@ -2212,7 +2221,30 @@ async fn run_scan(
.locate_nested_copies(&nm_roots, &filtered_crawled)
.await;
}
let filtered_crawled = policy.admit_crawled_copies(filtered_crawled, &supplement_purls);
let mut filtered_crawled = policy.admit_crawled_copies(filtered_crawled, &supplement_purls);
// Keep the full crawl above for GC: a protected package is still installed.
// Protect before querying/selecting, in every mode and in dry-run previews.
let user_patches = super::user_patches::inspect(&args.common).await;
let mut user_patch_withheld = 0usize;
filtered_crawled.retain(|package| {
let sources = user_patches.conflicts(&package.purl);
if sources.is_empty() {
return true;
}
if !args.common.allow_user_patches {
user_patch_withheld += 1;
}
layout_refusals.push((
if args.common.allow_user_patches {
super::user_patches::ALLOWED
} else {
super::user_patches::CONFLICT
}
.into(),
super::user_patches::detail(&package.purl, &sources, args.common.allow_user_patches),
));
args.common.allow_user_patches
});

// Gradle discovery notes (m2 gating, the user home) ride the run-level
// warnings; the lock set only annotates `packages[]` below.
Expand Down Expand Up @@ -2320,8 +2352,13 @@ async fn run_scan(
emit_scan(&env);
return code;
} else if !args.common.silent {
// A project the policy skipped as a whole is not an empty one.
if !policy.root_excluded() {
// A project the policy skipped as a whole is not an empty one,
// and neither is one whose every package the consent gate held.
if user_patch_withheld > 0 {
println!(
"All {user_patch_withheld} installed package(s) are protected by project patches (see the warnings above); nothing was scanned. Pass --allow-user-patches to scan them anyway."
);
} else if !policy.root_excluded() {
println!(
"{}",
render::no_packages_message(
Expand Down
Loading
Loading