1pub mod args;
9pub mod commands;
10pub mod ecosystem_dispatch;
11pub mod json_envelope;
12pub mod output;
13
14use clap::{Parser, Subcommand};
15
16#[derive(Parser)]
21#[command(
22 name = "socket-patch",
23 about = "CLI tool for applying security patches to dependencies",
24 version,
25 propagate_version = true
26)]
27pub struct Cli {
28 #[command(subcommand)]
29 pub command: Commands,
30}
31
32#[derive(Subcommand)]
33pub enum Commands {
34 Apply(commands::apply::ApplyArgs),
36
37 Rollback(commands::rollback::RollbackArgs),
39
40 #[command(visible_alias = "download")]
42 Get(commands::get::GetArgs),
43
44 Scan(commands::scan::ScanArgs),
46
47 List(commands::list::ListArgs),
49
50 Remove(commands::remove::RemoveArgs),
52
53 Setup(commands::setup::SetupArgs),
55
56 #[command(visible_alias = "gc")]
64 Repair(commands::repair::RepairArgs),
65
66 Unlock(commands::unlock::UnlockArgs),
71
72 Vex(commands::vex::VexArgs),
75}
76
77pub fn looks_like_uuid(s: &str) -> bool {
82 let parts: Vec<&str> = s.split('-').collect();
83 if parts.len() != 5 {
84 return false;
85 }
86 let expected = [8, 4, 4, 4, 12];
87 parts
88 .iter()
89 .zip(expected.iter())
90 .all(|(p, &len)| p.len() == len && p.chars().all(|c| c.is_ascii_hexdigit()))
91}
92
93pub fn parse_with_uuid_fallback(argv: Vec<String>) -> Result<Cli, clap::Error> {
99 match Cli::try_parse_from(&argv) {
100 Ok(cli) => Ok(cli),
101 Err(err) => {
102 if argv.len() >= 2 && looks_like_uuid(&argv[1]) {
103 let mut new_args = vec![argv[0].clone(), "get".into()];
104 new_args.extend_from_slice(&argv[1..]);
105 match Cli::try_parse_from(&new_args) {
106 Ok(cli) => Ok(cli),
107 Err(_) => Err(err),
108 }
109 } else {
110 Err(err)
111 }
112 }
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
123
124 #[test]
127 fn looks_like_uuid_accepts_canonical_lowercase() {
128 assert!(looks_like_uuid("80630680-4da6-45f9-bba8-b888e0ffd58c"));
129 }
130
131 #[test]
132 fn looks_like_uuid_accepts_uppercase() {
133 assert!(looks_like_uuid("80630680-4DA6-45F9-BBA8-B888E0FFD58C"));
136 }
137
138 #[test]
139 fn looks_like_uuid_accepts_mixed_case() {
140 assert!(looks_like_uuid("80630680-4Da6-45F9-bBa8-B888e0FfD58c"));
141 }
142
143 #[test]
144 fn looks_like_uuid_rejects_four_groups() {
145 assert!(!looks_like_uuid("80630680-4da6-45f9-bba8"));
147 }
148
149 #[test]
150 fn looks_like_uuid_rejects_six_groups() {
151 assert!(!looks_like_uuid(
153 "80630680-4da6-45f9-bba8-b888e0ffd58c-extra"
154 ));
155 }
156
157 #[test]
158 fn looks_like_uuid_rejects_8_4_4_4_13_group_lengths() {
159 assert!(!looks_like_uuid("80630680-4da6-45f9-bba8-b888e0ffd58cc"));
161 }
162
163 #[test]
164 fn looks_like_uuid_rejects_7_4_4_4_12_group_lengths() {
165 assert!(!looks_like_uuid("8063068-4da6-45f9-bba8-b888e0ffd58c0"));
167 }
168
169 #[test]
170 fn looks_like_uuid_rejects_non_hex_chars() {
171 assert!(!looks_like_uuid("g0630680-4da6-45f9-bba8-b888e0ffd58c"));
173 assert!(!looks_like_uuid("80630680-4dz6-45f9-bba8-b888e0ffd58c"));
174 assert!(!looks_like_uuid("80630680-4da6-45f9-bba8-b888e0ffd58z"));
175 }
176
177 #[test]
178 fn looks_like_uuid_rejects_empty_string() {
179 assert!(!looks_like_uuid(""));
180 }
181
182 #[test]
183 fn looks_like_uuid_rejects_string_with_no_dashes() {
184 assert!(!looks_like_uuid("806306804da645f9bba8b888e0ffd58c"));
186 }
187
188 #[test]
189 fn looks_like_uuid_rejects_bare_dashes() {
190 assert!(!looks_like_uuid("----"));
192 }
193
194 #[test]
195 fn looks_like_uuid_accepts_nil_uuid() {
196 assert!(looks_like_uuid("00000000-0000-0000-0000-000000000000"));
198 }
199
200 #[test]
201 fn looks_like_uuid_rejects_surrounding_whitespace() {
202 assert!(!looks_like_uuid(" 80630680-4da6-45f9-bba8-b888e0ffd58c"));
205 assert!(!looks_like_uuid("80630680-4da6-45f9-bba8-b888e0ffd58c "));
206 }
207
208 #[test]
209 fn looks_like_uuid_rejects_internal_space() {
210 assert!(!looks_like_uuid("8063068 -4da6-45f9-bba8-b888e0ffd58c"));
213 }
214
215 const UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c";
218
219 fn argv(items: &[&str]) -> Vec<String> {
220 items.iter().map(|s| (*s).to_string()).collect()
221 }
222
223 #[test]
224 fn fallback_rewrites_bare_uuid_to_get() {
225 let cli = parse_with_uuid_fallback(argv(&["socket-patch", UUID])).unwrap();
226 match cli.command {
227 Commands::Get(args) => assert_eq!(args.identifier, UUID),
228 _ => panic!("expected Commands::Get"),
229 }
230 }
231
232 #[test]
233 fn fallback_preserves_trailing_flags() {
234 let cli = parse_with_uuid_fallback(argv(&["socket-patch", UUID, "--json"])).unwrap();
236 match cli.command {
237 Commands::Get(args) => {
238 assert_eq!(args.identifier, UUID);
239 assert!(args.common.json, "--json should be forwarded to get");
240 }
241 _ => panic!("expected Commands::Get"),
242 }
243 }
244
245 #[test]
246 fn fallback_returns_original_error_when_first_arg_is_not_uuid() {
247 let err = match parse_with_uuid_fallback(argv(&["socket-patch", "not-a-uuid"])) {
251 Ok(_) => panic!("expected parse to fail"),
252 Err(e) => e,
253 };
254 assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
255 }
256
257 #[test]
258 fn fallback_is_skipped_when_normal_parse_succeeds() {
259 let cli = parse_with_uuid_fallback(argv(&["socket-patch", "list"])).unwrap();
261 assert!(matches!(cli.command, Commands::List(_)));
262 }
263
264 #[test]
265 fn fallback_does_not_double_rewrite_explicit_get() {
266 let cli = parse_with_uuid_fallback(argv(&["socket-patch", "get", UUID])).unwrap();
268 match cli.command {
269 Commands::Get(args) => assert_eq!(args.identifier, UUID),
270 _ => panic!("expected Commands::Get"),
271 }
272 }
273
274 #[test]
275 fn fallback_forwards_multiple_flags_in_order() {
276 let cli = parse_with_uuid_fallback(argv(&["socket-patch", UUID, "--id", "--json"]))
280 .unwrap();
281 match cli.command {
282 Commands::Get(args) => {
283 assert_eq!(args.identifier, UUID);
284 assert!(args.id, "--id should be forwarded to get");
285 assert!(args.common.json, "--json should be forwarded to get");
286 }
287 _ => panic!("expected Commands::Get"),
288 }
289 }
290
291 #[test]
292 fn fallback_handles_no_args_without_panicking() {
293 let err = match parse_with_uuid_fallback(argv(&["socket-patch"])) {
297 Ok(_) => panic!("expected parse to fail without a subcommand"),
298 Err(e) => e,
299 };
300 assert_eq!(
301 err.kind(),
302 clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
303 "bare invocation should surface clap's missing-subcommand help, not panic"
304 );
305 }
306
307 #[test]
308 fn fallback_rewrites_uppercase_uuid_end_to_end() {
309 const UPPER: &str = "80630680-4DA6-45F9-BBA8-B888E0FFD58C";
312 let cli = parse_with_uuid_fallback(argv(&["socket-patch", UPPER])).unwrap();
313 match cli.command {
314 Commands::Get(args) => assert_eq!(args.identifier, UPPER),
315 _ => panic!("expected Commands::Get"),
316 }
317 }
318
319 #[test]
320 fn fallback_surfaces_original_error_when_rewrite_also_fails() {
321 let err = match parse_with_uuid_fallback(argv(&[
326 "socket-patch",
327 UUID,
328 "--invalid-flag-that-get-does-not-accept",
329 ])) {
330 Ok(_) => panic!("expected parse to fail"),
331 Err(e) => e,
332 };
333 assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
338 }
339}