Skip to main content

rustferry_github/
git_process.rs

1//! Fixed-tool, environment-cleared Git process policy.
2
3use crate::git_endpoint::GithubGitTransport;
4
5/// Network capability granted to one isolated Git invocation.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum GitNetworkPolicy {
8    /// No transport helper may be invoked.
9    Offline,
10    /// Canonical HTTPS; Windows uses fixed GCM, while Unix disables credential helpers.
11    HttpsWithCredentialManager,
12    /// Only canonical GitHub SSH with the retained Windows OpenSSH client may be used.
13    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    /// A retained executable whose path, object identity, and bytes cannot change on Windows.
59    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        /// Canonical executable path retained for process creation.
91        pub fn path(&self) -> &Path {
92            &self.path
93        }
94
95        /// Stable filesystem identity of the retained executable.
96        pub const fn identity(&self) -> &FileIdentityHandle {
97            &self.identity
98        }
99
100        /// Lowercase SHA-256 of the exact retained executable bytes.
101        pub fn sha256(&self) -> &str {
102            &self.sha256
103        }
104
105        /// Revalidate the named path against the retained handle and byte digest.
106        ///
107        /// # Errors
108        ///
109        /// Returns a path-free failure if the executable was replaced or modified.
110        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(&current)? != 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    /// Canonical Git-for-Windows, GCM, and operating-system OpenSSH installation.
201    ///
202    /// Construction accepts only the standard Git-for-Windows `cmd/git.exe` layout. Every
203    /// executable and containing directory remains open without delete sharing for the complete
204    /// lifetime of this value. OpenSSH and `cmd.exe` are derived from the operating system's
205    /// authoritative Windows directory, never from ambient `PATH` or `SystemRoot` values.
206    #[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        /// Resolve a strict Git-for-Windows layout from one explicit canonical `git.exe` path.
230        ///
231        /// # Errors
232        ///
233        /// Rejects relative, linked, renamed, oversized, missing, nonstandard, or internally
234        /// inconsistent Git/GCM/OpenSSH layouts.
235        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            // `cmd/git.exe` launches the mingw runtime, and GCM may itself run `git config`.
269            // Retain both locations that the fixed PATH/GIT_EXEC_PATH make executable.
270            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        /// Retained Git executable.
336        pub const fn git(&self) -> &RetainedGitExecutable {
337            &self.git
338        }
339
340        /// Retained Git Credential Manager executable.
341        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        /// Retained Windows OpenSSH executable.
356        pub const fn ssh(&self) -> &RetainedGitExecutable {
357            &self.ssh
358        }
359
360        /// Retained Git HTTPS remote helper.
361        pub const fn https_helper(&self) -> &RetainedGitExecutable {
362            &self.https_helper
363        }
364
365        /// Authoritative Windows directory used for `SystemRoot` and `WINDIR`.
366        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        /// Revalidate every retained executable and containing directory.
376        ///
377        /// # Errors
378        ///
379        /// Fails closed if any named path or executable bytes changed.
380        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        /// Build one environment-cleared Git command bound to controlled directories.
408        ///
409        /// # Errors
410        ///
411        /// Rejects changed tool identities or invalid process directories.
412        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                // Git has no argument-array interface for SSH options. This fixed string contains
495                // no caller input; `-F` also suppresses ambient user and system OpenSSH config.
496                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    /// Resolve the only Git-for-Windows entrypoint accepted by production.
507    ///
508    /// The path comes from the operating system's authoritative Windows directory, never from
509    /// `PATH`, `ProgramFiles`, or another caller-controlled environment variable.
510    ///
511    /// # Errors
512    ///
513    /// Rejects an unavailable Windows directory or any missing/nonstandard Git-for-Windows tool.
514    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    /// Canonical private directories supplied to one Git process.
526    #[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        /// Create a canonical process context, optionally binding a private bare Git directory.
537        ///
538        /// # Errors
539        ///
540        /// Rejects relative, linked, missing, or non-directory paths.
541        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        /// Canonical private bare repository, when the command needs one.
558        pub fn git_directory(&self) -> Option<&Path> {
559            self.git_dir.as_deref()
560        }
561
562        /// Canonical process working directory.
563        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    /// One exact Git command whose environment starts empty.
587    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        /// Construct the process after revalidating all retained tool identities.
596        ///
597        /// # Errors
598        ///
599        /// Fails if a tool or installation directory changed after planning.
600        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        /// Exact argument vector after `git.exe`.
612        pub fn arguments(&self) -> &[OsString] {
613            &self.arguments
614        }
615
616        /// Exact allowlisted environment. Values contain only controlled local paths and flags.
617        pub const fn environment(&self) -> &BTreeMap<OsString, OsString> {
618            &self.environment
619        }
620
621        /// Exact process working directory.
622        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    /// Stable, path-free process-policy configuration failure.
642    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
643    pub enum GitProcessPolicyError {
644        /// Executable path, type, name, size, identity, or contents are invalid.
645        InvalidExecutable,
646        /// Git-for-Windows installation layout is incomplete or nonstandard.
647        InvalidToolLayout,
648        /// Operating system did not provide a trustworthy Windows directory.
649        InvalidWindowsDirectory,
650        /// A retained executable path or byte digest changed.
651        ExecutableIdentityChanged,
652        /// A retained tool directory path changed.
653        ToolLayoutIdentityChanged,
654        /// Private process directory is invalid.
655        InvalidProcessDirectory,
656        /// Private process directory changed after planning.
657        ProcessDirectoryChanged,
658        /// Executable bytes could not be read safely.
659        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                // This occurs after planning and immediately before the modeled helper spawn.
1122                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    /// Root-owned system Git/OpenSSH policy for Unix clients.
1243    #[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        /// Require the root-owned `/usr/bin/git` and `/usr/bin/ssh` entrypoints.
1254        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        /// Build one environment-cleared Git command bound to controlled directories.
1282        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                // Git has no argument-array interface for SSH options. This fixed string contains
1357                // no caller input; `-F` also suppresses ambient user and system OpenSSH config.
1358                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    /// Resolve and validate the fixed root-owned Git entrypoint accepted on Unix.
1382    ///
1383    /// # Errors
1384    ///
1385    /// Rejects a missing, mutable, non-root-owned, or otherwise unsafe system toolchain.
1386    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    /// Canonical private directories supplied to one Unix Git process.
1393    #[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    /// One exact Unix Git command whose environment starts empty.
1467    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};