use std::{
convert::AsRef,
ffi::OsStr,
fmt::{Debug, Display},
iter::Iterator,
process::{Command, ExitStatus, Stdio},
str::FromStr,
};
use log::{debug, error, trace, warn};
use serde::{Deserialize, Serialize, Serializer};
use serde_with::{DisplayFromStr, PickFirst, serde_as};
use crate::error::{ErrorKind, RusticError, RusticResult};
#[derive(thiserror::Error, Debug, displaydoc::Display)]
#[non_exhaustive]
pub enum CommandInputErrorKind {
CommandExecutionFailed {
context: String,
what: String,
source: std::io::Error,
},
CommandErrorStatus {
context: String,
what: String,
status: ExitStatus,
},
SplittingArgumentsFailed {
arguments: String,
source: shell_words::ParseError,
},
ProcessExecutionFailed {
command: CommandInput,
path: std::path::PathBuf,
source: std::io::Error,
},
}
pub(crate) type CommandInputResult<T> = Result<T, CommandInputErrorKind>;
#[serde_as]
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
pub struct CommandInput(
)]
#[serde_as(as = "PickFirst<(DisplayFromStr,_)>")] _CommandInput,
);
impl Serialize for CommandInput {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if self.0.on_failure == OnFailure::default() {
serializer.serialize_str(&self.to_string())
} else {
self.0.serialize(serializer)
}
}
}
impl From<Vec<String>> for CommandInput {
fn from(value: Vec<String>) -> Self {
Self(value.into())
}
}
impl From<CommandInput> for Vec<String> {
fn from(value: CommandInput) -> Self {
value.0.iter().cloned().collect()
}
}
impl CommandInput {
#[must_use]
pub fn is_set(&self) -> bool {
!self.0.command.is_empty()
}
#[must_use]
pub fn command(&self) -> &str {
&self.0.command
}
#[must_use]
pub fn args(&self) -> &[String] {
&self.0.args
}
#[must_use]
pub fn on_failure(&self) -> OnFailure {
self.0.on_failure
}
pub fn append_arg(&mut self, arg: String) {
self.0.args.push(arg);
}
pub fn run<I, K, V>(&self, context: &str, what: &str, env: I) -> RusticResult<()>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
if !self.is_set() {
trace!("not calling command {context}:{what} - not set");
return Ok(());
}
debug!("calling command {context}:{what}: {self:?}");
let status = Command::new(self.command())
.args(self.args())
.envs(env)
.status();
self.on_failure().handle_status(status, context, what)?;
Ok(())
}
pub fn stdout(&self) -> RusticResult<Vec<u8>> {
debug!("commands: {self:?}");
let run_command = Command::new(self.command())
.args(self.args())
.stdout(Stdio::piped())
.spawn();
let process = match run_command {
Ok(process) => process,
Err(err) => {
error!("password-command could not be executed: {err}");
return Err(RusticError::with_source(
ErrorKind::Credentials,
"Password command `{command}` could not be executed",
err,
)
.attach_context("command", self.to_string()));
}
};
let output = match process.wait_with_output() {
Ok(output) => output,
Err(err) => {
error!("error reading output from password-command: {err}");
return Err(RusticError::with_source(
ErrorKind::Credentials,
"Error reading output from password command `{command}`",
err,
)
.attach_context("command", self.to_string()));
}
};
if !output.status.success() {
#[allow(clippy::option_if_let_else)]
let s = match output.status.code() {
Some(c) => format!("exited with status code {c}"),
None => "was terminated".into(),
};
error!("password-command {s}");
return Err(RusticError::new(
ErrorKind::Credentials,
"Password command `{command}` did not exit successfully: `{status}`",
)
.attach_context("command", self.to_string())
.attach_context("status", s));
}
Ok(output.stdout)
}
pub(crate) fn uses_plural_placeholders(&self) -> RusticResult<bool> {
let cmd_str = self.to_string();
let has_id = contains_exact(&cmd_str, "%id");
let has_ids = contains_exact(&cmd_str, "%ids");
let has_path = contains_exact(&cmd_str, "%path");
let has_paths = contains_exact(&cmd_str, "%paths");
if !has_id && !has_ids && !has_path && !has_paths {
return Err(RusticError::new(
ErrorKind::MissingInput,
"No placeholder found in warm-up command. Please specify at least one of: %id, %ids, %path, %paths",
)
.attach_context("command", cmd_str));
}
let has_singular = has_id || has_path;
let has_plural = has_ids || has_paths;
if has_singular && has_plural {
return Err(RusticError::new(
ErrorKind::InvalidInput,
"Cannot mix singular (%id, %path) and plural (%ids, %paths) placeholders in warm-up command",
)
.attach_context("command", cmd_str));
}
Ok(has_plural)
}
}
impl FromStr for CommandInput {
type Err = CommandInputErrorKind;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(_CommandInput::from_str(s)?))
}
}
impl Display for CommandInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self.0, f)
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
struct _CommandInput {
command: String,
args: Vec<String>,
on_failure: OnFailure,
}
impl _CommandInput {
fn iter(&self) -> impl Iterator<Item = &String> {
std::iter::once(&self.command).chain(self.args.iter())
}
}
impl From<Vec<String>> for _CommandInput {
fn from(mut value: Vec<String>) -> Self {
if value.is_empty() {
Self::default()
} else {
let command = value.remove(0);
Self {
command,
args: value,
..Default::default()
}
}
}
}
impl FromStr for _CommandInput {
type Err = CommandInputErrorKind;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(split(s)?.into())
}
}
impl Display for _CommandInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = shell_words::join(self.iter());
f.write_str(&s)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OnFailure {
#[default]
Error,
Warn,
Ignore,
}
impl OnFailure {
fn eval<T>(self, res: CommandInputResult<T>) -> RusticResult<Option<T>> {
let res = self.display_result(res);
match (res, self) {
(Err(err), Self::Error) => Err(err),
(Err(_), _) => Ok(None),
(Ok(res), _) => Ok(Some(res)),
}
}
pub fn display_result<T>(self, res: CommandInputResult<T>) -> RusticResult<T> {
if let Err(err) = &res {
match self {
Self::Error => {
error!("{err}");
}
Self::Warn => {
warn!("{err}");
}
Self::Ignore => {}
}
}
res.map_err(|err| {
RusticError::with_source(
ErrorKind::ExternalCommand,
"Experienced an error while calling an external command.",
err,
)
})
}
pub fn handle_status(
self,
status: Result<ExitStatus, std::io::Error>,
context: &str,
what: &str,
) -> RusticResult<()> {
let status = status.map_err(|err| CommandInputErrorKind::CommandExecutionFailed {
context: context.to_string(),
what: what.to_string(),
source: err,
});
let Some(status) = self.eval(status)? else {
return Ok(());
};
if !status.success() {
let _: Option<()> = self.eval(Err(CommandInputErrorKind::CommandErrorStatus {
context: context.to_string(),
what: what.to_string(),
status,
}))?;
}
Ok(())
}
}
fn split(s: &str) -> CommandInputResult<Vec<String>> {
shell_words::split(s).map_err(|err| CommandInputErrorKind::SplittingArgumentsFailed {
arguments: s.to_string(),
source: err,
})
}
fn contains_exact(s: &str, pattern: &str) -> bool {
let mut start = 0;
while let Some(pos) = s[start..].find(pattern) {
let actual_pos = start + pos;
let pattern_end = actual_pos + pattern.len();
let is_word_boundary = pattern_end >= s.len()
|| !s[pattern_end..]
.chars()
.next()
.is_some_and(char::is_alphanumeric);
if is_word_boundary {
return true;
}
start = pattern_end;
}
false
}