Repository navigation
Expand file tree
/
Copy pathargs.rs
More file actions
1765 lines (1642 loc) · 71.7 KB
/
Copy pathargs.rs
File metadata and controls
1765 lines (1642 loc) · 71.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Shared CLI arguments flattened into every subcommand.
//!
//! `GlobalArgs` defines the flags that apply uniformly across every
//! `socket-patch` subcommand. Each subcommand `#[command(flatten)]`s this
//! struct into its own `Args` struct so the surface stays consistent.
//!
//! Subcommands that don't actually use a given global flag still accept it
//! silently (no-op). See `CLI_CONTRACT.md` for the full contract.
//!
//! Precedence for every flag: CLI arg > env var > default.
//!
//! All env-var names use the `SOCKET_*` prefix.
use std::path::{Path, PathBuf};
use clap::Args;
use socket_patch_core::api::client::{
resolve_ambient_credentials, ApiClient, ApiClientEnvOverrides,
};
use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH;
use socket_patch_core::crawlers::Ecosystem;
use socket_patch_core::telemetry::TelemetryAuth;
use socket_patch_core::vendor::{VendorServiceConfig, VendorSource};
/// clap value-parser for each `--ecosystems` / `SOCKET_ECOSYSTEMS` token.
///
/// Rejects any name that is not a supported ecosystem, so typos fail
/// loudly instead of silently matching nothing.
///
/// Without this, an unsupported name parsed fine and was then silently
/// dropped by `partition_purls`/`crawl_ecosystems`, so the user got a
/// "0 patches" result with no hint that the ecosystem name was the cause.
fn parse_supported_ecosystem(s: &str) -> Result<String, String> {
if Ecosystem::all().iter().any(|e| e.cli_name() == s) {
Ok(s.to_string())
} else {
let supported = Ecosystem::all()
.iter()
.map(|e| e.cli_name())
.collect::<Vec<_>>()
.join(", ");
Err(unsupported_ecosystem_message(s, &supported))
}
}
/// The `--ecosystems` rejection text. An empty token (`--ecosystems ,npm`,
/// a trailing comma) gets its own wording: "unsupported ecosystem ``"
/// reads like a rendering glitch.
fn unsupported_ecosystem_message(token: &str, supported: &str) -> String {
if token.trim().is_empty() {
format!("empty ecosystem name in the list (supported: {supported})")
} else {
format!("unsupported ecosystem `{token}` (supported: {supported})")
}
}
/// clap value-parser for `--vendor-source` / `SOCKET_VENDOR_SOURCE`.
///
/// Validates the token against [`VendorSource`] (`auto` | `service` | `build`,
/// case-insensitive) at parse time so a typo fails the command immediately
/// rather than at vendor time, and normalizes it to the canonical lowercase
/// tag. Mirrors [`parse_supported_ecosystem`]'s fail-loud-on-typo posture.
fn parse_vendor_source(s: &str) -> Result<String, String> {
VendorSource::parse(s).map(|v| v.as_tag().to_string())
}
/// clap value-parser for boolean flags backed by an env var.
///
/// Identical to clap's stock `BoolishValueParser` (case-insensitive
/// `true/false`, `yes/no`, `on/off`, `1/0`) **except** that an empty string is
/// treated as `false` rather than rejected.
///
/// Without this, an exported-but-empty env var — e.g. `SOCKET_OFFLINE=` or
/// `SOCKET_JSON=`, which shells and CI routinely set to mean "unset" — made
/// clap abort the whole command with `invalid value '' for '--offline': value
/// was not a boolean`. Every bool flag here reads such an env var, so a single
/// stray empty var crashed every subcommand before it could do any work.
pub(crate) fn parse_bool_flag(s: &str) -> Result<bool, String> {
match s.trim().to_ascii_lowercase().as_str() {
"" | "n" | "no" | "f" | "false" | "off" | "0" => Ok(false),
"y" | "yes" | "t" | "true" | "on" | "1" => Ok(true),
other => Err(format!(
"`{other}` is not a boolean (expected one of: true, false, yes, no, on, off, 1, 0)"
)),
}
}
/// `--help` heading for every [`GlobalArgs`] flag, so each subcommand's own
/// flags (listed first, under "Options") are not buried among the ~24 shared
/// ones. Set per-arg rather than as the struct's `next_help_heading`: that
/// would leak onto the subcommand-local flags declared after the flatten.
pub(crate) const GLOBAL_OPTIONS: &str = "Global options";
// Arguments inherited by every subcommand via `#[command(flatten)]`.
//
// **Every** global flag is parseable on **every** subcommand. Commands that
// don't use a given flag ignore it silently — e.g. `list --global` parses
// fine and the `global` field is unused at runtime. The one exception is
// the path flags (`--cwd`, `--global-prefix`, `--manifest-path`): `main`
// validates them on every project command, used or not
// ([`GlobalArgs::validate_paths`]), because a path that names nothing must
// never read as an empty project.
//
// (Plain `//` comments: clap turns a doc comment here into the `--help`
// description of any subcommand that has none of its own.)
#[derive(Args, Debug, Clone)]
pub struct GlobalArgs {
/// Working directory.
#[arg(help_heading = GLOBAL_OPTIONS, long, env = "SOCKET_CWD", default_value = ".")]
pub cwd: PathBuf,
/// Path to patch manifest file (resolved relative to --cwd).
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "manifest-path",
env = "SOCKET_MANIFEST_PATH",
default_value = DEFAULT_PATCH_MANIFEST_PATH,
)]
pub manifest_path: String,
/// Socket API URL (authenticated endpoint). Falls back to the socket-cli
/// config file, then https://api.socket.dev.
//
// No clap default: `None` lets the core resolver fall through env and the
// socket-cli config file before applying `DEFAULT_SOCKET_API_URL`.
#[arg(help_heading = GLOBAL_OPTIONS, long = "api-url", env = "SOCKET_API_URL")]
pub api_url: Option<String>,
/// Socket API token. Absence selects the public patch proxy.
#[arg(help_heading = GLOBAL_OPTIONS, long = "api-token", env = "SOCKET_API_TOKEN")]
pub api_token: Option<String>,
/// Organization slug. Auto-resolved when omitted and a token is set.
#[arg(help_heading = GLOBAL_OPTIONS, long = "org", short = 'o', env = "SOCKET_ORG_SLUG")]
pub org: Option<String>,
/// Public patch proxy URL used when no API token is set
/// [default: https://patches-api.socket.dev].
//
// No clap default, matching `api_url` — resolution happens in
// `get_api_client_with_overrides`.
#[arg(help_heading = GLOBAL_OPTIONS, long = "proxy-url", env = "SOCKET_PROXY_URL")]
pub proxy_url: Option<String>,
/// Restrict to these ecosystems (comma-separated). Names that are not
/// supported ecosystems are rejected.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "ecosystems",
short = 'e',
env = "SOCKET_ECOSYSTEMS",
value_delimiter = ',',
value_parser = parse_supported_ecosystem,
)]
pub ecosystems: Option<Vec<String>>,
/// Which kind of patch artifact to download when local files are missing.
/// `diff` (default) fetches the smallest delta archive; `file` falls back
/// to legacy per-file blobs.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "download-mode",
env = "SOCKET_DOWNLOAD_MODE",
default_value = "diff"
)]
pub download_mode: String,
/// Download installable patched artifacts from the patch service.
/// `service` is the default; `auto` is a compatibility alias. Local
/// artifact building is no longer supported. Healthy committed artifacts
/// can be reused offline; missing or corrupt artifacts require a download.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "vendor-source",
env = "SOCKET_VENDOR_SOURCE",
default_value = "service",
value_parser = parse_vendor_source,
)]
pub vendor_source: String,
/// Vendored Maven: auto writes the repository tail; none uses only the file repository.
/// The choice is preserved on subsequent runs.
#[arg(help_heading = GLOBAL_OPTIONS, long, value_parser = ["auto", "none"])]
pub maven_config: Option<String>,
/// Base URL for the patch vendoring service. Defaults to the active API base (`--api-url`) when
/// authenticated or the proxy base (`--proxy-url`) otherwise. Override to
/// point `vendor` at staging / local dev independently of `--api-url`.
// A dev/testing knob: listed in `--help`, left out of the `-h` summary.
#[arg(help_heading = GLOBAL_OPTIONS, long = "vendor-url", env = "SOCKET_VENDOR_URL", hide_short_help = true)]
pub vendor_url: Option<String>,
/// Override the host of the prebuilt-archive download URL the vendoring
/// service returns. When set, the CLI rewrites the
/// scheme + host (+ port) of the returned URL to this base, preserving the
/// path. `vex` and `scan` also accept lockfile URLs on this origin as
/// hosted patch references (attested, and counted as live hosted wiring)
/// next to patch.socket.dev. Mainly for local-dev / testing, where the
/// host the server bakes into the URL is not the one to actually fetch
/// from.
// A dev/testing knob: listed in `--help`, left out of the `-h` summary.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "patch-server-url",
env = "SOCKET_PATCH_SERVER_URL",
hide_short_help = true
)]
pub patch_server_url: Option<String>,
/// Strict airgap: never contact the network. Operations that need remote
/// data fail loudly when this is set.
#[arg(
help_heading = GLOBAL_OPTIONS,
long,
env = "SOCKET_OFFLINE",
default_value_t = false,
value_parser = parse_bool_flag,
)]
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.
#[arg(
help_heading = GLOBAL_OPTIONS,
long,
env = "SOCKET_STRICT",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub strict: bool,
/// Operate on globally-installed packages.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "global",
short = 'g',
env = "SOCKET_GLOBAL",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub global: bool,
/// Override the path used to discover globally-installed packages.
#[arg(help_heading = GLOBAL_OPTIONS, long = "global-prefix", env = "SOCKET_GLOBAL_PREFIX")]
pub global_prefix: Option<PathBuf>,
/// Emit machine-readable JSON output.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "json",
short = 'j',
env = "SOCKET_JSON",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub json: bool,
/// Show extra detail in human-readable output.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "verbose",
short = 'v',
env = "SOCKET_VERBOSE",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub verbose: bool,
/// Suppress non-error output.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "silent",
short = 's',
env = "SOCKET_SILENT",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub silent: bool,
/// Preview the operation without making any mutations.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "dry-run",
env = "SOCKET_DRY_RUN",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub dry_run: bool,
/// Skip interactive prompts.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "yes",
short = 'y',
env = "SOCKET_YES",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub yes: bool,
/// Seconds to wait for the `.socket/apply.lock` lock before giving up.
/// By default (or with `0`) the lock is tried once, failing immediately
/// if another process holds it. A positive value retries with a 100 ms
/// backoff until the lock frees or the budget elapses. Only meaningful
/// for the commands that take the lock (`apply`, `rollback`, `repair`,
/// `remove`, `vendor`, `get` and `scan` when they record, apply,
/// vendor or host patches);
/// other commands accept it silently. Every holder removes the lock file on exit, so a leftover
/// from a crashed run never contends.
#[arg(help_heading = GLOBAL_OPTIONS, long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")]
pub lock_timeout: Option<u64>,
/// Emit verbose debug logs to stderr.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "debug",
env = "SOCKET_DEBUG",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub debug: bool,
/// Disable anonymous usage telemetry.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "no-telemetry",
env = "SOCKET_TELEMETRY_DISABLED",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub no_telemetry: bool,
/// Hosted mode (`scan`/`get --mode hosted`): do NOT auto-configure
/// `trustLockfile: true` in pnpm-workspace.yaml after a pnpm-lock.yaml
/// (lockfileVersion >= 9) is repointed at the hosted patch server.
/// pnpm >= 11 rejects the repointed lock without that trust grant, so
/// opting out means every install needs `pnpm install --trust-lockfile`
/// instead (the run's warning spells out both recoveries). Only
/// hosted-mode `scan` and `get` read this; other subcommands accept it
/// silently.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "no-trust-lockfile-config",
env = "SOCKET_NO_TRUST_LOCKFILE_CONFIG",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub no_trust_lockfile_config: bool,
/// Hosted mode (`scan`/`get --mode hosted`): do NOT auto-configure
/// `allow-remote=all` in the project .npmrc after a root
/// package-lock.json / npm-shrinkwrap.json is repointed at the hosted
/// patch server. npm >= 12 defaults to `allow-remote=none` and refuses
/// the repointed lock (EALLOWREMOTE), so opting out means every install
/// needs `npm ci --allow-remote=all` instead (the run's warning spells
/// it out). Only hosted-mode `scan` and `get` read this; other
/// subcommands accept it silently.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "no-npm-allow-remote-config",
env = "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub no_npm_allow_remote_config: bool,
/// Hosted mode (`scan`/`get --mode hosted`, and `rollback`/`remove` of
/// hosted patches): do NOT remove stale vlt installed copies
/// (`node_modules/.vlt-lock.json` and the stale `node_modules/.vlt`
/// entries) after `vlt-lock.json` is repointed or restored. vlt never
/// refreshes an installed copy on its own, so opting out means running
/// `vlt ci` instead (the run's `redirect_vlt_reinstall_required`
/// advisory says so). Optional dependencies' copies are never removed.
/// Other subcommands accept it silently.
#[arg(
help_heading = GLOBAL_OPTIONS,
long = "no-vlt-install-cleanup",
env = "SOCKET_NO_VLT_INSTALL_CLEANUP",
default_value_t = false,
value_parser = parse_bool_flag,
)]
pub no_vlt_install_cleanup: bool,
}
impl GlobalArgs {
/// Reject path flags that name nothing: `--cwd` and `--global-prefix`
/// (flag or env) must be existing directories, and a `--manifest-path`
/// other than the default must sit in an existing project directory and
/// must not itself be a directory. `main` maps `Err` to the usage exit
/// (2), the same exit a hosted/vendored `scan` PATH that is not a
/// directory gets.
///
/// Without this a typo in `--cwd` / `SOCKET_CWD` read as an empty
/// project: `apply`, `list`, `scan`, `get` and the `vendor --check` CI
/// gate all exited 0 having checked nothing.
///
/// A missing manifest FILE stays legal: hosted and vendored projects
/// have none, and `get` / `scan --mode agent` create it. Only its
/// project directory has to exist — except for the commands that
/// create the manifest (`creates_manifest`: `get`, `scan`), which make
/// a missing directory as they always have; writing it is no vacuous
/// pass.
pub fn validate_paths(&self, creates_manifest: bool) -> Result<(), String> {
let not_dir = |flag: &str, env: &str, path: &Path, what: &str| {
format!("{flag} (or {env}) `{}` {what}", path.display())
};
if !self.cwd.is_dir() {
let what = if self.cwd.exists() {
"is not a directory"
} else {
"does not exist"
};
return Err(not_dir("--cwd", "SOCKET_CWD", &self.cwd, what));
}
if let Some(prefix) = &self.global_prefix {
if !prefix.is_dir() {
let what = if prefix.exists() {
"is not a directory"
} else {
"does not exist"
};
return Err(not_dir(
"--global-prefix",
"SOCKET_GLOBAL_PREFIX",
prefix,
what,
));
}
}
if self.manifest_path != DEFAULT_PATCH_MANIFEST_PATH {
let manifest = self.resolved_manifest_path();
if manifest.is_dir() {
return Err(not_dir(
"--manifest-path",
"SOCKET_MANIFEST_PATH",
&manifest,
"is a directory, not a manifest file",
));
}
let root = self.project_root();
if !creates_manifest && !root.is_dir() {
return Err(not_dir(
"--manifest-path",
"SOCKET_MANIFEST_PATH",
&manifest,
&format!(
"is in a project directory that does not exist ({})",
root.display()
),
));
}
}
Ok(())
}
/// The crawler options this run's `--cwd` / `--global` /
/// `--global-prefix` select.
pub(crate) fn crawler_options(&self) -> socket_patch_core::crawlers::CrawlerOptions {
socket_patch_core::crawlers::CrawlerOptions {
cwd: self.cwd.clone(),
global: self.global,
global_prefix: self.global_prefix.clone(),
}
}
/// Whether this run targets globally installed packages (`--global` or
/// `--global-prefix`) rather than the project at `--cwd`.
pub(crate) fn is_global(&self) -> bool {
self.global || self.global_prefix.is_some()
}
/// Whether `--ecosystems` selects `eco` (every ecosystem when unset or
/// empty). The names are validated at parse time, so this is an exact
/// match.
pub(crate) fn ecosystem_selected(&self, eco: Ecosystem) -> bool {
self.ecosystems
.as_ref()
.is_none_or(|list| list.is_empty() || list.iter().any(|name| name == eco.cli_name()))
}
/// [`Self::ecosystem_selected`] for the ecosystem of `purl`; a purl of
/// no known ecosystem is selected only when `--ecosystems` is unset.
pub(crate) fn purl_ecosystem_selected(&self, purl: &str) -> bool {
match Ecosystem::from_purl(purl) {
Some(eco) => self.ecosystem_selected(eco),
None => self.ecosystems.as_ref().is_none_or(Vec::is_empty),
}
}
/// Resolve `manifest_path` against `cwd`: absolute paths are returned
/// as-is, relative paths are joined to `cwd`.
pub(crate) fn resolved_manifest_path(&self) -> PathBuf {
if Path::new(&self.manifest_path).is_absolute() {
PathBuf::from(&self.manifest_path)
} else {
self.cwd.join(&self.manifest_path)
}
}
/// The project root whose `.socket/` state stores — manifest, vendor
/// ledger — belong together: the RESOLVED manifest's
/// directory, stepping out of a standard `.socket/` layout when the
/// manifest lives in one. For the default `<cwd>/.socket/manifest.json`
/// this is exactly `cwd`; for a `--manifest-path` into another project
/// it is that project's root (its `.socket` parent's parent); for a
/// bare file like `--manifest-path /tmp/x/abs.json` it is the file's
/// own directory. Every command that reads more than one store must
/// derive them from THIS root, so `--manifest-path` can never
/// interleave two projects' state (CLI_CONTRACT.md: both stores always
/// come from the SAME project).
pub(crate) fn project_root(&self) -> PathBuf {
let manifest_path = self.resolved_manifest_path();
match manifest_path.parent() {
Some(dir)
if dir.file_name()
== Some(std::ffi::OsStr::new(
socket_patch_core::constants::SOCKET_DIR,
)) =>
{
dir.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| self.cwd.clone())
}
Some(dir) => dir.to_path_buf(),
None => self.cwd.clone(),
}
}
/// The directory the manifest lives in — where `apply.lock`, `blobs/`,
/// `diffs/` and `packages/` sit (`<cwd>/.socket` by default). The one
/// derivation every lock acquire and artifact probe uses; see
/// [`socket_dir_of`] for callers holding a raw manifest path.
pub(crate) fn socket_dir(&self) -> PathBuf {
socket_dir_of(&self.resolved_manifest_path(), &self.cwd)
}
/// Build [`ApiClientEnvOverrides`] from the CLI flags.
///
/// Every field is forwarded as `Some(_)` only when set and non-empty.
/// `None` (no flag, no env var — the fields carry no clap default)
/// defers resolution to `get_api_client_with_overrides`, which falls
/// through env vars and the socket-cli config file to the built-in
/// defaults. The empty filter keeps `--api-url ""` meaning "unset"
/// rather than forwarding a blank override.
pub fn api_client_overrides(&self) -> ApiClientEnvOverrides {
ApiClientEnvOverrides {
api_url: self.api_url.clone().filter(|s| !s.is_empty()),
api_token: self.api_token.clone().filter(|s| !s.is_empty()),
org_slug: self.org.clone().filter(|s| !s.is_empty()),
proxy_url: self.proxy_url.clone().filter(|s| !s.is_empty()),
}
}
/// The `(api_token, org_slug)` telemetry is attributed with, resolved
/// through the API client's own credential chain (flag → the
/// `SOCKET_NO_API_TOKEN` veto → env → `socket login` config) WITHOUT
/// building a client. For the purely local commands (`list`,
/// `vex`): a client would add the org-slug auto-resolve round-trip and
/// the "No SOCKET_API_TOKEN set" advisory to a command that needs
/// neither, while anything less than the full chain reported a
/// `socket login`-only caller's events anonymously to the public proxy
/// — off the on-prem host every other command reports to.
pub(crate) fn telemetry_credentials(&self) -> (Option<String>, Option<String>) {
let overrides = self.api_client_overrides();
resolve_ambient_credentials(overrides.api_token, overrides.org_slug)
}
/// Telemetry's route for a run that built no API client:
/// [`Self::telemetry_credentials`] as a [`TelemetryAuth`] (the org
/// endpoint only for a token + slug; never a network call). A run that
/// has a client uses [`TelemetryAuth::for_client`] instead.
pub(crate) fn telemetry_auth(&self) -> TelemetryAuth {
let (api_token, org_slug) = self.telemetry_credentials();
TelemetryAuth::from_credentials(api_token.as_deref(), org_slug.as_deref())
}
/// The vendoring-service config every vendor entry point (`vendor`,
/// `scan`/`get --mode vendored`) builds from the same flags —
/// `--vendor-source` / `--vendor-url` / `--patch-server-url` /
/// `--offline` — so they commit byte-identical artifacts and lock
/// integrity for the same patch. `client` is the run-level API client
/// (moved in; the service reuses it for the package-reference request)
/// and `use_public_proxy` its proxy-fallback state. `vendor_source` was
/// validated by clap, so the parse cannot fail; the `auto` default is
/// the defensive fallback. A pure assembler (no async, no network).
pub(crate) fn vendor_service_config(
&self,
client: Option<ApiClient>,
use_public_proxy: bool,
) -> VendorServiceConfig {
VendorServiceConfig {
maven_config: self.maven_config.as_deref().map(|v| v != "none"),
source: VendorSource::parse(&self.vendor_source).unwrap_or_default(),
client,
use_public_proxy,
vendor_url: self.vendor_url.clone(),
patch_server_url: self.patch_server_url.clone(),
offline: self.offline,
}
}
}
/// The `.socket/`-role directory for `manifest_path`: its parent, falling
/// back to `cwd` for a bare relative file name — never `"."`, which is
/// wrong under a non-default `--cwd`. [`GlobalArgs::resolved_manifest_path`]
/// always joins a relative path onto `cwd`, so the fallback is reachable
/// only for callers handed an unresolved path.
pub(crate) fn socket_dir_of(manifest_path: &Path, cwd: &Path) -> PathBuf {
manifest_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| cwd.to_path_buf())
}
/// Apply CLI-flag toggles for env-driven knobs by mirroring them into env
/// vars. This is how `--offline` / `--debug` / `--no-telemetry` reach core
/// code that reads `SOCKET_OFFLINE` / `SOCKET_DEBUG` /
/// `SOCKET_TELEMETRY_DISABLED` directly. Idempotent and a no-op when the
/// flags are off.
///
/// `offline` matters most: the telemetry kill-switch
/// (`socket_patch_core::telemetry::is_telemetry_disabled`) honors the
/// strict-airgap contract by reading `SOCKET_OFFLINE` from the env, so
/// without this mirror a bare `--offline` flag (or a truthy spelling like
/// `SOCKET_OFFLINE=yes` that core's `"1" | "true"` match doesn't recognize)
/// still let telemetry fire a network request.
///
/// `--api-url` / `--proxy-url` are mirrored for the same reason: the
/// telemetry sender resolves its endpoint from the env only
/// (`resolve_api_base_url` / `proxy_url_from_env`) and never sees the parsed
/// flags, so without the mirror an on-prem `--api-url` run POSTed the event —
/// Bearer token included — to the default `api.socket.dev`. Empty values mean
/// "unset" (as in [`GlobalArgs::api_client_overrides`]) and are not mirrored.
/// `--api-token` / `--org` are deliberately NOT mirrored: telemetry receives
/// them as explicit arguments, and exporting a secret into the process env
/// would leak it to every spawned package-manager child.
pub(crate) fn apply_env_toggles(common: &GlobalArgs) {
if common.offline {
std::env::set_var("SOCKET_OFFLINE", "1");
}
if common.debug {
std::env::set_var("SOCKET_DEBUG", "1");
}
if common.no_telemetry {
std::env::set_var("SOCKET_TELEMETRY_DISABLED", "1");
}
if let Some(url) = common.api_url.as_deref().filter(|s| !s.is_empty()) {
std::env::set_var("SOCKET_API_URL", url);
}
if let Some(url) = common.proxy_url.as_deref().filter(|s| !s.is_empty()) {
std::env::set_var("SOCKET_PROXY_URL", url);
}
}
/// Every env var `GlobalArgs` binds (one per `env = "..."` attribute above).
/// Single source of truth for [`scrub_empty_env_vars`] and the
/// clean-environment test harnesses.
pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[
"SOCKET_CWD",
"SOCKET_MANIFEST_PATH",
"SOCKET_API_URL",
"SOCKET_API_TOKEN",
"SOCKET_ORG_SLUG",
"SOCKET_PROXY_URL",
"SOCKET_ECOSYSTEMS",
"SOCKET_DOWNLOAD_MODE",
"SOCKET_VENDOR_SOURCE",
"SOCKET_VENDOR_URL",
"SOCKET_PATCH_SERVER_URL",
"SOCKET_OFFLINE",
"SOCKET_STRICT",
"SOCKET_GLOBAL",
"SOCKET_GLOBAL_PREFIX",
"SOCKET_JSON",
"SOCKET_VERBOSE",
"SOCKET_SILENT",
"SOCKET_DRY_RUN",
"SOCKET_YES",
"SOCKET_LOCK_TIMEOUT",
"SOCKET_DEBUG",
"SOCKET_TELEMETRY_DISABLED",
"SOCKET_NO_TRUST_LOCKFILE_CONFIG",
"SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG",
"SOCKET_NO_VLT_INSTALL_CLEANUP",
];
/// Every env var a **subcommand-local** flag binds (one per `env = "..."`
/// attribute in `commands/*.rs`). Same contract as [`GLOBAL_ARG_ENV_VARS`]:
/// single source of truth for [`scrub_empty_env_vars`] and the
/// clean-environment test harnesses. A flag added with an `env` binding but
/// missing here escapes the empty-var scrub — the invariant tests below
/// parse every entry against its owning subcommand to keep this honest.
pub const LOCAL_ARG_ENV_VARS: &[&str] = &[
"SOCKET_PATCH_VERSION",
"SOCKET_SAVE_ONLY",
"SOCKET_ALL_RELEASES",
"SOCKET_SKIP_ROLLBACK",
"SOCKET_PRESERVE_STATE",
"SOCKET_DOWNLOAD_ONLY",
"SOCKET_VENDOR_REVERT",
"SOCKET_BATCH_SIZE",
"SOCKET_SCAN_PACKAGES",
"SOCKET_VEX",
"SOCKET_VEX_OUTPUT",
"SOCKET_VEX_PRODUCT",
"SOCKET_VEX_NO_VERIFY",
"SOCKET_VEX_DOC_ID",
"SOCKET_VEX_COMPACT",
];
/// Remove exported-but-**empty** flag-bound env vars before clap parses.
///
/// `SOCKET_CWD=` — the conventional shell/CI idiom for blanking a variable
/// without unsetting it — must mean "unset, fall back to the default", not
/// abort the command. [`parse_bool_flag`] already gives the bool flags that
/// semantic, but clap rejects an empty `SOCKET_CWD` / `SOCKET_GLOBAL_PREFIX`
/// ("a value is required"), `SOCKET_LOCK_TIMEOUT` / `SOCKET_BATCH_SIZE`
/// ("cannot parse integer from empty string") and `SOCKET_ECOSYSTEMS` (the
/// per-token validator) outright — a single stray blank var crashed every
/// subcommand — and an empty `SOCKET_DOWNLOAD_MODE` / `SOCKET_MANIFEST_PATH`
/// (or `SOCKET_VEX_OUTPUT`, which would silently target `""`) leaked `""`
/// past the documented defaults. Called from `main` after peer-alias
/// promotion and before clap runs. Only exactly-empty values are scrubbed;
/// whitespace is significant in paths, so it is left for the parsers to
/// judge.
pub fn scrub_empty_env_vars() {
for &var in GLOBAL_ARG_ENV_VARS.iter().chain(LOCAL_ARG_ENV_VARS) {
if matches!(std::env::var(var).as_deref(), Ok("")) {
std::env::remove_var(var);
}
}
}
impl Default for GlobalArgs {
/// Defaults intended for **test struct literals** (e.g. `..GlobalArgs::default()`).
///
/// In production every field is populated by clap (with the
/// `default_value = ".."` attribute providing the documented defaults
/// when neither CLI flag nor env var is set), so this `Default` is
/// only reached from tests building `GlobalArgs` directly.
///
/// `api_url` and `proxy_url` are `None` here (not the production
/// default URLs). That lets tests set `SOCKET_API_URL` /
/// `SOCKET_PROXY_URL` via `std::env::set_var` *after* constructing
/// the args struct and have those env vars flow through to the API
/// client — `api_client_overrides` forwards `None` so the underlying
/// `get_api_client_with_overrides` falls back to env-var resolution.
fn default() -> Self {
Self {
cwd: PathBuf::from("."),
manifest_path: DEFAULT_PATCH_MANIFEST_PATH.to_string(),
api_url: None,
api_token: None,
org: None,
proxy_url: None,
ecosystems: None,
download_mode: "diff".to_string(),
vendor_source: "service".to_string(),
maven_config: None,
vendor_url: None,
patch_server_url: None,
offline: false,
strict: false,
global: false,
global_prefix: None,
json: false,
verbose: false,
silent: false,
dry_run: false,
yes: false,
lock_timeout: None,
debug: false,
no_telemetry: false,
no_trust_lockfile_config: false,
no_npm_allow_remote_config: false,
no_vlt_install_cleanup: false,
}
}
}
/// True for a golang PURL in local mode (no `--global` / `--global-prefix`):
/// `apply` redirects it to a project-local patched copy via a `go.mod`
/// `replace`, and `rollback` drops the same redirect.
pub(crate) fn is_local_go(purl: &str, common: &GlobalArgs) -> bool {
!common.is_global() && Ecosystem::from_purl(purl) == Some(Ecosystem::Golang)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn ecosystem_rejection_messages() {
assert_eq!(
unsupported_ecosystem_message("rubygems", "npm, pypi"),
"unsupported ecosystem `rubygems` (supported: npm, pypi)"
);
assert_eq!(
unsupported_ecosystem_message("", "npm, pypi"),
"empty ecosystem name in the list (supported: npm, pypi)"
);
assert_eq!(
unsupported_ecosystem_message(" ", "npm"),
"empty ecosystem name in the list (supported: npm)"
);
let err = parse_supported_ecosystem("bogus").unwrap_err();
assert!(
err.starts_with("unsupported ecosystem `bogus` (supported: npm"),
"{err}"
);
assert!(!err.contains("in this build"), "{err}");
}
/// Minimal harness so we can exercise clap's parse + env-var resolution of
/// `GlobalArgs` exactly as a real subcommand would (it is `flatten`ed).
#[derive(Parser, Debug)]
struct TestCli {
#[command(flatten)]
common: GlobalArgs,
}
/// Snapshot/clear each var in `vars`, run `f`, then restore. Keeps the
/// env-mutating clap tests hermetic and reversible.
fn with_env_cleared(vars: &[&str], f: impl FnOnce()) {
let saved: Vec<(&str, Option<String>)> =
vars.iter().map(|&k| (k, std::env::var(k).ok())).collect();
for &k in vars {
std::env::remove_var(k);
}
f();
for (k, v) in saved {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
}
/// Clear every env var a flag reads — global and subcommand-local (the
/// production lists, so the scrub and the harness can't drift), giving
/// each clap-parse test a known-clean environment with no ambient
/// `SOCKET_*` bleed-through.
fn with_clean_socket_env(f: impl FnOnce()) {
with_env_cleared(GLOBAL_ARG_ENV_VARS, || {
with_env_cleared(LOCAL_ARG_ENV_VARS, f);
});
}
/// Clear the extra env the core telemetry gate reads beyond the
/// `SOCKET_*` set (`is_telemetry_disabled` also consults `VITEST` — the
/// kill-switch socket-cli's vitest suite relies on), so the airgap tests
/// below can't pass or fail vacuously. Restores afterwards.
fn with_clean_telemetry_env(f: impl FnOnce()) {
with_env_cleared(&["VITEST"], f);
}
/// `--offline` promises "never contact the network", but the telemetry
/// kill-switch (`socket_patch_core::telemetry::is_telemetry_disabled`)
/// reads the `SOCKET_OFFLINE` env var directly — it never sees the parsed
/// flag. `apply_env_toggles` must therefore mirror `--offline` into the
/// env exactly like `--debug` / `--no-telemetry`, or an airgapped
/// `socket-patch apply --offline` still fires a telemetry HTTP request.
#[test]
#[serial_test::serial]
fn apply_env_toggles_mirrors_offline_into_env_for_airgap() {
with_clean_socket_env(|| {
with_clean_telemetry_env(|| {
let args = GlobalArgs {
offline: true,
..GlobalArgs::default()
};
apply_env_toggles(&args);
assert_eq!(std::env::var("SOCKET_OFFLINE").as_deref(), Ok("1"));
assert!(
socket_patch_core::telemetry::is_telemetry_disabled(),
"--offline must disable telemetry (strict airgap: never contact the network)",
);
});
});
}
/// The full `SOCKET_OFFLINE` vocabulary must reach the telemetry gate.
/// clap (via `parse_bool_flag`) accepts `yes`/`on`/`y`/`t` as true, but
/// core's direct env read matches only `"1" | "true"` — so the toggle
/// mirror has to re-export the parsed flag in normalized form.
#[test]
#[serial_test::serial]
fn truthy_offline_env_vocabulary_reaches_telemetry_gate() {
with_clean_socket_env(|| {
with_clean_telemetry_env(|| {
std::env::set_var("SOCKET_OFFLINE", "yes");
let cli = TestCli::try_parse_from(["socket-patch"]).unwrap();
assert!(cli.common.offline, "SOCKET_OFFLINE=yes parses as offline");
apply_env_toggles(&cli.common);
assert!(
socket_patch_core::telemetry::is_telemetry_disabled(),
"SOCKET_OFFLINE=yes must disable telemetry like SOCKET_OFFLINE=1",
);
});
});
}
/// `--api-url` / `--proxy-url` must reach the telemetry sender, which
/// resolves its endpoint from the env only
/// (`socket_cli_config::resolve_api_base_url` reads `SOCKET_API_URL`,
/// `env_compat::proxy_url_from_env` reads `SOCKET_PROXY_URL`) and never
/// sees the parsed flags. Without the mirror,
/// `socket-patch scan --api-url https://socket.internal --api-token <tok>
/// --org acme` POSTs the event — `Authorization: Bearer <tok>` header
/// included — to the default `https://api.socket.dev`, egressing an
/// on-prem token to the very host the operator redirected away from.
#[test]
#[serial_test::serial]
fn apply_env_toggles_mirrors_api_and_proxy_urls_for_telemetry() {
with_clean_socket_env(|| {
// Guard against a vacuous pass: the resolver must start at the
// built-in default (the cargo `[env]` `SOCKET_NO_CONFIG=1` keeps
// a developer's real socket-cli config out of the chain).
assert_eq!(
socket_patch_core::utils::socket_cli_config::resolve_api_base_url(),
socket_patch_core::constants::DEFAULT_SOCKET_API_URL,
);
let args = GlobalArgs {
api_url: Some("https://socket.internal.example".to_string()),
proxy_url: Some("https://proxy.internal.example".to_string()),
..GlobalArgs::default()
};
apply_env_toggles(&args);
assert_eq!(
socket_patch_core::utils::socket_cli_config::resolve_api_base_url(),
"https://socket.internal.example",
"--api-url must reach the telemetry endpoint resolver",
);
assert_eq!(
std::env::var("SOCKET_PROXY_URL").as_deref(),
Ok("https://proxy.internal.example"),
"--proxy-url must reach the tokenless telemetry endpoint resolver",
);
});
}
/// The URL mirror must not invent an override: `None` (no flag, no env
/// var) has to stay unset so `get_api_client_with_overrides` /
/// `resolve_api_base_url` still fall through env → socket-cli config →
/// default, and `--api-url ""` keeps meaning "unset" exactly as
/// [`GlobalArgs::api_client_overrides`] already treats it.
#[test]
#[serial_test::serial]
fn apply_env_toggles_url_mirror_skips_unset_and_empty() {
with_clean_socket_env(|| {
apply_env_toggles(&GlobalArgs::default());
assert!(std::env::var("SOCKET_API_URL").is_err());
assert!(std::env::var("SOCKET_PROXY_URL").is_err());
let blank = GlobalArgs {
api_url: Some(String::new()),
proxy_url: Some(String::new()),
..GlobalArgs::default()
};
apply_env_toggles(&blank);
assert!(
std::env::var("SOCKET_API_URL").is_err(),
"an empty --api-url must not be mirrored as a blank override",
);
assert!(std::env::var("SOCKET_PROXY_URL").is_err());
});
}
/// `scrub_empty_env_vars` removes exactly-empty `SOCKET_*` flag vars
/// (the `VAR=` blank-without-unsetting idiom) — global and local — and
/// nothing else: set, non-empty values — even whitespace-only ones,
/// which are significant in paths — survive, and the parse then sees
/// plain defaults.
#[test]
#[serial_test::serial]
fn scrub_empty_env_vars_unsets_only_empties() {
with_clean_socket_env(|| {
std::env::set_var("SOCKET_CWD", "");
std::env::set_var("SOCKET_LOCK_TIMEOUT", "");
std::env::set_var("SOCKET_GLOBAL_PREFIX", "");
std::env::set_var("SOCKET_ECOSYSTEMS", "");
std::env::set_var("SOCKET_DOWNLOAD_MODE", "");
std::env::set_var("SOCKET_VENDOR_SOURCE", "");
std::env::set_var("SOCKET_BATCH_SIZE", "");
std::env::set_var("SOCKET_VEX_OUTPUT", "");
std::env::set_var("SOCKET_MANIFEST_PATH", "keep.json");
std::env::set_var("SOCKET_ORG_SLUG", " ");
scrub_empty_env_vars();
assert!(
std::env::var("SOCKET_CWD").is_err(),