use std::ffi::OsString;
use std::path::Path;
use strop_containers::ContainerError;
use strop_core::worker::CancelToken;
use strop_remote::{RemoteCommand, RemoteCommandError};
use strop_workspace::{ContainerId, RemoteEndpoint};
use crate::target::RepoTarget;
#[derive(Debug, Clone)]
pub struct GitRun {
pub success: bool,
pub code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub stdout_dropped: u64,
pub stderr_dropped: u64,
}
impl GitRun {
pub fn require_full_stdout(&self, op: &str) -> Result<&[u8], String> {
if self.stdout_dropped > 0 {
return Err(format!(
"{op}: remote output truncated ({} bytes dropped)",
self.stdout_dropped
));
}
Ok(&self.stdout)
}
}
#[derive(Debug)]
pub enum GitExecError {
Spawn(String),
Remote(RemoteCommandError),
Container(ContainerError),
}
impl std::fmt::Display for GitExecError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Spawn(message) => write!(f, "{message}"),
Self::Remote(error) => write!(f, "{error}"),
Self::Container(error) => write!(f, "{error}"),
}
}
}
const CONTAINER_STDOUT_LIMIT: u64 = 16 * 1024 * 1024;
fn argv_text(arg: &OsString) -> Result<String, GitExecError> {
arg.clone().into_string().map_err(|arg| {
GitExecError::Container(ContainerError::CapabilityRefused {
what: format!(
"container git: argument is not UTF-8 ({:?})",
arg.to_string_lossy()
),
})
})
}
#[derive(Debug, Clone)]
pub enum GitExec<'a> {
Local {
workdir: &'a Path,
},
Remote {
endpoint: RemoteEndpoint,
workdir: &'a Path,
},
Container {
container: ContainerId,
workdir: &'a Path,
},
}
impl<'a> GitExec<'a> {
pub fn for_target(target: &'a RepoTarget) -> Self {
match target {
RepoTarget::Local { workdir } => Self::Local { workdir },
RepoTarget::Remote { endpoint, workdir } => Self::Remote {
endpoint: endpoint.clone(),
workdir,
},
RepoTarget::Container { container, workdir } => Self::Container {
container: container.clone(),
workdir,
},
}
}
pub fn run(&self, argv: &[OsString], cancel: &CancelToken) -> Result<GitRun, GitExecError> {
match self {
Self::Local { workdir } => {
let output = std::process::Command::new("git")
.arg("-C")
.arg(workdir)
.args(argv)
.output()
.map_err(|error| {
GitExecError::Spawn(format!(
"spawn git {}: {error}",
argv.first()
.map(|a| a.to_string_lossy().into_owned())
.unwrap_or_default()
))
})?;
Ok(GitRun {
success: output.status.success(),
code: output.status.code(),
stdout: output.stdout,
stderr: output.stderr,
stdout_dropped: 0,
stderr_dropped: 0,
})
}
Self::Remote { endpoint, workdir } => {
let command = RemoteCommand::new("git", argv.to_vec(), workdir)
.map_err(GitExecError::Remote)?;
let output =
strop_remote::run(endpoint, &command, cancel).map_err(GitExecError::Remote)?;
Ok(GitRun {
success: output.status.success(),
code: output
.status
.code()
.and_then(|code| i32::try_from(code).ok()),
stdout: output.stdout,
stderr: output.stderr,
stdout_dropped: output.stdout_dropped,
stderr_dropped: output.stderr_dropped,
})
}
Self::Container { container, workdir } => {
let Some(workdir_text) = workdir.to_str() else {
return Err(GitExecError::Container(ContainerError::CapabilityRefused {
what: "container git: the working directory is not UTF-8".into(),
}));
};
let mut args = Vec::with_capacity(argv.len() + 2);
args.push("-C".to_string());
args.push(workdir_text.to_string());
for arg in argv {
args.push(argv_text(arg)?);
}
let engine = strop_containers::engine(cancel).map_err(GitExecError::Container)?;
let output = strop_containers::exec_capture(
&engine,
container,
"git",
&args,
workdir,
CONTAINER_STDOUT_LIMIT,
cancel,
)
.map_err(GitExecError::Container)?;
Ok(GitRun {
success: output.code == Some(0),
code: output.code,
stdout: output.stdout,
stderr: output.stderr,
stdout_dropped: output.stdout_dropped,
stderr_dropped: 0,
})
}
}
}
pub fn run_records(
&self,
op: &str,
argv: &[OsString],
cancel: &CancelToken,
) -> Result<Vec<u8>, String> {
let run = self
.run(argv, cancel)
.map_err(|error| format!("{op}: {error}"))?;
if !run.success {
return Err(format!(
"{op}: {}",
String::from_utf8_lossy(&run.stderr).trim()
));
}
if run.stderr_dropped > 0 {
return Err(format!(
"{op}: remote stderr truncated ({} bytes dropped)",
run.stderr_dropped
));
}
run.require_full_stdout(op).map(|bytes| bytes.to_vec())
}
}
#[cfg(test)]
pub(crate) fn with_token<T>(work: impl FnOnce(CancelToken) -> T) -> T {
let (tokens, receiver) = std::sync::mpsc::channel();
let (release, waiting) = std::sync::mpsc::channel::<()>();
let owner = strop_core::worker::spawn(
"git-exec-test",
|_| {},
move |token| {
tokens.send(token).expect("test receives token");
let _ = waiting.recv();
strop_core::worker::Outcome::Success(())
},
);
let token = receiver.recv().expect("worker issued token");
let result = work(token);
drop(release);
drop(owner);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn native_path_arguments_select_the_exact_index_entry() {
use std::os::unix::ffi::OsStrExt;
let directory = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(directory.path()).unwrap();
let name = std::ffi::OsStr::from_bytes(b"- odd \xff.txt");
let path = std::path::Path::new(name);
std::fs::write(directory.path().join(path), "content\n").unwrap();
let mut index = repo.index().unwrap();
index.add_path(path).unwrap();
index.write().unwrap();
let exec = GitExec::Local {
workdir: directory.path(),
};
let argv = ["ls-files".into(), "-z".into(), "--".into(), name.into()];
let run = with_token(|token| exec.run(&argv, &token)).expect("git runs");
assert!(run.success);
assert_eq!(run.stdout, b"- odd \xff.txt\0");
}
#[test]
fn local_exit_codes_are_data() {
let directory = tempfile::tempdir().unwrap();
let _repo = git2::Repository::init(directory.path()).unwrap();
let exec = GitExec::Local {
workdir: directory.path(),
};
let argv: Vec<OsString> = vec![
"rev-parse".into(),
"--verify".into(),
"--quiet".into(),
"no-such-ref".into(),
];
let run = with_token(|token| exec.run(&argv, &token)).expect("git runs");
assert!(!run.success);
assert_eq!(run.code, Some(1));
}
#[test]
fn run_records_reports_nonzero_exits() {
let directory = tempfile::tempdir().unwrap();
let _repo = git2::Repository::init(directory.path()).unwrap();
let exec = GitExec::Local {
workdir: directory.path(),
};
let argv: Vec<OsString> = vec!["log".into(), "--format=".into(), "no-such-sha".into()];
assert!(
with_token(|token| exec.run_records("git log", &argv, &token)).is_err(),
"an invalid revision must not become an empty successful record set"
);
}
#[test]
fn truncated_stdout_is_refused() {
let run = GitRun {
success: true,
code: Some(0),
stdout: b"only-a-head".to_vec(),
stderr: Vec::new(),
stdout_dropped: 4096,
stderr_dropped: 0,
};
let error = run.require_full_stdout("git log").unwrap_err();
assert!(error.contains("truncated"), "{error}");
}
#[test]
fn for_target_selects_the_only_valid_backend() {
let local = RepoTarget::Local {
workdir: std::path::PathBuf::from("/w"),
};
assert!(matches!(GitExec::for_target(&local), GitExec::Local { .. }));
let remote = RepoTarget::Remote {
endpoint: RemoteEndpoint::parse("ssh://fixture@box:2222").unwrap(),
workdir: std::path::PathBuf::from("/srv/proj"),
};
match GitExec::for_target(&remote) {
GitExec::Remote { endpoint, workdir } => {
assert_eq!(endpoint, remote.endpoint().unwrap().clone());
assert_eq!(workdir, Path::new("/srv/proj"));
}
other => panic!("remote target built {other:?}"),
}
}
#[test]
fn for_target_selects_container_for_a_container_target() {
let target = RepoTarget::Container {
container: ContainerId::canonical("d".repeat(64)).unwrap(),
workdir: std::path::PathBuf::from("/work/src"),
};
match GitExec::for_target(&target) {
GitExec::Container { container, workdir } => {
assert_eq!(container.as_str(), &"d".repeat(64));
assert_eq!(workdir, Path::new("/work/src"));
}
other => panic!("container target built {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn container_run_refuses_a_non_utf8_workdir_typed() {
use std::os::unix::ffi::OsStrExt;
let workdir = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(b"/w/\xff"));
let exec = GitExec::Container {
container: ContainerId::canonical("d".repeat(64)).unwrap(),
workdir: &workdir,
};
let argv: Vec<OsString> = vec!["status".into()];
let error = with_token(|token| exec.run(&argv, &token)).unwrap_err();
match error {
GitExecError::Container(ContainerError::CapabilityRefused { what }) => {
assert!(what.contains("UTF-8"), "{what}");
}
other => panic!("expected a typed capability refusal, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn container_run_refuses_a_non_utf8_argument_typed() {
use std::os::unix::ffi::OsStrExt;
let workdir = std::path::PathBuf::from("/work");
let exec = GitExec::Container {
container: ContainerId::canonical("d".repeat(64)).unwrap(),
workdir: &workdir,
};
let argv: Vec<OsString> = vec![
"ls-files".into(),
std::ffi::OsStr::from_bytes(b"\xff.txt").into(),
];
let error = with_token(|token| exec.run(&argv, &token)).unwrap_err();
assert!(
matches!(
error,
GitExecError::Container(ContainerError::CapabilityRefused { .. })
),
"{error:?}"
);
}
#[test]
fn container_error_displays_the_boundary_diagnosis() {
let error = GitExecError::Container(ContainerError::NotRunning { id: "abc".into() });
assert_eq!(error.to_string(), "container is not running: abc");
}
}