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}