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 const UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c";
197
198 fn argv(items: &[&str]) -> Vec<String> {
199 items.iter().map(|s| (*s).to_string()).collect()
200 }
201
202 #[test]
203 fn fallback_rewrites_bare_uuid_to_get() {
204 let cli = parse_with_uuid_fallback(argv(&["socket-patch", UUID])).unwrap();
205 match cli.command {
206 Commands::Get(args) => assert_eq!(args.identifier, UUID),
207 _ => panic!("expected Commands::Get"),
208 }
209 }
210
211 #[test]
212 fn fallback_preserves_trailing_flags() {
213 let cli = parse_with_uuid_fallback(argv(&["socket-patch", UUID, "--json"])).unwrap();
215 match cli.command {
216 Commands::Get(args) => {
217 assert_eq!(args.identifier, UUID);
218 assert!(args.common.json, "--json should be forwarded to get");
219 }
220 _ => panic!("expected Commands::Get"),
221 }
222 }
223
224 #[test]
225 fn fallback_returns_original_error_when_first_arg_is_not_uuid() {
226 let err = match parse_with_uuid_fallback(argv(&["socket-patch", "not-a-uuid"])) {
230 Ok(_) => panic!("expected parse to fail"),
231 Err(e) => e,
232 };
233 assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
234 }
235
236 #[test]
237 fn fallback_is_skipped_when_normal_parse_succeeds() {
238 let cli = parse_with_uuid_fallback(argv(&["socket-patch", "list"])).unwrap();
240 assert!(matches!(cli.command, Commands::List(_)));
241 }
242
243 #[test]
244 fn fallback_does_not_double_rewrite_explicit_get() {
245 let cli = parse_with_uuid_fallback(argv(&["socket-patch", "get", UUID])).unwrap();
247 match cli.command {
248 Commands::Get(args) => assert_eq!(args.identifier, UUID),
249 _ => panic!("expected Commands::Get"),
250 }
251 }
252
253 #[test]
254 fn fallback_surfaces_original_error_when_rewrite_also_fails() {
255 let err = match parse_with_uuid_fallback(argv(&[
260 "socket-patch",
261 UUID,
262 "--invalid-flag-that-get-does-not-accept",
263 ])) {
264 Ok(_) => panic!("expected parse to fail"),
265 Err(e) => e,
266 };
267 assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
272 }
273}