use std::ffi::OsString;
use crate::AutosshError;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StrictArgs {
pub monitor: Option<String>,
pub background: bool,
pub version: bool,
pub one_shot: bool,
pub ssh_args: Vec<String>,
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum StrictError {
#[error("{0}")]
UnknownShort(String),
#[error("{0}")]
UnknownLong(String),
#[error("autossh: option requires an argument -- '{0}'")]
MissingValue(char),
#[error("strict parse failed: {0}")]
Internal(String),
}
impl From<StrictError> for AutosshError {
fn from(_err: StrictError) -> Self {
AutosshError::Internal("strict parse error")
}
}
pub fn format_unknown_flag(token: &str) -> String {
if let Some(rest) = token.strip_prefix("--") {
format!("autossh: unrecognized option '--{rest}'")
} else if let Some(rest) = token.strip_prefix('-') {
let first = rest.chars().next().unwrap_or('?');
format!("autossh: invalid option -- '{first}'")
} else {
format!("autossh: unrecognized option '{token}'")
}
}
const EXCLUDED_SHORT: &[char] = &['d', 'D', 'X', 'T', 'a', 'N', 'Y', 'q'];
const EXCLUDED_LONG: &[&str] = &[
"monitor-port",
"poll",
"first-poll",
"gate-time",
"max-start",
"max-lifetime",
"ssh-path",
"log-file",
"pid-file",
"debug",
"log-level",
"background",
"version",
"one-shot",
];
const STRICT_NOOP_LONG: &[&str] = &["strict", "no-strict"];
pub fn parse_argv(argv: &[OsString]) -> Result<StrictArgs, StrictError> {
let mut out = StrictArgs::default();
let mut i = 0;
let mut passthrough = false;
while i < argv.len() {
let raw = argv[i].clone();
let Some(tok) = raw.to_str() else {
out.ssh_args.push(raw.to_string_lossy().into_owned());
i += 1;
continue;
};
if passthrough {
out.ssh_args.push(tok.to_string());
i += 1;
continue;
}
if tok == "--" {
passthrough = true;
i += 1;
continue;
}
if let Some(rest) = tok.strip_prefix("--") {
let name = rest.split('=').next().unwrap_or(rest);
if STRICT_NOOP_LONG.contains(&name) {
i += 1;
continue;
}
if EXCLUDED_LONG.contains(&name) {
return Err(StrictError::UnknownLong(format_unknown_flag(tok)));
}
return Err(StrictError::UnknownLong(format_unknown_flag(tok)));
}
if let Some(rest) = tok.strip_prefix('-') {
if rest == "1" {
out.one_shot = true;
i += 1;
continue;
}
let mut chars = rest.chars();
let Some(first) = chars.next() else {
out.ssh_args.push(tok.to_string());
i += 1;
continue;
};
match first {
'M' => {
let inline: String = chars.collect();
if !inline.is_empty() {
out.monitor = Some(inline);
} else if i + 1 < argv.len() {
let val = argv[i + 1].to_string_lossy().into_owned();
out.monitor = Some(val);
i += 1;
} else {
return Err(StrictError::MissingValue('M'));
}
}
'f' => out.background = true,
'V' => out.version = true,
c if EXCLUDED_SHORT.contains(&c) => {
return Err(StrictError::UnknownShort(format_unknown_flag(tok)));
}
_ => {
return Err(StrictError::UnknownShort(format_unknown_flag(tok)));
}
}
i += 1;
continue;
}
if tok == "completions" {
return Err(StrictError::UnknownLong(format_unknown_flag(tok)));
}
passthrough = true;
out.ssh_args.push(tok.to_string());
i += 1;
}
Ok(out)
}
#[cfg(test)]
#[allow(non_snake_case)] mod tests {
use super::*;
fn argv(s: &[&str]) -> Vec<OsString> {
s.iter().map(|x| OsString::from(*x)).collect()
}
#[test]
fn format_short_unknown_flag_matches_upstream() {
assert_eq!(format_unknown_flag("-X"), "autossh: invalid option -- 'X'");
}
#[test]
fn format_long_unknown_flag_matches_upstream() {
assert_eq!(
format_unknown_flag("--monitor-port"),
"autossh: unrecognized option '--monitor-port'"
);
}
#[test]
fn format_bare_subcommand_token_matches_upstream() {
assert_eq!(
format_unknown_flag("completions"),
"autossh: unrecognized option 'completions'"
);
}
#[test]
fn parses_dash_M_with_separate_value() {
let args = parse_argv(&argv(&["-M", "20000", "user@host"])).unwrap();
assert_eq!(args.monitor.as_deref(), Some("20000"));
assert_eq!(args.ssh_args, vec!["user@host".to_string()]);
}
#[test]
fn parses_dash_M_with_inline_value() {
let args = parse_argv(&argv(&["-M20000", "user@host"])).unwrap();
assert_eq!(args.monitor.as_deref(), Some("20000"));
}
#[test]
fn parses_dash_f_dash_one() {
let args = parse_argv(&argv(&["-f", "-1", "user@host"])).unwrap();
assert!(args.background);
assert!(args.one_shot);
}
#[test]
fn rejects_excluded_short_dash_X() {
let err = parse_argv(&argv(&["-X"])).unwrap_err();
match err {
StrictError::UnknownShort(s) => assert_eq!(s, "autossh: invalid option -- 'X'"),
_ => panic!("expected UnknownShort"),
}
}
#[test]
fn rejects_excluded_long_monitor_port() {
let err = parse_argv(&argv(&["--monitor-port", "20000"])).unwrap_err();
match err {
StrictError::UnknownLong(s) => {
assert_eq!(s, "autossh: unrecognized option '--monitor-port'");
}
_ => panic!("expected UnknownLong"),
}
}
#[test]
fn rejects_completions_subcommand() {
let err = parse_argv(&argv(&["completions", "bash"])).unwrap_err();
match err {
StrictError::UnknownLong(s) => {
assert_eq!(s, "autossh: unrecognized option 'completions'");
}
_ => panic!("expected UnknownLong"),
}
}
#[test]
fn double_dash_starts_passthrough() {
let args = parse_argv(&argv(&["-f", "--", "--strict", "-X"])).unwrap();
assert!(args.background);
assert_eq!(
args.ssh_args,
vec!["--strict".to_string(), "-X".to_string()]
);
}
}