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    // ---------- parse_with_uuid_fallback ----------
195
196    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        // Flags after the UUID must be forwarded to the synthesized `get`.
214        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        // No rewrite should happen; the original clap error must surface.
227        // `Cli` doesn't derive `Debug`, so `unwrap_err()` doesn't compile —
228        // pull the error out via `match` instead.
229        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        // `list` parses normally — fallback should not engage.
239        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        // `socket-patch get <UUID>` already parses; fallback never runs.
246        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        // UUID is valid-shaped so a rewrite is attempted, but `get` doesn't
256        // accept this flag — the rewrite parse fails and we must return the
257        // ORIGINAL error (the one from the un-rewritten parse), not the
258        // rewrite's error.
259        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        // The original parse failed because `<UUID>` isn't a known
268        // subcommand, so the surfaced error must be InvalidSubcommand —
269        // NOT UnknownArgument (which is what the rewrite parse would have
270        // produced).
271        assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
272    }
273}