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}