use std::io::{Read, Write};
use std::path::Path;
use std::process::{Child, Command, Output};
pub const REPO_LOCATING_VARS: [&str; 7] = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_COMMON_DIR",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CEILING_DIRECTORIES",
];
pub fn insulate_repo_location(cmd: &mut Command) {
for var in REPO_LOCATING_VARS {
cmd.env_remove(var);
}
}
pub const MESSAGE_LOCALE_VARS: [(&str, LocaleAction); 4] = [
("LC_ALL", LocaleAction::Pin),
("LC_MESSAGES", LocaleAction::Pin),
("LANG", LocaleAction::Pin),
("LANGUAGE", LocaleAction::Drop),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocaleAction {
Pin,
Drop,
}
pub const C_LOCALE: &str = "C";
pub fn pin_message_locale(cmd: &mut Command) {
for (var, action) in MESSAGE_LOCALE_VARS {
match action {
LocaleAction::Pin => cmd.env(var, C_LOCALE),
LocaleAction::Drop => cmd.env_remove(var),
};
}
}
pub const ABSENT_GLOBAL_CONFIG: &str = "absent-global-gitconfig";
pub const ABSENT_SYSTEM_CONFIG: &str = "absent-system-gitconfig";
pub fn insulate_config(cmd: &mut Command, scratch_dir: &Path) {
cmd.env("GIT_CONFIG_GLOBAL", scratch_dir.join(ABSENT_GLOBAL_CONFIG));
cmd.env("GIT_CONFIG_SYSTEM", scratch_dir.join(ABSENT_SYSTEM_CONFIG));
}
pub fn insulated_git(dir: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.current_dir(dir);
insulate_config(&mut cmd, dir);
insulate_repo_location(&mut cmd);
pin_message_locale(&mut cmd);
cmd
}
#[derive(Debug)]
pub enum FeedError {
Spawn(std::io::Error),
Write(std::io::Error),
WriterPanicked,
ReaderPanicked,
Wait(std::io::Error),
}
impl std::fmt::Display for FeedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FeedError::Spawn(e) => write!(f, "could not start the process: {e}"),
FeedError::Write(e) => write!(f, "could not write the input: {e}"),
FeedError::WriterPanicked => write!(f, "the thread feeding the input panicked"),
FeedError::ReaderPanicked => write!(f, "a thread reading the output panicked"),
FeedError::Wait(e) => write!(f, "could not collect the output: {e}"),
}
}
}
impl std::error::Error for FeedError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
FeedError::Spawn(e) | FeedError::Write(e) | FeedError::Wait(e) => Some(e),
FeedError::WriterPanicked | FeedError::ReaderPanicked => None,
}
}
}
pub fn feed_and_wait(cmd: &mut Command, bytes: &[u8]) -> Result<Output, FeedError> {
use std::process::Stdio;
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(FeedError::Spawn)?;
let mut stdin = child.stdin.take().expect("stdin was configured as piped");
let mut child_stdout = child.stdout.take().expect("stdout was configured as piped");
let mut child_stderr = child.stderr.take().expect("stderr was configured as piped");
let outcome = std::thread::scope(|scope| {
let writer = scope.spawn(move || stdin.write_all(bytes));
let out = scope.spawn(move || read_to_end(&mut child_stdout));
let err = scope.spawn(move || read_to_end(&mut child_stderr));
let status = wait_or_kill(&mut child);
FeedOutcome {
written: writer.join(),
stdout: out.join(),
stderr: err.join(),
status,
}
});
assemble(outcome)
}
struct FeedOutcome {
written: std::thread::Result<std::io::Result<()>>,
stdout: std::thread::Result<std::io::Result<Vec<u8>>>,
stderr: std::thread::Result<std::io::Result<Vec<u8>>>,
status: std::io::Result<std::process::ExitStatus>,
}
fn assemble(outcome: FeedOutcome) -> Result<Output, FeedError> {
let FeedOutcome {
written,
stdout,
stderr,
status,
} = outcome;
match written {
Ok(Err(e)) if e.kind() != std::io::ErrorKind::BrokenPipe => {
return Err(FeedError::Write(e));
}
Err(_) => return Err(FeedError::WriterPanicked),
_ => {}
}
let stdout = stdout.map_err(|_| FeedError::ReaderPanicked)?;
let stderr = stderr.map_err(|_| FeedError::ReaderPanicked)?;
Ok(Output {
status: status.map_err(FeedError::Wait)?,
stdout: stdout.map_err(FeedError::Wait)?,
stderr: stderr.map_err(FeedError::Wait)?,
})
}
fn wait_or_kill(child: &mut Child) -> std::io::Result<std::process::ExitStatus> {
let status = child.wait();
if status.is_err() {
let _ = child.kill();
}
status
}
fn read_to_end(pipe: &mut impl Read) -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
pipe.read_to_end(&mut buf)?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_insulation_covers_both_files_git_reads() {
let mut cmd = Command::new("git");
insulate_config(&mut cmd, Path::new("/scratch"));
let envs: Vec<_> = cmd.get_envs().collect();
for (var, name) in [
("GIT_CONFIG_GLOBAL", ABSENT_GLOBAL_CONFIG),
("GIT_CONFIG_SYSTEM", ABSENT_SYSTEM_CONFIG),
] {
let (_, value) = envs
.iter()
.find(|(k, _)| *k == var)
.unwrap_or_else(|| panic!("{var} must be set"));
assert_eq!(
value.map(Path::new),
Some(Path::new("/scratch").join(name).as_path()),
"{var} must point inside the scratch directory"
);
}
}
#[test]
fn a_panicking_reader_is_not_reported_as_a_failed_feed() {
let panicked: std::thread::Result<std::io::Result<Vec<u8>>> =
Err(Box::new("the reading thread panicked"));
let err = assemble(FeedOutcome {
written: Ok(Ok(())),
stdout: panicked,
stderr: Ok(Ok(Vec::new())),
status: Err(std::io::Error::other("the status is never reached")),
})
.expect_err("a panicking reader is an error");
assert!(
err.to_string().contains("reading"),
"the message must name the reading side: {err}"
);
}
#[test]
fn each_stream_of_the_child_lands_in_its_own_field() {
let status = Command::new("git")
.arg("--version")
.stdout(std::process::Stdio::null())
.status();
let out = assemble(FeedOutcome {
written: Ok(Ok(())),
stdout: Ok(Ok(b"out".to_vec())),
stderr: Ok(Ok(b"err".to_vec())),
status,
})
.expect("nothing failed");
assert_eq!(out.stdout, b"out", "stdout must carry what stdout said");
assert_eq!(out.stderr, b"err", "stderr must carry what stderr said");
}
#[test]
fn a_child_that_answers_at_length_is_read_to_the_end() {
let dir = crate::gittest::repo_with_file("a\n");
let mut diff = String::new();
for i in 0..4000 {
diff.push_str(&format!(
"--- a/f{i}\n+++ b/f{i}\n@@ -1 +1 @@\n-a{i}\n+b{i}\n"
));
}
let mut cmd = Command::new("git");
cmd.arg("apply").arg("--check").current_dir(dir.path());
insulate_config(&mut cmd, dir.path());
insulate_repo_location(&mut cmd);
pin_message_locale(&mut cmd);
let out = feed_and_wait(&mut cmd, diff.as_bytes()).expect("git ran and answered");
assert!(!out.status.success(), "the diff does not apply");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.len() > 64 * 1024,
"the child's answer must outgrow a pipe buffer for this to test anything: {} bytes",
stderr.len()
);
assert!(
stderr.contains("f3999"),
"the tail of the answer must survive: {}",
&stderr[stderr.len().saturating_sub(200)..]
);
}
#[test]
fn insulation_covers_every_variable_the_module_names() {
let mut cmd = Command::new("git");
insulate_repo_location(&mut cmd);
pin_message_locale(&mut cmd);
let envs: Vec<_> = cmd.get_envs().collect();
for var in REPO_LOCATING_VARS {
assert!(
envs.iter().any(|(k, v)| *k == var && v.is_none()),
"{var} must be dropped"
);
}
for (var, action) in MESSAGE_LOCALE_VARS {
let entry = envs.iter().find(|(k, _)| *k == var);
let (_, value) = entry.unwrap_or_else(|| panic!("{var} must be set or dropped"));
match action {
LocaleAction::Pin => assert_eq!(
*value,
Some(C_LOCALE.as_ref()),
"{var} must be pinned to the C locale"
),
LocaleAction::Drop => assert!(value.is_none(), "{var} must be dropped"),
}
}
}
}