@@ -18,6 +18,12 @@ use socket_patch_core::patch::cargo_redirect::{
1818} ;
1919#[ cfg( feature = "cargo" ) ]
2020use socket_patch_core:: utils:: purl:: parse_cargo_purl;
21+ #[ cfg( feature = "golang" ) ]
22+ use socket_patch_core:: patch:: go_redirect:: {
23+ apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state,
24+ } ;
25+ #[ cfg( feature = "golang" ) ]
26+ use socket_patch_core:: utils:: purl:: parse_golang_purl;
2127
2228use crate :: commands:: lock_cli:: { acquire_or_emit, lock_broken_event} ;
2329use socket_patch_core:: utils:: purl:: strip_purl_qualifiers;
@@ -255,12 +261,112 @@ async fn reconcile_local_cargo(common: &GlobalArgs, target_manifest_purls: &Hash
255261#[ cfg( not( feature = "cargo" ) ) ]
256262async fn reconcile_local_cargo ( _common : & GlobalArgs , _target_manifest_purls : & HashSet < String > ) { }
257263
258- /// Read-only verification of committed cargo redirects (CI / GitHub-App audit).
259- /// Lock-free, crawl-free, offline-safe. Exits 0 when in sync, 1 on drift.
260- #[ cfg( feature = "cargo" ) ]
261- async fn run_check ( args : & ApplyArgs , manifest_path : & Path ) -> i32 {
262- use socket_patch_core:: patch:: cargo_redirect:: Drift ;
264+ // ── local-go redirect helpers ────────────────────────────────────────────────
265+ // The Go analog of the cargo helpers above: in local mode a `pkg:golang/…` PURL
266+ // redirects to a project-local patched copy under `.socket/go-patches/` wired via
267+ // a `go.mod` `replace` directive. Inert stubs without the `golang` feature.
268+
269+ /// True for a golang PURL in local mode (no `--global` / `--global-prefix`).
270+ #[ cfg( feature = "golang" ) ]
271+ fn is_local_go ( purl : & str , common : & GlobalArgs ) -> bool {
272+ !common. global
273+ && common. global_prefix . is_none ( )
274+ && Ecosystem :: from_purl ( purl) == Some ( Ecosystem :: Golang )
275+ }
276+
277+ /// Whether local-go redirects are in scope (local mode + golang not filtered out
278+ /// by `--ecosystems`). Gates reconcile / `--check`.
279+ #[ cfg( feature = "golang" ) ]
280+ fn go_in_local_scope ( common : & GlobalArgs ) -> bool {
281+ if common. global || common. global_prefix . is_some ( ) {
282+ return false ;
283+ }
284+ match & common. ecosystems {
285+ None => true ,
286+ Some ( list) => list
287+ . iter ( )
288+ . any ( |e| e. eq_ignore_ascii_case ( "golang" ) || e. eq_ignore_ascii_case ( "go" ) ) ,
289+ }
290+ }
291+
292+ /// Materialise a local-go redirect for `purl`, or `None` if `purl` isn't a
293+ /// local-go target (the caller then falls back to in-place apply, i.e. the
294+ /// `--global` module-cache path).
295+ #[ cfg( feature = "golang" ) ]
296+ async fn try_local_go_apply (
297+ purl : & str ,
298+ pkg_path : & Path ,
299+ patch : & PatchRecord ,
300+ sources : & PatchSources < ' _ > ,
301+ common : & GlobalArgs ,
302+ force : bool ,
303+ ) -> Option < ApplyResult > {
304+ if !is_local_go ( purl, common) {
305+ return None ;
306+ }
307+ // `pkg_path` is the pristine, case-encoded module-cache dir; `module`/
308+ // `version` are the decoded PURL components keying the copy + `replace`.
309+ let ( module, version) = parse_golang_purl ( purl) ?;
310+ Some (
311+ apply_go_redirect (
312+ purl,
313+ module,
314+ version,
315+ pkg_path,
316+ & common. cwd ,
317+ & patch. files ,
318+ sources,
319+ Some ( & patch. uuid ) ,
320+ common. dry_run ,
321+ force,
322+ )
323+ . await ,
324+ )
325+ }
326+
327+ #[ cfg( not( feature = "golang" ) ) ]
328+ async fn try_local_go_apply (
329+ _purl : & str ,
330+ _pkg_path : & Path ,
331+ _patch : & PatchRecord ,
332+ _sources : & PatchSources < ' _ > ,
333+ _common : & GlobalArgs ,
334+ _force : bool ,
335+ ) -> Option < ApplyResult > {
336+ None
337+ }
338+
339+ /// After the apply loop: prune local-go redirects whose patches were dropped
340+ /// from the manifest. No-op unless local go is in scope.
341+ #[ cfg( feature = "golang" ) ]
342+ async fn reconcile_local_go ( common : & GlobalArgs , target_manifest_purls : & HashSet < String > ) {
343+ if !go_in_local_scope ( common) {
344+ return ;
345+ }
346+ let desired: HashSet < String > = target_manifest_purls
347+ . iter ( )
348+ . filter ( |p| Ecosystem :: from_purl ( p) == Some ( Ecosystem :: Golang ) )
349+ . cloned ( )
350+ . collect ( ) ;
351+ let removed = reconcile_go_redirects ( & common. cwd , & desired, common. dry_run ) . await ;
352+ if !removed. is_empty ( ) && !common. silent && !common. json {
353+ let verb = if common. dry_run { "Would remove" } else { "Removed" } ;
354+ println ! ( "{verb} {} stale go patch redirect(s):" , removed. len( ) ) ;
355+ for purl in & removed {
356+ println ! ( " {purl}" ) ;
357+ }
358+ }
359+ }
263360
361+ #[ cfg( not( feature = "golang" ) ) ]
362+ async fn reconcile_local_go ( _common : & GlobalArgs , _target_manifest_purls : & HashSet < String > ) { }
363+
364+ /// Read-only verification of committed local redirects (cargo + go) for CI /
365+ /// GitHub-App auditing and the build-time guard probe. Lock-free, crawl-free,
366+ /// offline-safe. Exits 0 when in sync, 1 on drift. Verifies every redirect
367+ /// ecosystem that is both compiled in and in `--ecosystems` scope.
368+ #[ cfg( any( feature = "cargo" , feature = "golang" ) ) ]
369+ async fn run_check ( args : & ApplyArgs , manifest_path : & Path ) -> i32 {
264370 let manifest = match read_manifest ( manifest_path) . await {
265371 Ok ( Some ( m) ) => m,
266372 // The caller already confirmed the manifest file exists. `Ok(None)` means
@@ -271,82 +377,116 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 {
271377 Err ( e) => {
272378 if !args. common . silent && !args. common . json {
273379 eprintln ! (
274- "Cargo patch redirect check could not read the manifest ({e}); \
380+ "Patch redirect check could not read the manifest ({e}); \
275381 treating as drift (fail-closed)."
276382 ) ;
277383 }
278384 return 1 ;
279385 }
280386 } ;
281387
282- let desired: HashSet < String > = if cargo_in_local_scope ( & args. common ) {
283- manifest
284- . patches
285- . keys ( )
286- . filter ( |p| Ecosystem :: from_purl ( p) == Some ( Ecosystem :: Cargo ) )
287- . cloned ( )
288- . collect ( )
289- } else {
290- HashSet :: new ( )
291- } ;
292-
293- match verify_cargo_redirect_state ( & args. common . cwd , & manifest, & desired) . await {
294- Ok ( ( ) ) => {
295- if args. common . json {
296- println ! ( "{}" , Envelope :: new( Command :: Apply ) . to_pretty_json( ) ) ;
297- } else if !args. common . silent {
298- println ! (
299- "Cargo patch redirects are in sync ({} checked)." ,
300- desired. len( )
301- ) ;
388+ // (purl_or_name, reason_code, detail) for each drift across ecosystems.
389+ let mut drifts: Vec < ( String , & ' static str , String ) > = Vec :: new ( ) ;
390+ let mut checked: usize = 0 ;
391+
392+ #[ cfg( feature = "cargo" ) ]
393+ {
394+ use socket_patch_core:: patch:: cargo_redirect:: Drift as CargoDrift ;
395+ if cargo_in_local_scope ( & args. common ) {
396+ let desired: HashSet < String > = manifest
397+ . patches
398+ . keys ( )
399+ . filter ( |p| Ecosystem :: from_purl ( p) == Some ( Ecosystem :: Cargo ) )
400+ . cloned ( )
401+ . collect ( ) ;
402+ checked += desired. len ( ) ;
403+ if let Err ( ds) = verify_cargo_redirect_state ( & args. common . cwd , & manifest, & desired) . await
404+ {
405+ for d in & ds {
406+ let id = match d {
407+ CargoDrift :: MissingCopy { purl }
408+ | CargoDrift :: StaleCopy { purl, .. }
409+ | CargoDrift :: MissingEntry { purl }
410+ | CargoDrift :: WrongEntryPath { purl, .. }
411+ | CargoDrift :: ResolvedVersionMismatch { purl, .. } => purl. clone ( ) ,
412+ CargoDrift :: OrphanEntry { name } => name. clone ( ) ,
413+ } ;
414+ drifts. push ( ( id, "cargo_redirect_drift" , d. to_string ( ) ) ) ;
415+ }
302416 }
303- 0
304417 }
305- Err ( drifts) => {
306- if args. common . json {
307- let mut env = Envelope :: new ( Command :: Apply ) ;
308- for d in & drifts {
309- let purl = match d {
310- Drift :: MissingCopy { purl }
311- | Drift :: StaleCopy { purl, .. }
312- | Drift :: MissingEntry { purl }
313- | Drift :: WrongEntryPath { purl, .. }
314- | Drift :: ResolvedVersionMismatch { purl, .. } => purl. clone ( ) ,
315- Drift :: OrphanEntry { name } => name. clone ( ) ,
418+ }
419+
420+ #[ cfg( feature = "golang" ) ]
421+ {
422+ use socket_patch_core:: patch:: go_redirect:: Drift as GoDrift ;
423+ if go_in_local_scope ( & args. common ) {
424+ let desired: HashSet < String > = manifest
425+ . patches
426+ . keys ( )
427+ . filter ( |p| Ecosystem :: from_purl ( p) == Some ( Ecosystem :: Golang ) )
428+ . cloned ( )
429+ . collect ( ) ;
430+ checked += desired. len ( ) ;
431+ if let Err ( ds) = verify_go_redirect_state ( & args. common . cwd , & manifest, & desired) . await {
432+ for d in & ds {
433+ let id = match d {
434+ GoDrift :: MissingCopy { purl }
435+ | GoDrift :: StaleCopy { purl, .. }
436+ | GoDrift :: MissingReplace { purl }
437+ | GoDrift :: WrongReplacePath { purl, .. }
438+ | GoDrift :: ResolvedVersionMismatch { purl, .. } => purl. clone ( ) ,
439+ GoDrift :: OrphanReplace { module } => module. clone ( ) ,
316440 } ;
317- env. record (
318- PatchEvent :: new ( PatchAction :: Failed , purl)
319- . with_reason ( "cargo_redirect_drift" , d. to_string ( ) ) ,
320- ) ;
441+ drifts. push ( ( id, "go_redirect_drift" , d. to_string ( ) ) ) ;
321442 }
322- env. mark_partial_failure ( ) ;
323- println ! ( "{}" , env. to_pretty_json( ) ) ;
324- } else if !args. common . silent {
325- eprintln ! ( "Cargo patch redirects are OUT OF SYNC:" ) ;
326- for d in & drifts {
327- eprintln ! ( " {d}" ) ;
328- }
329- eprintln ! ( "Run `socket-patch apply` to regenerate them." ) ;
330443 }
331- 1
332444 }
333445 }
446+
447+ if drifts. is_empty ( ) {
448+ if args. common . json {
449+ println ! ( "{}" , Envelope :: new( Command :: Apply ) . to_pretty_json( ) ) ;
450+ } else if !args. common . silent {
451+ println ! ( "Patch redirects are in sync ({checked} checked)." ) ;
452+ }
453+ 0
454+ } else {
455+ if args. common . json {
456+ let mut env = Envelope :: new ( Command :: Apply ) ;
457+ for ( id, code, detail) in & drifts {
458+ env. record (
459+ PatchEvent :: new ( PatchAction :: Failed , id. clone ( ) )
460+ . with_reason ( * code, detail. clone ( ) ) ,
461+ ) ;
462+ }
463+ env. mark_partial_failure ( ) ;
464+ println ! ( "{}" , env. to_pretty_json( ) ) ;
465+ } else if !args. common . silent {
466+ eprintln ! ( "Patch redirects are OUT OF SYNC:" ) ;
467+ for ( _, _, detail) in & drifts {
468+ eprintln ! ( " {detail}" ) ;
469+ }
470+ eprintln ! ( "Run `socket-patch apply` to regenerate them." ) ;
471+ }
472+ 1
473+ }
334474}
335475
336- #[ cfg( not( feature = "cargo" ) ) ]
476+ #[ cfg( not( any ( feature = "cargo" , feature = "golang" ) ) ) ]
337477async fn run_check ( args : & ApplyArgs , _manifest_path : & Path ) -> i32 {
338- // Fail-closed: `--check` is the cargo patch- redirect audit. A socket-patch
339- // built WITHOUT the ` cargo` feature cannot verify those redirects, so it must
340- // NOT report "in sync" (exit 0). The build-time guard probes whatever
478+ // Fail-closed: `--check` is the redirect audit. A socket-patch built WITHOUT
479+ // any redirect ecosystem ( cargo/golang) cannot verify those redirects, so it
480+ // must NOT report "in sync" (exit 0). The build-time guard probes whatever
341481 // `socket-patch` is on the build machine's PATH; if a feature-off binary
342482 // answered 0 here, the guard would silently proceed against possibly
343483 // stale/unpatched copies — defeating its whole purpose. Exit non-zero with a
344484 // clear reason so the guard fails the build instead.
345485 if !args. common . silent && !args. common . json {
346486 eprintln ! (
347- "socket-patch: this build has no cargo support, so it cannot verify cargo \
487+ "socket-patch: this build has no cargo/golang support, so it cannot verify \
348488 patch redirects (`--check`). Install a socket-patch built with the `cargo` \
349- feature, or point SOCKET_PATCH_BIN at one."
489+ and/or `golang` feature, or point SOCKET_PATCH_BIN at one."
350490 ) ;
351491 }
352492 2
@@ -954,6 +1094,7 @@ async fn apply_patches_inner(
9541094 // now lists zero in-scope cargo patches (the all-removed case). No-op
9551095 // unless local cargo is in scope.
9561096 reconcile_local_cargo ( & args. common , & target_manifest_purls) . await ;
1097+ reconcile_local_go ( & args. common , & target_manifest_purls) . await ;
9571098
9581099 let crawler_options = CrawlerOptions {
9591100 cwd : args. common . cwd . clone ( ) ,
@@ -1101,11 +1242,11 @@ async fn apply_patches_inner(
11011242 packages_path : Some ( & packages_path) ,
11021243 diffs_path : Some ( & diffs_path) ,
11031244 } ;
1104- // Local cargo redirects to a project-local patched copy
1105- // (`apply_cargo_redirect`); everything else — npm/pypi, and cargo
1106- // under --global/--global-prefix — patches in place via
1107- // `apply_package_patch`. In a build without the `cargo` feature
1108- // `try_local_cargo_apply` is an inert `None`.
1245+ // Local cargo/go redirect to a project-local patched copy
1246+ // (`apply_cargo_redirect` / `apply_go_redirect` ); everything else —
1247+ // npm/pypi, and cargo/go under --global/--global-prefix — patches in
1248+ // place via `apply_package_patch`. Without the respective feature the
1249+ // `try_local_*_apply` helpers are inert `None`s .
11091250 let result = match try_local_cargo_apply (
11101251 purl,
11111252 pkg_path,
@@ -1117,18 +1258,30 @@ async fn apply_patches_inner(
11171258 . await
11181259 {
11191260 Some ( r) => r,
1120- None => {
1121- apply_package_patch (
1122- purl,
1123- pkg_path,
1124- & patch. files ,
1125- & sources,
1126- Some ( & patch. uuid ) ,
1127- args. common . dry_run ,
1128- args. force ,
1129- )
1130- . await
1131- }
1261+ None => match try_local_go_apply (
1262+ purl,
1263+ pkg_path,
1264+ patch,
1265+ & sources,
1266+ & args. common ,
1267+ args. force ,
1268+ )
1269+ . await
1270+ {
1271+ Some ( r) => r,
1272+ None => {
1273+ apply_package_patch (
1274+ purl,
1275+ pkg_path,
1276+ & patch. files ,
1277+ & sources,
1278+ Some ( & patch. uuid ) ,
1279+ args. common . dry_run ,
1280+ args. force ,
1281+ )
1282+ . await
1283+ }
1284+ } ,
11321285 } ;
11331286
11341287 if !result. success {
0 commit comments