Skip to content

Commit fefd900

Browse files
aalekhpatel07NobodyXuCdnCentreForChildProtection
authored
Allow conversion of std::process::Command into a openssh::Command given a openssh::Session. (#112)
* Add OverSsh trait to allow conversion of std::process::Command into a openssh::Session. Signed-off-by: Aalekh Patel <[email protected]> * Add a doc comment and example usage. Signed-off-by: Aalekh Patel <[email protected]> * Add tests for OverSsh's with_session Signed-off-by: Aalekh Patel <[email protected]> * Use raw_command and raw_args. Refactor the name to 'over_ssh'. Signed-off-by: Aalekh Patel <[email protected]> * Remove a dangling test import. Use better name for test. Signed-off-by: Aalekh Patel <[email protected]> * Clean up doc comment. Signed-off-by: Aalekh Patel <[email protected]> * Apply suggestions of code review. Signed-off-by: Aalekh Patel <[email protected]> * Update src/command.rs Co-authored-by: Jiahao XU <[email protected]> * Apply more suggestions from the code review. Signed-off-by: Aalekh Patel <[email protected]> * Apply more suggestions from the code review. Signed-off-by: Aalekh Patel <[email protected]> * Update src/command.rs Co-authored-by: Jiahao XU <[email protected]> * Update src/lib.rs Co-authored-by: Jiahao XU <[email protected]> * Update src/escape.rs Co-authored-by: Jiahao XU <[email protected]> * Apply more suggestions. Add a test for non-utf8 chars for escape. * Slight refactor of escape. Signed-off-by: Aalekh Patel <[email protected]> * Run cargo clippy --fix and persist all modifications to escape.rs Signed-off-by: Aalekh Patel <[email protected]> * Apply rustfmt to command.rs and escape.rs Signed-off-by: Aalekh Patel <[email protected]> * Lint over_session test Signed-off-by: Aalekh Patel <[email protected]> * Update src/escape.rs Co-authored-by: Jiahao XU <[email protected]> * Update src/escape.rs Co-authored-by: Jiahao XU <[email protected]> * Update src/escape.rs Co-authored-by: Jiahao XU <[email protected]> * Apply more suggestions. Signed-off-by: Aalekh Patel <[email protected]> * Rustfmt fix. Signed-off-by: Aalekh Patel <[email protected]> * Fix a doctest for overssh. Signed-off-by: Aalekh Patel <[email protected]> * Add a test for overssh that requires escaping the argument passed to the command. Signed-off-by: Aalekh Patel <[email protected]> * Update overssh require-escaping-arguments test to contain a space. Signed-off-by: Aalekh Patel <[email protected]> * Apply rustfmt to tests/openssh.rs Signed-off-by: Aalekh Patel <[email protected]> --------- Signed-off-by: Aalekh Patel <[email protected]> Signed-off-by: Aalekh Patel <[email protected]> Co-authored-by: Jiahao XU <[email protected]> Co-authored-by: Aalekh Patel <[email protected]>
1 parent 3319a81 commit fefd900

File tree

5 files changed

+314
-1
lines changed

5 files changed

+314
-1
lines changed

src/command.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use crate::escape::escape;
2+
13
use super::stdio::TryFromChildIo;
24
use super::RemoteChild;
35
use super::Stdio;
@@ -49,6 +51,130 @@ macro_rules! delegate {
4951
}};
5052
}
5153

54+
/// If a command is `OverSsh` then it can be executed over an SSH session.
55+
///
56+
/// Primarily a way to allow `std::process::Command` to be turned directly into an `openssh::Command`.
57+
pub trait OverSsh {
58+
/// Given an ssh session, return a command that can be executed over that ssh session.
59+
///
60+
/// ### Notes
61+
///
62+
/// The command to be executed on the remote machine should not explicitly
63+
/// set environment variables or the current working directory. It errors if the source command
64+
/// has environment variables or a current working directory set, since `openssh` doesn't (yet) have
65+
/// a method to set environment variables and `ssh` doesn't support setting a current working directory
66+
/// outside of `bash/dash/zsh` (which is not always available).
67+
///
68+
/// ### Examples
69+
///
70+
/// 1. Consider the implementation of `OverSsh` for `std::process::Command`. Let's build a
71+
/// `ls -l -a -h` command and execute it over an SSH session.
72+
///
73+
/// ```no_run
74+
/// # #[tokio::main(flavor = "current_thread")]
75+
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
76+
/// use std::process::Command;
77+
/// use openssh::{Session, KnownHosts, OverSsh};
78+
///
79+
/// let session = Session::connect_mux("[email protected]", KnownHosts::Strict).await?;
80+
/// let ls =
81+
/// Command::new("ls")
82+
/// .arg("-l")
83+
/// .arg("-a")
84+
/// .arg("-h")
85+
/// .over_ssh(&session)?
86+
/// .output()
87+
/// .await?;
88+
///
89+
/// assert!(String::from_utf8(ls.stdout).unwrap().contains("total"));
90+
/// # Ok(())
91+
/// }
92+
///
93+
/// ```
94+
/// 2. Building a command with environment variables or a current working directory set will
95+
/// results in an error.
96+
///
97+
/// ```no_run
98+
/// # #[tokio::main(flavor = "current_thread")]
99+
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
100+
/// use std::process::Command;
101+
/// use openssh::{Session, KnownHosts, OverSsh};
102+
///
103+
/// let session = Session::connect_mux("[email protected]", KnownHosts::Strict).await?;
104+
/// let echo =
105+
/// Command::new("echo")
106+
/// .env("MY_ENV_VAR", "foo")
107+
/// .arg("$MY_ENV_VAR")
108+
/// .over_ssh(&session);
109+
/// assert!(matches!(echo, Err(openssh::Error::CommandHasEnv)));
110+
///
111+
/// # Ok(())
112+
/// }
113+
///
114+
/// ```
115+
fn over_ssh<'session>(
116+
&self,
117+
session: &'session Session,
118+
) -> Result<crate::Command<'session>, crate::Error>;
119+
}
120+
121+
impl OverSsh for std::process::Command {
122+
fn over_ssh<'session>(
123+
&self,
124+
session: &'session Session,
125+
) -> Result<Command<'session>, crate::Error> {
126+
// I'd really like `!self.get_envs().is_empty()` here, but that's
127+
// behind a `exact_size_is_empty` feature flag.
128+
if self.get_envs().len() > 0 {
129+
return Err(crate::Error::CommandHasEnv);
130+
}
131+
132+
if self.get_current_dir().is_some() {
133+
return Err(crate::Error::CommandHasCwd);
134+
}
135+
136+
let program_escaped: Cow<'_, OsStr> = escape(self.get_program());
137+
let mut command = session.raw_command(program_escaped);
138+
139+
let args = self.get_args().map(escape);
140+
command.raw_args(args);
141+
Ok(command)
142+
}
143+
}
144+
145+
impl OverSsh for tokio::process::Command {
146+
fn over_ssh<'session>(
147+
&self,
148+
session: &'session Session,
149+
) -> Result<Command<'session>, crate::Error> {
150+
self.as_std().over_ssh(session)
151+
}
152+
}
153+
154+
impl<S> OverSsh for &S
155+
where
156+
S: OverSsh,
157+
{
158+
fn over_ssh<'session>(
159+
&self,
160+
session: &'session Session,
161+
) -> Result<Command<'session>, crate::Error> {
162+
<S as OverSsh>::over_ssh(self, session)
163+
}
164+
}
165+
166+
impl<S> OverSsh for &mut S
167+
where
168+
S: OverSsh,
169+
{
170+
fn over_ssh<'session>(
171+
&self,
172+
session: &'session Session,
173+
) -> Result<Command<'session>, crate::Error> {
174+
<S as OverSsh>::over_ssh(self, session)
175+
}
176+
}
177+
52178
/// A remote process builder, providing fine-grained control over how a new remote process should
53179
/// be spawned.
54180
///

src/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ pub enum Error {
6767
/// IO Error when creating/reading/writing from ChildStdin, ChildStdout, ChildStderr.
6868
#[error("failure while accessing standard i/o of remote process")]
6969
ChildIo(#[source] io::Error),
70+
71+
/// The command has some env variables that it expects to carry over ssh.
72+
/// However, OverSsh does not support passing env variables over ssh.
73+
#[error("rejected runing a command over ssh that expects env variables to be carried over to remote.")]
74+
CommandHasEnv,
75+
76+
/// The command expects to be in a specific working directory in remote.
77+
/// However, OverSsh does not support setting a working directory for commands to be executed over ssh.
78+
#[error("rejected runing a command over ssh that expects a specific working directory to be carried over to remote.")]
79+
CommandHasCwd,
7080
}
7181

7282
#[cfg(feature = "native-mux")]

src/escape.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//! Escape characters that may have special meaning in a shell, including spaces.
2+
//! This is a modified version of the [`shell-escape::unix`] module of [`shell-escape`] crate.
3+
//!
4+
//! [`shell-escape`]: https://crates.io/crates/shell-escape
5+
//! [`shell-escape::unix`]: https://docs.rs/shell-escape/latest/src/shell_escape/lib.rs.html#101
6+
7+
use std::{
8+
borrow::Cow,
9+
ffi::{OsStr, OsString},
10+
os::unix::ffi::OsStrExt,
11+
os::unix::ffi::OsStringExt,
12+
};
13+
14+
fn allowed(byte: u8) -> bool {
15+
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'=' | b'/' | b',' | b'.' | b'+')
16+
}
17+
18+
/// Escape characters that may have special meaning in a shell, including spaces.
19+
///
20+
/// **Note**: This function is an adaptation of [`shell-escape::unix::escape`].
21+
/// This function exists only for type compatibility and the implementation is
22+
/// almost exactly the same as [`shell-escape::unix::escape`].
23+
///
24+
/// [`shell-escape::unix::escape`]: https://docs.rs/shell-escape/latest/src/shell_escape/lib.rs.html#101
25+
///
26+
pub(crate) fn escape(s: &OsStr) -> Cow<'_, OsStr> {
27+
let as_bytes = s.as_bytes();
28+
let all_allowed = as_bytes.iter().copied().all(allowed);
29+
30+
if !as_bytes.is_empty() && all_allowed {
31+
return Cow::Borrowed(s);
32+
}
33+
34+
let mut escaped = Vec::with_capacity(as_bytes.len() + 2);
35+
escaped.push(b'\'');
36+
37+
for &b in as_bytes {
38+
match b {
39+
b'\'' | b'!' => {
40+
escaped.reserve(4);
41+
escaped.push(b'\'');
42+
escaped.push(b'\\');
43+
escaped.push(b);
44+
escaped.push(b'\'');
45+
}
46+
_ => escaped.push(b),
47+
}
48+
}
49+
escaped.push(b'\'');
50+
OsString::from_vec(escaped).into()
51+
}
52+
53+
#[cfg(test)]
54+
mod tests {
55+
use super::*;
56+
57+
fn test_escape_case(input: &str, expected: &str) {
58+
test_escape_from_bytes(input.as_bytes(), expected.as_bytes())
59+
}
60+
61+
fn test_escape_from_bytes(input: &[u8], expected: &[u8]) {
62+
let input_os_str = OsStr::from_bytes(input);
63+
let observed_os_str = escape(input_os_str);
64+
let expected_os_str = OsStr::from_bytes(expected);
65+
assert_eq!(observed_os_str, expected_os_str);
66+
}
67+
68+
// These tests are courtesy of the `shell-escape` crate.
69+
#[test]
70+
fn test_escape() {
71+
test_escape_case(
72+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+",
73+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+",
74+
);
75+
test_escape_case("--aaa=bbb-ccc", "--aaa=bbb-ccc");
76+
test_escape_case(
77+
"linker=gcc -L/foo -Wl,bar",
78+
r#"'linker=gcc -L/foo -Wl,bar'"#,
79+
);
80+
test_escape_case(r#"--features="default""#, r#"'--features="default"'"#);
81+
test_escape_case(r#"'!\$`\\\n "#, r#"''\'''\!'\$`\\\n '"#);
82+
test_escape_case("", r#"''"#);
83+
test_escape_case(" ", r#"' '"#);
84+
test_escape_case("*", r#"'*'"#);
85+
86+
test_escape_from_bytes(
87+
&[0x66, 0x6f, 0x80, 0x6f],
88+
&[b'\'', 0x66, 0x6f, 0x80, 0x6f, b'\''],
89+
);
90+
}
91+
}

src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@ mod builder;
167167
pub use builder::{KnownHosts, SessionBuilder};
168168

169169
mod command;
170-
pub use command::Command;
170+
pub use command::{Command, OverSsh};
171+
172+
mod escape;
171173

172174
mod child;
173175
pub use child::RemoteChild;

tests/openssh.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,90 @@ async fn stdout() {
292292
}
293293
}
294294

295+
#[tokio::test]
296+
#[cfg_attr(not(ci), ignore)]
297+
async fn over_session_ok() {
298+
for session in connects().await {
299+
let mut command = std::process::Command::new("echo")
300+
.arg("foo")
301+
.over_ssh(&session)
302+
.expect("No env vars or current working dir is set.");
303+
304+
let child = command.output().await.unwrap();
305+
assert_eq!(child.stdout, b"foo\n");
306+
307+
let child = session
308+
.command("echo")
309+
.arg("foo")
310+
.raw_arg(">")
311+
.arg("/dev/stderr")
312+
.output()
313+
.await
314+
.unwrap();
315+
assert!(child.stdout.is_empty());
316+
317+
session.close().await.unwrap();
318+
}
319+
}
320+
321+
#[tokio::test]
322+
#[cfg_attr(not(ci), ignore)]
323+
async fn over_session_ok_require_escaping_arguments() {
324+
for session in connects().await {
325+
let mut command = std::process::Command::new("echo")
326+
.arg("\"\'\' foo \'\'\"")
327+
.over_ssh(&session)
328+
.expect("No env vars or current working dir is set.");
329+
330+
let child = command.output().await.unwrap();
331+
assert_eq!(child.stdout, b"\"\'\' foo \'\'\"\n");
332+
333+
let child = session
334+
.command("echo")
335+
.arg("foo")
336+
.raw_arg(">")
337+
.arg("/dev/stderr")
338+
.output()
339+
.await
340+
.unwrap();
341+
assert!(child.stdout.is_empty());
342+
343+
session.close().await.unwrap();
344+
}
345+
}
346+
347+
/// Test that `over_ssh` errors if the source command has env vars specified.
348+
#[tokio::test]
349+
#[cfg_attr(not(ci), ignore)]
350+
async fn over_session_err_because_env_var() {
351+
for session in connects().await {
352+
let command_with_env = std::process::Command::new("printenv")
353+
.arg("MY_ENV_VAR")
354+
.env("MY_ENV_VAR", "foo")
355+
.over_ssh(&session);
356+
assert!(matches!(
357+
command_with_env,
358+
Err(openssh::Error::CommandHasEnv)
359+
));
360+
}
361+
}
362+
363+
/// Test that `over_ssh` errors if the source command has a `current_dir` specified.
364+
#[tokio::test]
365+
#[cfg_attr(not(ci), ignore)]
366+
async fn over_session_err_because_cwd() {
367+
for session in connects().await {
368+
let command_with_current_dir = std::process::Command::new("echo")
369+
.arg("foo")
370+
.current_dir("/tmp")
371+
.over_ssh(&session);
372+
assert!(matches!(
373+
command_with_current_dir,
374+
Err(openssh::Error::CommandHasCwd)
375+
));
376+
}
377+
}
378+
295379
#[tokio::test]
296380
#[cfg_attr(not(ci), ignore)]
297381
async fn shell() {

0 commit comments

Comments
 (0)