Skip to main content

rusty_autossh/
strict.rs

1//! Hand-rolled Strict-mode argv parser.
2//!
3//! Per AD-007 + FR-052 + FR-053: clap's diagnostics cannot byte-equal
4//! upstream `autossh(1)`. This module implements a tokenizer state machine
5//! that:
6//!
7//! - Accepts the in-scope short flags `-M <port[:echo]>`, `-f`, `-V`, `-1`.
8//! - Rejects v0.1-excluded short flags (`-d`, `-D`, `-X`, `-T`, `-a`,
9//!   `-N`, `-Y`, `-q`) with `autossh: invalid option -- '<char>'`.
10//! - Rejects v0.1-excluded long flags (any `--<name>` not listed) AND the
11//!   `completions` subcommand token with
12//!   `autossh: unrecognized option '--<flag>'` (or `'completions'` for
13//!   the subcommand per Clarifications Q3).
14//! - Collects all remaining tokens after the autossh-known prefix as
15//!   ssh argv passthrough per FR-012.
16//!
17//! Full integration with the supervisor + snapshot byte-equality lands in
18//! Phase 5 (T070).
19
20use std::ffi::OsString;
21
22use crate::AutosshError;
23
24/// Result of a successful Strict-mode argv parse.
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct StrictArgs {
27    /// `-M <port[:echo]>` value when supplied.
28    pub monitor: Option<String>,
29    /// `-f` background flag.
30    pub background: bool,
31    /// `-V` version-print flag.
32    pub version: bool,
33    /// `-1` one-shot flag.
34    pub one_shot: bool,
35    /// Remaining argv tokens passed verbatim to ssh.
36    pub ssh_args: Vec<String>,
37}
38
39/// Errors surfaced by [`parse_argv`].
40#[non_exhaustive]
41#[derive(Debug, thiserror::Error)]
42pub enum StrictError {
43    /// An excluded short flag was supplied. The `String` payload is the
44    /// upstream-exact stderr line (per [`format_unknown_flag`]).
45    #[error("{0}")]
46    UnknownShort(String),
47    /// An excluded long flag was supplied. The `String` payload is the
48    /// upstream-exact stderr line.
49    #[error("{0}")]
50    UnknownLong(String),
51    /// An expected argument value was missing (e.g. `-M` with no port).
52    #[error("autossh: option requires an argument -- '{0}'")]
53    MissingValue(char),
54    /// Conversion of [`StrictError`] into [`AutosshError`] for the public
55    /// API.
56    #[error("strict parse failed: {0}")]
57    Internal(String),
58}
59
60impl From<StrictError> for AutosshError {
61    fn from(_err: StrictError) -> Self {
62        // Strict errors are formatted at the CLI boundary; the library
63        // surface only sees a generic Internal tag.
64        AutosshError::Internal("strict parse error")
65    }
66}
67
68/// Format an unknown-flag stderr line in upstream-`autossh 1.4g` style.
69///
70/// - Short flag (single dash + one char): `autossh: invalid option -- '<char>'`.
71/// - Long flag (`--<name>`): `autossh: unrecognized option '--<name>'`.
72/// - Bare subcommand token (e.g. `completions` per Clarifications Q3):
73///   `autossh: unrecognized option '<token>'`.
74///
75/// The literal `autossh:` prefix is preserved (per FR-051 — Strict mode
76/// must produce upstream-byte-equal stderr so upstream-peer interop is
77/// unaffected).
78pub fn format_unknown_flag(token: &str) -> String {
79    if let Some(rest) = token.strip_prefix("--") {
80        // Long flag.
81        format!("autossh: unrecognized option '--{rest}'")
82    } else if let Some(rest) = token.strip_prefix('-') {
83        // Short flag — take only the first char per getopt's diagnostic
84        // format.
85        let first = rest.chars().next().unwrap_or('?');
86        format!("autossh: invalid option -- '{first}'")
87    } else {
88        // Bare subcommand token (e.g. `completions` per Clarifications
89        // Q3).
90        format!("autossh: unrecognized option '{token}'")
91    }
92}
93
94/// Excluded short flags per spec §Strict-Mode Coverage.
95const EXCLUDED_SHORT: &[char] = &['d', 'D', 'X', 'T', 'a', 'N', 'Y', 'q'];
96
97/// Excluded long flags per spec §Strict-Mode Coverage.
98///
99/// Note: `--strict` and `--no-strict` are NOT in this list — they are
100/// consumed by [`crate::mode::resolve`] before strict parsing runs, so the
101/// strict parser treats them as transparent no-ops (otherwise the user
102/// invoking `rusty-autossh --strict ...` would immediately receive an
103/// `autossh: unrecognized option '--strict'` diagnostic, which would be
104/// hostile UX).
105const EXCLUDED_LONG: &[&str] = &[
106    "monitor-port",
107    "poll",
108    "first-poll",
109    "gate-time",
110    "max-start",
111    "max-lifetime",
112    "ssh-path",
113    "log-file",
114    "pid-file",
115    "debug",
116    "log-level",
117    "background",
118    "version",
119    "one-shot",
120];
121
122/// Long flags that the strict parser silently accepts (consumed earlier
123/// by [`crate::mode::resolve`]). They never reach ssh-args passthrough.
124const STRICT_NOOP_LONG: &[&str] = &["strict", "no-strict"];
125
126/// Parse a Strict-mode argv.
127///
128/// Tokens are walked left-to-right:
129/// - `-M <val>` / `-Mval` — consumes the next token (or inline value).
130/// - `-f` / `-V` / `-1` — boolean flags.
131/// - Any other `-X` short flag → [`StrictError::UnknownShort`] (excluded
132///   short flag per FR-052).
133/// - Any `--<name>` long flag → [`StrictError::UnknownLong`] (excluded
134///   long flag per FR-053; covers `completions` subcommand token via
135///   Clarifications Q3).
136/// - `--` separator → switch to ssh-argv-passthrough mode for all
137///   remaining tokens.
138/// - Other tokens → ssh-argv-passthrough.
139pub fn parse_argv(argv: &[OsString]) -> Result<StrictArgs, StrictError> {
140    let mut out = StrictArgs::default();
141    let mut i = 0;
142    let mut passthrough = false;
143
144    while i < argv.len() {
145        let raw = argv[i].clone();
146        let Some(tok) = raw.to_str() else {
147            // Non-UTF8 token — treat as ssh-args passthrough.
148            out.ssh_args.push(raw.to_string_lossy().into_owned());
149            i += 1;
150            continue;
151        };
152
153        if passthrough {
154            out.ssh_args.push(tok.to_string());
155            i += 1;
156            continue;
157        }
158
159        if tok == "--" {
160            passthrough = true;
161            i += 1;
162            continue;
163        }
164
165        // Long-form flag rejection.
166        if let Some(rest) = tok.strip_prefix("--") {
167            // Split off `=value` suffix for matching.
168            let name = rest.split('=').next().unwrap_or(rest);
169            // `--strict`/`--no-strict` are consumed by mode::resolve;
170            // treat them as transparent no-ops here.
171            if STRICT_NOOP_LONG.contains(&name) {
172                i += 1;
173                continue;
174            }
175            if EXCLUDED_LONG.contains(&name) {
176                return Err(StrictError::UnknownLong(format_unknown_flag(tok)));
177            }
178            // Unknown long flag — same diagnostic format.
179            return Err(StrictError::UnknownLong(format_unknown_flag(tok)));
180        }
181
182        // Short flag handling.
183        if let Some(rest) = tok.strip_prefix('-') {
184            // `-1` is a flag, not an option group.
185            if rest == "1" {
186                out.one_shot = true;
187                i += 1;
188                continue;
189            }
190            let mut chars = rest.chars();
191            let Some(first) = chars.next() else {
192                // Bare `-` token → passthrough to ssh.
193                out.ssh_args.push(tok.to_string());
194                i += 1;
195                continue;
196            };
197
198            match first {
199                'M' => {
200                    // Inline value or next token.
201                    let inline: String = chars.collect();
202                    if !inline.is_empty() {
203                        out.monitor = Some(inline);
204                    } else if i + 1 < argv.len() {
205                        let val = argv[i + 1].to_string_lossy().into_owned();
206                        out.monitor = Some(val);
207                        i += 1;
208                    } else {
209                        return Err(StrictError::MissingValue('M'));
210                    }
211                }
212                'f' => out.background = true,
213                'V' => out.version = true,
214                c if EXCLUDED_SHORT.contains(&c) => {
215                    return Err(StrictError::UnknownShort(format_unknown_flag(tok)));
216                }
217                _ => {
218                    // Unknown short flag.
219                    return Err(StrictError::UnknownShort(format_unknown_flag(tok)));
220                }
221            }
222
223            i += 1;
224            continue;
225        }
226
227        // Bare token: per Clarifications Q3 the `completions` subcommand
228        // token in Strict mode is rejected as an unrecognized option.
229        if tok == "completions" {
230            return Err(StrictError::UnknownLong(format_unknown_flag(tok)));
231        }
232
233        // Anything else (e.g. `user@host`, `sleep 60`, etc.) starts the
234        // ssh-args passthrough.
235        passthrough = true;
236        out.ssh_args.push(tok.to_string());
237        i += 1;
238    }
239
240    Ok(out)
241}
242
243#[cfg(test)]
244#[allow(non_snake_case)] // Test names mirror upstream short-flag names (-M, -X).
245mod tests {
246    use super::*;
247
248    fn argv(s: &[&str]) -> Vec<OsString> {
249        s.iter().map(|x| OsString::from(*x)).collect()
250    }
251
252    #[test]
253    fn format_short_unknown_flag_matches_upstream() {
254        assert_eq!(format_unknown_flag("-X"), "autossh: invalid option -- 'X'");
255    }
256
257    #[test]
258    fn format_long_unknown_flag_matches_upstream() {
259        assert_eq!(
260            format_unknown_flag("--monitor-port"),
261            "autossh: unrecognized option '--monitor-port'"
262        );
263    }
264
265    #[test]
266    fn format_bare_subcommand_token_matches_upstream() {
267        assert_eq!(
268            format_unknown_flag("completions"),
269            "autossh: unrecognized option 'completions'"
270        );
271    }
272
273    #[test]
274    fn parses_dash_M_with_separate_value() {
275        let args = parse_argv(&argv(&["-M", "20000", "user@host"])).unwrap();
276        assert_eq!(args.monitor.as_deref(), Some("20000"));
277        assert_eq!(args.ssh_args, vec!["user@host".to_string()]);
278    }
279
280    #[test]
281    fn parses_dash_M_with_inline_value() {
282        let args = parse_argv(&argv(&["-M20000", "user@host"])).unwrap();
283        assert_eq!(args.monitor.as_deref(), Some("20000"));
284    }
285
286    #[test]
287    fn parses_dash_f_dash_one() {
288        let args = parse_argv(&argv(&["-f", "-1", "user@host"])).unwrap();
289        assert!(args.background);
290        assert!(args.one_shot);
291    }
292
293    #[test]
294    fn rejects_excluded_short_dash_X() {
295        let err = parse_argv(&argv(&["-X"])).unwrap_err();
296        match err {
297            StrictError::UnknownShort(s) => assert_eq!(s, "autossh: invalid option -- 'X'"),
298            _ => panic!("expected UnknownShort"),
299        }
300    }
301
302    #[test]
303    fn rejects_excluded_long_monitor_port() {
304        let err = parse_argv(&argv(&["--monitor-port", "20000"])).unwrap_err();
305        match err {
306            StrictError::UnknownLong(s) => {
307                assert_eq!(s, "autossh: unrecognized option '--monitor-port'");
308            }
309            _ => panic!("expected UnknownLong"),
310        }
311    }
312
313    #[test]
314    fn rejects_completions_subcommand() {
315        let err = parse_argv(&argv(&["completions", "bash"])).unwrap_err();
316        match err {
317            StrictError::UnknownLong(s) => {
318                assert_eq!(s, "autossh: unrecognized option 'completions'");
319            }
320            _ => panic!("expected UnknownLong"),
321        }
322    }
323
324    #[test]
325    fn double_dash_starts_passthrough() {
326        let args = parse_argv(&argv(&["-f", "--", "--strict", "-X"])).unwrap();
327        assert!(args.background);
328        assert_eq!(
329            args.ssh_args,
330            vec!["--strict".to_string(), "-X".to_string()]
331        );
332    }
333}