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}