use std::borrow::Cow;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::process::ExitStatusExt;
use std::process::ExitStatus;
const MAX_DIAGNOSTIC_BYTES: usize = 64;
const REDACTED_ARGUMENT: &str = "<redacted>";
const TRUNCATED_TOKEN: &str = "<truncated>";
const SEPARATOR_TOKEN: &str = ";";
#[derive(Clone, Copy)]
enum ArgumentSensitivity {
Public,
Sensitive,
}
#[derive(Clone)]
struct CommandArg {
value: OsString,
sensitivity: ArgumentSensitivity,
}
impl CommandArg {
fn public(value: OsString) -> Self {
Self {
value,
sensitivity: ArgumentSensitivity::Public,
}
}
fn sensitive(value: OsString) -> Self {
Self {
value,
sensitivity: ArgumentSensitivity::Sensitive,
}
}
fn diagnostic(&self) -> SummaryArgument {
match self.sensitivity {
ArgumentSensitivity::Public => SummaryArgument::Public(escape_diagnostic(&self.value)),
ArgumentSensitivity::Sensitive => SummaryArgument::Sensitive,
}
}
fn lower(&self) -> OsString {
lower_logical_token(&self.value)
}
fn value(&self) -> &OsStr {
&self.value
}
}
impl fmt::Debug for CommandArg {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CommandArg")
.field("diagnostic", &self.diagnostic().as_str())
.finish()
}
}
#[derive(Clone, Eq, PartialEq)]
enum SummaryArgument {
Public(String),
Sensitive,
Separator,
}
impl SummaryArgument {
fn as_str(&self) -> &str {
match self {
Self::Public(value) => value,
Self::Sensitive => REDACTED_ARGUMENT,
Self::Separator => SEPARATOR_TOKEN,
}
}
}
#[derive(Clone)]
#[must_use = "a command has no effect until it is dispatched"]
pub struct Command {
subcommand: CommandArg,
arguments: Vec<CommandArg>,
}
impl Command {
#[must_use = "a command has no effect until it is dispatched"]
pub fn new(subcommand: impl Into<OsString>) -> Self {
Self {
subcommand: CommandArg::public(subcommand.into()),
arguments: Vec::new(),
}
}
#[must_use = "use the returned command to retain the appended argument"]
pub fn arg(mut self, argument: impl Into<OsString>) -> Self {
self.arguments.push(CommandArg::public(argument.into()));
self
}
pub(crate) fn target(&self) -> Option<&OsStr> {
let mut arguments = self.arguments.iter();
while let Some(argument) = arguments.next() {
if argument.value() == OsStr::new("-t") {
return arguments.next().map(CommandArg::value);
}
}
None
}
#[cfg(feature = "control-mode")]
pub(crate) fn control_mode_line(&self) -> Option<String> {
let mut line = render_control_mode_token(&self.subcommand.value)?;
for argument in &self.arguments {
line.push(' ');
line.push_str(&render_control_mode_token(&argument.value)?);
}
Some(line)
}
#[must_use = "use the returned command to retain the appended sensitive argument"]
pub fn sensitive_arg(mut self, argument: impl Into<OsString>) -> Self {
self.arguments.push(CommandArg::sensitive(argument.into()));
self
}
pub(crate) fn targeting(mut self, target: impl Into<OsString>) -> Self {
self.arguments.splice(
0..0,
[
CommandArg::public(OsString::from("-t")),
CommandArg::public(target.into()),
],
);
self
}
#[must_use]
pub fn summary(&self) -> CommandSummary {
CommandSummary::from_parts(
escape_diagnostic(&self.subcommand.value),
self.arguments.iter().map(CommandArg::diagnostic).collect(),
)
}
fn extend_argv(&self, argv: &mut Vec<OsString>) {
argv.push(self.subcommand.lower());
argv.extend(self.arguments.iter().map(CommandArg::lower));
}
fn extend_summary(&self, arguments: &mut Vec<SummaryArgument>) {
arguments.push(self.subcommand.diagnostic());
arguments.extend(self.arguments.iter().map(CommandArg::diagnostic));
}
}
impl fmt::Debug for Command {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Command")
.field("summary", &self.summary())
.finish()
}
}
#[derive(Clone)]
#[must_use = "a chain has no effect until it is dispatched"]
pub struct CommandChain {
first: Command,
rest: Vec<Command>,
}
impl CommandChain {
pub fn new(command: Command) -> Self {
Self {
first: command,
rest: Vec::new(),
}
}
pub fn then(mut self, command: Command) -> Self {
self.rest.push(command);
self
}
#[must_use]
pub fn command_count(&self) -> usize {
1 + self.rest.len()
}
#[must_use]
pub fn summary(&self) -> CommandSummary {
let mut arguments: Vec<SummaryArgument> = self
.first
.arguments
.iter()
.map(CommandArg::diagnostic)
.collect();
for command in &self.rest {
arguments.push(SummaryArgument::Separator);
command.extend_summary(&mut arguments);
}
CommandSummary::from_parts(escape_diagnostic(&self.first.subcommand.value), arguments)
}
fn into_argv(self, global_argv: &[OsString]) -> (Vec<OsString>, usize) {
let mut argv = Vec::with_capacity(global_argv.len() + 2 * self.command_count());
argv.extend_from_slice(global_argv);
let logical_subcommand_index = argv.len();
self.first.extend_argv(&mut argv);
for command in &self.rest {
argv.push(OsString::from(SEPARATOR_TOKEN));
command.extend_argv(&mut argv);
}
(argv, logical_subcommand_index)
}
}
impl fmt::Debug for CommandChain {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CommandChain")
.field("summary", &self.summary())
.field("command_count", &self.command_count())
.finish()
}
}
#[cfg(feature = "control-mode")]
fn render_control_mode_token(value: &OsStr) -> Option<String> {
use std::os::unix::ffi::OsStrExt as _;
let text = std::str::from_utf8(value.as_bytes()).ok()?;
let safe = !text.is_empty()
&& text
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_./=@%:,+".contains(&byte))
&& !opens_a_condition(text);
if safe {
return Some(text.to_owned());
}
let mut rendered = String::with_capacity(text.len() + 2);
rendered.push('"');
for character in text.chars() {
if matches!(character, '\\' | '"') {
rendered.push('\\');
}
rendered.push(character);
}
rendered.push('"');
Some(rendered)
}
#[cfg(feature = "control-mode")]
fn opens_a_condition(text: &str) -> bool {
text.starts_with('%')
&& !text
.bytes()
.all(|byte| byte == b'%' || byte.is_ascii_digit())
}
#[derive(Clone, Eq, PartialEq)]
pub struct CommandSummary {
subcommand: String,
arguments: Vec<SummaryArgument>,
public_argument_count: usize,
sensitive_argument_count: usize,
}
impl CommandSummary {
fn from_parts(subcommand: String, arguments: Vec<SummaryArgument>) -> Self {
let mut public_argument_count = 0;
let mut sensitive_argument_count = 0;
for argument in &arguments {
match argument {
SummaryArgument::Public(_) => public_argument_count += 1,
SummaryArgument::Sensitive => sensitive_argument_count += 1,
SummaryArgument::Separator => {}
}
}
Self {
subcommand,
arguments,
public_argument_count,
sensitive_argument_count,
}
}
#[must_use]
pub const fn argument_count(&self) -> usize {
self.public_argument_count + self.sensitive_argument_count
}
#[must_use]
pub const fn public_argument_count(&self) -> usize {
self.public_argument_count
}
#[must_use]
pub const fn sensitive_argument_count(&self) -> usize {
self.sensitive_argument_count
}
}
impl fmt::Display for CommandSummary {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "\"{}\"", self.subcommand)?;
for argument in &self.arguments {
match argument {
SummaryArgument::Public(value) => write!(formatter, " \"{value}\"")?,
SummaryArgument::Sensitive => write!(formatter, " {REDACTED_ARGUMENT}")?,
SummaryArgument::Separator => write!(formatter, " {SEPARATOR_TOKEN}")?,
}
}
Ok(())
}
}
impl fmt::Debug for CommandSummary {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CommandSummary")
.field("diagnostic", &self.to_string())
.field("argument_count", &self.argument_count())
.field("public_argument_count", &self.public_argument_count)
.field("sensitive_argument_count", &self.sensitive_argument_count)
.finish_non_exhaustive()
}
}
fn escape_diagnostic(value: &OsStr) -> String {
let bytes = value.as_bytes();
let mut escaped = String::with_capacity(bytes.len().min(MAX_DIAGNOSTIC_BYTES));
for &byte in bytes.iter().take(MAX_DIAGNOSTIC_BYTES) {
match byte {
b'\n' => escaped.push_str("\\n"),
b'\r' => escaped.push_str("\\r"),
b'\t' => escaped.push_str("\\t"),
b'\\' => escaped.push_str("\\\\"),
b'"' => escaped.push_str("\\\""),
b' '..=b'~' => escaped.push(char::from(byte)),
_ => push_hex_escape(&mut escaped, byte),
}
}
if bytes.len() > MAX_DIAGNOSTIC_BYTES {
escaped.push_str(TRUNCATED_TOKEN);
}
escaped
}
fn push_hex_escape(output: &mut String, byte: u8) {
const HEX: &[u8; 16] = b"0123456789abcdef";
output.push_str("\\x");
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
fn lower_logical_token(value: &OsStr) -> OsString {
let bytes = value.as_bytes();
if bytes.last() != Some(&b';') {
return value.to_os_string();
}
let mut lowered = Vec::with_capacity(bytes.len() + 1);
lowered.extend_from_slice(&bytes[..bytes.len() - 1]);
lowered.push(b'\\');
lowered.push(b';');
OsString::from_vec(lowered)
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) struct RequestId(u64);
impl RequestId {
pub(crate) const fn new(value: u64) -> Self {
Self(value)
}
pub(crate) const fn get(self) -> u64 {
self.0
}
}
pub(crate) struct CommandRequest {
request_id: RequestId,
command: CommandSummary,
argv: Vec<OsString>,
logical_subcommand_index: usize,
}
impl CommandRequest {
pub(crate) fn new(request_id: RequestId, command: Command) -> Self {
Self::with_global_argv(request_id, &[], command)
}
pub(crate) fn with_global_argv(
request_id: RequestId,
global_argv: &[OsString],
command: Command,
) -> Self {
let summary = command.summary();
let Command {
subcommand,
arguments,
} = command;
let mut argv = Vec::with_capacity(global_argv.len() + arguments.len() + 1);
argv.extend_from_slice(global_argv);
let logical_subcommand_index = argv.len();
argv.push(subcommand.lower());
argv.extend(arguments.iter().map(CommandArg::lower));
Self {
request_id,
command: summary,
argv,
logical_subcommand_index,
}
}
pub(crate) fn chain_with_global_argv(
request_id: RequestId,
global_argv: &[OsString],
chain: CommandChain,
) -> Self {
let command = chain.summary();
let (argv, logical_subcommand_index) = chain.into_argv(global_argv);
Self {
request_id,
command,
argv,
logical_subcommand_index,
}
}
pub(crate) const fn request_id(&self) -> RequestId {
self.request_id
}
pub(crate) fn summary(&self) -> &CommandSummary {
&self.command
}
pub(crate) fn argv(&self) -> &[OsString] {
&self.argv
}
pub(crate) const fn logical_subcommand_index(&self) -> usize {
self.logical_subcommand_index
}
}
impl fmt::Debug for CommandRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CommandRequest")
.field("request_id", &self.request_id)
.field("command", &self.command)
.finish_non_exhaustive()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ProcessStatus {
success: bool,
code: Option<i32>,
signal: Option<i32>,
}
impl ProcessStatus {
pub(crate) fn from_exit_status(status: ExitStatus) -> Self {
Self {
success: status.success(),
code: status.code(),
signal: status.signal(),
}
}
pub(crate) const fn success(self) -> bool {
self.success
}
pub(crate) const fn code(self) -> Option<i32> {
self.code
}
pub(crate) const fn signal(self) -> Option<i32> {
self.signal
}
}
pub struct CommandResult {
request_id: RequestId,
command: CommandSummary,
status: ProcessStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
impl CommandResult {
pub(crate) fn new(
request_id: RequestId,
command: CommandSummary,
status: ProcessStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
) -> Self {
Self {
request_id,
command,
status,
stdout,
stderr,
}
}
#[must_use]
pub const fn request_id(&self) -> u64 {
self.request_id.get()
}
#[must_use]
pub const fn command(&self) -> &CommandSummary {
&self.command
}
#[must_use]
pub fn stdout(&self) -> &[u8] {
&self.stdout
}
#[must_use]
pub fn stderr(&self) -> &[u8] {
&self.stderr
}
#[must_use]
pub fn into_streams(self) -> (Vec<u8>, Vec<u8>) {
(self.stdout, self.stderr)
}
pub fn stdout_utf8(&self) -> Result<&str, std::str::Utf8Error> {
std::str::from_utf8(&self.stdout)
}
pub fn stderr_utf8(&self) -> Result<&str, std::str::Utf8Error> {
std::str::from_utf8(&self.stderr)
}
#[must_use]
pub fn stdout_lossy(&self) -> Cow<'_, str> {
String::from_utf8_lossy(&self.stdout)
}
#[must_use]
pub fn stderr_lossy(&self) -> Cow<'_, str> {
String::from_utf8_lossy(&self.stderr)
}
#[must_use]
pub const fn success(&self) -> bool {
self.status.success()
}
#[must_use]
pub fn refusal_for(&self, operation: &'static str) -> Option<crate::Error> {
if self.success() {
None
} else {
Some(crate::Error::from_refused_result(operation, self, None))
}
}
#[must_use]
pub const fn exit_code(&self) -> Option<i32> {
self.status.code()
}
#[must_use]
pub const fn signal(&self) -> Option<i32> {
self.status.signal()
}
}
impl fmt::Debug for CommandResult {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CommandResult")
.field("request_id", &self.request_id)
.field("command", &self.command)
.field("status", &self.status)
.field("stdout_len", &self.stdout.len())
.field("stderr_len", &self.stderr.len())
.finish()
}
}
#[cfg(test)]
mod tests;