Skip to main content

rash/
cli.rs

1//! Splitting `argv` into rash's own options and ssh's.
2//!
3//! autossh validates every argument against a hardcoded `getopt(3)` string
4//! (`OPTION_STRING`, autossh.c:112) and prints usage for anything it does not
5//! recognise, so every new OpenSSH option breaks it until that string is
6//! updated. On OpenSSH 10.3 it already rejects `-B bind_interface` outright and
7//! mis-parses `-P tag` as a boolean.
8//!
9//! rash classifies only what it needs to — `-M`, `-f`, `-V` — and passes
10//! everything else through untouched, so an unrecognised option is forwarded to
11//! ssh rather than being an error here.
12
13use std::ffi::{OsStr, OsString};
14use std::fmt;
15use std::os::unix::ffi::{OsStrExt, OsStringExt};
16use std::path::PathBuf;
17
18/// Short options that take a value, from the ssh(1) synopsis (OpenSSH 10.3p1):
19///
20/// ```text
21/// ssh [-46AaCfGgKkMNnqsTtVvXxYy] [-B bind_interface] [-b bind_address]
22///     [-c cipher_spec] [-D [bind_address:]port] [-E log_file]
23///     [-e escape_char] [-F configfile] [-I pkcs11] [-i identity_file]
24///     [-J destination] [-L address] [-l login_name] [-m mac_spec]
25///     [-O ctl_cmd] [-o option] [-P tag] [-p port] [-R address]
26///     [-S ctl_path] [-T] [-W host:port] [-w local_tun[:remote_tun]]
27///     destination [command [argument ...]]
28/// ssh [-Q query_option]
29/// ```
30const SSH_VALUE_OPTS: &[u8] = b"BbcDEeFIiJLlmOoPpQRSWw";
31
32/// Short options that are booleans, from the same synopsis. `1` and `2` are long
33/// gone from OpenSSH but are kept so old scripts that still pass them work.
34///
35/// `f`, `M` and `V` appear here for faithfulness to ssh's grammar; rash
36/// intercepts all three before this table is consulted.
37const SSH_FLAG_OPTS: &[u8] = b"1246AaCfGgKkMNnqsTtVvXxYy";
38
39/// What rash was asked to do, before any environment or config file is consulted.
40#[derive(Debug, Default, PartialEq, Eq)]
41pub struct Invocation {
42    /// Raw `-M` spec, exactly as given. Validated later, by `config`.
43    pub monitor: Option<OsString>,
44    /// `--monitor SPEC`, which outranks both `-M` and the environment.
45    pub monitor_long: Option<OsString>,
46    pub background: bool,
47    pub version: bool,
48    pub help: bool,
49    /// `--man`: write the manual page to standard output and exit.
50    pub man: bool,
51    pub dry_run: bool,
52    /// `--list`: print the config file's session names and exit.
53    pub list: bool,
54    /// `--session NAME`: take settings from `[session.NAME]` in the config file.
55    pub session: Option<String>,
56    /// `--config PATH`: use this config file rather than the default one.
57    pub config: Option<PathBuf>,
58    /// Arguments for ssh, in order, with `-M`, `-f` and `-V` removed.
59    pub ssh_args: Vec<OsString>,
60    /// Where the `-L`/`-R` monitor forwards belong: the position `-M` occupied,
61    /// or 0 when the port came from the environment instead (autossh parity,
62    /// autossh.c:420-427).
63    pub inject_at: usize,
64}
65
66#[derive(Debug, PartialEq, Eq)]
67pub enum ParseError {
68    MissingValue(String),
69    UnknownLongOption(String),
70}
71
72impl fmt::Display for ParseError {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        match self {
75            Self::MissingValue(o) => write!(f, "option {o} requires an argument"),
76            Self::UnknownLongOption(o) => write!(f, "unknown option {o}"),
77        }
78    }
79}
80
81impl std::error::Error for ParseError {}
82
83/// Split `argv` (excluding argv\[0\]) into rash's options and ssh's.
84pub fn parse<I, S>(argv: I) -> Result<Invocation, ParseError>
85where
86    I: IntoIterator<Item = S>,
87    S: Into<OsString>,
88{
89    let argv: Vec<OsString> = argv.into_iter().map(Into::into).collect();
90    let mut inv = Invocation::default();
91    let mut saw_monitor = false;
92    let mut after_dashdash = false;
93    let mut i = 0;
94
95    while i < argv.len() {
96        let bytes = argv[i].as_bytes();
97
98        // Past the separator nothing is rewritten. autossh keeps stripping `f`
99        // here (autossh.c:443 runs unconditionally), so `autossh -M 0 host --
100        // cmd -flag` hands ssh `-lag`.
101        if after_dashdash {
102            inv.ssh_args.push(argv[i].clone());
103            i += 1;
104            continue;
105        }
106
107        // The first `--` is rash's own; a second one is passed through.
108        if bytes == b"--" {
109            after_dashdash = true;
110            i += 1;
111            continue;
112        }
113
114        if bytes.starts_with(b"--") {
115            i += parse_long(&mut inv, &argv, i)?;
116            continue;
117        }
118
119        // A lone `-`, or a token not starting with `-`, is not an option: it is
120        // the destination, a remote command word, or a value we did not consume.
121        if bytes.len() < 2 || bytes[0] != b'-' {
122            inv.ssh_args.push(argv[i].clone());
123            i += 1;
124            continue;
125        }
126
127        let mut kept: Vec<u8> = vec![b'-'];
128        let mut detached: Option<OsString> = None;
129        let mut consumed_next = false;
130        let mut j = 1;
131
132        while j < bytes.len() {
133            let c = bytes[j];
134            j += 1;
135
136            match c {
137                // rash appropriates ssh's `-M` (ControlMaster), as autossh does.
138                // Use `-o ControlMaster=yes` if you want ssh's meaning.
139                b'M' => {
140                    if !saw_monitor {
141                        saw_monitor = true;
142                        inv.inject_at = inv.ssh_args.len();
143                    }
144                    let rest = &bytes[j..];
145                    if rest.is_empty() {
146                        let v = argv
147                            .get(i + 1)
148                            .ok_or_else(|| ParseError::MissingValue("-M".into()))?;
149                        inv.monitor = Some(v.clone());
150                        consumed_next = true;
151                    } else {
152                        inv.monitor = Some(OsString::from_vec(rest.to_vec()));
153                    }
154                    break;
155                }
156                b'f' => inv.background = true,
157                b'V' => inv.version = true,
158                c if SSH_VALUE_OPTS.contains(&c) => {
159                    kept.push(c);
160                    let rest = &bytes[j..];
161                    if rest.is_empty() {
162                        if let Some(v) = argv.get(i + 1) {
163                            detached = Some(v.clone());
164                            consumed_next = true;
165                        }
166                    } else {
167                        kept.extend_from_slice(rest);
168                    }
169                    break;
170                }
171                c if SSH_FLAG_OPTS.contains(&c) => kept.push(c),
172                // An unknown letter: we cannot know whether it takes a value, so
173                // keep it and the whole remainder of the token exactly as given
174                // and stop classifying. Letters already stripped were provably
175                // option letters, so stripping them stays safe.
176                _ => {
177                    kept.extend_from_slice(&bytes[j - 1..]);
178                    break;
179                }
180            }
181        }
182
183        // A cluster that was entirely stripped leaves a bare `-`; drop it, as
184        // autossh does (autossh.c:569-571).
185        if kept.len() > 1 {
186            inv.ssh_args.push(OsString::from_vec(kept));
187        }
188        if let Some(v) = detached {
189            inv.ssh_args.push(v);
190        }
191
192        i += 1 + usize::from(consumed_next);
193    }
194
195    Ok(inv)
196}
197
198/// Handle one `--long` option. Returns how many argv entries it consumed.
199fn parse_long(inv: &mut Invocation, argv: &[OsString], i: usize) -> Result<usize, ParseError> {
200    let body = &argv[i].as_bytes()[2..];
201    let (name, inline) = match body.iter().position(|&b| b == b'=') {
202        Some(p) => (&body[..p], Some(OsString::from_vec(body[p + 1..].to_vec()))),
203        None => (body, None),
204    };
205    let name = String::from_utf8_lossy(name).into_owned();
206
207    match name.as_str() {
208        "help" => {
209            inv.help = true;
210            Ok(1)
211        }
212        "version" => {
213            inv.version = true;
214            Ok(1)
215        }
216        "man" => {
217            inv.man = true;
218            Ok(1)
219        }
220        "dry-run" => {
221            inv.dry_run = true;
222            Ok(1)
223        }
224        "list" => {
225            inv.list = true;
226            Ok(1)
227        }
228        "monitor" => {
229            let (v, step) = value_for(&name, inline, argv, i)?;
230            inv.monitor_long = Some(v);
231            Ok(step)
232        }
233        "session" => {
234            let (v, step) = value_for(&name, inline, argv, i)?;
235            inv.session = Some(v.to_string_lossy().into_owned());
236            Ok(step)
237        }
238        "config" => {
239            let (v, step) = value_for(&name, inline, argv, i)?;
240            inv.config = Some(PathBuf::from(v));
241            Ok(step)
242        }
243        _ => Err(ParseError::UnknownLongOption(format!("--{name}"))),
244    }
245}
246
247/// Resolve a long option's value from `--name=value` or a following `argv` entry.
248fn value_for(
249    name: &str,
250    inline: Option<OsString>,
251    argv: &[OsString],
252    i: usize,
253) -> Result<(OsString, usize), ParseError> {
254    match inline {
255        Some(v) => Ok((v, 1)),
256        None => argv
257            .get(i + 1)
258            .map(|v| (v.clone(), 2))
259            .ok_or_else(|| ParseError::MissingValue(format!("--{name}"))),
260    }
261}
262
263/// Splice the monitor forwards into `args` at `at`, clamped to the end.
264pub fn splice_forwards(args: &mut Vec<OsString>, at: usize, forwards: Vec<OsString>) {
265    let at = at.min(args.len());
266    args.splice(at..at, forwards);
267}
268
269/// Render an argv the way a shell would need it written, for `--dry-run`.
270pub fn quote(arg: &OsStr) -> String {
271    let s = arg.to_string_lossy();
272    if !s.is_empty()
273        && s.bytes()
274            .all(|b| b.is_ascii_alphanumeric() || b"@%+=:,./-_".contains(&b))
275    {
276        s.into_owned()
277    } else {
278        format!("'{}'", s.replace('\'', r"'\''"))
279    }
280}