use std::ffi::{OsStr, OsString};
use std::ops::Deref;
use std::path::Path;
use itertools::intersperse;
use tempfile::TempDir;
use crate::config::Shell;
use crate::hook::Error;
use crate::languages::resolve_command;
use crate::store::Store;
const HOOK_REPO_PLACEHOLDER: &str = "{hook_repo}";
fn expand_placeholders(argv: &mut [OsString], repo_path: &Path) {
for arg in argv {
let Some(value) = arg.to_str() else {
continue;
};
if !value.contains(HOOK_REPO_PLACEHOLDER) {
continue;
}
*arg = intersperse(
value.split(HOOK_REPO_PLACEHOLDER).map(OsStr::new),
repo_path.as_os_str(),
)
.collect();
}
}
#[derive(Debug)]
pub(crate) struct PreparedHookEntry {
argv: Vec<OsString>,
_temp_dir: Option<TempDir>,
}
impl PreparedHookEntry {
pub(crate) fn argv(argv: Vec<OsString>) -> Self {
Self {
argv,
_temp_dir: None,
}
}
fn shell(argv: Vec<OsString>, temp_dir: TempDir) -> Self {
Self {
argv,
_temp_dir: Some(temp_dir),
}
}
pub(crate) fn argv_mut(&mut self) -> &mut Vec<OsString> {
&mut self.argv
}
}
impl Deref for PreparedHookEntry {
type Target = [OsString];
fn deref(&self) -> &Self::Target {
&self.argv
}
}
#[derive(Debug, Clone)]
pub(crate) enum HookEntry {
Argv(ArgvHookEntry),
Shell(ShellHookEntry),
}
impl HookEntry {
pub(crate) fn new(hook: String, entry: String, shell: Option<Shell>) -> Self {
match shell {
Some(shell) => Self::Shell(ShellHookEntry { hook, entry, shell }),
None => Self::Argv(ArgvHookEntry { hook, entry }),
}
}
pub(crate) fn resolve(
&self,
repo_path: &Path,
env_path: Option<&OsStr>,
cwd: &Path,
store: &Store,
) -> Result<PreparedHookEntry, Error> {
match self {
Self::Argv(entry) => entry.resolve(repo_path, env_path, cwd),
Self::Shell(entry) => entry.resolve(env_path, cwd, store),
}
}
pub(crate) fn resolve_script(
&self,
repo_path: &Path,
env_path: Option<&OsStr>,
cwd: &Path,
store: &Store,
) -> Result<PreparedHookEntry, Error> {
match self {
Self::Argv(entry) => entry.resolve_script(repo_path, env_path, cwd),
Self::Shell(entry) => entry.resolve(env_path, cwd, store),
}
}
pub(crate) fn as_argv_entry(&self) -> Option<&ArgvHookEntry> {
match self {
Self::Argv(entry) => Some(entry),
Self::Shell(_) => None,
}
}
pub(crate) fn expect_argv_entry(&self) -> &ArgvHookEntry {
match self {
Self::Argv(entry) => entry,
Self::Shell(entry) => panic!(
"hook `{}` uses `shell` in an argv-only execution path",
entry.hook
),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ArgvHookEntry {
hook: String,
entry: String,
}
impl ArgvHookEntry {
pub(crate) fn resolve(
&self,
repo_path: &Path,
env_path: Option<&OsStr>,
cwd: &Path,
) -> Result<PreparedHookEntry, Error> {
let argv = self.split_expanded(repo_path)?;
let argv = resolve_command(argv, env_path, cwd);
Ok(PreparedHookEntry::argv(argv))
}
fn resolve_script(
&self,
repo_path: &Path,
env_path: Option<&OsStr>,
cwd: &Path,
) -> Result<PreparedHookEntry, Error> {
let mut argv = self.split_expanded(repo_path)?;
argv[0] = repo_path.join(&argv[0]).into_os_string();
let argv = resolve_command(argv, env_path, cwd);
Ok(PreparedHookEntry::argv(argv))
}
pub(crate) fn split(&self) -> Result<Vec<OsString>, Error> {
let splits = shlex::split(&self.entry).ok_or_else(|| Error::Hook {
hook: self.hook.clone(),
error: anyhow::anyhow!("Failed to parse entry `{}` as commands", self.entry),
})?;
if splits.is_empty() {
return Err(Error::Hook {
hook: self.hook.clone(),
error: anyhow::anyhow!("Failed to parse entry: entry is empty"),
});
}
Ok(splits.into_iter().map(OsString::from).collect())
}
pub(crate) fn split_expanded(&self, repo_path: &Path) -> Result<Vec<OsString>, Error> {
let mut argv = self.split()?;
expand_placeholders(&mut argv, repo_path);
Ok(argv)
}
pub(crate) fn split_with_args(&self, args: &[String]) -> Result<Vec<OsString>, Error> {
let mut split = self.split()?;
split.extend(args.iter().map(OsString::from));
Ok(split)
}
pub(crate) fn raw(&self) -> &str {
&self.entry
}
}
#[derive(Debug, Clone)]
pub(crate) struct ShellHookEntry {
hook: String,
entry: String,
shell: Shell,
}
impl ShellHookEntry {
fn resolve(
&self,
env_path: Option<&OsStr>,
cwd: &Path,
store: &Store,
) -> Result<PreparedHookEntry, Error> {
let temp_dir = tempfile::tempdir_in(store.scratch_path())?;
let script_path = temp_dir
.path()
.join("entry")
.with_extension(self.shell.extension());
fs_err::write(&script_path, &self.entry).map_err(|err| Error::Hook {
hook: self.hook.clone(),
error: anyhow::anyhow!(err).context("Failed to write shell entry script"),
})?;
let argv = resolve_command(self.shell.argv_for_script(&script_path), env_path, cwd);
Ok(PreparedHookEntry::shell(argv, temp_dir))
}
}
impl Shell {
fn extension(self) -> &'static str {
match self {
Self::Sh | Self::Bash => "sh",
Self::Pwsh | Self::Powershell => "ps1",
Self::Cmd => "cmd",
}
}
fn argv_for_script(self, script_path: &Path) -> Vec<OsString> {
let script = script_path.as_os_str().to_owned();
match self {
Self::Sh => vec![OsString::from("sh"), OsString::from("-e"), script],
Self::Bash => bash_argv(script),
Self::Pwsh => powershell_argv("pwsh", script),
Self::Powershell => powershell_argv("powershell", script),
Self::Cmd => cmd_argv(script),
}
}
}
fn bash_argv(script: OsString) -> Vec<OsString> {
const BASH_ARGV_PREFIX: &[&str] = &["bash", "--noprofile", "--norc", "-eo", "pipefail"];
let mut argv = BASH_ARGV_PREFIX
.iter()
.map(OsString::from)
.collect::<Vec<_>>();
argv.push(script);
argv
}
fn powershell_argv(command: &str, script: OsString) -> Vec<OsString> {
let mut argv = vec![
OsString::from(command),
OsString::from("-NoProfile"),
OsString::from("-NonInteractive"),
];
#[cfg(windows)]
argv.extend([OsString::from("-ExecutionPolicy"), OsString::from("Bypass")]);
argv.extend([OsString::from("-File"), script]);
argv
}
fn cmd_argv(script: OsString) -> Vec<OsString> {
const CMD_ARGV_PREFIX: &[&str] = &["cmd", "/D", "/E:ON", "/V:OFF", "/S", "/C", "CALL"];
let mut argv = CMD_ARGV_PREFIX
.iter()
.map(OsString::from)
.collect::<Vec<_>>();
argv.push(script);
argv
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::path::Path;
use super::expand_placeholders;
#[test]
fn expand_placeholders_replaces_hook_repo() {
let repo_path = Path::new("hook repo");
let mut argv = vec![
OsString::from("tool"),
OsString::from("--config={hook_repo}/path with spaces/config.toml"),
];
let mut config_arg = OsString::from("--config=");
config_arg.push(repo_path);
config_arg.push("/path with spaces/config.toml");
expand_placeholders(&mut argv, repo_path);
assert_eq!(argv, vec![OsString::from("tool"), config_arg]);
}
}