1use std::ffi::OsString;
16use std::path::Path;
17
18use strop_containers::ContainerError;
19use strop_core::worker::CancelToken;
20use strop_remote::{RemoteCommand, RemoteCommandError};
21use strop_workspace::{ContainerId, RemoteEndpoint};
22
23use crate::target::RepoTarget;
24
25#[derive(Debug, Clone)]
27pub struct GitRun {
28 pub success: bool,
30 pub code: Option<i32>,
32 pub stdout: Vec<u8>,
33 pub stderr: Vec<u8>,
34 pub stdout_dropped: u64,
38 pub stderr_dropped: u64,
39}
40
41impl GitRun {
42 pub fn require_full_stdout(&self, op: &str) -> Result<&[u8], String> {
46 if self.stdout_dropped > 0 {
47 return Err(format!(
48 "{op}: remote output truncated ({} bytes dropped)",
49 self.stdout_dropped
50 ));
51 }
52 Ok(&self.stdout)
53 }
54}
55
56#[derive(Debug)]
58pub enum GitExecError {
59 Spawn(String),
61 Remote(RemoteCommandError),
65 Container(ContainerError),
70}
71
72impl std::fmt::Display for GitExecError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Self::Spawn(message) => write!(f, "{message}"),
76 Self::Remote(error) => write!(f, "{error}"),
77 Self::Container(error) => write!(f, "{error}"),
78 }
79 }
80}
81
82const CONTAINER_STDOUT_LIMIT: u64 = 16 * 1024 * 1024;
86
87fn argv_text(arg: &OsString) -> Result<String, GitExecError> {
92 arg.clone().into_string().map_err(|arg| {
93 GitExecError::Container(ContainerError::CapabilityRefused {
94 what: format!(
95 "container git: argument is not UTF-8 ({:?})",
96 arg.to_string_lossy()
97 ),
98 })
99 })
100}
101
102#[derive(Debug, Clone)]
105pub enum GitExec<'a> {
106 Local {
107 workdir: &'a Path,
108 },
109 Remote {
110 endpoint: RemoteEndpoint,
111 workdir: &'a Path,
112 },
113 Container {
114 container: ContainerId,
115 workdir: &'a Path,
116 },
117}
118
119impl<'a> GitExec<'a> {
120 pub fn for_target(target: &'a RepoTarget) -> Self {
126 match target {
127 RepoTarget::Local { workdir } => Self::Local { workdir },
128 RepoTarget::Remote { endpoint, workdir } => Self::Remote {
129 endpoint: endpoint.clone(),
130 workdir,
131 },
132 RepoTarget::Container { container, workdir } => Self::Container {
133 container: container.clone(),
134 workdir,
135 },
136 }
137 }
138
139 pub fn run(&self, argv: &[OsString], cancel: &CancelToken) -> Result<GitRun, GitExecError> {
147 match self {
148 Self::Local { workdir } => {
149 let output = std::process::Command::new("git")
150 .arg("-C")
151 .arg(workdir)
152 .args(argv)
153 .output()
154 .map_err(|error| {
155 GitExecError::Spawn(format!(
156 "spawn git {}: {error}",
157 argv.first()
158 .map(|a| a.to_string_lossy().into_owned())
159 .unwrap_or_default()
160 ))
161 })?;
162 Ok(GitRun {
163 success: output.status.success(),
164 code: output.status.code(),
165 stdout: output.stdout,
166 stderr: output.stderr,
167 stdout_dropped: 0,
168 stderr_dropped: 0,
169 })
170 }
171 Self::Remote { endpoint, workdir } => {
172 let command = RemoteCommand::new("git", argv.to_vec(), workdir)
173 .map_err(GitExecError::Remote)?;
174 let output =
175 strop_remote::run(endpoint, &command, cancel).map_err(GitExecError::Remote)?;
176 Ok(GitRun {
177 success: output.status.success(),
178 code: output
179 .status
180 .code()
181 .and_then(|code| i32::try_from(code).ok()),
182 stdout: output.stdout,
183 stderr: output.stderr,
184 stdout_dropped: output.stdout_dropped,
185 stderr_dropped: output.stderr_dropped,
186 })
187 }
188 Self::Container { container, workdir } => {
189 let Some(workdir_text) = workdir.to_str() else {
194 return Err(GitExecError::Container(ContainerError::CapabilityRefused {
195 what: "container git: the working directory is not UTF-8".into(),
196 }));
197 };
198 let mut args = Vec::with_capacity(argv.len() + 2);
199 args.push("-C".to_string());
200 args.push(workdir_text.to_string());
201 for arg in argv {
202 args.push(argv_text(arg)?);
203 }
204 let engine = strop_containers::engine(cancel).map_err(GitExecError::Container)?;
205 let output = strop_containers::exec_capture(
206 &engine,
207 container,
208 "git",
209 &args,
210 workdir,
211 CONTAINER_STDOUT_LIMIT,
212 cancel,
213 )
214 .map_err(GitExecError::Container)?;
215 Ok(GitRun {
216 success: output.code == Some(0),
217 code: output.code,
218 stdout: output.stdout,
219 stderr: output.stderr,
220 stdout_dropped: output.stdout_dropped,
221 stderr_dropped: 0,
224 })
225 }
226 }
227 }
228
229 pub fn run_records(
234 &self,
235 op: &str,
236 argv: &[OsString],
237 cancel: &CancelToken,
238 ) -> Result<Vec<u8>, String> {
239 let run = self
240 .run(argv, cancel)
241 .map_err(|error| format!("{op}: {error}"))?;
242 if !run.success {
243 return Err(format!(
244 "{op}: {}",
245 String::from_utf8_lossy(&run.stderr).trim()
246 ));
247 }
248 if run.stderr_dropped > 0 {
249 return Err(format!(
250 "{op}: remote stderr truncated ({} bytes dropped)",
251 run.stderr_dropped
252 ));
253 }
254 run.require_full_stdout(op).map(|bytes| bytes.to_vec())
255 }
256}
257
258#[cfg(test)]
262pub(crate) fn with_token<T>(work: impl FnOnce(CancelToken) -> T) -> T {
263 let (tokens, receiver) = std::sync::mpsc::channel();
264 let (release, waiting) = std::sync::mpsc::channel::<()>();
265 let owner = strop_core::worker::spawn(
266 "git-exec-test",
267 |_| {},
268 move |token| {
269 tokens.send(token).expect("test receives token");
270 let _ = waiting.recv();
271 strop_core::worker::Outcome::Success(())
272 },
273 );
274 let token = receiver.recv().expect("worker issued token");
275 let result = work(token);
276 drop(release);
277 drop(owner);
278 result
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[cfg(unix)]
286 #[test]
287 fn native_path_arguments_select_the_exact_index_entry() {
288 use std::os::unix::ffi::OsStrExt;
289 let directory = tempfile::tempdir().unwrap();
290 let repo = git2::Repository::init(directory.path()).unwrap();
291 let name = std::ffi::OsStr::from_bytes(b"- odd \xff.txt");
292 let path = std::path::Path::new(name);
293 std::fs::write(directory.path().join(path), "content\n").unwrap();
294 let mut index = repo.index().unwrap();
295 index.add_path(path).unwrap();
296 index.write().unwrap();
297 let exec = GitExec::Local {
298 workdir: directory.path(),
299 };
300 let argv = ["ls-files".into(), "-z".into(), "--".into(), name.into()];
301 let run = with_token(|token| exec.run(&argv, &token)).expect("git runs");
302 assert!(run.success);
303 assert_eq!(run.stdout, b"- odd \xff.txt\0");
304 }
305
306 #[test]
309 fn local_exit_codes_are_data() {
310 let directory = tempfile::tempdir().unwrap();
311 let _repo = git2::Repository::init(directory.path()).unwrap();
312 let exec = GitExec::Local {
313 workdir: directory.path(),
314 };
315 let argv: Vec<OsString> = vec![
316 "rev-parse".into(),
317 "--verify".into(),
318 "--quiet".into(),
319 "no-such-ref".into(),
320 ];
321 let run = with_token(|token| exec.run(&argv, &token)).expect("git runs");
322 assert!(!run.success);
323 assert_eq!(run.code, Some(1));
324 }
325
326 #[test]
329 fn run_records_reports_nonzero_exits() {
330 let directory = tempfile::tempdir().unwrap();
331 let _repo = git2::Repository::init(directory.path()).unwrap();
332 let exec = GitExec::Local {
333 workdir: directory.path(),
334 };
335 let argv: Vec<OsString> = vec!["log".into(), "--format=".into(), "no-such-sha".into()];
336 assert!(
337 with_token(|token| exec.run_records("git log", &argv, &token)).is_err(),
338 "an invalid revision must not become an empty successful record set"
339 );
340 }
341
342 #[test]
344 fn truncated_stdout_is_refused() {
345 let run = GitRun {
346 success: true,
347 code: Some(0),
348 stdout: b"only-a-head".to_vec(),
349 stderr: Vec::new(),
350 stdout_dropped: 4096,
351 stderr_dropped: 0,
352 };
353 let error = run.require_full_stdout("git log").unwrap_err();
354 assert!(error.contains("truncated"), "{error}");
355 }
356
357 #[test]
360 fn for_target_selects_the_only_valid_backend() {
361 let local = RepoTarget::Local {
362 workdir: std::path::PathBuf::from("/w"),
363 };
364 assert!(matches!(GitExec::for_target(&local), GitExec::Local { .. }));
365 let remote = RepoTarget::Remote {
366 endpoint: RemoteEndpoint::parse("ssh://fixture@box:2222").unwrap(),
367 workdir: std::path::PathBuf::from("/srv/proj"),
368 };
369 match GitExec::for_target(&remote) {
370 GitExec::Remote { endpoint, workdir } => {
371 assert_eq!(endpoint, remote.endpoint().unwrap().clone());
372 assert_eq!(workdir, Path::new("/srv/proj"));
373 }
374 other => panic!("remote target built {other:?}"),
375 }
376 }
377
378 #[test]
381 fn for_target_selects_container_for_a_container_target() {
382 let target = RepoTarget::Container {
383 container: ContainerId::canonical("d".repeat(64)).unwrap(),
384 workdir: std::path::PathBuf::from("/work/src"),
385 };
386 match GitExec::for_target(&target) {
387 GitExec::Container { container, workdir } => {
388 assert_eq!(container.as_str(), &"d".repeat(64));
389 assert_eq!(workdir, Path::new("/work/src"));
390 }
391 other => panic!("container target built {other:?}"),
392 }
393 }
394
395 #[cfg(unix)]
400 #[test]
401 fn container_run_refuses_a_non_utf8_workdir_typed() {
402 use std::os::unix::ffi::OsStrExt;
403 let workdir = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(b"/w/\xff"));
404 let exec = GitExec::Container {
405 container: ContainerId::canonical("d".repeat(64)).unwrap(),
406 workdir: &workdir,
407 };
408 let argv: Vec<OsString> = vec!["status".into()];
409 let error = with_token(|token| exec.run(&argv, &token)).unwrap_err();
410 match error {
411 GitExecError::Container(ContainerError::CapabilityRefused { what }) => {
412 assert!(what.contains("UTF-8"), "{what}");
413 }
414 other => panic!("expected a typed capability refusal, got {other:?}"),
415 }
416 }
417
418 #[cfg(unix)]
422 #[test]
423 fn container_run_refuses_a_non_utf8_argument_typed() {
424 use std::os::unix::ffi::OsStrExt;
425 let workdir = std::path::PathBuf::from("/work");
426 let exec = GitExec::Container {
427 container: ContainerId::canonical("d".repeat(64)).unwrap(),
428 workdir: &workdir,
429 };
430 let argv: Vec<OsString> = vec![
431 "ls-files".into(),
432 std::ffi::OsStr::from_bytes(b"\xff.txt").into(),
433 ];
434 let error = with_token(|token| exec.run(&argv, &token)).unwrap_err();
435 assert!(
436 matches!(
437 error,
438 GitExecError::Container(ContainerError::CapabilityRefused { .. })
439 ),
440 "{error:?}"
441 );
442 }
443
444 #[test]
447 fn container_error_displays_the_boundary_diagnosis() {
448 let error = GitExecError::Container(ContainerError::NotRunning { id: "abc".into() });
449 assert_eq!(error.to_string(), "container is not running: abc");
450 }
451}