Skip to main content

socket_patch_cli/
lib.rs

1//! socket-patch CLI library crate.
2//!
3//! Exposes the clap parser types so integration tests can verify the public
4//! CLI contract without invoking the binary. The `main.rs` binary entry point
5//! is a thin wrapper that delegates to [`parse_with_uuid_fallback`] and the
6//! `run` function on each command's `Args`.
7
8pub mod args;
9pub mod commands;
10pub mod ecosystem_dispatch;
11pub mod json_envelope;
12pub mod output;
13
14use clap::{Parser, Subcommand};
15
16// CLI contract surface — subcommand names, visible_alias values, flag names,
17// defaults, JSON shapes, and exit codes are PUBLIC and SEMVER-SIGNIFICANT.
18// Changes here require a MAJOR bump + `scripts/version-sync.sh`.
19// See crates/socket-patch-cli/CLI_CONTRACT.md.
20#[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 security patches to dependencies
35    Apply(commands::apply::ApplyArgs),
36
37    /// Rollback patches to restore original files
38    Rollback(commands::rollback::RollbackArgs),
39
40    /// Get security patches from Socket API and apply them
41    #[command(visible_alias = "download")]
42    Get(commands::get::GetArgs),
43
44    /// Scan installed packages for available security patches
45    Scan(commands::scan::ScanArgs),
46
47    /// List all patches in the local manifest
48    List(commands::list::ListArgs),
49
50    /// Remove a patch from the manifest by PURL or UUID (rolls back files first)
51    Remove(commands::remove::RemoveArgs),
52
53    /// Configure package.json postinstall scripts to apply patches
54    Setup(commands::setup::SetupArgs),
55
56    /// Download missing blobs and clean up unused blobs.
57    ///
58    /// `repair` (alias `gc`) is a first-class command for cleaning up
59    /// the `.socket/` directory without running a scan. For the
60    /// combined workflow (discover + apply + GC), use
61    /// `scan --sync --json --yes`. `repair`/`gc` remain useful on
62    /// their own when the user wants to clean up without an apply pass.
63    #[command(visible_alias = "gc")]
64    Repair(commands::repair::RepairArgs),
65
66    /// Inspect (and optionally release) the `<.socket>/apply.lock`
67    /// advisory file lock used by mutating subcommands. Exits 0
68    /// when free, 1 when held. Pass `--release` to also delete the
69    /// lock file when it is free.
70    Unlock(commands::unlock::UnlockArgs),
71
72    /// Generate an OpenVEX 0.2.0 attestation describing the
73    /// vulnerabilities mitigated by the applied patches.
74    Vex(commands::vex::VexArgs),
75}
76
77/// Check whether `s` looks like a UUID (8-4-4-4-12 hex pattern).
78///
79/// Used by [`parse_with_uuid_fallback`] to detect the convenience form
80/// `socket-patch <UUID>` and rewrite it to `socket-patch get <UUID>`.
81pub 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
93/// Parse a full argv vector, falling back to `get <UUID>` when the user
94/// invoked `socket-patch <UUID> [...]` directly. Returns the original clap
95/// error if the fallback also fails or if the first arg isn't a UUID.
96///
97/// Pulled out of `main.rs` so the fallback path is unit-testable.
98pub 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    //! Unit tests for the bare-UUID fallback. These tests lock in the
119    //! `socket-patch <UUID>` rewrite shortcut and the shape predicate it
120    //! uses — both of which are part of the CLI contract (see
121    //! `CLI_CONTRACT.md`).
122    use super::*;
123
124    // ---------- looks_like_uuid ----------
125
126    #[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        // `is_ascii_hexdigit` accepts A-F as well as a-f, so all-uppercase
134        // UUIDs must still pass the shape check.
135        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        // 8-4-4-4 — missing the final 12-char group.
146        assert!(!looks_like_uuid("80630680-4da6-45f9-bba8"));
147    }
148
149    #[test]
150    fn looks_like_uuid_rejects_six_groups() {
151        // One too many groups — the split count must be exactly 5.
152        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        // Final group has 13 chars instead of 12.
160        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        // First group has 7 chars instead of 8.
166        assert!(!looks_like_uuid("8063068-4da6-45f9-bba8-b888e0ffd58c0"));
167    }
168
169    #[test]
170    fn looks_like_uuid_rejects_non_hex_chars() {
171        // `g` is not a hex digit — must fail even though the shape is right.
172        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        // 32 hex chars, no dashes — close to a UUID but not the right shape.
185        assert!(!looks_like_uuid("806306804da645f9bba8b888e0ffd58c"));
186    }
187
188    #[test]
189    fn looks_like_uuid_rejects_bare_dashes() {
190        // Five empty groups — split count is right, group lengths aren't.
191        assert!(!looks_like_uuid("----"));
192    }
193
194    #[test]
195    fn looks_like_uuid_accepts_nil_uuid() {
196        // The all-zeros nil UUID is correctly shaped and all-hex.
197        assert!(looks_like_uuid("00000000-0000-0000-0000-000000000000"));
198    }
199
200    #[test]
201    fn looks_like_uuid_rejects_surrounding_whitespace() {
202        // The predicate must not trim: a leading/trailing space makes the
203        // first/last group the wrong length (and the space is non-hex).
204        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        // A space inside a group keeps the byte length right in one spot but
211        // fails the hex check — guards against byte-length-only acceptance.
212        assert!(!looks_like_uuid("8063068 -4da6-45f9-bba8-b888e0ffd58c"));
213    }
214
215    // ---------- parse_with_uuid_fallback ----------
216
217    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        // Flags after the UUID must be forwarded to the synthesized `get`.
235        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        // No rewrite should happen; the original clap error must surface.
248        // `Cli` doesn't derive `Debug`, so `unwrap_err()` doesn't compile —
249        // pull the error out via `match` instead.
250        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        // `list` parses normally — fallback should not engage.
260        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        // `socket-patch get <UUID>` already parses; fallback never runs.
267        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        // Every arg after the program name (UUID included) must be forwarded
277        // after the synthesized `get`, preserving order, so multiple flags
278        // all reach the rewritten command.
279        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        // Only the program name is present (argv.len() == 1). The
294        // `argv.len() >= 2` guard must short-circuit before indexing argv[1],
295        // so this returns the original clap error rather than panicking.
296        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        // The shape check accepts uppercase; confirm the full fallback path
310        // (not just `looks_like_uuid`) rewrites an uppercase bare UUID to get.
311        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        // UUID is valid-shaped so a rewrite is attempted, but `get` doesn't
322        // accept this flag — the rewrite parse fails and we must return the
323        // ORIGINAL error (the one from the un-rewritten parse), not the
324        // rewrite's error.
325        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        // The original parse failed because `<UUID>` isn't a known
334        // subcommand, so the surfaced error must be InvalidSubcommand —
335        // NOT UnknownArgument (which is what the rewrite parse would have
336        // produced).
337        assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
338    }
339}