Skip to main content

socket_patch_cli/commands/
apply.rs

1use clap::Args;
2use socket_patch_core::api::blob_fetcher::{
3    fetch_missing_blobs, fetch_missing_sources, format_fetch_result, get_missing_archives,
4    get_missing_blobs, DownloadMode,
5};
6use socket_patch_core::api::client::get_api_client_with_overrides;
7use socket_patch_core::crawlers::{
8    detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager,
9};
10use socket_patch_core::manifest::operations::read_manifest;
11use socket_patch_core::patch::apply::{
12    apply_package_patch, verify_file_patch, ApplyResult, PatchSources, VerifyStatus,
13};
14
15use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event};
16use socket_patch_core::utils::purl::strip_purl_qualifiers;
17use socket_patch_core::utils::telemetry::{track_patch_applied, track_patch_apply_failed};
18use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21use tempfile::TempDir;
22
23use crate::args::{apply_env_toggles, GlobalArgs};
24use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs};
25use crate::json_envelope::{
26    AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, Status,
27    VexSummary,
28};
29
30/// Overlay every regular file from `src` into `dst` via hard link (falling
31/// back to copy if hard linking fails — e.g. cross-filesystem, permission
32/// quirk). Skips files that already exist at `dst`. Silently no-ops if
33/// `src` doesn't exist so fresh projects with no `.socket/` cache work.
34///
35/// Used by `apply` to stage a transient overlay of the persistent
36/// `.socket/` cache inside a tempdir so the apply pipeline can read
37/// pre-cached artifacts and freshly-fetched ones from the same path
38/// without ever mutating `.socket/`.
39async fn overlay_dir(src: &Path, dst: &Path) {
40    let mut entries = match tokio::fs::read_dir(src).await {
41        Ok(e) => e,
42        Err(_) => return,
43    };
44    while let Ok(Some(entry)) = entries.next_entry().await {
45        let file_type = match entry.file_type().await {
46            Ok(t) => t,
47            Err(_) => continue,
48        };
49        if !file_type.is_file() {
50            continue;
51        }
52        let from = entry.path();
53        let to = dst.join(entry.file_name());
54        if tokio::fs::metadata(&to).await.is_ok() {
55            continue;
56        }
57        if tokio::fs::hard_link(&from, &to).await.is_err() {
58            let _ = tokio::fs::copy(&from, &to).await;
59        }
60    }
61}
62
63use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls};
64
65#[derive(Args)]
66pub struct ApplyArgs {
67    #[command(flatten)]
68    pub common: GlobalArgs,
69
70    /// Skip pre-application hash verification (apply even if package version differs).
71    #[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)]
72    pub force: bool,
73
74    /// On a successful apply, also generate an OpenVEX 0.2.0 document.
75    /// `--vex <path>` is the trigger; the `--vex-*` knobs mirror the
76    /// standalone `vex` command. A requested-but-failed VEX makes the
77    /// whole command exit non-zero even when patches applied cleanly.
78    #[command(flatten)]
79    pub vex: VexEmbedArgs,
80}
81
82/// True when every file the engine verified for this package is already
83/// at its `afterHash` — i.e. the patch is a complete no-op on disk.
84///
85/// Single source of truth for the `already_patched` classification, shared
86/// by [`result_to_event`] (which feeds the JSON envelope) and the
87/// human-readable summaries so both label packages identically.
88///
89/// The `!is_empty()` guard is essential: `Iterator::all` over an empty
90/// slice is vacuously `true`. Without the guard a result with no verified
91/// files — a zero-file patch, or a freshly-applied package whose
92/// `files_verified` came back empty — would be mislabeled "already
93/// patched" and counted as a no-op even though nothing matched `afterHash`.
94fn all_files_already_patched(result: &ApplyResult) -> bool {
95    !result.files_verified.is_empty()
96        && result
97            .files_verified
98            .iter()
99            .all(|f| f.status == VerifyStatus::AlreadyPatched)
100}
101
102/// Decide whether a release variant describes the distribution that is
103/// actually installed on disk, based on the verification status of its
104/// first patched file.
105///
106/// This is the apply-side mirror of
107/// [`select_installed_variants`](socket_patch_core::patch::apply::select_installed_variants),
108/// which `rollback` and `get` use: a variant matches only when its first
109/// file is [`Ready`](VerifyStatus::Ready) (its `beforeHash` matches the
110/// on-disk bytes) or [`AlreadyPatched`](VerifyStatus::AlreadyPatched)
111/// (its `afterHash` already matches). A variant with no files (`None`)
112/// has nothing to disqualify it and is treated as a match.
113///
114/// Crucially, both [`HashMismatch`](VerifyStatus::HashMismatch) **and**
115/// [`NotFound`](VerifyStatus::NotFound) mean "this variant's
116/// distribution is not the one on disk" and must be skipped. A
117/// `NotFound` arises when a non-installed variant patches a file that
118/// only exists in *its* distribution (e.g. an sdist patching `setup.py`
119/// while a wheel is installed). Skipping it avoids attempting — and
120/// spuriously reporting a `Failed` event for — a variant that was never
121/// installed.
122fn variant_matches_installed(first_file_status: Option<&VerifyStatus>) -> bool {
123    match first_file_status {
124        None => true,
125        Some(status) => {
126            *status == VerifyStatus::Ready || *status == VerifyStatus::AlreadyPatched
127        }
128    }
129}
130
131/// Translate the core engine's per-package [`ApplyResult`] into a single
132/// patch-level [`PatchEvent`] for the unified envelope.
133///
134/// Action mapping (in priority order):
135///   * `!result.success`                         → `Failed`
136///   * `dry_run` and any file was Ready/Patched → `Verified`
137///   * all `files_verified` are AlreadyPatched   → `Skipped` (already_patched)
138///   * something was actually patched on disk    → `Applied`
139///
140/// `files` enumerates only the files that participated in the action —
141/// for `Applied`, the patched ones with their `applied_via` strategy;
142/// for `Verified`, every file the engine confirmed could be patched.
143pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent {
144    let purl = result.package_key.clone();
145    if !result.success {
146        return PatchEvent::new(PatchAction::Failed, purl).with_error(
147            "apply_failed",
148            result
149                .error
150                .clone()
151                .unwrap_or_else(|| "unknown error".to_string()),
152        );
153    }
154
155    if all_files_already_patched(result) {
156        return PatchEvent::new(PatchAction::Skipped, purl)
157            .with_reason("already_patched", "All files already match afterHash");
158    }
159
160    if dry_run {
161        let files = result
162            .files_verified
163            .iter()
164            .filter(|f| {
165                f.status == VerifyStatus::Ready || f.status == VerifyStatus::AlreadyPatched
166            })
167            .map(|f| PatchEventFile {
168                path: f.file.clone(),
169                verified: true,
170                applied_via: None,
171            })
172            .collect();
173        return PatchEvent::new(PatchAction::Verified, purl).with_files(files);
174    }
175
176    let files = result
177        .files_patched
178        .iter()
179        .map(|f| PatchEventFile {
180            path: f.clone(),
181            verified: true,
182            applied_via: result
183                .applied_via
184                .get(f)
185                .copied()
186                .map(AppliedVia::from_core),
187        })
188        .collect();
189    // Sidecar data is NOT attached here — it's surfaced at the
190    // envelope level under `Envelope.sidecars[]` by the run loop.
191    // See `Envelope::record_sidecar`. Keeping events clean of
192    // sidecar info means each event describes only the apply
193    // action; sidecar reporting is a separate, JOIN-able list.
194    PatchEvent::new(PatchAction::Applied, purl).with_files(files)
195}
196
197pub async fn run(args: ApplyArgs) -> i32 {
198    apply_env_toggles(&args.common);
199    let (telemetry_client, _) =
200        get_api_client_with_overrides(args.common.api_client_overrides()).await;
201    let api_token = telemetry_client.api_token().cloned();
202    let org_slug = telemetry_client.org_slug().cloned();
203
204    let manifest_path = args.common.resolved_manifest_path();
205
206    // Check if manifest exists - exit successfully if no .socket folder is set up
207    if tokio::fs::metadata(&manifest_path).await.is_err() {
208        if args.common.json {
209            let mut env = Envelope::new(Command::Apply);
210            env.status = Status::NoManifest;
211            env.dry_run = args.common.dry_run;
212            println!("{}", env.to_pretty_json());
213        } else if !args.common.silent {
214            println!("No .socket folder found, skipping patch application.");
215        }
216        return 0;
217    }
218
219    // Serialize against concurrent socket-patch runs targeting the same
220    // `.socket/` directory. The guard releases on function return; see
221    // `socket_patch_core::patch::apply_lock`.
222    let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
223    let acquired = match acquire_or_emit(
224        socket_dir,
225        Command::Apply,
226        args.common.json,
227        args.common.silent,
228        args.common.dry_run,
229        Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
230        args.common.break_lock,
231    ) {
232        Ok(acquired) => acquired,
233        Err(code) => return code,
234    };
235    let _lock = acquired.guard;
236    let lock_was_broken = acquired.broke_lock;
237
238    // Package-manager layout detection. yarn-berry PnP keeps packages
239    // inside `.yarn/cache/*.zip` and resolves them via `.pnp.cjs` —
240    // the npm crawler can't reach them and rewriting zips is a
241    // different operation entirely. Refuse with a clear pointer to
242    // `yarn patch`. pnpm gets an informational event; the CoW guard
243    // in `apply_file_patch` does the substantive safety work.
244    let pkg_manager = detect_npm_pkg_manager(&args.common.cwd);
245    match pkg_manager {
246        NpmPkgManager::YarnBerryPnP => {
247            if args.common.json {
248                let mut env = Envelope::new(Command::Apply);
249                env.dry_run = args.common.dry_run;
250                env.mark_error(EnvelopeError::new(
251                    "yarn_pnp_unsupported",
252                    "yarn-berry Plug'n'Play layout is not supported by socket-patch (packages live inside .yarn/cache zips). Use `yarn patch <pkg>` instead.",
253                ));
254                println!("{}", env.to_pretty_json());
255            } else if !args.common.silent {
256                eprintln!("Error: yarn-berry Plug'n'Play layout is not supported.");
257                eprintln!(
258                    "  Packages live inside .yarn/cache/*.zip — socket-patch cannot rewrite them in place."
259                );
260                eprintln!("  Use `yarn patch <pkg>` instead.");
261            }
262            return 1;
263        }
264        NpmPkgManager::Pnpm => {
265            if !args.common.json && !args.common.silent {
266                eprintln!(
267                    "Note: pnpm layout detected. Copy-on-write will keep the global store untouched."
268                );
269            }
270            // Non-fatal — CoW handles the safety. JSON consumers see
271            // the layout-detected info in the apply envelope's
272            // existing events (no separate event added here yet).
273        }
274        NpmPkgManager::Bun => {
275            if !args.common.json && !args.common.silent {
276                eprintln!(
277                    "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched."
278                );
279            }
280            // Same shape as pnpm: bun hard-links from its global
281            // install cache by default. The CoW guard handles the
282            // safety; this is informational only.
283        }
284        _ => {}
285    }
286
287    match apply_patches_inner(&args, &manifest_path).await {
288        Ok((success, results, unmatched)) => {
289            let patched_count = results
290                .iter()
291                .filter(|r| r.success && !r.files_patched.is_empty())
292                .count();
293
294            // Embedded VEX: only on a successful apply and only when
295            // `--vex <path>` was passed. Re-read the manifest fresh so
296            // verification observes the just-applied on-disk state. The
297            // result is folded into the JSON envelope / human output
298            // below and flips the exit code on failure (per the
299            // fail-the-command contract). `None` => not requested.
300            let vex_result = if success && args.vex.vex.is_some() {
301                let params = args.vex.to_build_params();
302                Some(
303                    generate_vex_from_manifest_path(&args.common, &params, &manifest_path)
304                        .await,
305                )
306            } else {
307                None
308            };
309            let vex_failed = matches!(vex_result, Some(Err(_)));
310
311            if args.common.json {
312                let mut env = Envelope::new(Command::Apply);
313                env.dry_run = args.common.dry_run;
314                if lock_was_broken {
315                    env.record(lock_broken_event(socket_dir));
316                }
317                for result in &results {
318                    env.record(result_to_event(result, args.common.dry_run));
319                    // Sidecar records live on the envelope, not on
320                    // individual events. Consumers iterate
321                    // `envelope.sidecars[]` and JOIN against
322                    // `events[]` by `purl` for per-package context.
323                    if let Some(ref sidecar) = result.sidecar {
324                        env.record_sidecar(sidecar.clone());
325                    }
326                }
327                // Manifest entries that targeted in-scope ecosystems but
328                // had no installed package on disk — emit one Skipped
329                // event per purl so downstream consumers can surface them.
330                for purl in &unmatched {
331                    env.record(
332                        PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason(
333                            "package_not_installed",
334                            "No installed package matches this PURL",
335                        ),
336                    );
337                }
338                if !success {
339                    env.mark_partial_failure();
340                }
341                match &vex_result {
342                    Some(Ok(summary)) => {
343                        env.vex = Some(VexSummary {
344                            path: args.vex.vex.as_ref().unwrap().display().to_string(),
345                            statements: summary.statements,
346                            format: "openvex-0.2.0".to_string(),
347                        });
348                    }
349                    Some(Err(e)) => {
350                        env.mark_error(EnvelopeError::new(e.code, e.message.clone()));
351                    }
352                    None => {}
353                }
354                println!("{}", env.to_pretty_json());
355            } else if !args.common.silent && !results.is_empty() {
356                let patched: Vec<_> = results.iter().filter(|r| r.success).collect();
357                let already_patched: Vec<_> = results
358                    .iter()
359                    .filter(|r| all_files_already_patched(r))
360                    .collect();
361
362                if args.common.dry_run {
363                    // An already-patched package is `Skipped` in the JSON
364                    // envelope, not `Verified`. Mirror that split here so
365                    // "can be patched" excludes the no-ops instead of
366                    // double-counting them against "already patched".
367                    let can_be_patched = patched.len().saturating_sub(already_patched.len());
368                    println!("\nPatch verification complete:");
369                    println!("  {} package(s) can be patched", can_be_patched);
370                    if !already_patched.is_empty() {
371                        println!("  {} package(s) already patched", already_patched.len());
372                    }
373                } else {
374                    println!("\nPatched packages:");
375                    for result in &patched {
376                        if !result.files_patched.is_empty() {
377                            // Summarize the per-file strategy used by this
378                            // package: if everything came from the same
379                            // source, show just that tag; otherwise list
380                            // distinct sources.
381                            let mut tags: Vec<&'static str> = result
382                                .applied_via
383                                .values()
384                                .map(|v| v.as_tag())
385                                .collect();
386                            tags.sort_unstable();
387                            tags.dedup();
388                            let suffix = if tags.is_empty() {
389                                String::new()
390                            } else {
391                                format!(" (via {})", tags.join("+"))
392                            };
393                            println!("  {}{}", result.package_key, suffix);
394                        } else if all_files_already_patched(result) {
395                            println!("  {} (already patched)", result.package_key);
396                        }
397                    }
398                }
399
400                if args.common.verbose {
401                    println!("\nDetailed verification:");
402                    for result in &results {
403                        println!("  {}:", result.package_key);
404                        for f in &result.files_verified {
405                            let status_str = match f.status {
406                                VerifyStatus::Ready => "ready",
407                                VerifyStatus::AlreadyPatched => "already patched",
408                                VerifyStatus::HashMismatch => "hash mismatch",
409                                VerifyStatus::NotFound => "not found",
410                            };
411                            println!("    {} [{}]", f.file, status_str);
412                            if let Some(ref msg) = f.message {
413                                println!("      message: {msg}");
414                            }
415                            if args.common.verbose {
416                                if let Some(ref h) = f.current_hash {
417                                    println!("      current:  {h}");
418                                }
419                                if let Some(ref h) = f.expected_hash {
420                                    println!("      expected: {h}");
421                                }
422                                if let Some(ref h) = f.target_hash {
423                                    println!("      target:   {h}");
424                                }
425                            }
426                        }
427                    }
428                }
429            }
430
431            // Human-readable VEX status (JSON mode already folded the
432            // outcome into the envelope above).
433            if !args.common.json && !args.common.silent {
434                match &vex_result {
435                    Some(Ok(summary)) => {
436                        println!(
437                            "Wrote OpenVEX document with {} statement(s) to {}",
438                            summary.statements,
439                            args.vex.vex.as_ref().unwrap().display(),
440                        );
441                    }
442                    Some(Err(e)) => {
443                        eprintln!("Error: VEX generation failed: {}", e.message);
444                    }
445                    None => {}
446                }
447            }
448
449            // Track telemetry
450            if success {
451                track_patch_applied(patched_count, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
452            } else {
453                track_patch_apply_failed("One or more patches failed to apply", args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
454            }
455
456            // A requested-but-failed VEX flips an otherwise-successful
457            // apply to a non-zero exit (fail-the-command contract).
458            if success && !vex_failed {
459                0
460            } else {
461                1
462            }
463        }
464        Err(e) => {
465            track_patch_apply_failed(&e, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
466            if args.common.json {
467                let mut env = Envelope::new(Command::Apply);
468                env.dry_run = args.common.dry_run;
469                env.mark_error(EnvelopeError::new("apply_failed", e.clone()));
470                println!("{}", env.to_pretty_json());
471            } else if !args.common.silent {
472                eprintln!("Error: {e}");
473            }
474            1
475        }
476    }
477}
478
479async fn apply_patches_inner(
480    args: &ApplyArgs,
481    manifest_path: &Path,
482) -> Result<(bool, Vec<ApplyResult>, Vec<String>), String> {
483    let manifest = read_manifest(manifest_path)
484        .await
485        .map_err(|e| e.to_string())?
486        .ok_or_else(|| "Invalid manifest".to_string())?;
487
488    // The persistent cache directories under `.socket/`. Apply only ever
489    // *reads* from these — writes (downloads, cleanup) happen against a
490    // transient overlay tempdir constructed below when fetching is needed.
491    let socket_dir = manifest_path.parent().unwrap();
492    let socket_blobs_path = socket_dir.join("blobs");
493    let socket_diffs_path = socket_dir.join("diffs");
494    let socket_packages_path = socket_dir.join("packages");
495
496    let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?;
497
498    // Compute per-patch source availability so both the offline guard
499    // (next block) and the `download_needed` decision below share the
500    // same notion of what's already on disk. These probes are read-only.
501    let missing_blobs = get_missing_blobs(&manifest, &socket_blobs_path).await;
502    let missing_diff_archives = get_missing_archives(&manifest, &socket_diffs_path).await;
503    let missing_package_archives = get_missing_archives(&manifest, &socket_packages_path).await;
504
505    // A patch is "locally applicable" iff at least one of:
506    //   - every `after_hash` blob it references is on disk, OR
507    //   - its diff archive is on disk, OR
508    //   - its package archive is on disk.
509    // The apply pipeline will pick whichever is present per file.
510    let patches_without_source: Vec<&str> = manifest
511        .patches
512        .iter()
513        .filter_map(|(purl, record)| {
514            let all_blobs_present = record
515                .files
516                .values()
517                .all(|f| !missing_blobs.contains(&f.after_hash));
518            let diff_present = !missing_diff_archives.contains(&record.uuid);
519            let pkg_present = !missing_package_archives.contains(&record.uuid);
520            if all_blobs_present || diff_present || pkg_present {
521                None
522            } else {
523                Some(purl.as_str())
524            }
525        })
526        .collect();
527
528    if args.common.offline {
529        // Offline: bail only if some patch has no usable local source.
530        // Note: with `--force`, the apply pipeline can short-circuit
531        // verification on its own; we still surface the no-source
532        // diagnosis so the user runs `repair` before retrying.
533        if !patches_without_source.is_empty() {
534            if !args.common.silent && !args.common.json {
535                eprintln!(
536                    "Error: {} patch(es) have no local source and --offline is set:",
537                    patches_without_source.len()
538                );
539                for purl in patches_without_source.iter().take(5) {
540                    eprintln!("  - {}", purl);
541                }
542                if patches_without_source.len() > 5 {
543                    eprintln!("  ... and {} more", patches_without_source.len() - 5);
544                }
545                eprintln!("Run \"socket-patch repair\" to download missing artifacts.");
546            }
547            return Ok((false, Vec::new(), Vec::new()));
548        }
549    }
550
551    // Decide what (if anything) needs downloading.
552    //
553    // The apply pipeline tries sources in the order package → diff →
554    // blob locally. We honor `--download-mode` for the primary fetch
555    // when there's actually a gap to close. Skip the archive fetch
556    // entirely when all file blobs are already present locally —
557    // apply will succeed via the blob path, and the archive endpoints
558    // would just 404 (current server doesn't serve them yet).
559    let download_needed = !args.common.offline
560        && match download_mode {
561            DownloadMode::File => !missing_blobs.is_empty(),
562            DownloadMode::Diff | DownloadMode::Package if missing_blobs.is_empty() => false,
563            DownloadMode::Diff => !missing_diff_archives.is_empty(),
564            DownloadMode::Package => !missing_package_archives.is_empty(),
565        };
566
567    // Determine where the apply pipeline should read patch sources from.
568    //
569    // - If nothing needs downloading (offline mode, or every required
570    //   artifact is already in `.socket/`), read straight from `.socket/`.
571    //   Apply is purely read-only against the persistent cache.
572    // - Otherwise, stage a transient overlay tempdir that hardlinks every
573    //   existing `.socket/` artifact and receives fresh downloads. Apply
574    //   reads exclusively from the tempdir; `.socket/` is never mutated.
575    //
576    // `_stage_dir` keeps the `TempDir` handle alive for the rest of this
577    // function — on drop the OS removes the directory and any downloaded
578    // bytes go with it.
579    let (blobs_path, diffs_path, packages_path, _stage_dir): (
580        PathBuf,
581        PathBuf,
582        PathBuf,
583        Option<TempDir>,
584    ) = if download_needed {
585        let stage = tempfile::tempdir().map_err(|e| e.to_string())?;
586        let stage_blobs = stage.path().join("blobs");
587        let stage_diffs = stage.path().join("diffs");
588        let stage_packages = stage.path().join("packages");
589        for dir in [&stage_blobs, &stage_diffs, &stage_packages] {
590            tokio::fs::create_dir_all(dir)
591                .await
592                .map_err(|e| e.to_string())?;
593        }
594        overlay_dir(&socket_blobs_path, &stage_blobs).await;
595        overlay_dir(&socket_diffs_path, &stage_diffs).await;
596        overlay_dir(&socket_packages_path, &stage_packages).await;
597
598        if !args.common.silent && !args.common.json {
599            println!(
600                "Downloading missing patch artifacts (mode: {})...",
601                download_mode.as_tag()
602            );
603        }
604
605        let (client, _) =
606            get_api_client_with_overrides(args.common.api_client_overrides()).await;
607        let sources = PatchSources {
608            blobs_path: &stage_blobs,
609            packages_path: Some(&stage_packages),
610            diffs_path: Some(&stage_diffs),
611        };
612        let fetch_result =
613            fetch_missing_sources(&manifest, &sources, download_mode, &client, None).await;
614
615        if !args.common.silent && !args.common.json {
616            println!("{}", format_fetch_result(&fetch_result));
617        }
618
619        // For non-file modes, automatically fetch any still-missing file
620        // blobs as a fallback. Patches that lack the requested mode on
621        // the server will still apply via the legacy blob path.
622        if download_mode != DownloadMode::File {
623            let still_missing_blobs = get_missing_blobs(&manifest, &stage_blobs).await;
624            if !still_missing_blobs.is_empty() {
625                if !args.common.silent && !args.common.json {
626                    println!(
627                        "Falling back to per-file blob downloads for {} blob(s)...",
628                        still_missing_blobs.len()
629                    );
630                }
631                let blob_result =
632                    fetch_missing_blobs(&manifest, &stage_blobs, &client, None).await;
633                if !args.common.silent && !args.common.json {
634                    println!("{}", format_fetch_result(&blob_result));
635                }
636                if blob_result.failed > 0 && fetch_result.failed > 0 {
637                    if !args.common.silent && !args.common.json {
638                        eprintln!("Some artifacts could not be downloaded. Cannot apply patches.");
639                    }
640                    return Ok((false, Vec::new(), Vec::new()));
641                }
642            }
643        } else if fetch_result.failed > 0 {
644            if !args.common.silent && !args.common.json {
645                eprintln!("Some blobs could not be downloaded. Cannot apply patches.");
646            }
647            return Ok((false, Vec::new(), Vec::new()));
648        }
649
650        (stage_blobs, stage_diffs, stage_packages, Some(stage))
651    } else {
652        (
653            socket_blobs_path.clone(),
654            socket_diffs_path.clone(),
655            socket_packages_path.clone(),
656            None,
657        )
658    };
659
660    // Partition manifest PURLs by ecosystem
661    let manifest_purls: Vec<String> = manifest.patches.keys().cloned().collect();
662    let partitioned =
663        partition_purls(&manifest_purls, args.common.ecosystems.as_deref());
664
665    let target_manifest_purls: HashSet<String> = partitioned
666        .values()
667        .flat_map(|purls| purls.iter().cloned())
668        .collect();
669
670    let crawler_options = CrawlerOptions {
671        cwd: args.common.cwd.clone(),
672        global: args.common.global,
673        global_prefix: args.common.global_prefix.clone(),
674        batch_size: 100,
675    };
676
677    let all_packages =
678        find_packages_for_purls(&partitioned, &crawler_options, args.common.silent || args.common.json).await;
679
680    let has_any_purls = !partitioned.is_empty();
681
682    if all_packages.is_empty() && !has_any_purls {
683        if !args.common.silent && !args.common.json {
684            if args.common.global || args.common.global_prefix.is_some() {
685                eprintln!("No global packages found");
686            } else {
687                eprintln!("No package directories found");
688            }
689        }
690        return Ok((false, Vec::new(), Vec::new()));
691    }
692
693    if all_packages.is_empty() {
694        if !args.common.silent && !args.common.json {
695            eprintln!("Warning: No packages found that match available patches");
696            eprintln!(
697                "  {} targeted manifest patch(es) were in scope, but no matching packages were found on disk.",
698                target_manifest_purls.len()
699            );
700            eprintln!("  Check that packages are installed and --cwd points to the right directory.");
701        }
702        let unmatched: Vec<String> = target_manifest_purls.iter().cloned().collect();
703        return Ok((false, Vec::new(), unmatched));
704    }
705
706    // Apply patches
707    let mut results: Vec<ApplyResult> = Vec::new();
708    let mut has_errors = false;
709
710    // Group release-variant PURLs by base. PyPI (`?artifact_id=`),
711    // RubyGems (`?platform=`), and Maven (`?classifier=&ext=`) carry
712    // qualifiers distinguishing releases of one `package@version`; the
713    // crawler emits the base PURL, so we match the manifest's qualified
714    // variants against it here.
715    let mut variant_qualified_groups: HashMap<String, Vec<String>> = HashMap::new();
716    for (eco, purls) in &partitioned {
717        if eco.supports_release_variants() {
718            for purl in purls {
719                variant_qualified_groups
720                    .entry(strip_purl_qualifiers(purl).to_string())
721                    .or_default()
722                    .push(purl.clone());
723            }
724        }
725    }
726
727    let mut applied_base_purls: HashSet<String> = HashSet::new();
728    let mut matched_manifest_purls: HashSet<String> = HashSet::new();
729
730    for (purl, pkg_path) in &all_packages {
731        if Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()) {
732            let base_purl = strip_purl_qualifiers(purl).to_string();
733            if applied_base_purls.contains(&base_purl) {
734                continue;
735            }
736
737            let variants = variant_qualified_groups
738                .get(&base_purl)
739                .cloned()
740                .unwrap_or_else(|| vec![base_purl.clone()]);
741            let mut applied = false;
742
743            for variant_purl in &variants {
744                let patch = match manifest.patches.get(variant_purl) {
745                    Some(p) => p,
746                    None => continue,
747                };
748
749                // Check the first file's status (skip when --force). A
750                // mismatch *or* a missing file means this variant's
751                // distribution isn't the one on disk, so skip it —
752                // attempting it would only produce a spurious failure.
753                // Mirrors `select_installed_variants`, used by rollback/get.
754                if !args.force {
755                    let first_status = match patch.files.iter().next() {
756                        Some((file_name, file_info)) => {
757                            Some(verify_file_patch(pkg_path, file_name, file_info).await.status)
758                        }
759                        None => None,
760                    };
761                    if !variant_matches_installed(first_status.as_ref()) {
762                        continue;
763                    }
764                }
765
766                let sources = PatchSources {
767                    blobs_path: &blobs_path,
768                    packages_path: Some(&packages_path),
769                    diffs_path: Some(&diffs_path),
770                };
771                let result = apply_package_patch(
772                    variant_purl,
773                    pkg_path,
774                    &patch.files,
775                    &sources,
776                    Some(&patch.uuid),
777                    args.common.dry_run,
778                    args.force,
779                )
780                .await;
781
782                if result.success {
783                    applied = true;
784                    results.push(result);
785                    matched_manifest_purls.insert(variant_purl.clone());
786                    // No `break`: apply *every* matching variant. PyPI/gem
787                    // have exactly one installed distribution (the rest
788                    // hash-mismatch and were skipped above), so this
789                    // applies a single variant for them; Maven's coexisting
790                    // classifier jars each get patched.
791                } else {
792                    results.push(result);
793                }
794            }
795
796            if applied {
797                applied_base_purls.insert(base_purl.clone());
798            } else {
799                has_errors = true;
800                if !args.common.silent && !args.common.json {
801                    eprintln!("Failed to patch {base_purl}: no matching variant found");
802                }
803            }
804        } else {
805            // npm PURLs: direct lookup
806            let patch = match manifest.patches.get(purl) {
807                Some(p) => p,
808                None => continue,
809            };
810
811            let sources = PatchSources {
812                blobs_path: &blobs_path,
813                packages_path: Some(&packages_path),
814                diffs_path: Some(&diffs_path),
815            };
816            let result = apply_package_patch(
817                purl,
818                pkg_path,
819                &patch.files,
820                &sources,
821                Some(&patch.uuid),
822                args.common.dry_run,
823                args.force,
824            )
825            .await;
826
827            if !result.success {
828                has_errors = true;
829                if !args.common.silent && !args.common.json {
830                    eprintln!(
831                        "Failed to patch {}: {}",
832                        purl,
833                        result.error.as_deref().unwrap_or("unknown error")
834                    );
835                }
836            }
837            results.push(result);
838            matched_manifest_purls.insert(purl.clone());
839        }
840    }
841
842    // Check if targeted manifest entries had no matches
843    let unmatched: Vec<String> = target_manifest_purls
844        .iter()
845        .filter(|p| !matched_manifest_purls.contains(*p))
846        .cloned()
847        .collect();
848
849    if !unmatched.is_empty() && !args.common.silent && !args.common.json {
850        eprintln!("\nWarning: {} manifest patch(es) had no matching installed package:", unmatched.len());
851        for purl in &unmatched {
852            eprintln!("  - {}", purl);
853        }
854    }
855
856    if !target_manifest_purls.is_empty() && matched_manifest_purls.is_empty() && !all_packages.is_empty() {
857        if !args.common.silent && !args.common.json {
858            eprintln!("Warning: None of the targeted manifest patches matched installed packages.");
859        }
860        has_errors = true;
861    }
862
863    // Post-apply summary
864    if !args.common.silent && !args.common.json {
865        let applied_count = results.iter().filter(|r| r.success && !r.files_patched.is_empty()).count();
866        let already_count = results.iter().filter(|r| all_files_already_patched(r)).count();
867        println!(
868            "\nSummary: {}/{} targeted patches applied, {} already patched, {} not found on disk",
869            applied_count,
870            target_manifest_purls.len(),
871            already_count,
872            unmatched.len()
873        );
874    }
875
876    // Note: `apply` deliberately does NOT garbage-collect unused blobs in
877    // `.socket/`. GC is the responsibility of `socket-patch repair` /
878    // `gc` / `scan --prune`. Keeping apply read-only against `.socket/`
879    // means it can run repeatedly (CI dry-runs, deploy hooks) without
880    // mutating patch state.
881
882    Ok((!has_errors, results, unmatched))
883}
884
885#[cfg(test)]
886mod tests {
887    //! Tests for `result_to_event` — the per-package → per-patch event
888    //! translator that feeds apply's unified JSON envelope. Every
889    //! contract value here (action tags, `errorCode` reasons, `files[].path`
890    //! shape) is documented in `CLI_CONTRACT.md`.
891    use super::*;
892    use socket_patch_core::patch::apply::{
893        AppliedVia as CoreAppliedVia, ApplyResult, VerifyResult, VerifyStatus,
894    };
895
896    /// Build a successful `ApplyResult` with one patched file and one
897    /// verified file. Used as the base for action-routing tests.
898    fn sample_applied(status: VerifyStatus) -> ApplyResult {
899        let mut applied_via = HashMap::new();
900        applied_via.insert("package/index.js".to_string(), CoreAppliedVia::Diff);
901        ApplyResult {
902            package_key: "pkg:npm/minimist@1.2.2".to_string(),
903            package_path: "/tmp/node_modules/minimist".to_string(),
904            success: true,
905            files_verified: vec![VerifyResult {
906                file: "package/index.js".to_string(),
907                status,
908                message: None,
909                current_hash: None,
910                expected_hash: None,
911                target_hash: None,
912            }],
913            files_patched: vec!["package/index.js".to_string()],
914            applied_via,
915            error: None,
916            sidecar: None,
917        }
918    }
919
920    #[test]
921    fn failed_result_maps_to_failed_action() {
922        let mut result = sample_applied(VerifyStatus::Ready);
923        result.success = false;
924        result.error = Some("hash mismatch".into());
925
926        let event = result_to_event(&result, false);
927        let v: serde_json::Value =
928            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
929        assert_eq!(v["action"], "failed");
930        assert_eq!(v["errorCode"], "apply_failed");
931        assert_eq!(v["error"], "hash mismatch");
932    }
933
934    #[test]
935    fn all_already_patched_maps_to_skipped() {
936        let result = sample_applied(VerifyStatus::AlreadyPatched);
937        let event = result_to_event(&result, false);
938        let v: serde_json::Value =
939            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
940        assert_eq!(v["action"], "skipped");
941        assert_eq!(v["errorCode"], "already_patched");
942    }
943
944    #[test]
945    fn dry_run_maps_to_verified() {
946        let result = sample_applied(VerifyStatus::Ready);
947        let event = result_to_event(&result, true);
948        let v: serde_json::Value =
949            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
950        assert_eq!(v["action"], "verified");
951        // Dry-run events list verified files but never an `appliedVia`
952        // — nothing was actually written.
953        assert_eq!(v["files"][0]["path"], "package/index.js");
954        assert!(v["files"][0].as_object().unwrap().get("appliedVia").is_none());
955    }
956
957    #[test]
958    fn successful_apply_maps_to_applied_with_files() {
959        let result = sample_applied(VerifyStatus::Ready);
960        let event = result_to_event(&result, false);
961        let v: serde_json::Value =
962            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
963        assert_eq!(v["action"], "applied");
964        assert_eq!(v["purl"], "pkg:npm/minimist@1.2.2");
965        let files = v["files"].as_array().unwrap();
966        assert_eq!(files.len(), 1);
967        assert_eq!(files[0]["path"], "package/index.js");
968        assert_eq!(files[0]["verified"], true);
969        // `appliedVia` is camelCase + lowercase tag — contract value.
970        assert_eq!(files[0]["appliedVia"], "diff");
971    }
972
973    #[test]
974    fn applied_event_emits_one_file_entry_per_patched_file() {
975        let mut applied_via = HashMap::new();
976        applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Diff);
977        applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Package);
978        applied_via.insert("package/c.js".to_string(), CoreAppliedVia::Blob);
979        let result = ApplyResult {
980            package_key: "pkg:npm/foo@1.0.0".to_string(),
981            package_path: "/tmp/foo".to_string(),
982            success: true,
983            files_verified: Vec::new(),
984            files_patched: vec![
985                "package/a.js".to_string(),
986                "package/b.js".to_string(),
987                "package/c.js".to_string(),
988            ],
989            applied_via,
990            error: None,
991            sidecar: None,
992        };
993
994        let event = result_to_event(&result, false);
995        let v: serde_json::Value =
996            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
997        let files = v["files"].as_array().unwrap();
998        assert_eq!(files.len(), 3);
999        let by_path: std::collections::HashMap<String, &serde_json::Value> = files
1000            .iter()
1001            .map(|f| (f["path"].as_str().unwrap().to_string(), f))
1002            .collect();
1003        assert_eq!(by_path["package/a.js"]["appliedVia"], "diff");
1004        assert_eq!(by_path["package/b.js"]["appliedVia"], "package");
1005        assert_eq!(by_path["package/c.js"]["appliedVia"], "blob");
1006    }
1007
1008    /// Build a successful `ApplyResult` whose verified files carry the
1009    /// given statuses, with no patched files. Used to exercise the
1010    /// `already_patched` classification directly.
1011    fn sample_verified(statuses: &[VerifyStatus]) -> ApplyResult {
1012        let files_verified = statuses
1013            .iter()
1014            .enumerate()
1015            .map(|(i, status)| VerifyResult {
1016                file: format!("package/f{i}.js"),
1017                status: status.clone(),
1018                message: None,
1019                current_hash: None,
1020                expected_hash: None,
1021                target_hash: None,
1022            })
1023            .collect();
1024        ApplyResult {
1025            package_key: "pkg:npm/foo@1.0.0".to_string(),
1026            package_path: "/tmp/foo".to_string(),
1027            success: true,
1028            files_verified,
1029            files_patched: Vec::new(),
1030            applied_via: HashMap::new(),
1031            error: None,
1032            sidecar: None,
1033        }
1034    }
1035
1036    #[test]
1037    fn all_files_already_patched_true_when_every_file_matches() {
1038        let result = sample_verified(&[
1039            VerifyStatus::AlreadyPatched,
1040            VerifyStatus::AlreadyPatched,
1041        ]);
1042        assert!(all_files_already_patched(&result));
1043    }
1044
1045    #[test]
1046    fn all_files_already_patched_false_when_any_file_differs() {
1047        let result = sample_verified(&[
1048            VerifyStatus::AlreadyPatched,
1049            VerifyStatus::Ready,
1050        ]);
1051        assert!(!all_files_already_patched(&result));
1052    }
1053
1054    /// Regression: `Iterator::all` over an empty slice is vacuously true.
1055    /// A result with no verified files must NOT be reported as
1056    /// "already patched" — the `!is_empty()` guard enforces this so the
1057    /// human summaries and the JSON envelope agree.
1058    #[test]
1059    fn all_files_already_patched_false_when_no_verified_files() {
1060        let mut result = sample_verified(&[]);
1061        assert!(result.files_verified.is_empty());
1062        assert!(!all_files_already_patched(&result));
1063
1064        // A freshly-applied package (files patched, none left verified)
1065        // is likewise not a no-op.
1066        result.files_patched = vec!["package/a.js".to_string()];
1067        assert!(!all_files_already_patched(&result));
1068    }
1069
1070    /// Regression: a non-installed release variant whose first patched
1071    /// file is `NotFound` (e.g. an sdist patching `setup.py` while only a
1072    /// wheel is on disk) must be treated as NOT installed and skipped —
1073    /// exactly like a `HashMismatch`. Before the fix the loop only skipped
1074    /// `HashMismatch`, so a `NotFound` variant slipped through to
1075    /// `apply_package_patch` and produced a spurious `Failed` event in the
1076    /// JSON envelope. This pins the apply-side decision to the same
1077    /// Ready/AlreadyPatched contract as `select_installed_variants`.
1078    #[test]
1079    fn variant_matches_only_when_first_file_ready_or_already_patched() {
1080        // Installed distribution: first file applies cleanly, or is
1081        // already at afterHash → this variant is the one on disk.
1082        assert!(variant_matches_installed(Some(&VerifyStatus::Ready)));
1083        assert!(variant_matches_installed(Some(&VerifyStatus::AlreadyPatched)));
1084
1085        // Not the installed distribution → must be skipped. The NotFound
1086        // case is the specific regression this guards.
1087        assert!(!variant_matches_installed(Some(&VerifyStatus::HashMismatch)));
1088        assert!(!variant_matches_installed(Some(&VerifyStatus::NotFound)));
1089
1090        // A variant with no files has nothing to disqualify it — match,
1091        // mirroring `select_installed_variants`.
1092        assert!(variant_matches_installed(None));
1093    }
1094
1095    /// Regression: a freshly-applied result with an empty `files_verified`
1096    /// must map to `Applied`, never `Skipped`/`already_patched`. This is
1097    /// the same classification the human-readable summary relies on via
1098    /// `all_files_already_patched`.
1099    #[test]
1100    fn applied_with_empty_verified_is_not_skipped() {
1101        let mut applied_via = HashMap::new();
1102        applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Blob);
1103        let result = ApplyResult {
1104            package_key: "pkg:npm/foo@1.0.0".to_string(),
1105            package_path: "/tmp/foo".to_string(),
1106            success: true,
1107            files_verified: Vec::new(),
1108            files_patched: vec!["package/a.js".to_string()],
1109            applied_via,
1110            error: None,
1111            sidecar: None,
1112        };
1113        let event = result_to_event(&result, false);
1114        let v: serde_json::Value =
1115            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
1116        assert_eq!(v["action"], "applied");
1117    }
1118}