1use crate::git_endpoint::GithubGitTransport;
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum GitNetworkPolicy {
8 Offline,
10 HttpsWithCredentialManager,
12 GithubSsh,
14}
15
16impl From<GithubGitTransport> for GitNetworkPolicy {
17 fn from(value: GithubGitTransport) -> Self {
18 match value {
19 GithubGitTransport::Https => Self::HttpsWithCredentialManager,
20 GithubGitTransport::Ssh => Self::GithubSsh,
21 }
22 }
23}
24
25#[cfg(windows)]
26mod windows {
27 use std::{
28 collections::BTreeMap,
29 error::Error,
30 ffi::{OsStr, OsString},
31 fmt,
32 fs::{self, File, OpenOptions},
33 io::{Read, Seek, SeekFrom},
34 os::windows::fs::{MetadataExt as _, OpenOptionsExt as _},
35 path::{Component, Path, PathBuf},
36 process::Command,
37 };
38
39 use rustferry_core::windows_system_root;
40 use same_file::Handle as FileIdentityHandle;
41 use sha2::{Digest, Sha256};
42 use windows_sys::Win32::Storage::FileSystem::{
43 FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
44 FILE_SHARE_READ, FILE_SHARE_WRITE,
45 };
46
47 use super::GitNetworkPolicy;
48
49 const MAX_EXECUTABLE_BYTES: u64 = 256 * 1024 * 1024;
50 const WINDOWS_GIT_CMD_DIRECTORY: &str = "cmd";
51 const WINDOWS_GIT_EXECUTABLE: &str = "git.exe";
52 const WINDOWS_GCM_EXECUTABLE: &str = "git-credential-manager.exe";
53 const WINDOWS_HTTPS_HELPER: &str = "git-remote-https.exe";
54 const WINDOWS_SHELL_EXECUTABLE: &str = "sh.exe";
55 const WINDOWS_SSH_EXECUTABLE: &str = "ssh.exe";
56 const WINDOWS_COMMAND_INTERPRETER: &str = "cmd.exe";
57
58 pub struct RetainedGitExecutable {
60 path: PathBuf,
61 identity: FileIdentityHandle,
62 sha256: String,
63 _file: File,
64 }
65
66 impl RetainedGitExecutable {
67 fn open(path: &Path, expected_name: &str) -> Result<Self, GitProcessPolicyError> {
68 let path = canonical_file(path, expected_name)?;
69 let file = open_retained_regular_file(&path)?;
70 let identity = FileIdentityHandle::from_file(
71 file.try_clone()
72 .map_err(|_| GitProcessPolicyError::InvalidExecutable)?,
73 )
74 .map_err(|_| GitProcessPolicyError::InvalidExecutable)?;
75 if FileIdentityHandle::from_path(&path)
76 .map_err(|_| GitProcessPolicyError::InvalidExecutable)?
77 != identity
78 {
79 return Err(GitProcessPolicyError::ExecutableIdentityChanged);
80 }
81 let sha256 = hash_retained_file(&file)?;
82 Ok(Self {
83 path,
84 identity,
85 sha256,
86 _file: file,
87 })
88 }
89
90 pub fn path(&self) -> &Path {
92 &self.path
93 }
94
95 pub const fn identity(&self) -> &FileIdentityHandle {
97 &self.identity
98 }
99
100 pub fn sha256(&self) -> &str {
102 &self.sha256
103 }
104
105 pub fn verify(&self) -> Result<(), GitProcessPolicyError> {
111 if FileIdentityHandle::from_path(&self.path)
112 .map_err(|_| GitProcessPolicyError::ExecutableIdentityChanged)?
113 != self.identity
114 {
115 return Err(GitProcessPolicyError::ExecutableIdentityChanged);
116 }
117 let current = open_retained_regular_file(&self.path)?;
118 if FileIdentityHandle::from_file(
119 current
120 .try_clone()
121 .map_err(|_| GitProcessPolicyError::InvalidExecutable)?,
122 )
123 .map_err(|_| GitProcessPolicyError::InvalidExecutable)?
124 != self.identity
125 || hash_retained_file(¤t)? != self.sha256
126 {
127 return Err(GitProcessPolicyError::ExecutableIdentityChanged);
128 }
129 Ok(())
130 }
131 }
132
133 impl fmt::Debug for RetainedGitExecutable {
134 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135 formatter
136 .debug_struct("RetainedGitExecutable")
137 .field("name", &self.path.file_name())
138 .field("identity", &self.identity)
139 .field("sha256", &self.sha256)
140 .finish_non_exhaustive()
141 }
142 }
143
144 struct RetainedToolDirectory {
145 path: PathBuf,
146 identity: FileIdentityHandle,
147 _file: File,
148 }
149
150 impl RetainedToolDirectory {
151 fn open(path: &Path) -> Result<Self, GitProcessPolicyError> {
152 let path = canonical_directory(path)?;
153 let mut options = OpenOptions::new();
154 options
155 .read(true)
156 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
157 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT);
158 let file = options
159 .open(&path)
160 .map_err(|_| GitProcessPolicyError::InvalidToolLayout)?;
161 let identity = FileIdentityHandle::from_file(
162 file.try_clone()
163 .map_err(|_| GitProcessPolicyError::InvalidToolLayout)?,
164 )
165 .map_err(|_| GitProcessPolicyError::InvalidToolLayout)?;
166 if FileIdentityHandle::from_path(&path)
167 .map_err(|_| GitProcessPolicyError::InvalidToolLayout)?
168 != identity
169 {
170 return Err(GitProcessPolicyError::ToolLayoutIdentityChanged);
171 }
172 Ok(Self {
173 path,
174 identity,
175 _file: file,
176 })
177 }
178
179 fn verify(&self) -> Result<(), GitProcessPolicyError> {
180 if FileIdentityHandle::from_path(&self.path)
181 .map_err(|_| GitProcessPolicyError::ToolLayoutIdentityChanged)?
182 != self.identity
183 {
184 return Err(GitProcessPolicyError::ToolLayoutIdentityChanged);
185 }
186 Ok(())
187 }
188 }
189
190 impl fmt::Debug for RetainedToolDirectory {
191 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192 formatter
193 .debug_struct("RetainedToolDirectory")
194 .field("name", &self.path.file_name())
195 .field("identity", &self.identity)
196 .finish_non_exhaustive()
197 }
198 }
199
200 #[derive(Debug)]
207 pub struct WindowsGitToolchain {
208 git_root: RetainedToolDirectory,
209 git_cmd_directory: RetainedToolDirectory,
210 git_bin_directory: RetainedToolDirectory,
211 git_exec_directory: RetainedToolDirectory,
212 git_usr_bin_directory: RetainedToolDirectory,
213 system_directory: RetainedToolDirectory,
214 openssh_directory: RetainedToolDirectory,
215 git: RetainedGitExecutable,
216 git_runtime: RetainedGitExecutable,
217 git_exec_runtime: RetainedGitExecutable,
218 credential_manager: RetainedGitExecutable,
219 https_helper: RetainedGitExecutable,
220 shell: RetainedGitExecutable,
221 ssh: RetainedGitExecutable,
222 command_interpreter: RetainedGitExecutable,
223 system_root: PathBuf,
224 fixed_path: OsString,
225 fixed_ssh_command: OsString,
226 }
227
228 impl WindowsGitToolchain {
229 pub fn new(git_executable: impl AsRef<Path>) -> Result<Self, GitProcessPolicyError> {
236 let git = RetainedGitExecutable::open(git_executable.as_ref(), WINDOWS_GIT_EXECUTABLE)?;
237 let git_cmd_path = git
238 .path()
239 .parent()
240 .ok_or(GitProcessPolicyError::InvalidToolLayout)?;
241 if !file_name_eq(git_cmd_path, WINDOWS_GIT_CMD_DIRECTORY) {
242 return Err(GitProcessPolicyError::InvalidToolLayout);
243 }
244 let git_root_path = git_cmd_path
245 .parent()
246 .ok_or(GitProcessPolicyError::InvalidToolLayout)?;
247 let system_root = windows_system_root()
248 .map_err(|_| GitProcessPolicyError::InvalidWindowsDirectory)?;
249 let system_drive_root = system_root
250 .parent()
251 .ok_or(GitProcessPolicyError::InvalidWindowsDirectory)?;
252 let standard_git_root = fs::canonicalize(system_drive_root.join("Program Files/Git"))
253 .map_err(|_| GitProcessPolicyError::InvalidToolLayout)?;
254 if fs::canonicalize(git_root_path)
255 .map_err(|_| GitProcessPolicyError::InvalidToolLayout)?
256 != standard_git_root
257 {
258 return Err(GitProcessPolicyError::InvalidToolLayout);
259 }
260 let git_root = RetainedToolDirectory::open(git_root_path)?;
261 let git_cmd_directory = RetainedToolDirectory::open(git_cmd_path)?;
262 let git_bin_directory =
263 RetainedToolDirectory::open(&git_root.path.join("mingw64/bin"))?;
264 let git_exec_directory =
265 RetainedToolDirectory::open(&git_root.path.join("mingw64/libexec/git-core"))?;
266 let git_usr_bin_directory =
267 RetainedToolDirectory::open(&git_root.path.join("usr/bin"))?;
268 let git_runtime = RetainedGitExecutable::open(
271 &git_bin_directory.path.join(WINDOWS_GIT_EXECUTABLE),
272 WINDOWS_GIT_EXECUTABLE,
273 )?;
274 let git_exec_runtime = RetainedGitExecutable::open(
275 &git_exec_directory.path.join(WINDOWS_GIT_EXECUTABLE),
276 WINDOWS_GIT_EXECUTABLE,
277 )?;
278 let credential_manager = RetainedGitExecutable::open(
279 &git_bin_directory.path.join(WINDOWS_GCM_EXECUTABLE),
280 WINDOWS_GCM_EXECUTABLE,
281 )?;
282 let https_helper = RetainedGitExecutable::open(
283 &git_exec_directory.path.join(WINDOWS_HTTPS_HELPER),
284 WINDOWS_HTTPS_HELPER,
285 )?;
286 let shell = RetainedGitExecutable::open(
287 &git_usr_bin_directory.path.join(WINDOWS_SHELL_EXECUTABLE),
288 WINDOWS_SHELL_EXECUTABLE,
289 )?;
290
291 let system_directory = RetainedToolDirectory::open(&system_root.join("System32"))?;
292 let openssh_directory =
293 RetainedToolDirectory::open(&system_directory.path.join("OpenSSH"))?;
294 let ssh = RetainedGitExecutable::open(
295 &openssh_directory.path.join(WINDOWS_SSH_EXECUTABLE),
296 WINDOWS_SSH_EXECUTABLE,
297 )?;
298 let command_interpreter = RetainedGitExecutable::open(
299 &system_directory.path.join(WINDOWS_COMMAND_INTERPRETER),
300 WINDOWS_COMMAND_INTERPRETER,
301 )?;
302 let fixed_path_entries = [
303 external_path(&openssh_directory.path),
304 external_path(&git_bin_directory.path),
305 external_path(&git_usr_bin_directory.path),
306 external_path(&system_directory.path),
307 ];
308 let fixed_path = std::env::join_paths(&fixed_path_entries)
309 .map_err(|_| GitProcessPolicyError::InvalidToolLayout)?;
310 let fixed_ssh_command = fixed_windows_ssh_command(ssh.path())?;
311 let toolchain = Self {
312 git_root,
313 git_cmd_directory,
314 git_bin_directory,
315 git_exec_directory,
316 git_usr_bin_directory,
317 system_directory,
318 openssh_directory,
319 git,
320 git_runtime,
321 git_exec_runtime,
322 credential_manager,
323 https_helper,
324 shell,
325 ssh,
326 command_interpreter,
327 system_root,
328 fixed_path,
329 fixed_ssh_command,
330 };
331 toolchain.verify()?;
332 Ok(toolchain)
333 }
334
335 pub const fn git(&self) -> &RetainedGitExecutable {
337 &self.git
338 }
339
340 pub const fn credential_manager(&self) -> &RetainedGitExecutable {
342 &self.credential_manager
343 }
344
345 #[cfg(test)]
346 pub(crate) const fn git_runtime(&self) -> &RetainedGitExecutable {
347 &self.git_runtime
348 }
349
350 #[cfg(test)]
351 pub(crate) const fn git_exec_runtime(&self) -> &RetainedGitExecutable {
352 &self.git_exec_runtime
353 }
354
355 pub const fn ssh(&self) -> &RetainedGitExecutable {
357 &self.ssh
358 }
359
360 pub const fn https_helper(&self) -> &RetainedGitExecutable {
362 &self.https_helper
363 }
364
365 pub fn system_root(&self) -> &Path {
367 &self.system_root
368 }
369
370 #[cfg(test)]
371 pub(crate) fn fixed_path(&self) -> &OsStr {
372 &self.fixed_path
373 }
374
375 pub fn verify(&self) -> Result<(), GitProcessPolicyError> {
381 for directory in [
382 &self.git_root,
383 &self.git_cmd_directory,
384 &self.git_bin_directory,
385 &self.git_exec_directory,
386 &self.git_usr_bin_directory,
387 &self.system_directory,
388 &self.openssh_directory,
389 ] {
390 directory.verify()?;
391 }
392 for executable in [
393 &self.git,
394 &self.git_runtime,
395 &self.git_exec_runtime,
396 &self.credential_manager,
397 &self.https_helper,
398 &self.shell,
399 &self.ssh,
400 &self.command_interpreter,
401 ] {
402 executable.verify()?;
403 }
404 Ok(())
405 }
406
407 pub fn process_spec<'a>(
413 &'a self,
414 context: &GitProcessContext,
415 network: GitNetworkPolicy,
416 arguments: impl IntoIterator<Item = impl Into<OsString>>,
417 ) -> Result<GitProcessSpec<'a>, GitProcessPolicyError> {
418 self.verify()?;
419 context.verify()?;
420 let mut fixed_arguments = fixed_git_arguments(network);
421 fixed_arguments.extend(arguments.into_iter().map(Into::into));
422 let environment = self.fixed_environment(context, network);
423 Ok(GitProcessSpec {
424 toolchain: self,
425 arguments: fixed_arguments,
426 environment,
427 current_directory: context.cwd.clone(),
428 })
429 }
430
431 fn fixed_environment(
432 &self,
433 context: &GitProcessContext,
434 network: GitNetworkPolicy,
435 ) -> BTreeMap<OsString, OsString> {
436 let allowed_protocol = match network {
437 GitNetworkPolicy::Offline => "",
438 GitNetworkPolicy::HttpsWithCredentialManager => "https",
439 GitNetworkPolicy::GithubSsh => "ssh",
440 };
441 let mut environment = BTreeMap::from([
442 (
443 OsString::from("COMSPEC"),
444 external_path(self.command_interpreter.path()),
445 ),
446 (OsString::from("GCM_INTERACTIVE"), OsString::from("Never")),
447 (OsString::from("GIT_ATTR_NOSYSTEM"), OsString::from("1")),
448 (
449 OsString::from("GIT_ALLOW_PROTOCOL"),
450 OsString::from(allowed_protocol),
451 ),
452 (OsString::from("GIT_CONFIG_GLOBAL"), OsString::from("NUL")),
453 (OsString::from("GIT_CONFIG_NOSYSTEM"), OsString::from("1")),
454 (OsString::from("GIT_CONFIG_SYSTEM"), OsString::from("NUL")),
455 (
456 OsString::from("GIT_EXEC_PATH"),
457 external_path(&self.git_exec_directory.path),
458 ),
459 (
460 OsString::from("GIT_NO_REPLACE_OBJECTS"),
461 OsString::from("1"),
462 ),
463 (OsString::from("GIT_NO_LAZY_FETCH"), OsString::from("1")),
464 (OsString::from("GIT_OPTIONAL_LOCKS"), OsString::from("0")),
465 (OsString::from("GIT_PAGER"), OsString::from("cat")),
466 (OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0")),
467 (OsString::from("HOME"), external_path(&context.home)),
468 (OsString::from("LANG"), OsString::from("C")),
469 (OsString::from("LC_ALL"), OsString::from("C")),
470 (OsString::from("PATH"), self.fixed_path.clone()),
471 (
472 OsString::from("PATHEXT"),
473 OsString::from(".COM;.EXE;.BAT;.CMD"),
474 ),
475 (OsString::from("PROGRAMDATA"), external_path(&context.home)),
476 (
477 OsString::from("SystemRoot"),
478 external_path(&self.system_root),
479 ),
480 (OsString::from("TEMP"), external_path(&context.temp)),
481 (OsString::from("TMP"), external_path(&context.temp)),
482 (OsString::from("USERPROFILE"), external_path(&context.home)),
483 (OsString::from("WINDIR"), external_path(&self.system_root)),
484 (
485 OsString::from("XDG_CONFIG_HOME"),
486 external_path(&context.xdg_config),
487 ),
488 ]);
489 if let Some(git_directory) = &context.git_dir {
490 environment.insert(OsString::from("GIT_DIR"), external_path(git_directory));
491 }
492 if network == GitNetworkPolicy::GithubSsh {
493 environment.insert(OsString::from("GIT_SSH"), external_path(self.ssh.path()));
494 environment.insert(
497 OsString::from("GIT_SSH_COMMAND"),
498 self.fixed_ssh_command.clone(),
499 );
500 environment.insert(OsString::from("GIT_SSH_VARIANT"), OsString::from("ssh"));
501 }
502 environment
503 }
504 }
505
506 pub fn trusted_git_executable() -> Result<PathBuf, GitProcessPolicyError> {
515 let system_root =
516 windows_system_root().map_err(|_| GitProcessPolicyError::InvalidWindowsDirectory)?;
517 let drive_root = system_root
518 .parent()
519 .ok_or(GitProcessPolicyError::InvalidWindowsDirectory)?;
520 let executable = drive_root.join("Program Files/Git/cmd/git.exe");
521 WindowsGitToolchain::new(&executable)?;
522 Ok(executable)
523 }
524
525 #[derive(Clone, Debug, Eq, PartialEq)]
527 pub struct GitProcessContext {
528 cwd: PathBuf,
529 git_dir: Option<PathBuf>,
530 home: PathBuf,
531 xdg_config: PathBuf,
532 temp: PathBuf,
533 }
534
535 impl GitProcessContext {
536 pub fn new(
542 current_directory: impl AsRef<Path>,
543 git_directory: Option<&Path>,
544 home_directory: impl AsRef<Path>,
545 xdg_config_directory: impl AsRef<Path>,
546 temporary_directory: impl AsRef<Path>,
547 ) -> Result<Self, GitProcessPolicyError> {
548 Ok(Self {
549 cwd: canonical_directory(current_directory.as_ref())?,
550 git_dir: git_directory.map(canonical_directory).transpose()?,
551 home: canonical_directory(home_directory.as_ref())?,
552 xdg_config: canonical_directory(xdg_config_directory.as_ref())?,
553 temp: canonical_directory(temporary_directory.as_ref())?,
554 })
555 }
556
557 pub fn git_directory(&self) -> Option<&Path> {
559 self.git_dir.as_deref()
560 }
561
562 pub fn current_directory(&self) -> &Path {
564 &self.cwd
565 }
566
567 fn verify(&self) -> Result<(), GitProcessPolicyError> {
568 for path in [
569 Some(self.cwd.as_path()),
570 self.git_dir.as_deref(),
571 Some(self.home.as_path()),
572 Some(self.xdg_config.as_path()),
573 Some(self.temp.as_path()),
574 ]
575 .into_iter()
576 .flatten()
577 {
578 if canonical_directory(path)? != path {
579 return Err(GitProcessPolicyError::ProcessDirectoryChanged);
580 }
581 }
582 Ok(())
583 }
584 }
585
586 pub struct GitProcessSpec<'a> {
588 toolchain: &'a WindowsGitToolchain,
589 arguments: Vec<OsString>,
590 environment: BTreeMap<OsString, OsString>,
591 current_directory: PathBuf,
592 }
593
594 impl GitProcessSpec<'_> {
595 pub fn command(&self) -> Result<Command, GitProcessPolicyError> {
601 self.toolchain.verify()?;
602 let mut command = Command::new(self.toolchain.git.path());
603 command
604 .env_clear()
605 .envs(self.environment.iter())
606 .args(&self.arguments)
607 .current_dir(&self.current_directory);
608 Ok(command)
609 }
610
611 pub fn arguments(&self) -> &[OsString] {
613 &self.arguments
614 }
615
616 pub const fn environment(&self) -> &BTreeMap<OsString, OsString> {
618 &self.environment
619 }
620
621 pub fn current_directory(&self) -> &Path {
623 &self.current_directory
624 }
625 }
626
627 impl fmt::Debug for GitProcessSpec<'_> {
628 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
629 formatter
630 .debug_struct("GitProcessSpec")
631 .field("argument_count", &self.arguments.len())
632 .field(
633 "environment_names",
634 &self.environment.keys().collect::<Vec<_>>(),
635 )
636 .field("current_directory_identity", &"private")
637 .finish_non_exhaustive()
638 }
639 }
640
641 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
643 pub enum GitProcessPolicyError {
644 InvalidExecutable,
646 InvalidToolLayout,
648 InvalidWindowsDirectory,
650 ExecutableIdentityChanged,
652 ToolLayoutIdentityChanged,
654 InvalidProcessDirectory,
656 ProcessDirectoryChanged,
658 ExecutableReadFailed,
660 }
661
662 impl fmt::Display for GitProcessPolicyError {
663 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
664 match self {
665 Self::InvalidExecutable => formatter.write_str("Git executable is invalid"),
666 Self::InvalidToolLayout => {
667 formatter.write_str("Git-for-Windows tool layout is invalid")
668 }
669 Self::InvalidWindowsDirectory => {
670 formatter.write_str("Windows system directory is invalid")
671 }
672 Self::ExecutableIdentityChanged => {
673 formatter.write_str("retained Git executable identity changed")
674 }
675 Self::ToolLayoutIdentityChanged => {
676 formatter.write_str("retained Git tool directory identity changed")
677 }
678 Self::InvalidProcessDirectory => {
679 formatter.write_str("isolated Git process directory is invalid")
680 }
681 Self::ProcessDirectoryChanged => {
682 formatter.write_str("isolated Git process directory changed")
683 }
684 Self::ExecutableReadFailed => {
685 formatter.write_str("Git executable could not be read safely")
686 }
687 }
688 }
689 }
690
691 impl Error for GitProcessPolicyError {}
692
693 fn fixed_git_arguments(network: GitNetworkPolicy) -> Vec<OsString> {
694 let mut arguments = vec![
695 OsString::from("--no-optional-locks"),
696 OsString::from("--no-replace-objects"),
697 ];
698 arguments.extend(
699 [
700 "core.hooksPath=NUL",
701 "core.fsmonitor=false",
702 "core.attributesFile=NUL",
703 "core.excludesFile=NUL",
704 "diff.external=",
705 "credential.interactive=never",
706 "protocol.allow=never",
707 "protocol.file.allow=never",
708 "maintenance.auto=false",
709 "gc.auto=0",
710 "fetch.writeCommitGraph=false",
711 ]
712 .into_iter()
713 .flat_map(|configuration| [OsString::from("-c"), OsString::from(configuration)]),
714 );
715 let network_configuration: &[&str] = match network {
716 GitNetworkPolicy::Offline => &["credential.helper="],
717 GitNetworkPolicy::HttpsWithCredentialManager => &[
718 "protocol.https.allow=always",
719 "credential.helper=",
720 "credential.helper=manager",
721 "credential.useHttpPath=true",
722 "http.sslVerify=true",
723 "http.sslBackend=schannel",
724 "http.proxy=",
725 ],
726 GitNetworkPolicy::GithubSsh => &["protocol.ssh.allow=always", "credential.helper="],
727 };
728 arguments.extend(
729 network_configuration
730 .iter()
731 .flat_map(|configuration| [OsString::from("-c"), OsString::from(configuration)]),
732 );
733 arguments
734 }
735
736 fn canonical_file(path: &Path, expected_name: &str) -> Result<PathBuf, GitProcessPolicyError> {
737 if !is_absolute_normal(path) {
738 return Err(GitProcessPolicyError::InvalidExecutable);
739 }
740 let metadata =
741 fs::symlink_metadata(path).map_err(|_| GitProcessPolicyError::InvalidExecutable)?;
742 if metadata.file_type().is_symlink()
743 || !metadata.is_file()
744 || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
745 || metadata.len() == 0
746 || metadata.len() > MAX_EXECUTABLE_BYTES
747 || !path
748 .file_name()
749 .and_then(OsStr::to_str)
750 .is_some_and(|name| name.eq_ignore_ascii_case(expected_name))
751 {
752 return Err(GitProcessPolicyError::InvalidExecutable);
753 }
754 let canonical =
755 fs::canonicalize(path).map_err(|_| GitProcessPolicyError::InvalidExecutable)?;
756 if !canonical
757 .file_name()
758 .and_then(OsStr::to_str)
759 .is_some_and(|name| name.eq_ignore_ascii_case(expected_name))
760 {
761 return Err(GitProcessPolicyError::InvalidExecutable);
762 }
763 Ok(canonical)
764 }
765
766 fn canonical_directory(path: &Path) -> Result<PathBuf, GitProcessPolicyError> {
767 if !is_absolute_normal(path) {
768 return Err(GitProcessPolicyError::InvalidProcessDirectory);
769 }
770 let metadata = fs::symlink_metadata(path)
771 .map_err(|_| GitProcessPolicyError::InvalidProcessDirectory)?;
772 if metadata.file_type().is_symlink() || !metadata.is_dir() {
773 return Err(GitProcessPolicyError::InvalidProcessDirectory);
774 }
775 fs::canonicalize(path).map_err(|_| GitProcessPolicyError::InvalidProcessDirectory)
776 }
777
778 fn open_retained_regular_file(path: &Path) -> Result<File, GitProcessPolicyError> {
779 let mut options = OpenOptions::new();
780 options
781 .read(true)
782 .share_mode(FILE_SHARE_READ)
783 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
784 options
785 .open(path)
786 .map_err(|_| GitProcessPolicyError::InvalidExecutable)
787 }
788
789 fn hash_retained_file(file: &File) -> Result<String, GitProcessPolicyError> {
790 let metadata = file
791 .metadata()
792 .map_err(|_| GitProcessPolicyError::ExecutableReadFailed)?;
793 if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_EXECUTABLE_BYTES {
794 return Err(GitProcessPolicyError::InvalidExecutable);
795 }
796 let mut reader = file
797 .try_clone()
798 .map_err(|_| GitProcessPolicyError::ExecutableReadFailed)?;
799 reader
800 .seek(SeekFrom::Start(0))
801 .map_err(|_| GitProcessPolicyError::ExecutableReadFailed)?;
802 let mut digest = Sha256::new();
803 let mut buffer = [0_u8; 16 * 1024];
804 let mut total = 0_u64;
805 loop {
806 let read = reader
807 .read(&mut buffer)
808 .map_err(|_| GitProcessPolicyError::ExecutableReadFailed)?;
809 if read == 0 {
810 break;
811 }
812 total = total
813 .checked_add(
814 u64::try_from(read).map_err(|_| GitProcessPolicyError::ExecutableReadFailed)?,
815 )
816 .ok_or(GitProcessPolicyError::ExecutableReadFailed)?;
817 if total > MAX_EXECUTABLE_BYTES {
818 return Err(GitProcessPolicyError::InvalidExecutable);
819 }
820 digest.update(&buffer[..read]);
821 }
822 if total != metadata.len() {
823 return Err(GitProcessPolicyError::ExecutableReadFailed);
824 }
825 Ok(hex::encode(digest.finalize()))
826 }
827
828 fn file_name_eq(path: &Path, expected: &str) -> bool {
829 path.file_name()
830 .and_then(OsStr::to_str)
831 .is_some_and(|name| name.eq_ignore_ascii_case(expected))
832 }
833
834 pub(crate) fn external_path(path: &Path) -> OsString {
835 let value = path.as_os_str().to_string_lossy();
836 if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
837 OsString::from(format!(r"\\{unc}"))
838 } else if let Some(normal) = value.strip_prefix(r"\\?\") {
839 OsString::from(normal)
840 } else {
841 path.as_os_str().to_owned()
842 }
843 }
844
845 fn fixed_windows_ssh_command(path: &Path) -> Result<OsString, GitProcessPolicyError> {
846 let external = external_path(path);
847 let external = external
848 .to_str()
849 .ok_or(GitProcessPolicyError::InvalidToolLayout)?
850 .replace('\\', "/");
851 if !external.is_ascii()
852 || external.is_empty()
853 || external.chars().any(|character| {
854 !character.is_ascii_alphanumeric()
855 && !matches!(character, ':' | '/' | '.' | '_' | '-' | ' ')
856 })
857 {
858 return Err(GitProcessPolicyError::InvalidToolLayout);
859 }
860 Ok(OsString::from(format!(
861 r#""{external}" -F "$HOME/.ssh/config""#
862 )))
863 }
864
865 fn is_absolute_normal(path: &Path) -> bool {
866 path.is_absolute()
867 && path
868 .components()
869 .all(|component| !matches!(component, Component::CurDir | Component::ParentDir))
870 }
871
872 #[cfg(test)]
873 mod tests {
874 use super::*;
875
876 fn installed_git() -> Option<PathBuf> {
877 let path = PathBuf::from(r"C:\Program Files\Git\cmd\git.exe");
878 path.is_file().then_some(path)
879 }
880
881 #[test]
882 fn installed_windows_toolchain_has_retained_exact_companions() {
883 let Some(git) = installed_git() else {
884 return;
885 };
886 let toolchain = WindowsGitToolchain::new(git).expect("Git-for-Windows toolchain");
887 toolchain.verify().expect("stable toolchain");
888 for executable in [
889 toolchain.git(),
890 toolchain.git_runtime(),
891 toolchain.git_exec_runtime(),
892 toolchain.credential_manager(),
893 toolchain.https_helper(),
894 toolchain.ssh(),
895 ] {
896 assert_eq!(executable.sha256().len(), 64);
897 assert!(
898 executable
899 .sha256()
900 .bytes()
901 .all(|byte| byte.is_ascii_hexdigit())
902 );
903 }
904 }
905
906 #[test]
907 #[allow(
908 clippy::too_many_lines,
909 reason = "one policy matrix proves the complete environment and fixed-argument allowlist"
910 )]
911 fn process_policy_never_inherits_malicious_environment_names() {
912 let Some(git) = installed_git() else {
913 return;
914 };
915 let toolchain = WindowsGitToolchain::new(git).expect("Git-for-Windows toolchain");
916 let directory = tempfile::tempdir().expect("private process fixture");
917 for name in ["git", "home", "xdg", "tmp"] {
918 fs::create_dir(directory.path().join(name)).expect("fixture directory");
919 }
920 let context = GitProcessContext::new(
921 directory.path(),
922 Some(&directory.path().join("git")),
923 directory.path().join("home"),
924 directory.path().join("xdg"),
925 directory.path().join("tmp"),
926 )
927 .expect("process context");
928 for network in [
929 GitNetworkPolicy::Offline,
930 GitNetworkPolicy::HttpsWithCredentialManager,
931 GitNetworkPolicy::GithubSsh,
932 ] {
933 let spec = toolchain
934 .process_spec(&context, network, ["version"])
935 .expect("process spec");
936 let environment_names = spec
937 .environment()
938 .keys()
939 .map(|name| name.to_string_lossy().into_owned())
940 .collect::<std::collections::BTreeSet<_>>();
941 for forbidden in [
942 "GH_TOKEN",
943 "GITHUB_TOKEN",
944 "HTTP_PROXY",
945 "HTTPS_PROXY",
946 "ALL_PROXY",
947 "NO_PROXY",
948 "SSH_AUTH_SOCK",
949 "GIT_ASKPASS",
950 "SSH_ASKPASS",
951 "GIT_CONFIG_COUNT",
952 "GIT_CONFIG_PARAMETERS",
953 "GIT_CONFIG_KEY_0",
954 "GIT_CONFIG_VALUE_0",
955 "GIT_OBJECT_DIRECTORY",
956 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
957 "GIT_PROXY_COMMAND",
958 "GCM_HTTP_PROXY",
959 "GCM_CREDENTIAL_STORE",
960 "GCM_PROVIDER",
961 "GCM_TRACE",
962 ] {
963 assert!(!environment_names.contains(forbidden), "{forbidden}");
964 }
965 assert_eq!(
966 spec.environment().get(OsStr::new("GIT_ALLOW_PROTOCOL")),
967 Some(&OsString::from(match network {
968 GitNetworkPolicy::Offline => "",
969 GitNetworkPolicy::HttpsWithCredentialManager => "https",
970 GitNetworkPolicy::GithubSsh => "ssh",
971 }))
972 );
973 assert_eq!(
974 spec.environment().get(OsStr::new("GIT_NO_LAZY_FETCH")),
975 Some(&OsString::from("1"))
976 );
977 assert_eq!(
978 spec.environment()
979 .get(OsStr::new("GIT_SSH_COMMAND"))
980 .map(OsString::as_os_str),
981 (network == GitNetworkPolicy::GithubSsh)
982 .then_some(toolchain.fixed_ssh_command.as_os_str())
983 );
984 assert_eq!(
985 spec.environment()
986 .get(OsStr::new("GIT_DIR"))
987 .map(OsString::as_os_str),
988 context.git_directory().map(external_path).as_deref()
989 );
990 assert!(
991 spec.arguments()
992 .iter()
993 .any(|argument| argument == "protocol.allow=never")
994 );
995 assert!(
996 spec.arguments()
997 .iter()
998 .any(|argument| argument == "credential.helper=")
999 );
1000 assert_eq!(
1001 spec.arguments()
1002 .iter()
1003 .any(|argument| argument == "http.proxy="),
1004 network == GitNetworkPolicy::HttpsWithCredentialManager
1005 );
1006 assert_eq!(
1007 spec.arguments()
1008 .iter()
1009 .any(|argument| argument == "credential.helper=manager"),
1010 network == GitNetworkPolicy::HttpsWithCredentialManager
1011 );
1012 }
1013 }
1014
1015 #[test]
1016 fn git_shell_executes_the_retained_system_openssh_image() {
1017 let Some(git) = installed_git() else {
1018 return;
1019 };
1020 let toolchain = WindowsGitToolchain::new(git).expect("Git-for-Windows toolchain");
1021 let directory = tempfile::tempdir().expect("private process fixture");
1022 for name in ["home", "xdg", "tmp"] {
1023 fs::create_dir(directory.path().join(name)).expect("fixture directory");
1024 }
1025 let context = GitProcessContext::new(
1026 directory.path(),
1027 None,
1028 directory.path().join("home"),
1029 directory.path().join("xdg"),
1030 directory.path().join("tmp"),
1031 )
1032 .expect("process context");
1033 let spec = toolchain
1034 .process_spec(
1035 &context,
1036 GitNetworkPolicy::GithubSsh,
1037 [
1038 "-c",
1039 r#"alias.rustferry-ssh-version=!eval "$GIT_SSH_COMMAND -V""#,
1040 "rustferry-ssh-version",
1041 ],
1042 )
1043 .expect("SSH process spec");
1044 let selected = spec.command().expect("sealed Git command").output().expect(
1045 "Git shell must execute the fixed absolute SSH command without network access",
1046 );
1047 let retained = Command::new(toolchain.ssh().path())
1048 .arg("-V")
1049 .output()
1050 .expect("retained OpenSSH version");
1051 let bundled = Command::new(toolchain.git_usr_bin_directory.path.join("ssh.exe"))
1052 .arg("-V")
1053 .output()
1054 .expect("bundled OpenSSH version");
1055 assert!(selected.status.success());
1056 assert_eq!(selected.stderr, retained.stderr);
1057 assert_ne!(selected.stderr, bundled.stderr);
1058 }
1059
1060 #[test]
1061 fn offline_policy_denies_ext_after_a_later_config_override() {
1062 let Some(git) = installed_git() else {
1063 return;
1064 };
1065 let toolchain = WindowsGitToolchain::new(git).expect("Git-for-Windows toolchain");
1066 let directory = tempfile::tempdir().expect("private process fixture");
1067 for name in ["home", "xdg", "tmp"] {
1068 fs::create_dir(directory.path().join(name)).expect("fixture directory");
1069 }
1070 let context = GitProcessContext::new(
1071 directory.path(),
1072 None,
1073 directory.path().join("home"),
1074 directory.path().join("xdg"),
1075 directory.path().join("tmp"),
1076 )
1077 .expect("process context");
1078 let canary = external_path(&directory.path().join("ext-canary"))
1079 .to_string_lossy()
1080 .replace('\\', "/");
1081 let remote = format!("ext::cmd.exe /c echo escaped > {canary}");
1082 let spec = toolchain
1083 .process_spec(
1084 &context,
1085 GitNetworkPolicy::Offline,
1086 [
1087 OsString::from("-c"),
1088 OsString::from("protocol.ext.allow=always"),
1089 OsString::from("ls-remote"),
1090 OsString::from(remote),
1091 ],
1092 )
1093 .expect("offline process spec");
1094 let output = spec
1095 .command()
1096 .expect("sealed Git command")
1097 .output()
1098 .expect("bounded local policy probe");
1099 assert!(!output.status.success());
1100 assert!(
1101 String::from_utf8_lossy(&output.stderr).contains("transport 'ext' not allowed")
1102 );
1103 assert!(!directory.path().join("ext-canary").exists());
1104 }
1105
1106 #[test]
1107 fn retained_helper_chain_executables_block_post_plan_replacement() {
1108 let Some(git) = installed_git() else {
1109 return;
1110 };
1111 let directory = tempfile::tempdir().expect("fixture");
1112 for location in ["path-runtime", "exec-path-runtime"] {
1113 let runtime = directory.path().join(location);
1114 fs::create_dir(&runtime).expect("runtime fixture");
1115 let executable = runtime.join("git.exe");
1116 let displaced = runtime.join("old-git.exe");
1117 fs::copy(&git, &executable).expect("copy fixture executable");
1118 let retained = RetainedGitExecutable::open(&executable, "git.exe")
1119 .expect("retained executable");
1120
1121 assert!(fs::rename(&executable, &displaced).is_err());
1123 assert!(OpenOptions::new().write(true).open(&executable).is_err());
1124 retained.verify().expect("unchanged retained executable");
1125 }
1126 }
1127 }
1128}
1129
1130#[cfg(windows)]
1131pub(crate) use windows::external_path;
1132#[cfg(windows)]
1133pub use windows::{
1134 GitProcessContext, GitProcessPolicyError, GitProcessSpec, RetainedGitExecutable,
1135 WindowsGitToolchain, trusted_git_executable,
1136};
1137
1138#[cfg(unix)]
1139#[allow(missing_docs, clippy::missing_errors_doc)]
1140mod unix {
1141 use std::{
1142 collections::BTreeMap,
1143 error::Error,
1144 ffi::OsString,
1145 fmt,
1146 fs::{self, File},
1147 io::{Read, Seek, SeekFrom},
1148 os::unix::fs::{FileTypeExt as _, MetadataExt as _, PermissionsExt as _},
1149 path::{Component, Path, PathBuf},
1150 process::Command,
1151 };
1152
1153 use sha2::{Digest, Sha256};
1154
1155 use super::GitNetworkPolicy;
1156
1157 const TRUSTED_GIT: &str = "/usr/bin/git";
1158 const TRUSTED_SSH: &str = "/usr/bin/ssh";
1159 const TRUSTED_SHELL: &str = "/bin/sh";
1160 const FIXED_SSH_COMMAND: &str = r#"/usr/bin/ssh -F "$HOME/.ssh/config""#;
1161 const MAX_EXECUTABLE_BYTES: u64 = 256 * 1024 * 1024;
1162 const MAX_EXEC_PATH_BYTES: usize = 4096;
1163
1164 #[derive(Debug)]
1165 struct RetainedUnixExecutable {
1166 path: PathBuf,
1167 device: u64,
1168 inode: u64,
1169 length: u64,
1170 sha256: String,
1171 _file: File,
1172 }
1173
1174 impl RetainedUnixExecutable {
1175 fn open(path: &Path, expected_path: &Path) -> Result<Self, GitProcessPolicyError> {
1176 if path != expected_path || !is_absolute_normal(path) {
1177 return Err(GitProcessPolicyError::InvalidExecutable);
1178 }
1179 validate_trusted_directory(
1180 path.parent()
1181 .ok_or(GitProcessPolicyError::InvalidExecutable)?,
1182 )?;
1183 let canonical_target =
1184 fs::canonicalize(path).map_err(|_| GitProcessPolicyError::InvalidExecutable)?;
1185 validate_trusted_directory(
1186 canonical_target
1187 .parent()
1188 .ok_or(GitProcessPolicyError::InvalidExecutable)?,
1189 )?;
1190 let link_metadata =
1191 fs::symlink_metadata(path).map_err(|_| GitProcessPolicyError::InvalidExecutable)?;
1192 let is_symlink = link_metadata.file_type().is_symlink();
1193 if !(link_metadata.is_file() || is_symlink)
1194 || link_metadata.uid() != 0
1195 || (!is_symlink && link_metadata.permissions().mode() & 0o022 != 0)
1196 {
1197 return Err(GitProcessPolicyError::InvalidExecutable);
1198 }
1199 let file = File::open(path).map_err(|_| GitProcessPolicyError::InvalidExecutable)?;
1200 let metadata = file
1201 .metadata()
1202 .map_err(|_| GitProcessPolicyError::InvalidExecutable)?;
1203 if !metadata.is_file()
1204 || metadata.uid() != 0
1205 || metadata.permissions().mode() & 0o022 != 0
1206 || metadata.len() == 0
1207 || metadata.len() > MAX_EXECUTABLE_BYTES
1208 {
1209 return Err(GitProcessPolicyError::InvalidExecutable);
1210 }
1211 let sha256 = hash_retained_file(&file)?;
1212 Ok(Self {
1213 path: path.to_owned(),
1214 device: metadata.dev(),
1215 inode: metadata.ino(),
1216 length: metadata.len(),
1217 sha256,
1218 _file: file,
1219 })
1220 }
1221
1222 fn verify(&self) -> Result<(), GitProcessPolicyError> {
1223 let file = File::open(&self.path)
1224 .map_err(|_| GitProcessPolicyError::ExecutableIdentityChanged)?;
1225 let metadata = file
1226 .metadata()
1227 .map_err(|_| GitProcessPolicyError::ExecutableIdentityChanged)?;
1228 if !metadata.is_file()
1229 || metadata.uid() != 0
1230 || metadata.permissions().mode() & 0o022 != 0
1231 || metadata.dev() != self.device
1232 || metadata.ino() != self.inode
1233 || metadata.len() != self.length
1234 || hash_retained_file(&file)? != self.sha256
1235 {
1236 return Err(GitProcessPolicyError::ExecutableIdentityChanged);
1237 }
1238 Ok(())
1239 }
1240 }
1241
1242 #[derive(Debug)]
1244 pub struct UnixGitToolchain {
1245 git: RetainedUnixExecutable,
1246 ssh: RetainedUnixExecutable,
1247 shell: RetainedUnixExecutable,
1248 git_exec_path: PathBuf,
1249 ssh_auth_sock: Option<PathBuf>,
1250 }
1251
1252 impl UnixGitToolchain {
1253 pub fn new(git_executable: impl AsRef<Path>) -> Result<Self, GitProcessPolicyError> {
1255 let git =
1256 RetainedUnixExecutable::open(git_executable.as_ref(), Path::new(TRUSTED_GIT))?;
1257 let ssh = RetainedUnixExecutable::open(Path::new(TRUSTED_SSH), Path::new(TRUSTED_SSH))?;
1258 let shell =
1259 RetainedUnixExecutable::open(Path::new(TRUSTED_SHELL), Path::new(TRUSTED_SHELL))?;
1260 let git_exec_path = discover_git_exec_path(&git)?;
1261 let ssh_auth_sock = std::env::var_os("SSH_AUTH_SOCK").map(PathBuf::from);
1262 let toolchain = Self {
1263 git,
1264 ssh,
1265 shell,
1266 git_exec_path,
1267 ssh_auth_sock,
1268 };
1269 toolchain.verify()?;
1270 Ok(toolchain)
1271 }
1272
1273 fn verify(&self) -> Result<(), GitProcessPolicyError> {
1274 self.git.verify()?;
1275 self.ssh.verify()?;
1276 self.shell.verify()?;
1277 validate_trusted_directory(&self.git_exec_path)?;
1278 Ok(())
1279 }
1280
1281 pub fn process_spec<'a>(
1283 &'a self,
1284 context: &GitProcessContext,
1285 network: GitNetworkPolicy,
1286 arguments: impl IntoIterator<Item = impl Into<OsString>>,
1287 ) -> Result<GitProcessSpec<'a>, GitProcessPolicyError> {
1288 self.verify()?;
1289 context.verify()?;
1290 let ssh_auth_sock = (network == GitNetworkPolicy::GithubSsh)
1291 .then(|| {
1292 self.ssh_auth_sock
1293 .clone()
1294 .ok_or(GitProcessPolicyError::InvalidSshAgent)
1295 .and_then(validate_ssh_auth_socket)
1296 })
1297 .transpose()?;
1298 let mut fixed_arguments = fixed_git_arguments(network);
1299 fixed_arguments.extend(arguments.into_iter().map(Into::into));
1300 let allowed_protocol = match network {
1301 GitNetworkPolicy::Offline => "",
1302 GitNetworkPolicy::HttpsWithCredentialManager => "https",
1303 GitNetworkPolicy::GithubSsh => "ssh",
1304 };
1305 let mut environment = BTreeMap::from([
1306 (OsString::from("GIT_ATTR_NOSYSTEM"), OsString::from("1")),
1307 (
1308 OsString::from("GIT_ALLOW_PROTOCOL"),
1309 OsString::from(allowed_protocol),
1310 ),
1311 (
1312 OsString::from("GIT_CONFIG_GLOBAL"),
1313 OsString::from("/dev/null"),
1314 ),
1315 (OsString::from("GIT_CONFIG_NOSYSTEM"), OsString::from("1")),
1316 (
1317 OsString::from("GIT_CONFIG_SYSTEM"),
1318 OsString::from("/dev/null"),
1319 ),
1320 (
1321 OsString::from("GIT_EXEC_PATH"),
1322 self.git_exec_path.as_os_str().to_owned(),
1323 ),
1324 (
1325 OsString::from("GIT_NO_REPLACE_OBJECTS"),
1326 OsString::from("1"),
1327 ),
1328 (OsString::from("GIT_NO_LAZY_FETCH"), OsString::from("1")),
1329 (OsString::from("GIT_OPTIONAL_LOCKS"), OsString::from("0")),
1330 (OsString::from("GIT_PAGER"), OsString::from("cat")),
1331 (OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0")),
1332 (OsString::from("HOME"), context.home.as_os_str().to_owned()),
1333 (OsString::from("LANG"), OsString::from("C")),
1334 (OsString::from("LC_ALL"), OsString::from("C")),
1335 (OsString::from("PATH"), OsString::from("/usr/bin:/bin")),
1336 (
1337 OsString::from("TMPDIR"),
1338 context.temp.as_os_str().to_owned(),
1339 ),
1340 (
1341 OsString::from("XDG_CONFIG_HOME"),
1342 context.xdg_config.as_os_str().to_owned(),
1343 ),
1344 ]);
1345 if let Some(git_directory) = &context.git_dir {
1346 environment.insert(
1347 OsString::from("GIT_DIR"),
1348 git_directory.as_os_str().to_owned(),
1349 );
1350 }
1351 if network == GitNetworkPolicy::GithubSsh {
1352 environment.insert(
1353 OsString::from("GIT_SSH"),
1354 self.ssh.path.as_os_str().to_owned(),
1355 );
1356 environment.insert(
1359 OsString::from("GIT_SSH_COMMAND"),
1360 OsString::from(FIXED_SSH_COMMAND),
1361 );
1362 environment.insert(OsString::from("GIT_SSH_VARIANT"), OsString::from("ssh"));
1363 environment.insert(
1364 OsString::from("SSH_AUTH_SOCK"),
1365 ssh_auth_sock
1366 .as_ref()
1367 .ok_or(GitProcessPolicyError::InvalidSshAgent)?
1368 .as_os_str()
1369 .to_owned(),
1370 );
1371 }
1372 Ok(GitProcessSpec {
1373 toolchain: self,
1374 arguments: fixed_arguments,
1375 environment,
1376 current_directory: context.cwd.clone(),
1377 })
1378 }
1379 }
1380
1381 pub fn trusted_git_executable() -> Result<PathBuf, GitProcessPolicyError> {
1387 let executable = PathBuf::from(TRUSTED_GIT);
1388 UnixGitToolchain::new(&executable)?;
1389 Ok(executable)
1390 }
1391
1392 #[derive(Clone, Debug, Eq, PartialEq)]
1394 pub struct GitProcessContext {
1395 cwd: PathBuf,
1396 git_dir: Option<PathBuf>,
1397 home: PathBuf,
1398 xdg_config: PathBuf,
1399 temp: PathBuf,
1400 private_owner: u32,
1401 }
1402
1403 impl GitProcessContext {
1404 pub fn new(
1405 current_directory: impl AsRef<Path>,
1406 git_directory: Option<&Path>,
1407 home_directory: impl AsRef<Path>,
1408 xdg_config_directory: impl AsRef<Path>,
1409 temporary_directory: impl AsRef<Path>,
1410 ) -> Result<Self, GitProcessPolicyError> {
1411 let cwd = canonical_directory(current_directory.as_ref())?;
1412 let git_dir = git_directory.map(canonical_private_directory).transpose()?;
1413 let home = canonical_private_directory(home_directory.as_ref())?;
1414 let xdg_config = canonical_private_directory(xdg_config_directory.as_ref())?;
1415 let temp = canonical_private_directory(temporary_directory.as_ref())?;
1416 let private_owner = private_directory_owner(&home)?;
1417 if [
1418 git_dir.as_deref(),
1419 Some(xdg_config.as_path()),
1420 Some(temp.as_path()),
1421 ]
1422 .into_iter()
1423 .flatten()
1424 .any(|path| private_directory_owner(path) != Ok(private_owner))
1425 {
1426 return Err(GitProcessPolicyError::InvalidProcessDirectory);
1427 }
1428 Ok(Self {
1429 cwd,
1430 git_dir,
1431 home,
1432 xdg_config,
1433 temp,
1434 private_owner,
1435 })
1436 }
1437
1438 fn verify(&self) -> Result<(), GitProcessPolicyError> {
1439 if canonical_directory(&self.cwd)? != self.cwd
1440 || self
1441 .git_dir
1442 .as_deref()
1443 .map(canonical_private_directory)
1444 .transpose()?
1445 .as_ref()
1446 != self.git_dir.as_ref()
1447 || canonical_private_directory(&self.home)? != self.home
1448 || canonical_private_directory(&self.xdg_config)? != self.xdg_config
1449 || canonical_private_directory(&self.temp)? != self.temp
1450 || [
1451 self.git_dir.as_deref(),
1452 Some(self.home.as_path()),
1453 Some(self.xdg_config.as_path()),
1454 Some(self.temp.as_path()),
1455 ]
1456 .into_iter()
1457 .flatten()
1458 .any(|path| private_directory_owner(path) != Ok(self.private_owner))
1459 {
1460 return Err(GitProcessPolicyError::ProcessDirectoryChanged);
1461 }
1462 Ok(())
1463 }
1464 }
1465
1466 pub struct GitProcessSpec<'a> {
1468 toolchain: &'a UnixGitToolchain,
1469 arguments: Vec<OsString>,
1470 environment: BTreeMap<OsString, OsString>,
1471 current_directory: PathBuf,
1472 }
1473
1474 impl GitProcessSpec<'_> {
1475 pub fn command(&self) -> Result<Command, GitProcessPolicyError> {
1476 self.toolchain.verify()?;
1477 let mut command = Command::new(&self.toolchain.git.path);
1478 command
1479 .env_clear()
1480 .envs(self.environment.iter())
1481 .args(&self.arguments)
1482 .current_dir(&self.current_directory);
1483 Ok(command)
1484 }
1485
1486 pub fn arguments(&self) -> &[OsString] {
1487 &self.arguments
1488 }
1489
1490 pub const fn environment(&self) -> &BTreeMap<OsString, OsString> {
1491 &self.environment
1492 }
1493 }
1494
1495 impl fmt::Debug for GitProcessSpec<'_> {
1496 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1497 formatter
1498 .debug_struct("GitProcessSpec")
1499 .field("argument_count", &self.arguments.len())
1500 .field(
1501 "environment_names",
1502 &self.environment.keys().collect::<Vec<_>>(),
1503 )
1504 .finish_non_exhaustive()
1505 }
1506 }
1507
1508 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
1509 pub enum GitProcessPolicyError {
1510 InvalidExecutable,
1511 InvalidToolLayout,
1512 ExecutableIdentityChanged,
1513 InvalidProcessDirectory,
1514 ProcessDirectoryChanged,
1515 ExecutableReadFailed,
1516 InvalidSshAgent,
1517 }
1518
1519 impl fmt::Display for GitProcessPolicyError {
1520 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1521 match self {
1522 Self::InvalidExecutable => formatter.write_str("Git executable is invalid"),
1523 Self::InvalidToolLayout => formatter.write_str("system Git tool layout is invalid"),
1524 Self::ExecutableIdentityChanged => {
1525 formatter.write_str("retained Git executable identity changed")
1526 }
1527 Self::InvalidProcessDirectory => {
1528 formatter.write_str("isolated Git process directory is invalid")
1529 }
1530 Self::ProcessDirectoryChanged => {
1531 formatter.write_str("isolated Git process directory changed")
1532 }
1533 Self::ExecutableReadFailed => {
1534 formatter.write_str("Git executable could not be read safely")
1535 }
1536 Self::InvalidSshAgent => {
1537 formatter.write_str("SSH agent socket is unavailable or unsafe")
1538 }
1539 }
1540 }
1541 }
1542
1543 impl Error for GitProcessPolicyError {}
1544
1545 fn fixed_git_arguments(network: GitNetworkPolicy) -> Vec<OsString> {
1546 let mut arguments = vec![
1547 OsString::from("--no-optional-locks"),
1548 OsString::from("--no-replace-objects"),
1549 ];
1550 arguments.extend(
1551 [
1552 "core.hooksPath=/dev/null",
1553 "core.fsmonitor=false",
1554 "core.attributesFile=/dev/null",
1555 "core.excludesFile=/dev/null",
1556 "diff.external=",
1557 "credential.interactive=never",
1558 "protocol.allow=never",
1559 "protocol.file.allow=never",
1560 "maintenance.auto=false",
1561 "gc.auto=0",
1562 "fetch.writeCommitGraph=false",
1563 ]
1564 .into_iter()
1565 .flat_map(|configuration| [OsString::from("-c"), OsString::from(configuration)]),
1566 );
1567 let network_configuration: &[&str] = match network {
1568 GitNetworkPolicy::Offline => &["credential.helper="],
1569 GitNetworkPolicy::HttpsWithCredentialManager => &[
1570 "protocol.https.allow=always",
1571 "credential.helper=",
1572 "credential.useHttpPath=true",
1573 "http.sslVerify=true",
1574 "http.proxy=",
1575 ],
1576 GitNetworkPolicy::GithubSsh => &["protocol.ssh.allow=always", "credential.helper="],
1577 };
1578 arguments.extend(
1579 network_configuration
1580 .iter()
1581 .flat_map(|configuration| [OsString::from("-c"), OsString::from(configuration)]),
1582 );
1583 arguments
1584 }
1585
1586 fn discover_git_exec_path(
1587 git: &RetainedUnixExecutable,
1588 ) -> Result<PathBuf, GitProcessPolicyError> {
1589 git.verify()?;
1590 let output = Command::new(&git.path)
1591 .env_clear()
1592 .env("HOME", "/dev/null")
1593 .env("PATH", "/usr/bin:/bin")
1594 .env("LANG", "C")
1595 .env("LC_ALL", "C")
1596 .arg("--exec-path")
1597 .output()
1598 .map_err(|_| GitProcessPolicyError::InvalidToolLayout)?;
1599 if !output.status.success()
1600 || output.stdout.is_empty()
1601 || output.stdout.len() > MAX_EXEC_PATH_BYTES
1602 || !output.stderr.is_empty()
1603 {
1604 return Err(GitProcessPolicyError::InvalidToolLayout);
1605 }
1606 let text = std::str::from_utf8(&output.stdout)
1607 .map_err(|_| GitProcessPolicyError::InvalidToolLayout)?;
1608 let path = PathBuf::from(text.trim_end_matches(['\r', '\n']));
1609 let path = fs::canonicalize(path).map_err(|_| GitProcessPolicyError::InvalidToolLayout)?;
1610 validate_trusted_directory(&path)?;
1611 let https = path.join("git-remote-https");
1612 let metadata = fs::metadata(https).map_err(|_| GitProcessPolicyError::InvalidToolLayout)?;
1613 if !metadata.is_file() || metadata.uid() != 0 || metadata.permissions().mode() & 0o022 != 0
1614 {
1615 return Err(GitProcessPolicyError::InvalidToolLayout);
1616 }
1617 Ok(path)
1618 }
1619
1620 fn validate_trusted_directory(path: &Path) -> Result<(), GitProcessPolicyError> {
1621 let canonical =
1622 fs::canonicalize(path).map_err(|_| GitProcessPolicyError::InvalidToolLayout)?;
1623 let mut current = canonical.as_path();
1624 loop {
1625 let metadata =
1626 fs::metadata(current).map_err(|_| GitProcessPolicyError::InvalidToolLayout)?;
1627 if !metadata.is_dir()
1628 || metadata.uid() != 0
1629 || metadata.permissions().mode() & 0o022 != 0
1630 {
1631 return Err(GitProcessPolicyError::InvalidToolLayout);
1632 }
1633 let Some(parent) = current.parent() else {
1634 break;
1635 };
1636 if parent == current {
1637 break;
1638 }
1639 current = parent;
1640 }
1641 Ok(())
1642 }
1643
1644 fn validate_ssh_auth_socket(path: PathBuf) -> Result<PathBuf, GitProcessPolicyError> {
1645 if !is_absolute_normal(&path) {
1646 return Err(GitProcessPolicyError::InvalidSshAgent);
1647 }
1648 let metadata =
1649 fs::symlink_metadata(&path).map_err(|_| GitProcessPolicyError::InvalidSshAgent)?;
1650 let parent = path
1651 .parent()
1652 .ok_or(GitProcessPolicyError::InvalidSshAgent)?;
1653 let parent_metadata =
1654 fs::metadata(parent).map_err(|_| GitProcessPolicyError::InvalidSshAgent)?;
1655 if !metadata.file_type().is_socket()
1656 || metadata.uid() != parent_metadata.uid()
1657 || !parent_metadata.is_dir()
1658 || parent_metadata.permissions().mode() & 0o077 != 0
1659 {
1660 return Err(GitProcessPolicyError::InvalidSshAgent);
1661 }
1662 Ok(path)
1663 }
1664
1665 fn canonical_directory(path: &Path) -> Result<PathBuf, GitProcessPolicyError> {
1666 if !is_absolute_normal(path) {
1667 return Err(GitProcessPolicyError::InvalidProcessDirectory);
1668 }
1669 let metadata = fs::symlink_metadata(path)
1670 .map_err(|_| GitProcessPolicyError::InvalidProcessDirectory)?;
1671 if metadata.file_type().is_symlink() || !metadata.is_dir() {
1672 return Err(GitProcessPolicyError::InvalidProcessDirectory);
1673 }
1674 fs::canonicalize(path).map_err(|_| GitProcessPolicyError::InvalidProcessDirectory)
1675 }
1676
1677 fn canonical_private_directory(path: &Path) -> Result<PathBuf, GitProcessPolicyError> {
1678 let path = canonical_directory(path)?;
1679 let metadata =
1680 fs::metadata(&path).map_err(|_| GitProcessPolicyError::InvalidProcessDirectory)?;
1681 if metadata.permissions().mode() & 0o077 != 0 {
1682 return Err(GitProcessPolicyError::InvalidProcessDirectory);
1683 }
1684 Ok(path)
1685 }
1686
1687 fn private_directory_owner(path: &Path) -> Result<u32, GitProcessPolicyError> {
1688 fs::metadata(path)
1689 .map(|metadata| metadata.uid())
1690 .map_err(|_| GitProcessPolicyError::InvalidProcessDirectory)
1691 }
1692
1693 fn hash_retained_file(file: &File) -> Result<String, GitProcessPolicyError> {
1694 let metadata = file
1695 .metadata()
1696 .map_err(|_| GitProcessPolicyError::ExecutableReadFailed)?;
1697 if metadata.len() == 0 || metadata.len() > MAX_EXECUTABLE_BYTES {
1698 return Err(GitProcessPolicyError::InvalidExecutable);
1699 }
1700 let mut reader = file
1701 .try_clone()
1702 .map_err(|_| GitProcessPolicyError::ExecutableReadFailed)?;
1703 reader
1704 .seek(SeekFrom::Start(0))
1705 .map_err(|_| GitProcessPolicyError::ExecutableReadFailed)?;
1706 let mut digest = Sha256::new();
1707 let mut buffer = [0_u8; 16 * 1024];
1708 let mut total = 0_u64;
1709 loop {
1710 let read = reader
1711 .read(&mut buffer)
1712 .map_err(|_| GitProcessPolicyError::ExecutableReadFailed)?;
1713 if read == 0 {
1714 break;
1715 }
1716 total = total
1717 .checked_add(
1718 u64::try_from(read).map_err(|_| GitProcessPolicyError::ExecutableReadFailed)?,
1719 )
1720 .ok_or(GitProcessPolicyError::ExecutableReadFailed)?;
1721 if total > MAX_EXECUTABLE_BYTES {
1722 return Err(GitProcessPolicyError::InvalidExecutable);
1723 }
1724 digest.update(&buffer[..read]);
1725 }
1726 if total != metadata.len() {
1727 return Err(GitProcessPolicyError::ExecutableReadFailed);
1728 }
1729 Ok(hex::encode(digest.finalize()))
1730 }
1731
1732 fn is_absolute_normal(path: &Path) -> bool {
1733 path.is_absolute()
1734 && path
1735 .components()
1736 .all(|component| !matches!(component, Component::CurDir | Component::ParentDir))
1737 }
1738
1739 #[cfg(test)]
1740 mod tests {
1741 use std::ffi::OsStr;
1742 use std::os::unix::fs::DirBuilderExt as _;
1743 use std::os::unix::net::UnixListener;
1744
1745 use super::*;
1746
1747 fn system_toolchain() -> Option<UnixGitToolchain> {
1748 match UnixGitToolchain::new(TRUSTED_GIT) {
1749 Ok(toolchain) => Some(toolchain),
1750 Err(GitProcessPolicyError::InvalidToolLayout) => None,
1751 Err(error) => panic!("system toolchain: {error:?}"),
1752 }
1753 }
1754
1755 fn process_context(root: &Path) -> GitProcessContext {
1756 for name in ["home", "xdg", "tmp"] {
1757 let mut builder = fs::DirBuilder::new();
1758 builder
1759 .mode(0o700)
1760 .create(root.join(name))
1761 .expect("private directory");
1762 }
1763 GitProcessContext::new(
1764 root,
1765 None,
1766 root.join("home"),
1767 root.join("xdg"),
1768 root.join("tmp"),
1769 )
1770 .expect("process context")
1771 }
1772
1773 #[test]
1774 fn process_policy_clears_ambient_git_proxy_credential_and_ssh_settings() {
1775 if !Path::new(TRUSTED_GIT).is_file() || !Path::new(TRUSTED_SSH).is_file() {
1776 return;
1777 }
1778 let Some(toolchain) = system_toolchain() else {
1779 return;
1780 };
1781 let temporary = tempfile::tempdir().expect("fixture");
1782 for name in ["home", "xdg", "tmp"] {
1783 let mut builder = fs::DirBuilder::new();
1784 builder
1785 .mode(0o700)
1786 .create(temporary.path().join(name))
1787 .expect("private directory");
1788 }
1789 let context = GitProcessContext::new(
1790 temporary.path(),
1791 None,
1792 temporary.path().join("home"),
1793 temporary.path().join("xdg"),
1794 temporary.path().join("tmp"),
1795 )
1796 .expect("process context");
1797 for network in [
1798 GitNetworkPolicy::Offline,
1799 GitNetworkPolicy::HttpsWithCredentialManager,
1800 ] {
1801 let spec = toolchain
1802 .process_spec(&context, network, ["version"])
1803 .expect("process spec");
1804 for forbidden in [
1805 "HOME_FROM_CALLER",
1806 "HTTP_PROXY",
1807 "HTTPS_PROXY",
1808 "ALL_PROXY",
1809 "NO_PROXY",
1810 "SSH_AUTH_SOCK",
1811 "GIT_SSH_COMMAND",
1812 "GIT_ASKPASS",
1813 "SSH_ASKPASS",
1814 "GIT_CONFIG_COUNT",
1815 "GIT_CONFIG_PARAMETERS",
1816 "GIT_OBJECT_DIRECTORY",
1817 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
1818 "GIT_PROXY_COMMAND",
1819 "GCM_HTTP_PROXY",
1820 "GCM_CREDENTIAL_STORE",
1821 "GCM_PROVIDER",
1822 "GCM_TRACE",
1823 ] {
1824 assert!(!spec.environment().contains_key(OsStr::new(forbidden)));
1825 }
1826 assert_eq!(
1827 spec.environment().get(OsStr::new("PATH")),
1828 Some(&OsString::from("/usr/bin:/bin"))
1829 );
1830 assert_eq!(
1831 spec.environment().get(OsStr::new("GIT_ALLOW_PROTOCOL")),
1832 Some(&OsString::from(match network {
1833 GitNetworkPolicy::Offline => "",
1834 GitNetworkPolicy::HttpsWithCredentialManager => "https",
1835 GitNetworkPolicy::GithubSsh => "ssh",
1836 }))
1837 );
1838 assert_eq!(
1839 spec.environment().get(OsStr::new("GIT_NO_LAZY_FETCH")),
1840 Some(&OsString::from("1"))
1841 );
1842 assert!(
1843 spec.arguments()
1844 .iter()
1845 .any(|argument| argument == "protocol.allow=never")
1846 );
1847 assert!(
1848 spec.arguments()
1849 .iter()
1850 .any(|argument| argument == "credential.helper=")
1851 );
1852 assert_eq!(
1853 spec.arguments()
1854 .iter()
1855 .any(|argument| argument == "http.proxy="),
1856 network == GitNetworkPolicy::HttpsWithCredentialManager
1857 );
1858 }
1859 }
1860
1861 #[test]
1862 fn git_ssh_command_uses_the_retained_unix_image() {
1863 if !Path::new(TRUSTED_GIT).is_file()
1864 || !Path::new(TRUSTED_SSH).is_file()
1865 || !Path::new(TRUSTED_SHELL).exists()
1866 {
1867 return;
1868 }
1869 let temporary = tempfile::tempdir().expect("fixture");
1870 let agent_directory = temporary.path().join("agent");
1871 fs::DirBuilder::new()
1872 .mode(0o700)
1873 .create(&agent_directory)
1874 .expect("private agent directory");
1875 let agent_path = agent_directory.join("agent.sock");
1876 let _agent = UnixListener::bind(&agent_path).expect("fixture agent socket");
1877 let Some(mut toolchain) = system_toolchain() else {
1878 return;
1879 };
1880 toolchain.ssh_auth_sock = Some(agent_path);
1881 let context = process_context(temporary.path());
1882
1883 let ssh_spec = toolchain
1884 .process_spec(
1885 &context,
1886 GitNetworkPolicy::GithubSsh,
1887 [
1888 "-c",
1889 r#"alias.rustferry-ssh-version=!eval "$GIT_SSH_COMMAND -V""#,
1890 "rustferry-ssh-version",
1891 ],
1892 )
1893 .expect("SSH process spec");
1894 let selected_ssh = ssh_spec
1895 .command()
1896 .expect("sealed Git command")
1897 .output()
1898 .expect("Git shell SSH version");
1899 let retained_ssh = Command::new(TRUSTED_SSH)
1900 .arg("-V")
1901 .output()
1902 .expect("retained SSH version");
1903 assert!(selected_ssh.status.success());
1904 assert_eq!(selected_ssh.stderr, retained_ssh.stderr);
1905 toolchain.verify().expect("unchanged retained Unix chain");
1906 }
1907
1908 #[test]
1909 fn git_shell_command_uses_the_retained_unix_image() {
1910 if !Path::new(TRUSTED_GIT).is_file()
1911 || !Path::new(TRUSTED_SSH).is_file()
1912 || !Path::new(TRUSTED_SHELL).exists()
1913 {
1914 return;
1915 }
1916 let temporary = tempfile::tempdir().expect("fixture");
1917 let Some(toolchain) = system_toolchain() else {
1918 return;
1919 };
1920 let context = process_context(temporary.path());
1921
1922 #[cfg(target_os = "linux")]
1923 {
1924 let shell_spec = toolchain
1925 .process_spec(
1926 &context,
1927 GitNetworkPolicy::Offline,
1928 [
1929 "-c",
1930 r"alias.rustferry-shell=!readlink /proc/$$/exe",
1931 "rustferry-shell",
1932 ],
1933 )
1934 .expect("shell process spec");
1935 let selected_shell = shell_spec
1936 .command()
1937 .expect("sealed Git command")
1938 .output()
1939 .expect("Git shell image");
1940 assert!(selected_shell.status.success());
1941 assert_eq!(
1942 Path::new(
1943 std::str::from_utf8(&selected_shell.stdout)
1944 .expect("shell path UTF-8")
1945 .trim()
1946 ),
1947 fs::canonicalize(TRUSTED_SHELL)
1948 .expect("canonical retained shell")
1949 .as_path()
1950 );
1951 }
1952 #[cfg(target_os = "macos")]
1953 {
1954 let shell_spec = toolchain
1955 .process_spec(
1956 &context,
1957 GitNetworkPolicy::Offline,
1958 [
1959 "-c",
1960 r#"alias.rustferry-shell=!observed=$(/bin/ps -p $$ -o command=); printf '%s\n' "$observed""#,
1961 "rustferry-shell",
1962 ],
1963 )
1964 .expect("shell process spec");
1965 let selected_shell = shell_spec
1966 .command()
1967 .expect("sealed Git command")
1968 .output()
1969 .expect("Git shell image");
1970 assert!(selected_shell.status.success());
1971 let observed = std::str::from_utf8(&selected_shell.stdout)
1972 .expect("shell name UTF-8")
1973 .split_whitespace()
1974 .next()
1975 .expect("shell command path");
1976 assert_eq!(Path::new(observed), Path::new(TRUSTED_SHELL));
1977 }
1978 toolchain.verify().expect("unchanged retained Unix chain");
1979 }
1980
1981 #[cfg(target_os = "linux")]
1982 #[test]
1983 fn fixed_bin_sh_entry_is_accepted_when_it_is_a_root_owned_symlink() {
1984 let metadata = fs::symlink_metadata(TRUSTED_SHELL).expect("fixed shell entry");
1985 assert_eq!(
1986 metadata.uid(),
1987 0,
1988 "fixed shell entry must remain root-owned"
1989 );
1990 let retained =
1991 RetainedUnixExecutable::open(Path::new(TRUSTED_SHELL), Path::new(TRUSTED_SHELL))
1992 .expect("fixed root-owned shell entry");
1993 retained.verify().expect("unchanged retained shell target");
1994 }
1995 }
1996}
1997
1998#[cfg(unix)]
1999pub use unix::{
2000 GitProcessContext, GitProcessPolicyError, GitProcessSpec, UnixGitToolchain,
2001 trusted_git_executable,
2002};