Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce ShellQuoted to avoid shell quoting issues #56

Merged
merged 10 commits into from
Dec 1, 2023
8 changes: 6 additions & 2 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::shell_quoted::ShellQuoted;
use derive_more::{Display, From};
use std::{env::JoinPathsError, io, num::NonZeroI32, path::PathBuf};

Expand All @@ -13,8 +14,11 @@ pub enum PnError {
ScriptError { name: String, status: NonZeroI32 },

/// Subprocess finishes but without a status code.
#[display(fmt = "Command {command:?} has ended unexpectedly")]
UnexpectedTermination { command: String },
#[display(fmt = "Command ended unexpectedly: {command}")]
UnexpectedTermination {
// using ShellQuoted here so that output can be copy-pasted into shell
command: ShellQuoted,
},
vemoo marked this conversation as resolved.
Show resolved Hide resolved

/// Fail to spawn a subprocess.
#[display(fmt = "Failed to spawn process: {_0}")]
Expand Down
88 changes: 25 additions & 63 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
use clap::Parser;
use cli::Cli;
use error::{MainError, PnError};
use format_buf::format as format_buf;
use indexmap::IndexMap;
use itertools::Itertools;
use os_display::Quoted;
use pipe_trait::Pipe;
use serde::{Deserialize, Serialize};
use shell_quoted::ShellQuoted;
use std::{
borrow::Cow,
env,
ffi::OsString,
fs::File,
Expand All @@ -22,6 +19,7 @@ use yansi::Color::{Black, Red};
mod cli;
mod error;
mod passed_through;
mod shell_quoted;
mod workspace;

/// Structure of `package.json`.
Expand Down Expand Up @@ -64,25 +62,26 @@ fn run() -> Result<(), MainError> {
let manifest = read_package_manifest(&manifest_path)?;
Ok((cwd, manifest))
};
let print_and_run_script = |manifest: &NodeManifest, name: &str, command: &str, cwd: &Path| {
eprintln!(
"\n> {name}@{version} {cwd}",
name = &manifest.name,
version = &manifest.version,
cwd = dunce::canonicalize(cwd)
.unwrap_or_else(|_| cwd.to_path_buf())
.display(),
);
eprintln!("> {command}\n");
run_script(name, command, cwd)
};
let print_and_run_script =
|manifest: &NodeManifest, name: &str, command: ShellQuoted, cwd: &Path| {
eprintln!(
"\n> {name}@{version} {cwd}",
name = &manifest.name,
version = &manifest.version,
cwd = dunce::canonicalize(cwd)
.unwrap_or_else(|_| cwd.to_path_buf())
.display(),
);
eprintln!("> {command}\n");
run_script(name, command, cwd)
};
match cli.command {
cli::Command::Run(args) => {
let (cwd, manifest) = cwd_and_manifest()?;
if let Some(name) = args.script {
if let Some(command) = manifest.scripts.get(&name) {
let command = append_args(command, &args.args);
print_and_run_script(&manifest, &name, &command, &cwd)
let command = ShellQuoted::from_command_and_args(command.into(), &args.args);
print_and_run_script(&manifest, &name, command, &cwd)
} else {
PnError::MissingScript { name }
.pipe(MainError::Pn)
Expand All @@ -105,22 +104,22 @@ fn run() -> Result<(), MainError> {
return pass_to_pnpm(&args); // args already contain name, no need to prepend
}
if let Some(command) = manifest.scripts.get(name) {
let command = append_args(command, &args[1..]);
return print_and_run_script(&manifest, name, &command, &cwd);
let command = ShellQuoted::from_command_and_args(command.into(), &args[1..]);
return print_and_run_script(&manifest, name, command, &cwd);
}
}
pass_to_sub(args.join(" "))
pass_to_sub(ShellQuoted::from_args(args))
}
}
}

fn run_script(name: &str, command: &str, cwd: &Path) -> Result<(), MainError> {
fn run_script(name: &str, command: ShellQuoted, cwd: &Path) -> Result<(), MainError> {
let path_env = create_path_env()?;
let status = Command::new("sh")
.current_dir(cwd)
.env("PATH", path_env)
.arg("-c")
.arg(command)
.arg(&command)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
Expand All @@ -136,26 +135,12 @@ fn run_script(name: &str, command: &str, cwd: &Path) -> Result<(), MainError> {
name: name.to_string(),
status,
},
None => PnError::UnexpectedTermination {
command: command.to_string(),
},
None => PnError::UnexpectedTermination { command },
}
.pipe(MainError::Pn)
.pipe(Err)
}

fn append_args<'a>(command: &'a str, args: &[String]) -> Cow<'a, str> {
if args.is_empty() {
return Cow::Borrowed(command);
}
let mut command = command.to_string();
for arg in args {
let quoted = Quoted::unix(arg); // because pn uses `sh -c` even on Windows
format_buf!(command, " {quoted}");
}
Cow::Owned(command)
}

fn list_scripts(
mut stdout: impl Write,
script_map: impl IntoIterator<Item = (String, String)>,
Expand Down Expand Up @@ -184,12 +169,12 @@ fn pass_to_pnpm(args: &[String]) -> Result<(), MainError> {
Some(None) => return Ok(()),
Some(Some(status)) => MainError::Sub(status),
None => MainError::Pn(PnError::UnexpectedTermination {
command: format!("pnpm {}", args.iter().join(" ")),
command: ShellQuoted::from_command_and_args("pnpm".into(), args),
}),
})
}

fn pass_to_sub(command: String) -> Result<(), MainError> {
fn pass_to_sub(command: ShellQuoted) -> Result<(), MainError> {
let path_env = create_path_env()?;
let status = Command::new("sh")
.env("PATH", path_env)
Expand Down Expand Up @@ -250,29 +235,6 @@ mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;

#[test]
fn test_append_args_empty() {
let command = append_args("echo hello world", &[]);
dbg!(&command);
assert!(matches!(&command, Cow::Borrowed(_)));
}

#[test]
fn test_append_args_non_empty() {
let command = append_args(
"echo hello world",
&[
"abc".to_string(),
"def".to_string(),
"ghi jkl".to_string(),
"\"".to_string(),
],
);
dbg!(&command);
assert!(matches!(&command, Cow::Owned(_)));
assert_eq!(command, r#"echo hello world 'abc' 'def' 'ghi jkl' '"'"#);
}

#[test]
fn test_list_scripts() {
let script_map = [
Expand Down
73 changes: 73 additions & 0 deletions src/shell_quoted.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use os_display::Quoted;
use std::{
ffi::OsStr,
fmt::{self, Write as _},
};

#[derive(Debug, Clone, Default)]
pub struct ShellQuoted(String);

impl fmt::Display for ShellQuoted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
vemoo marked this conversation as resolved.
Show resolved Hide resolved

impl AsRef<OsStr> for ShellQuoted {
fn as_ref(&self) -> &OsStr {
self.0.as_ref()
}
}

impl From<ShellQuoted> for String {
fn from(value: ShellQuoted) -> Self {
value.0
}
}
vemoo marked this conversation as resolved.
Show resolved Hide resolved

impl ShellQuoted {
/// `command` will not be quoted
pub fn from_command(command: String) -> Self {
Self(command)
}

pub fn push_arg<S: AsRef<str>>(&mut self, arg: S) {
let delim = if self.0.is_empty() { "" } else { " " };
let quoted = Quoted::unix(arg.as_ref()); // because pn uses `sh -c` even on Windows
write!(&mut self.0, "{delim}{quoted}").expect("String write doesn't panic");
}

// convenience methods based on usage

vemoo marked this conversation as resolved.
Show resolved Hide resolved
pub fn from_command_and_args<S: AsRef<str>, I: IntoIterator<Item = S>>(
command: String,
args: I,
) -> Self {
vemoo marked this conversation as resolved.
Show resolved Hide resolved
let mut cmd = Self::from_command(command);
for arg in args {
cmd.push_arg(arg);
}
cmd
}

pub fn from_args<S: AsRef<str>, I: IntoIterator<Item = S>>(args: I) -> Self {
Self::from_command_and_args(String::default(), args)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_from_command_and_args() {
let command = ShellQuoted::from_command_and_args(
"echo hello world".into(),
["abc", ";ls /etc", "ghi jkl", "\"", "'"],
);
assert_eq!(
command.to_string(),
r#"echo hello world 'abc' ';ls /etc' 'ghi jkl' '"' "'""#
);
}
}
10 changes: 10 additions & 0 deletions tests/test_main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ fn run_script() {
"\n> [email protected] {}\n> echo hello world '\"me\"'\n\n",
temp_dir.path().pipe(dunce::canonicalize).unwrap().display(),
));

// other command not passthrough
vemoo marked this conversation as resolved.
Show resolved Hide resolved
Command::cargo_bin("pn")
.unwrap()
.current_dir(&temp_dir)
.args(["echo", ";ls"])
.assert()
.success()
.stdout(";ls\n")
.stderr("");
}

#[test]
Expand Down
Loading