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    pub dry_run: bool,
50    /// `--list`: print the config file's session names and exit.
51    pub list: bool,
52    /// `--session NAME`: take settings from `[session.NAME]` in the config file.
53    pub session: Option<String>,
54    /// `--config PATH`: use this config file rather than the default one.
55    pub config: Option<PathBuf>,
56    /// Arguments for ssh, in order, with `-M`, `-f` and `-V` removed.
57    pub ssh_args: Vec<OsString>,
58    /// Where the `-L`/`-R` monitor forwards belong: the position `-M` occupied,
59    /// or 0 when the port came from the environment instead (autossh parity,
60    /// autossh.c:420-427).
61    pub inject_at: usize,
62}
63
64#[derive(Debug, PartialEq, Eq)]
65pub enum ParseError {
66    MissingValue(String),
67    UnknownLongOption(String),
68}
69
70impl fmt::Display for ParseError {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Self::MissingValue(o) => write!(f, "option {o} requires an argument"),
74            Self::UnknownLongOption(o) => write!(f, "unknown option {o}"),
75        }
76    }
77}
78
79impl std::error::Error for ParseError {}
80
81/// Split `argv` (excluding argv\[0\]) into rash's options and ssh's.
82pub fn parse<I, S>(argv: I) -> Result<Invocation, ParseError>
83where
84    I: IntoIterator<Item = S>,
85    S: Into<OsString>,
86{
87    let argv: Vec<OsString> = argv.into_iter().map(Into::into).collect();
88    let mut inv = Invocation::default();
89    let mut saw_monitor = false;
90    let mut after_dashdash = false;
91    let mut i = 0;
92
93    while i < argv.len() {
94        let bytes = argv[i].as_bytes();
95
96        // Past the separator nothing is rewritten. autossh keeps stripping `f`
97        // here (autossh.c:443 runs unconditionally), so `autossh -M 0 host --
98        // cmd -flag` hands ssh `-lag`.
99        if after_dashdash {
100            inv.ssh_args.push(argv[i].clone());
101            i += 1;
102            continue;
103        }
104
105        // The first `--` is rash's own; a second one is passed through.
106        if bytes == b"--" {
107            after_dashdash = true;
108            i += 1;
109            continue;
110        }
111
112        if bytes.starts_with(b"--") {
113            i += parse_long(&mut inv, &argv, i)?;
114            continue;
115        }
116
117        // A lone `-`, or a token not starting with `-`, is not an option: it is
118        // the destination, a remote command word, or a value we did not consume.
119        if bytes.len() < 2 || bytes[0] != b'-' {
120            inv.ssh_args.push(argv[i].clone());
121            i += 1;
122            continue;
123        }
124
125        let mut kept: Vec<u8> = vec![b'-'];
126        let mut detached: Option<OsString> = None;
127        let mut consumed_next = false;
128        let mut j = 1;
129
130        while j < bytes.len() {
131            let c = bytes[j];
132            j += 1;
133
134            match c {
135                // rash appropriates ssh's `-M` (ControlMaster), as autossh does.
136                // Use `-o ControlMaster=yes` if you want ssh's meaning.
137                b'M' => {
138                    if !saw_monitor {
139                        saw_monitor = true;
140                        inv.inject_at = inv.ssh_args.len();
141                    }
142                    let rest = &bytes[j..];
143                    if rest.is_empty() {
144                        let v = argv
145                            .get(i + 1)
146                            .ok_or_else(|| ParseError::MissingValue("-M".into()))?;
147                        inv.monitor = Some(v.clone());
148                        consumed_next = true;
149                    } else {
150                        inv.monitor = Some(OsString::from_vec(rest.to_vec()));
151                    }
152                    break;
153                }
154                b'f' => inv.background = true,
155                b'V' => inv.version = true,
156                c if SSH_VALUE_OPTS.contains(&c) => {
157                    kept.push(c);
158                    let rest = &bytes[j..];
159                    if rest.is_empty() {
160                        if let Some(v) = argv.get(i + 1) {
161                            detached = Some(v.clone());
162                            consumed_next = true;
163                        }
164                    } else {
165                        kept.extend_from_slice(rest);
166                    }
167                    break;
168                }
169                c if SSH_FLAG_OPTS.contains(&c) => kept.push(c),
170                // An unknown letter: we cannot know whether it takes a value, so
171                // keep it and the whole remainder of the token exactly as given
172                // and stop classifying. Letters already stripped were provably
173                // option letters, so stripping them stays safe.
174                _ => {
175                    kept.extend_from_slice(&bytes[j - 1..]);
176                    break;
177                }
178            }
179        }
180
181        // A cluster that was entirely stripped leaves a bare `-`; drop it, as
182        // autossh does (autossh.c:569-571).
183        if kept.len() > 1 {
184            inv.ssh_args.push(OsString::from_vec(kept));
185        }
186        if let Some(v) = detached {
187            inv.ssh_args.push(v);
188        }
189
190        i += 1 + usize::from(consumed_next);
191    }
192
193    Ok(inv)
194}
195
196/// Handle one `--long` option. Returns how many argv entries it consumed.
197fn parse_long(inv: &mut Invocation, argv: &[OsString], i: usize) -> Result<usize, ParseError> {
198    let body = &argv[i].as_bytes()[2..];
199    let (name, inline) = match body.iter().position(|&b| b == b'=') {
200        Some(p) => (&body[..p], Some(OsString::from_vec(body[p + 1..].to_vec()))),
201        None => (body, None),
202    };
203    let name = String::from_utf8_lossy(name).into_owned();
204
205    match name.as_str() {
206        "help" => {
207            inv.help = true;
208            Ok(1)
209        }
210        "version" => {
211            inv.version = true;
212            Ok(1)
213        }
214        "dry-run" => {
215            inv.dry_run = true;
216            Ok(1)
217        }
218        "list" => {
219            inv.list = true;
220            Ok(1)
221        }
222        "monitor" => {
223            let (v, step) = value_for(&name, inline, argv, i)?;
224            inv.monitor_long = Some(v);
225            Ok(step)
226        }
227        "session" => {
228            let (v, step) = value_for(&name, inline, argv, i)?;
229            inv.session = Some(v.to_string_lossy().into_owned());
230            Ok(step)
231        }
232        "config" => {
233            let (v, step) = value_for(&name, inline, argv, i)?;
234            inv.config = Some(PathBuf::from(v));
235            Ok(step)
236        }
237        _ => Err(ParseError::UnknownLongOption(format!("--{name}"))),
238    }
239}
240
241/// Resolve a long option's value from `--name=value` or a following `argv` entry.
242fn value_for(
243    name: &str,
244    inline: Option<OsString>,
245    argv: &[OsString],
246    i: usize,
247) -> Result<(OsString, usize), ParseError> {
248    match inline {
249        Some(v) => Ok((v, 1)),
250        None => argv
251            .get(i + 1)
252            .map(|v| (v.clone(), 2))
253            .ok_or_else(|| ParseError::MissingValue(format!("--{name}"))),
254    }
255}
256
257/// Splice the monitor forwards into `args` at `at`, clamped to the end.
258pub fn splice_forwards(args: &mut Vec<OsString>, at: usize, forwards: Vec<OsString>) {
259    let at = at.min(args.len());
260    args.splice(at..at, forwards);
261}
262
263/// Render an argv the way a shell would need it written, for `--dry-run`.
264pub fn quote(arg: &OsStr) -> String {
265    let s = arg.to_string_lossy();
266    if !s.is_empty()
267        && s.bytes()
268            .all(|b| b.is_ascii_alphanumeric() || b"@%+=:,./-_".contains(&b))
269    {
270        s.into_owned()
271    } else {
272        format!("'{}'", s.replace('\'', r"'\''"))
273    }
274}