use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use crate::config::{Config, InvalidValue};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum Commands {
#[default]
Refuse,
Run,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Limits {
max_output_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_output_bytes: 1 << 30,
}
}
}
impl Limits {
pub fn max_output_bytes(&self) -> usize {
self.max_output_bytes
}
#[must_use]
pub fn with_max_output_bytes(mut self, max_output_bytes: usize) -> Self {
self.max_output_bytes = max_output_bytes;
self
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Direction {
Clean,
Smudge,
}
impl Direction {
pub(super) fn name(self) -> &'static str {
match self {
Self::Clean => "clean",
Self::Smudge => "smudge",
}
}
}
impl std::fmt::Display for Direction {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.name())
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DriverError {
#[error("running {direction} filter {:?}", String::from_utf8_lossy(driver))]
Io {
driver: Vec<u8>,
direction: Direction,
#[source]
source: std::io::Error,
},
#[error(
"{direction} filter {:?} failed: {status}",
String::from_utf8_lossy(driver)
)]
Failed {
driver: Vec<u8>,
direction: Direction,
status: std::process::ExitStatus,
},
#[error(
"{direction} filter {:?} exceeded the output limit",
String::from_utf8_lossy(driver)
)]
TooLarge {
driver: Vec<u8>,
direction: Direction,
},
#[error(
"{direction} filter {:?} reported an error",
String::from_utf8_lossy(driver)
)]
Rejected {
driver: Vec<u8>,
direction: Direction,
},
#[error("{direction} filter {:?} aborted", String::from_utf8_lossy(driver))]
Aborted {
driver: Vec<u8>,
direction: Direction,
},
#[error(
"required {direction} filter {:?} has no command",
String::from_utf8_lossy(driver)
)]
Missing {
driver: Vec<u8>,
direction: Direction,
},
}
#[derive(Clone, Debug, Default)]
pub(super) struct Driver {
pub(super) clean: Option<Vec<u8>>,
pub(super) smudge: Option<Vec<u8>>,
pub(super) process: Option<Vec<u8>>,
pub(super) required: bool,
}
impl Driver {
pub(super) fn command(&self, direction: Direction) -> Option<&[u8]> {
match direction {
Direction::Clean => self.clean.as_deref(),
Direction::Smudge => self.smudge.as_deref(),
}
}
}
#[derive(Clone, Debug)]
pub struct Drivers {
pub(super) drivers: HashMap<Vec<u8>, Driver>,
pub(super) commands: Commands,
pub(super) limits: Limits,
pub(super) directory: Option<PathBuf>,
}
impl Default for Drivers {
fn default() -> Self {
Self {
drivers: HashMap::new(),
commands: Commands::Refuse,
limits: Limits::default(),
directory: None,
}
}
}
impl Drivers {
pub fn from_config(config: &Config) -> Result<Self, InvalidValue> {
let mut drivers: HashMap<Vec<u8>, Driver> = HashMap::new();
for entry in config.entries() {
let Some(name) = entry
.subsection
.as_ref()
.filter(|_| entry.section.eq_ignore_ascii_case(b"filter"))
else {
continue;
};
let value = entry.value.as_deref();
let key = entry.name.to_ascii_lowercase();
let driver = drivers.entry(name.clone()).or_default();
let command = || value.filter(|value| !value.is_empty()).map(<[u8]>::to_vec);
match key.as_slice() {
b"clean" => driver.clean = command(),
b"smudge" => driver.smudge = command(),
b"process" => driver.process = command(),
b"required" => {
driver.required = crate::config::boolean(value)
.ok_or_else(|| InvalidValue::new("filter", "required", value))?;
}
_ => {}
}
}
Ok(Self {
drivers,
..Self::default()
})
}
pub fn commands(&self) -> Commands {
self.commands
}
#[must_use]
pub fn with_commands(mut self, commands: Commands) -> Self {
self.commands = commands;
self
}
pub fn limits(&self) -> Limits {
self.limits
}
#[must_use]
pub fn with_limits(mut self, limits: Limits) -> Self {
self.limits = limits;
self
}
#[must_use]
pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.directory = Some(directory.into());
self
}
pub(super) fn get(&self, name: &[u8]) -> Option<&Driver> {
self.drivers.get(name)
}
}
pub(super) fn substitute(command: &[u8], path: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(command.len() + path.len() + 2);
let mut bytes = command.iter().copied().peekable();
while let Some(byte) = bytes.next() {
match (byte, bytes.peek()) {
(b'%', Some(b'f')) => {
bytes.next();
out.push(b'\'');
for &byte in path {
if byte == b'\'' {
out.extend_from_slice(b"'\\''");
} else {
out.push(byte);
}
}
out.push(b'\'');
}
(b'%', Some(b'%')) => {
bytes.next();
out.push(b'%');
}
_ => out.push(byte),
}
}
out
}
pub(super) fn run(
name: &[u8],
direction: Direction,
command: &[u8],
path: &[u8],
input: &[u8],
drivers: &Drivers,
) -> Result<Vec<u8>, DriverError> {
let io = |source| DriverError::Io {
driver: name.to_vec(),
direction,
source,
};
let script = substitute(command, path);
let mut shell = Command::new("sh");
shell.arg("-c").arg(os_string(script));
if let Some(directory) = &drivers.directory {
shell.current_dir(directory);
}
let mut child = shell
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(io)?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| io(std::io::Error::other("no stdin")))?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| io(std::io::Error::other("no stdout")))?;
let limit = drivers.limits.max_output_bytes;
let (written, read) = std::thread::scope(|scope| {
let writer = scope.spawn(move || {
match stdin.write_all(input) {
Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
result => result,
}
});
let mut output = Vec::new();
let read = (&mut stdout)
.take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
.read_to_end(&mut output)
.map(|_| output);
if read.as_ref().is_ok_and(|output| output.len() > limit) {
let _ = child.kill();
}
drop(stdout);
(writer.join(), read)
});
let status = child.wait().map_err(io)?;
let output = read.map_err(io)?;
if output.len() > limit {
return Err(DriverError::TooLarge {
driver: name.to_vec(),
direction,
});
}
if !status.success() {
return Err(DriverError::Failed {
driver: name.to_vec(),
direction,
status,
});
}
match written {
Ok(result) => result.map_err(io)?,
Err(_) => return Err(io(std::io::Error::other("stdin writer panicked"))),
}
Ok(output)
}
#[cfg(unix)]
pub(super) fn os_string(bytes: Vec<u8>) -> std::ffi::OsString {
use std::os::unix::ffi::OsStringExt;
std::ffi::OsString::from_vec(bytes)
}
#[cfg(not(unix))]
pub(super) fn os_string(bytes: Vec<u8>) -> std::ffi::OsString {
String::from_utf8_lossy(&bytes).into_owned().into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn substitution_matches_git() {
assert_eq!(substitute(b"%% %f %x", b"a b.p"), b"% 'a b.p' %x");
assert_eq!(substitute(b"cat %f", b"it's"), b"cat 'it'\\''s'");
}
}