Skip to main content

rustferry_github/
git_repository.rs

1//! Private bare Git repository layout for isolated temporary-ref publication.
2
3#[cfg(windows)]
4mod windows {
5    use std::{
6        error::Error,
7        ffi::OsString,
8        fmt,
9        fs::{self, File},
10        io::{Read, Seek, SeekFrom, Write},
11        os::windows::{
12            fs::{MetadataExt as _, OpenOptionsExt as _},
13            io::AsHandle as _,
14        },
15        path::{Component, Path, PathBuf},
16    };
17
18    use rustferry_core::{DirectoryFilesystemIdentity, RetainedDirectoryIdentity};
19    use same_file::Handle as FileIdentityHandle;
20    use sha2::{Digest, Sha256};
21    use windows_sys::Win32::Storage::FileSystem::{
22        FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ,
23    };
24
25    use crate::git_process::{
26        GitNetworkPolicy, GitProcessContext, GitProcessPolicyError, GitProcessSpec,
27        WindowsGitToolchain, external_path,
28    };
29
30    const BARE_DIRECTORY_NAME: &str = "repository.git";
31    const HOME_DIRECTORY_NAME: &str = "home";
32    const XDG_DIRECTORY_NAME: &str = "xdg";
33    const TEMP_DIRECTORY_NAME: &str = "tmp";
34    const TEMPLATE_DIRECTORY_NAME: &str = "empty-template";
35    const SSH_DIRECTORY_NAME: &str = ".ssh";
36    const MAX_CONTROL_FILE_BYTES: usize = 64 * 1024;
37    const UNBORN_HEAD: &[u8] = b"ref: refs/heads/rustferry-unborn\n";
38
39    /// Pinned GitHub.com host keys published by GitHub for noninteractive SSH verification.
40    pub const GITHUB_KNOWN_HOSTS_V1: &str = concat!(
41        "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl\n",
42        "github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=\n",
43        "github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=\n",
44    );
45
46    const SSH_CONFIG_V1_TEMPLATE: &str = concat!(
47        "Host *\n",
48        "    BatchMode yes\n",
49        "    StrictHostKeyChecking yes\n",
50        "    UserKnownHostsFile @RUSTFERRY_KNOWN_HOSTS@\n",
51        "    GlobalKnownHostsFile none\n",
52        "    HostKeyAlgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256\n",
53        "    PreferredAuthentications publickey\n",
54        "    IdentityFile none\n",
55        "    PasswordAuthentication no\n",
56        "    KbdInteractiveAuthentication no\n",
57        "    ProxyCommand none\n",
58        "    ProxyJump none\n",
59        "    PermitLocalCommand no\n",
60        "    ForwardAgent no\n",
61        "    ClearAllForwardings yes\n",
62        "    RequestTTY no\n",
63        "    AddKeysToAgent no\n",
64        "    UpdateHostKeys no\n",
65        "    VerifyHostKeyDNS no\n",
66        "    CheckHostIP no\n",
67        "    CanonicalizeHostname no\n",
68        "    ConnectionAttempts 1\n",
69        "    ConnectTimeout 30\n",
70        "    LogLevel ERROR\n",
71        "Host github.com\n",
72        "    HostName github.com\n",
73        "    HostKeyAlias github.com\n",
74        "    User git\n",
75        "    Port 22\n",
76    );
77
78    fn ssh_config_v1(known_hosts: &Path) -> Result<Vec<u8>, PrivateGitRepositoryError> {
79        let external = external_path(known_hosts);
80        let value = external
81            .to_str()
82            .ok_or(PrivateGitRepositoryError::InvalidControlFile)?
83            .replace('\\', "/");
84        if !known_hosts.is_absolute()
85            || value.is_empty()
86            || value
87                .bytes()
88                .any(|byte| byte.is_ascii_control() || matches!(byte, b'%' | b'$' | b'"'))
89        {
90            return Err(PrivateGitRepositoryError::InvalidControlFile);
91        }
92        let rendered =
93            SSH_CONFIG_V1_TEMPLATE.replace("@RUSTFERRY_KNOWN_HOSTS@", &format!("\"{value}\""));
94        if rendered.len() > MAX_CONTROL_FILE_BYTES {
95            return Err(PrivateGitRepositoryError::InvalidControlFile);
96        }
97        Ok(rendered.into_bytes())
98    }
99
100    /// Canonical paths inside one private Git isolation root.
101    #[derive(Clone, Debug, Eq, PartialEq)]
102    pub struct PrivateGitRepositoryPaths {
103        root: PathBuf,
104        bare: PathBuf,
105        home: PathBuf,
106        xdg: PathBuf,
107        temp: PathBuf,
108        template: PathBuf,
109        ssh: PathBuf,
110        ssh_config: PathBuf,
111        known_hosts: PathBuf,
112    }
113
114    impl PrivateGitRepositoryPaths {
115        fn new(root: PathBuf) -> Self {
116            let bare = root.join(BARE_DIRECTORY_NAME);
117            let home = root.join(HOME_DIRECTORY_NAME);
118            let xdg = root.join(XDG_DIRECTORY_NAME);
119            let temp = root.join(TEMP_DIRECTORY_NAME);
120            let template = root.join(TEMPLATE_DIRECTORY_NAME);
121            let ssh = home.join(SSH_DIRECTORY_NAME);
122            let ssh_config = ssh.join("config");
123            let known_hosts = ssh.join("known_hosts");
124            Self {
125                root,
126                bare,
127                home,
128                xdg,
129                temp,
130                template,
131                ssh,
132                ssh_config,
133                known_hosts,
134            }
135        }
136
137        /// Private isolation root.
138        pub fn root(&self) -> &Path {
139            &self.root
140        }
141
142        /// Private bare repository directory.
143        pub fn bare(&self) -> &Path {
144            &self.bare
145        }
146
147        /// Empty private home used for Git, GCM, and OpenSSH.
148        pub fn home(&self) -> &Path {
149            &self.home
150        }
151
152        /// Private XDG configuration root.
153        pub fn xdg(&self) -> &Path {
154            &self.xdg
155        }
156
157        /// Private process temporary directory.
158        pub fn temp(&self) -> &Path {
159            &self.temp
160        }
161
162        /// Empty template directory used only by `git init`.
163        pub fn template(&self) -> &Path {
164            &self.template
165        }
166
167        /// Pinned private OpenSSH configuration file.
168        pub fn ssh_config(&self) -> &Path {
169            &self.ssh_config
170        }
171
172        /// Pinned private GitHub known-hosts file.
173        pub fn known_hosts(&self) -> &Path {
174            &self.known_hosts
175        }
176    }
177
178    struct PrivateDirectoryGuard {
179        path: PathBuf,
180        identity: DirectoryFilesystemIdentity,
181        retained: RetainedDirectoryIdentity,
182        private_handle: Option<File>,
183    }
184
185    impl PrivateDirectoryGuard {
186        fn open(path: &Path) -> Result<Self, PrivateGitRepositoryError> {
187            let path = canonical_private_directory(path)?;
188            let private_handle =
189                rustferry_core::windows_private_directory::open_private_directory_read_guard(&path)
190                    .map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
191            let retained = RetainedDirectoryIdentity::open(&path)
192                .map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
193            let identity = retained.identity().clone();
194            Ok(Self {
195                path,
196                identity,
197                retained,
198                private_handle: Some(private_handle),
199            })
200        }
201
202        fn open_git_generated(path: &Path) -> Result<Self, PrivateGitRepositoryError> {
203            let path = canonical_directory(path)?;
204            let retained = RetainedDirectoryIdentity::open(&path)
205                .map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
206            let identity = retained.identity().clone();
207            Ok(Self {
208                path,
209                identity,
210                retained,
211                private_handle: None,
212            })
213        }
214
215        fn verify(&self) -> Result<(), PrivateGitRepositoryError> {
216            if let Some(private_handle) = &self.private_handle {
217                rustferry_core::windows_private_directory::verify_private_directory_handle(
218                    private_handle.as_handle(),
219                )
220                .map_err(|_| PrivateGitRepositoryError::DirectoryIdentityChanged)?;
221            }
222            self.retained
223                .verify_path(&self.path)
224                .map_err(|_| PrivateGitRepositoryError::DirectoryIdentityChanged)?;
225            if self.retained.identity() != &self.identity {
226                return Err(PrivateGitRepositoryError::DirectoryIdentityChanged);
227            }
228            Ok(())
229        }
230    }
231
232    impl fmt::Debug for PrivateDirectoryGuard {
233        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
234            formatter
235                .debug_struct("PrivateDirectoryGuard")
236                .field("name", &self.path.file_name())
237                .field("identity", &self.identity)
238                .finish_non_exhaustive()
239        }
240    }
241
242    struct ExactPrivateFileGuard {
243        path: PathBuf,
244        identity: FileIdentityHandle,
245        sha256: String,
246        expected: Vec<u8>,
247        file: File,
248        require_private_acl: bool,
249    }
250
251    impl ExactPrivateFileGuard {
252        fn open(path: &Path, expected: &[u8]) -> Result<Self, PrivateGitRepositoryError> {
253            if expected.len() > MAX_CONTROL_FILE_BYTES {
254                return Err(PrivateGitRepositoryError::InvalidControlFile);
255            }
256            let file = rustferry_core::windows_private_directory::open_private_file(path)
257                .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
258            let identity = FileIdentityHandle::from_file(
259                file.try_clone()
260                    .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?,
261            )
262            .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
263            if FileIdentityHandle::from_path(path)
264                .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?
265                != identity
266            {
267                return Err(PrivateGitRepositoryError::ControlFileIdentityChanged);
268            }
269            let actual = read_bounded_file(&file)?;
270            if actual != expected {
271                return Err(PrivateGitRepositoryError::InvalidControlFile);
272            }
273            Ok(Self {
274                path: path.to_owned(),
275                identity,
276                sha256: hex::encode(Sha256::digest(expected)),
277                expected: expected.to_vec(),
278                file,
279                require_private_acl: true,
280            })
281        }
282
283        fn open_git_generated(
284            path: &Path,
285            expected: &[u8],
286        ) -> Result<Self, PrivateGitRepositoryError> {
287            if expected.len() > MAX_CONTROL_FILE_BYTES {
288                return Err(PrivateGitRepositoryError::InvalidControlFile);
289            }
290            let file = open_retained_generated_file(path)?;
291            let identity = FileIdentityHandle::from_file(
292                file.try_clone()
293                    .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?,
294            )
295            .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
296            if FileIdentityHandle::from_path(path)
297                .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?
298                != identity
299            {
300                return Err(PrivateGitRepositoryError::ControlFileIdentityChanged);
301            }
302            let actual = read_bounded_file(&file)?;
303            if actual != expected {
304                return Err(PrivateGitRepositoryError::InvalidControlFile);
305            }
306            Ok(Self {
307                path: path.to_owned(),
308                identity,
309                sha256: hex::encode(Sha256::digest(expected)),
310                expected: expected.to_vec(),
311                file,
312                require_private_acl: false,
313            })
314        }
315
316        fn verify(&self) -> Result<(), PrivateGitRepositoryError> {
317            if self.require_private_acl {
318                rustferry_core::windows_private_directory::verify_private_file_handle(
319                    self.file.as_handle(),
320                )
321                .map_err(|_| PrivateGitRepositoryError::ControlFileIdentityChanged)?;
322            }
323            if FileIdentityHandle::from_path(&self.path)
324                .map_err(|_| PrivateGitRepositoryError::ControlFileIdentityChanged)?
325                != self.identity
326                || read_bounded_file(&self.file)? != self.expected
327                || hex::encode(Sha256::digest(&self.expected)) != self.sha256
328            {
329                return Err(PrivateGitRepositoryError::ControlFileIdentityChanged);
330            }
331            Ok(())
332        }
333    }
334
335    impl fmt::Debug for ExactPrivateFileGuard {
336        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
337            formatter
338                .debug_struct("ExactPrivateFileGuard")
339                .field("name", &self.path.file_name())
340                .field("sha256", &self.sha256)
341                .finish_non_exhaustive()
342        }
343    }
344
345    /// Fresh or already initialized private layout retained across `git init`.
346    #[derive(Debug)]
347    pub struct PrivateGitRepositoryPreparation {
348        paths: PrivateGitRepositoryPaths,
349        directories: Vec<PrivateDirectoryGuard>,
350        ssh_config: ExactPrivateFileGuard,
351        known_hosts: ExactPrivateFileGuard,
352        needs_initialization: bool,
353    }
354
355    impl PrivateGitRepositoryPreparation {
356        /// Create or reopen fixed private directories and pinned SSH policy files.
357        ///
358        /// The isolation root must already satisfy `RustFerry`'s strict Windows private-directory
359        /// policy. A partially initialized bare repository fails closed and is never reinitialized.
360        ///
361        /// # Errors
362        ///
363        /// Rejects unsafe ACLs, reparse points, path replacement, unexpected files, or partial Git
364        /// initialization state.
365        pub fn prepare(root: impl AsRef<Path>) -> Result<Self, PrivateGitRepositoryError> {
366            let root = canonical_private_directory(root.as_ref())?;
367            let paths = PrivateGitRepositoryPaths::new(root);
368            let mut directories = vec![PrivateDirectoryGuard::open(&paths.root)?];
369            for directory in [
370                &paths.bare,
371                &paths.home,
372                &paths.xdg,
373                &paths.temp,
374                &paths.template,
375                &paths.ssh,
376            ] {
377                create_or_open_private_directory(directory)?;
378                directories.push(PrivateDirectoryGuard::open(directory)?);
379            }
380            ensure_directory_empty(&paths.template)?;
381            let expected_ssh_config = ssh_config_v1(&paths.known_hosts)?;
382            ensure_exact_private_file(&paths.ssh_config, &expected_ssh_config)?;
383            ensure_exact_private_file(&paths.known_hosts, GITHUB_KNOWN_HOSTS_V1.as_bytes())?;
384            let ssh_config = ExactPrivateFileGuard::open(&paths.ssh_config, &expected_ssh_config)?;
385            let known_hosts =
386                ExactPrivateFileGuard::open(&paths.known_hosts, GITHUB_KNOWN_HOSTS_V1.as_bytes())?;
387
388            let config_exists = paths.bare.join("config").try_exists().unwrap_or(false);
389            let head_exists = paths.bare.join("HEAD").try_exists().unwrap_or(false);
390            let needs_initialization = match (config_exists, head_exists) {
391                (false, false) => {
392                    ensure_directory_empty(&paths.bare)?;
393                    true
394                }
395                (true, true) => false,
396                (true, false) | (false, true) => {
397                    return Err(PrivateGitRepositoryError::PartialInitialization);
398                }
399            };
400            let preparation = Self {
401                paths,
402                directories,
403                ssh_config,
404                known_hosts,
405                needs_initialization,
406            };
407            preparation.verify_preparation()?;
408            Ok(preparation)
409        }
410
411        /// Whether the caller must execute [`Self::initialization_spec`] before sealing.
412        pub const fn needs_initialization(&self) -> bool {
413            self.needs_initialization
414        }
415
416        /// Exact offline `git init --bare` process specification for this retained layout.
417        ///
418        /// # Errors
419        ///
420        /// Rejects an already initialized repository or changed tool/directory identity.
421        pub fn initialization_spec<'a>(
422            &self,
423            toolchain: &'a WindowsGitToolchain,
424        ) -> Result<GitProcessSpec<'a>, PrivateGitRepositoryError> {
425            if !self.needs_initialization {
426                return Err(PrivateGitRepositoryError::AlreadyInitialized);
427            }
428            self.verify_preparation()?;
429            let context = GitProcessContext::new(
430                &self.paths.root,
431                None,
432                &self.paths.home,
433                &self.paths.xdg,
434                &self.paths.temp,
435            )?;
436            toolchain
437                .process_spec(
438                    &context,
439                    GitNetworkPolicy::Offline,
440                    [
441                        OsString::from("-c"),
442                        OsString::from("init.defaultBranch=rustferry-unborn"),
443                        OsString::from("init"),
444                        OsString::from("--bare"),
445                        OsString::from(format!(
446                            "--template={}",
447                            self.paths.template.to_string_lossy()
448                        )),
449                        self.paths.bare.as_os_str().to_owned(),
450                    ],
451                )
452                .map_err(Into::into)
453        }
454
455        /// Validate and retain an initialized bare repository.
456        ///
457        /// # Errors
458        ///
459        /// Rejects missing/malformed Git state, unsafe inherited ACLs, unexpected config keys,
460        /// alternates, path replacement, or any changed pinned policy file.
461        pub fn finish(self) -> Result<PrivateBareGitRepository, PrivateGitRepositoryError> {
462            self.verify_preparation()?;
463            let config_path = self.paths.bare.join("config");
464            let head_path = self.paths.bare.join("HEAD");
465            let config_bytes = read_private_control_path(&config_path)?;
466            validate_generated_git_config(&config_bytes)?;
467            let config = ExactPrivateFileGuard::open_git_generated(&config_path, &config_bytes)?;
468            let head = ExactPrivateFileGuard::open_git_generated(&head_path, UNBORN_HEAD)?;
469
470            let mut directories = self.directories;
471            for directory in [
472                self.paths.bare.join("objects"),
473                self.paths.bare.join("objects/info"),
474                self.paths.bare.join("objects/pack"),
475                self.paths.bare.join("refs"),
476                self.paths.bare.join("refs/heads"),
477                self.paths.bare.join("refs/tags"),
478            ] {
479                directories.push(PrivateDirectoryGuard::open_git_generated(&directory)?);
480            }
481            let alternates_path = self.paths.bare.join("objects/info/alternates");
482            let http_alternates_path = self.paths.bare.join("objects/info/http-alternates");
483            ensure_exact_private_file(&alternates_path, b"")?;
484            ensure_exact_private_file(&http_alternates_path, b"")?;
485            let alternates = ExactPrivateFileGuard::open(&alternates_path, b"")?;
486            let http_alternates = ExactPrivateFileGuard::open(&http_alternates_path, b"")?;
487            let repository = PrivateBareGitRepository {
488                paths: self.paths,
489                directories,
490                config,
491                head,
492                alternates,
493                http_alternates,
494                ssh_config: self.ssh_config,
495                known_hosts: self.known_hosts,
496            };
497            repository.verify()?;
498            Ok(repository)
499        }
500
501        fn verify_preparation(&self) -> Result<(), PrivateGitRepositoryError> {
502            for directory in &self.directories {
503                directory.verify()?;
504            }
505            self.ssh_config.verify()?;
506            self.known_hosts.verify()?;
507            ensure_directory_empty(&self.paths.template)
508        }
509    }
510
511    /// Sealed private bare repository and policy files retained for publisher lifetime.
512    #[derive(Debug)]
513    pub struct PrivateBareGitRepository {
514        paths: PrivateGitRepositoryPaths,
515        directories: Vec<PrivateDirectoryGuard>,
516        config: ExactPrivateFileGuard,
517        head: ExactPrivateFileGuard,
518        alternates: ExactPrivateFileGuard,
519        http_alternates: ExactPrivateFileGuard,
520        ssh_config: ExactPrivateFileGuard,
521        known_hosts: ExactPrivateFileGuard,
522    }
523
524    impl PrivateBareGitRepository {
525        /// Reopen an already initialized private repository without running Git.
526        ///
527        /// # Errors
528        ///
529        /// Rejects absent, partial, changed, or unsafe layouts.
530        pub fn open(root: impl AsRef<Path>) -> Result<Self, PrivateGitRepositoryError> {
531            let preparation = PrivateGitRepositoryPreparation::prepare(root)?;
532            if preparation.needs_initialization() {
533                return Err(PrivateGitRepositoryError::InitializationRequired);
534            }
535            preparation.finish()
536        }
537
538        /// Canonical retained layout paths.
539        pub const fn paths(&self) -> &PrivateGitRepositoryPaths {
540            &self.paths
541        }
542
543        /// Process context that binds every Git command to this bare object database.
544        ///
545        /// # Errors
546        ///
547        /// Rejects any path that changed after sealing.
548        pub fn process_context(&self) -> Result<GitProcessContext, PrivateGitRepositoryError> {
549            self.verify()?;
550            GitProcessContext::new(
551                &self.paths.root,
552                Some(&self.paths.bare),
553                &self.paths.home,
554                &self.paths.xdg,
555                &self.paths.temp,
556            )
557            .map_err(Into::into)
558        }
559
560        /// Revalidate all retained directories and exact policy/control files.
561        ///
562        /// # Errors
563        ///
564        /// Fails closed on identity, byte, ACL, template, config, or alternates drift.
565        pub fn verify(&self) -> Result<(), PrivateGitRepositoryError> {
566            for directory in &self.directories {
567                directory.verify()?;
568            }
569            for file in [
570                &self.config,
571                &self.head,
572                &self.alternates,
573                &self.http_alternates,
574                &self.ssh_config,
575                &self.known_hosts,
576            ] {
577                file.verify()?;
578            }
579            validate_generated_git_config(&self.config.expected)?;
580            ensure_directory_empty(&self.paths.template)
581        }
582    }
583
584    /// Stable, path-free private Git repository failure.
585    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
586    pub enum PrivateGitRepositoryError {
587        /// Root or child directory has an invalid path, type, ACL, or reparse state.
588        UnsafeDirectory,
589        /// Retained directory path no longer identifies the same object.
590        DirectoryIdentityChanged,
591        /// A fixed control file has an invalid ACL, type, or reparse state.
592        UnsafeControlFile,
593        /// Fixed control file contents are malformed or differ from policy.
594        InvalidControlFile,
595        /// Retained control file path or contents changed.
596        ControlFileIdentityChanged,
597        /// Directory expected to be empty contains unexpected state.
598        UnexpectedDirectoryEntry,
599        /// Bare repository contains only part of Git's initialization state.
600        PartialInitialization,
601        /// Initialization was requested for an existing repository.
602        AlreadyInitialized,
603        /// Existing-only open found an uninitialized repository.
604        InitializationRequired,
605        /// Git-generated local config contains an unexpected or unsafe key/value.
606        InvalidGitConfig,
607        /// Fixed process-policy construction failed.
608        ProcessPolicy(GitProcessPolicyError),
609        /// Bounded control-file I/O failed.
610        ControlFileIo,
611    }
612
613    impl fmt::Display for PrivateGitRepositoryError {
614        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
615            match self {
616                Self::UnsafeDirectory => formatter.write_str("private Git directory is unsafe"),
617                Self::DirectoryIdentityChanged => {
618                    formatter.write_str("private Git directory identity changed")
619                }
620                Self::UnsafeControlFile => {
621                    formatter.write_str("private Git control file is unsafe")
622                }
623                Self::InvalidControlFile => {
624                    formatter.write_str("private Git control file is invalid")
625                }
626                Self::ControlFileIdentityChanged => {
627                    formatter.write_str("private Git control file identity changed")
628                }
629                Self::UnexpectedDirectoryEntry => {
630                    formatter.write_str("private Git directory contains unexpected state")
631                }
632                Self::PartialInitialization => {
633                    formatter.write_str("private bare Git initialization is incomplete")
634                }
635                Self::AlreadyInitialized => {
636                    formatter.write_str("private bare Git repository is already initialized")
637                }
638                Self::InitializationRequired => {
639                    formatter.write_str("private bare Git repository requires initialization")
640                }
641                Self::InvalidGitConfig => formatter.write_str("private bare Git config is invalid"),
642                Self::ProcessPolicy(error) => {
643                    write!(formatter, "Git process policy failed: {error}")
644                }
645                Self::ControlFileIo => formatter.write_str("private Git control-file I/O failed"),
646            }
647        }
648    }
649
650    impl Error for PrivateGitRepositoryError {}
651
652    impl From<GitProcessPolicyError> for PrivateGitRepositoryError {
653        fn from(value: GitProcessPolicyError) -> Self {
654            Self::ProcessPolicy(value)
655        }
656    }
657
658    fn create_or_open_private_directory(path: &Path) -> Result<(), PrivateGitRepositoryError> {
659        match fs::symlink_metadata(path) {
660            Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
661            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
662                rustferry_core::windows_private_directory::create_private_directory(path)
663                    .map(drop)
664                    .map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
665            }
666            Ok(_) | Err(_) => return Err(PrivateGitRepositoryError::UnsafeDirectory),
667        }
668        PrivateDirectoryGuard::open(path).map(drop)
669    }
670
671    fn canonical_private_directory(path: &Path) -> Result<PathBuf, PrivateGitRepositoryError> {
672        let canonical = canonical_directory(path)?;
673        rustferry_core::windows_private_directory::open_private_directory_read_guard(&canonical)
674            .map(drop)
675            .map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
676        Ok(canonical)
677    }
678
679    fn canonical_directory(path: &Path) -> Result<PathBuf, PrivateGitRepositoryError> {
680        if !path.is_absolute()
681            || path
682                .components()
683                .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
684        {
685            return Err(PrivateGitRepositoryError::UnsafeDirectory);
686        }
687        let metadata =
688            fs::symlink_metadata(path).map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
689        if metadata.file_type().is_symlink() || !metadata.is_dir() {
690            return Err(PrivateGitRepositoryError::UnsafeDirectory);
691        }
692        fs::canonicalize(path).map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)
693    }
694
695    fn ensure_directory_empty(path: &Path) -> Result<(), PrivateGitRepositoryError> {
696        let mut entries =
697            fs::read_dir(path).map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
698        if entries
699            .next()
700            .transpose()
701            .map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?
702            .is_some()
703        {
704            return Err(PrivateGitRepositoryError::UnexpectedDirectoryEntry);
705        }
706        Ok(())
707    }
708
709    fn ensure_exact_private_file(
710        path: &Path,
711        expected: &[u8],
712    ) -> Result<(), PrivateGitRepositoryError> {
713        match fs::symlink_metadata(path) {
714            Ok(_) => ExactPrivateFileGuard::open(path, expected).map(drop),
715            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
716                let mut file = rustferry_core::windows_private_directory::create_private_file(path)
717                    .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
718                file.write_all(expected)
719                    .and_then(|()| file.sync_all())
720                    .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?;
721                drop(file);
722                ExactPrivateFileGuard::open(path, expected).map(drop)
723            }
724            Err(_) => Err(PrivateGitRepositoryError::UnsafeControlFile),
725        }
726    }
727
728    fn read_private_control_path(path: &Path) -> Result<Vec<u8>, PrivateGitRepositoryError> {
729        let file = open_retained_generated_file(path)?;
730        read_bounded_file(&file)
731    }
732
733    fn open_retained_generated_file(path: &Path) -> Result<File, PrivateGitRepositoryError> {
734        let metadata =
735            fs::symlink_metadata(path).map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
736        if !metadata.is_file()
737            || metadata.file_type().is_symlink()
738            || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
739        {
740            return Err(PrivateGitRepositoryError::UnsafeControlFile);
741        }
742        let mut options = fs::OpenOptions::new();
743        options
744            .read(true)
745            .share_mode(FILE_SHARE_READ)
746            .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
747        options
748            .open(path)
749            .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)
750    }
751
752    fn read_bounded_file(file: &File) -> Result<Vec<u8>, PrivateGitRepositoryError> {
753        let length = usize::try_from(
754            file.metadata()
755                .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?
756                .len(),
757        )
758        .map_err(|_| PrivateGitRepositoryError::InvalidControlFile)?;
759        if length > MAX_CONTROL_FILE_BYTES {
760            return Err(PrivateGitRepositoryError::InvalidControlFile);
761        }
762        let mut reader = file
763            .try_clone()
764            .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?;
765        reader
766            .seek(SeekFrom::Start(0))
767            .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?;
768        let mut bytes = Vec::with_capacity(length);
769        reader
770            .take(
771                u64::try_from(MAX_CONTROL_FILE_BYTES)
772                    .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?
773                    + 1,
774            )
775            .read_to_end(&mut bytes)
776            .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?;
777        if bytes.len() != length || bytes.len() > MAX_CONTROL_FILE_BYTES {
778            return Err(PrivateGitRepositoryError::InvalidControlFile);
779        }
780        Ok(bytes)
781    }
782
783    fn validate_generated_git_config(bytes: &[u8]) -> Result<(), PrivateGitRepositoryError> {
784        let text =
785            std::str::from_utf8(bytes).map_err(|_| PrivateGitRepositoryError::InvalidGitConfig)?;
786        if text.is_empty()
787            || text.len() > MAX_CONTROL_FILE_BYTES
788            || text.bytes().any(|byte| {
789                byte == 0
790                    || byte == b'\r'
791                    || byte.is_ascii_control() && !matches!(byte, b'\n' | b'\t')
792            })
793        {
794            return Err(PrivateGitRepositoryError::InvalidGitConfig);
795        }
796        let mut in_core = false;
797        let mut repository_format = false;
798        let mut bare = false;
799        let mut seen = std::collections::BTreeSet::new();
800        for line in text.lines() {
801            let trimmed = line.trim();
802            if trimmed.is_empty() {
803                continue;
804            }
805            if trimmed == "[core]" {
806                if in_core {
807                    return Err(PrivateGitRepositoryError::InvalidGitConfig);
808                }
809                in_core = true;
810                continue;
811            }
812            if !in_core || trimmed.starts_with('[') || trimmed.starts_with(['#', ';']) {
813                return Err(PrivateGitRepositoryError::InvalidGitConfig);
814            }
815            let (key, value) = trimmed
816                .split_once('=')
817                .ok_or(PrivateGitRepositoryError::InvalidGitConfig)?;
818            let key = key.trim().to_ascii_lowercase();
819            let value = value.trim().to_ascii_lowercase();
820            if !seen.insert(key.clone()) {
821                return Err(PrivateGitRepositoryError::InvalidGitConfig);
822            }
823            match (key.as_str(), value.as_str()) {
824                ("repositoryformatversion", "0") => repository_format = true,
825                ("bare", "true") => bare = true,
826                ("filemode" | "symlinks" | "ignorecase", "true" | "false")
827                | ("logallrefupdates", "false") => {}
828                _ => return Err(PrivateGitRepositoryError::InvalidGitConfig),
829            }
830        }
831        if in_core && repository_format && bare {
832            Ok(())
833        } else {
834            Err(PrivateGitRepositoryError::InvalidGitConfig)
835        }
836    }
837
838    #[cfg(test)]
839    mod tests {
840        use std::process::Stdio;
841
842        use super::*;
843
844        fn installed_git() -> Option<PathBuf> {
845            let path = PathBuf::from(r"C:\Program Files\Git\cmd\git.exe");
846            path.is_file().then_some(path)
847        }
848
849        fn private_root(temporary: &tempfile::TempDir) -> PathBuf {
850            let root = temporary.path().join("isolation");
851            rustferry_core::windows_private_directory::create_private_directory(&root)
852                .expect("private isolation root");
853            root
854        }
855
856        fn initialize(toolchain: &WindowsGitToolchain, root: &Path) -> PrivateBareGitRepository {
857            let preparation =
858                PrivateGitRepositoryPreparation::prepare(root).expect("private layout");
859            assert!(preparation.needs_initialization());
860            let spec = preparation
861                .initialization_spec(toolchain)
862                .expect("initialization spec");
863            let status = spec
864                .command()
865                .expect("initialization command")
866                .stdin(Stdio::null())
867                .stdout(Stdio::null())
868                .stderr(Stdio::null())
869                .status()
870                .expect("run git init");
871            assert!(status.success());
872            preparation.finish().expect("sealed bare repository")
873        }
874
875        #[test]
876        fn private_bare_repository_is_offline_and_ignores_ambient_config() {
877            let Some(git) = installed_git() else {
878                return;
879            };
880            let toolchain = WindowsGitToolchain::new(git).expect("toolchain");
881            let temporary = tempfile::tempdir().expect("fixture");
882            let repository = initialize(&toolchain, &private_root(&temporary));
883            repository.verify().expect("private repository");
884            let context = repository.process_context().expect("process context");
885            let spec = toolchain
886                .process_spec(
887                    &context,
888                    GitNetworkPolicy::Offline,
889                    ["config", "--local", "--no-includes", "--list"],
890                )
891                .expect("config query");
892            let output = spec
893                .command()
894                .expect("config command")
895                .output()
896                .expect("config output");
897            assert!(
898                output.status.success(),
899                "{}",
900                String::from_utf8_lossy(&output.stderr)
901            );
902            let output = String::from_utf8(output.stdout).expect("UTF-8 config");
903            assert!(output.contains("core.bare=true"));
904            for forbidden in [
905                "credential.helper",
906                "core.sshcommand",
907                "url.",
908                "http.proxy",
909                "http.extraheader",
910                "include.path",
911            ] {
912                assert!(!output.to_ascii_lowercase().contains(forbidden));
913            }
914            let reopened = PrivateBareGitRepository::open(repository.paths().root())
915                .expect("reopened repository");
916            reopened.verify().expect("stable reopened repository");
917        }
918
919        #[test]
920        fn managed_ssh_config_resolves_exact_private_known_hosts() {
921            let Some(git) = installed_git() else {
922                return;
923            };
924            let toolchain = WindowsGitToolchain::new(git).expect("toolchain");
925            let temporary = tempfile::tempdir().expect("fixture");
926            let repository = initialize(&toolchain, &private_root(&temporary));
927            let paths = repository.paths();
928            let config = String::from_utf8(fs::read(paths.ssh_config()).expect("SSH config"))
929                .expect("UTF-8 SSH config");
930            assert!(!config.contains('~'));
931            assert!(!config.contains(r"\\?\"));
932            assert!(!config.contains("//?/"));
933
934            let ambient_home = temporary.path().join("ambient-home");
935            let ambient_profile = temporary.path().join("ambient-profile");
936            fs::create_dir(&ambient_home).expect("ambient HOME fixture");
937            fs::create_dir(&ambient_profile).expect("ambient profile fixture");
938            let system_root = external_path(toolchain.system_root());
939            let command_interpreter =
940                external_path(&toolchain.system_root().join("System32").join("cmd.exe"));
941            let ambient_xdg = temporary.path().join("ambient-xdg");
942            fs::create_dir(&ambient_xdg).expect("ambient XDG fixture");
943            let output = std::process::Command::new(toolchain.ssh().path())
944                .args(["-G", "-F"])
945                .arg(external_path(paths.ssh_config()))
946                .arg("github.com")
947                .env_clear()
948                .env("COMSPEC", command_interpreter)
949                .env("HOME", &ambient_home)
950                .env("LANG", "C")
951                .env("LC_ALL", "C")
952                .env("PATH", toolchain.fixed_path())
953                .env("PATHEXT", ".COM;.EXE;.BAT;.CMD")
954                .env("SystemRoot", &system_root)
955                .env("TEMP", temporary.path())
956                .env("TMP", temporary.path())
957                .env("USERPROFILE", &ambient_profile)
958                .env("WINDIR", system_root)
959                .env("XDG_CONFIG_HOME", ambient_xdg)
960                .env("PROGRAMDATA", &ambient_home)
961                .output()
962                .expect("ssh -G");
963            assert!(
964                output.status.success(),
965                "status={:?}; {}",
966                output.status.code(),
967                String::from_utf8_lossy(&output.stderr)
968            );
969            let resolved = String::from_utf8(output.stdout).expect("UTF-8 ssh -G output");
970            let values = resolved
971                .lines()
972                .filter_map(|line| line.split_once(' '))
973                .filter(|(key, _)| key.eq_ignore_ascii_case("userknownhostsfile"))
974                .map(|(_, value)| value.trim().replace('\\', "/"))
975                .collect::<Vec<_>>();
976            assert_eq!(values.len(), 1);
977            let expected = external_path(paths.known_hosts())
978                .to_string_lossy()
979                .replace('\\', "/");
980            assert!(values[0].eq_ignore_ascii_case(&expected));
981            assert!(!values[0].contains("//?/"));
982        }
983
984        #[test]
985        fn malicious_local_config_is_rejected_before_use() {
986            let Some(git) = installed_git() else {
987                return;
988            };
989            let toolchain = WindowsGitToolchain::new(git).expect("toolchain");
990            let temporary = tempfile::tempdir().expect("fixture");
991            let root = private_root(&temporary);
992            let preparation = PrivateGitRepositoryPreparation::prepare(&root).expect("preparation");
993            let spec = preparation
994                .initialization_spec(&toolchain)
995                .expect("initialization spec");
996            assert!(
997                spec.command()
998                    .expect("command")
999                    .stdout(Stdio::null())
1000                    .stderr(Stdio::null())
1001                    .status()
1002                    .expect("git init")
1003                    .success()
1004            );
1005            fs::OpenOptions::new()
1006                .append(true)
1007                .open(root.join(BARE_DIRECTORY_NAME).join("config"))
1008                .and_then(|mut file| {
1009                    file.write_all(b"[credential]\nhelper = !cmd /c echo compromised > canary\n")
1010                })
1011                .expect("inject malicious local config fixture");
1012            assert!(matches!(
1013                preparation.finish(),
1014                Err(PrivateGitRepositoryError::InvalidGitConfig)
1015            ));
1016            assert!(!root.join("canary").exists());
1017        }
1018
1019        #[test]
1020        fn sealed_control_files_block_replacement() {
1021            let Some(git) = installed_git() else {
1022                return;
1023            };
1024            let toolchain = WindowsGitToolchain::new(git).expect("toolchain");
1025            let temporary = tempfile::tempdir().expect("fixture");
1026            let repository = initialize(&toolchain, &private_root(&temporary));
1027            let config = repository.paths().bare().join("config");
1028            let displaced = repository.paths().bare().join("config-old");
1029            assert!(fs::rename(&config, displaced).is_err());
1030            assert!(fs::OpenOptions::new().write(true).open(config).is_err());
1031            repository.verify().expect("stable repository");
1032        }
1033    }
1034}
1035
1036#[cfg(windows)]
1037pub use windows::{
1038    GITHUB_KNOWN_HOSTS_V1, PrivateBareGitRepository, PrivateGitRepositoryError,
1039    PrivateGitRepositoryPaths, PrivateGitRepositoryPreparation,
1040};
1041
1042#[cfg(unix)]
1043#[allow(missing_docs, clippy::missing_errors_doc)]
1044mod unix {
1045    use std::{
1046        collections::BTreeSet,
1047        error::Error,
1048        ffi::OsString,
1049        fmt,
1050        fs::{self, File},
1051        io::{Read, Seek, SeekFrom, Write},
1052        os::unix::fs::{
1053            DirBuilderExt as _, MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _,
1054        },
1055        path::{Component, Path, PathBuf},
1056    };
1057
1058    use crate::git_process::{
1059        GitNetworkPolicy, GitProcessContext, GitProcessPolicyError, GitProcessSpec,
1060        UnixGitToolchain,
1061    };
1062    use rustferry_core::{DirectoryFilesystemIdentity, verify_directory_identity};
1063
1064    const BARE_DIRECTORY_NAME: &str = "repository.git";
1065    const HOME_DIRECTORY_NAME: &str = "home";
1066    const XDG_DIRECTORY_NAME: &str = "xdg";
1067    const TEMP_DIRECTORY_NAME: &str = "tmp";
1068    const TEMPLATE_DIRECTORY_NAME: &str = "empty-template";
1069    const SSH_DIRECTORY_NAME: &str = ".ssh";
1070    const MAX_CONTROL_FILE_BYTES: usize = 64 * 1024;
1071    const UNBORN_HEAD: &[u8] = b"ref: refs/heads/rustferry-unborn\n";
1072
1073    pub const GITHUB_KNOWN_HOSTS_V1: &str = concat!(
1074        "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl\n",
1075        "github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=\n",
1076        "github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=\n",
1077    );
1078
1079    const SSH_CONFIG_V1_TEMPLATE: &str = concat!(
1080        "Host *\n",
1081        "    BatchMode yes\n",
1082        "    StrictHostKeyChecking yes\n",
1083        "    UserKnownHostsFile @RUSTFERRY_KNOWN_HOSTS@\n",
1084        "    GlobalKnownHostsFile none\n",
1085        "    HostKeyAlgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256\n",
1086        "    PreferredAuthentications publickey\n",
1087        "    IdentityFile none\n",
1088        "    PasswordAuthentication no\n",
1089        "    KbdInteractiveAuthentication no\n",
1090        "    ProxyCommand none\n",
1091        "    ProxyJump none\n",
1092        "    PermitLocalCommand no\n",
1093        "    ForwardAgent no\n",
1094        "    ClearAllForwardings yes\n",
1095        "    RequestTTY no\n",
1096        "    AddKeysToAgent no\n",
1097        "    UpdateHostKeys no\n",
1098        "    VerifyHostKeyDNS no\n",
1099        "    CheckHostIP no\n",
1100        "    CanonicalizeHostname no\n",
1101        "    ConnectionAttempts 1\n",
1102        "    ConnectTimeout 30\n",
1103        "    LogLevel ERROR\n",
1104        "Host github.com\n",
1105        "    HostName github.com\n",
1106        "    HostKeyAlias github.com\n",
1107        "    User git\n",
1108        "    Port 22\n",
1109    );
1110
1111    fn ssh_config_v1(known_hosts: &Path) -> Result<Vec<u8>, PrivateGitRepositoryError> {
1112        let value = known_hosts
1113            .to_str()
1114            .ok_or(PrivateGitRepositoryError::InvalidControlFile)?;
1115        if !known_hosts.is_absolute()
1116            || value.is_empty()
1117            || value
1118                .bytes()
1119                .any(|byte| byte.is_ascii_control() || matches!(byte, b'%' | b'$' | b'"' | b'\\'))
1120        {
1121            return Err(PrivateGitRepositoryError::InvalidControlFile);
1122        }
1123        let rendered =
1124            SSH_CONFIG_V1_TEMPLATE.replace("@RUSTFERRY_KNOWN_HOSTS@", &format!("\"{value}\""));
1125        if rendered.len() > MAX_CONTROL_FILE_BYTES {
1126            return Err(PrivateGitRepositoryError::InvalidControlFile);
1127        }
1128        Ok(rendered.into_bytes())
1129    }
1130
1131    #[derive(Clone, Debug, Eq, PartialEq)]
1132    pub struct PrivateGitRepositoryPaths {
1133        root: PathBuf,
1134        bare: PathBuf,
1135        home: PathBuf,
1136        xdg: PathBuf,
1137        temp: PathBuf,
1138        template: PathBuf,
1139        ssh: PathBuf,
1140        ssh_config: PathBuf,
1141        known_hosts: PathBuf,
1142    }
1143
1144    impl PrivateGitRepositoryPaths {
1145        fn new(root: PathBuf) -> Self {
1146            let bare = root.join(BARE_DIRECTORY_NAME);
1147            let home = root.join(HOME_DIRECTORY_NAME);
1148            let xdg = root.join(XDG_DIRECTORY_NAME);
1149            let temp = root.join(TEMP_DIRECTORY_NAME);
1150            let template = root.join(TEMPLATE_DIRECTORY_NAME);
1151            let ssh = home.join(SSH_DIRECTORY_NAME);
1152            let ssh_config = ssh.join("config");
1153            let known_hosts = ssh.join("known_hosts");
1154            Self {
1155                root,
1156                bare,
1157                home,
1158                xdg,
1159                temp,
1160                template,
1161                ssh,
1162                ssh_config,
1163                known_hosts,
1164            }
1165        }
1166
1167        pub fn root(&self) -> &Path {
1168            &self.root
1169        }
1170
1171        pub fn bare(&self) -> &Path {
1172            &self.bare
1173        }
1174
1175        pub fn home(&self) -> &Path {
1176            &self.home
1177        }
1178
1179        pub fn xdg(&self) -> &Path {
1180            &self.xdg
1181        }
1182
1183        pub fn temp(&self) -> &Path {
1184            &self.temp
1185        }
1186
1187        pub fn template(&self) -> &Path {
1188            &self.template
1189        }
1190
1191        pub fn ssh_config(&self) -> &Path {
1192            &self.ssh_config
1193        }
1194
1195        pub fn known_hosts(&self) -> &Path {
1196            &self.known_hosts
1197        }
1198    }
1199
1200    #[derive(Debug)]
1201    struct ExactPrivateFile {
1202        path: PathBuf,
1203        device: u64,
1204        inode: u64,
1205        expected: Vec<u8>,
1206        _file: File,
1207    }
1208
1209    impl ExactPrivateFile {
1210        fn open(path: &Path, expected: &[u8]) -> Result<Self, PrivateGitRepositoryError> {
1211            if expected.len() > MAX_CONTROL_FILE_BYTES {
1212                return Err(PrivateGitRepositoryError::InvalidControlFile);
1213            }
1214            let metadata = fs::symlink_metadata(path)
1215                .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
1216            if !metadata.is_file()
1217                || metadata.file_type().is_symlink()
1218                || metadata.uid() != private_parent_owner(path)?
1219                || metadata.permissions().mode() & 0o077 != 0
1220                || metadata.nlink() != 1
1221            {
1222                return Err(PrivateGitRepositoryError::UnsafeControlFile);
1223            }
1224            let file =
1225                File::open(path).map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
1226            let opened = file
1227                .metadata()
1228                .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
1229            if opened.dev() != metadata.dev()
1230                || opened.ino() != metadata.ino()
1231                || read_bounded_file(&file)? != expected
1232            {
1233                return Err(PrivateGitRepositoryError::InvalidControlFile);
1234            }
1235            Ok(Self {
1236                path: path.to_owned(),
1237                device: opened.dev(),
1238                inode: opened.ino(),
1239                expected: expected.to_vec(),
1240                _file: file,
1241            })
1242        }
1243
1244        fn verify(&self) -> Result<(), PrivateGitRepositoryError> {
1245            let metadata = fs::symlink_metadata(&self.path)
1246                .map_err(|_| PrivateGitRepositoryError::ControlFileIdentityChanged)?;
1247            if !metadata.is_file()
1248                || metadata.file_type().is_symlink()
1249                || metadata.uid() != private_parent_owner(&self.path)?
1250                || metadata.permissions().mode() & 0o077 != 0
1251                || metadata.nlink() != 1
1252                || metadata.dev() != self.device
1253                || metadata.ino() != self.inode
1254            {
1255                return Err(PrivateGitRepositoryError::ControlFileIdentityChanged);
1256            }
1257            let file = File::open(&self.path)
1258                .map_err(|_| PrivateGitRepositoryError::ControlFileIdentityChanged)?;
1259            if read_bounded_file(&file)? != self.expected {
1260                return Err(PrivateGitRepositoryError::ControlFileIdentityChanged);
1261            }
1262            Ok(())
1263        }
1264    }
1265
1266    #[derive(Debug)]
1267    pub struct PrivateGitRepositoryPreparation {
1268        paths: PrivateGitRepositoryPaths,
1269        root_identity: DirectoryFilesystemIdentity,
1270        owner_uid: u32,
1271        ssh_config: ExactPrivateFile,
1272        known_hosts: ExactPrivateFile,
1273        needs_initialization: bool,
1274    }
1275
1276    impl PrivateGitRepositoryPreparation {
1277        pub fn prepare(root: impl AsRef<Path>) -> Result<Self, PrivateGitRepositoryError> {
1278            let root = canonical_private_directory(root.as_ref())?;
1279            let owner_uid = directory_owner(&root)?;
1280            let paths = PrivateGitRepositoryPaths::new(root.clone());
1281            for directory in [
1282                &paths.bare,
1283                &paths.home,
1284                &paths.xdg,
1285                &paths.temp,
1286                &paths.template,
1287                &paths.ssh,
1288            ] {
1289                create_or_open_private_directory(directory)?;
1290            }
1291            ensure_directory_empty(&paths.template)?;
1292            let expected_ssh_config = ssh_config_v1(&paths.known_hosts)?;
1293            ensure_exact_private_file(&paths.ssh_config, &expected_ssh_config)?;
1294            ensure_exact_private_file(&paths.known_hosts, GITHUB_KNOWN_HOSTS_V1.as_bytes())?;
1295            let ssh_config = ExactPrivateFile::open(&paths.ssh_config, &expected_ssh_config)?;
1296            let known_hosts =
1297                ExactPrivateFile::open(&paths.known_hosts, GITHUB_KNOWN_HOSTS_V1.as_bytes())?;
1298            let config_exists = paths.bare.join("config").try_exists().unwrap_or(false);
1299            let head_exists = paths.bare.join("HEAD").try_exists().unwrap_or(false);
1300            let needs_initialization = match (config_exists, head_exists) {
1301                (false, false) => {
1302                    ensure_directory_empty(&paths.bare)?;
1303                    true
1304                }
1305                (true, true) => false,
1306                _ => return Err(PrivateGitRepositoryError::PartialInitialization),
1307            };
1308            let preparation = Self {
1309                root_identity: DirectoryFilesystemIdentity::capture(&root)
1310                    .map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?,
1311                owner_uid,
1312                paths,
1313                ssh_config,
1314                known_hosts,
1315                needs_initialization,
1316            };
1317            preparation.verify_preparation()?;
1318            Ok(preparation)
1319        }
1320
1321        pub const fn needs_initialization(&self) -> bool {
1322            self.needs_initialization
1323        }
1324
1325        pub fn initialization_spec<'a>(
1326            &self,
1327            toolchain: &'a UnixGitToolchain,
1328        ) -> Result<GitProcessSpec<'a>, PrivateGitRepositoryError> {
1329            if !self.needs_initialization {
1330                return Err(PrivateGitRepositoryError::AlreadyInitialized);
1331            }
1332            self.verify_preparation()?;
1333            let context = GitProcessContext::new(
1334                &self.paths.root,
1335                None,
1336                &self.paths.home,
1337                &self.paths.xdg,
1338                &self.paths.temp,
1339            )?;
1340            toolchain
1341                .process_spec(
1342                    &context,
1343                    GitNetworkPolicy::Offline,
1344                    [
1345                        OsString::from("-c"),
1346                        OsString::from("init.defaultBranch=rustferry-unborn"),
1347                        OsString::from("init"),
1348                        OsString::from("--bare"),
1349                        OsString::from(format!(
1350                            "--template={}",
1351                            self.paths.template.to_string_lossy()
1352                        )),
1353                        self.paths.bare.as_os_str().to_owned(),
1354                    ],
1355                )
1356                .map_err(Into::into)
1357        }
1358
1359        pub fn finish(self) -> Result<PrivateBareGitRepository, PrivateGitRepositoryError> {
1360            self.verify_preparation()?;
1361            let config_path = self.paths.bare.join("config");
1362            let head_path = self.paths.bare.join("HEAD");
1363            make_private_directory(&self.paths.bare.join("objects"), self.owner_uid)?;
1364            make_private_directory(&self.paths.bare.join("objects/info"), self.owner_uid)?;
1365            make_private_file(&config_path)?;
1366            make_private_file(&head_path)?;
1367            let config_bytes = read_control_path(&config_path)?;
1368            validate_generated_git_config(&config_bytes)?;
1369            let config = ExactPrivateFile::open(&config_path, &config_bytes)?;
1370            let head = ExactPrivateFile::open(&head_path, UNBORN_HEAD)?;
1371            let alternates_path = self.paths.bare.join("objects/info/alternates");
1372            let http_alternates_path = self.paths.bare.join("objects/info/http-alternates");
1373            ensure_exact_private_file(&alternates_path, b"")?;
1374            ensure_exact_private_file(&http_alternates_path, b"")?;
1375            let alternates = ExactPrivateFile::open(&alternates_path, b"")?;
1376            let http_alternates = ExactPrivateFile::open(&http_alternates_path, b"")?;
1377            let repository = PrivateBareGitRepository {
1378                paths: self.paths,
1379                root_identity: self.root_identity,
1380                owner_uid: self.owner_uid,
1381                config,
1382                head,
1383                alternates,
1384                http_alternates,
1385                ssh_config: self.ssh_config,
1386                known_hosts: self.known_hosts,
1387            };
1388            repository.verify()?;
1389            Ok(repository)
1390        }
1391
1392        fn verify_preparation(&self) -> Result<(), PrivateGitRepositoryError> {
1393            verify_directory_identity(&self.paths.root, &self.root_identity)
1394                .map_err(|_| PrivateGitRepositoryError::DirectoryIdentityChanged)?;
1395            for directory in [
1396                &self.paths.root,
1397                &self.paths.bare,
1398                &self.paths.home,
1399                &self.paths.xdg,
1400                &self.paths.temp,
1401                &self.paths.template,
1402                &self.paths.ssh,
1403            ] {
1404                canonical_private_directory(directory)?;
1405                if directory_owner(directory)? != self.owner_uid {
1406                    return Err(PrivateGitRepositoryError::UnsafeDirectory);
1407                }
1408            }
1409            self.ssh_config.verify()?;
1410            self.known_hosts.verify()?;
1411            ensure_directory_empty(&self.paths.template)
1412        }
1413    }
1414
1415    #[derive(Debug)]
1416    pub struct PrivateBareGitRepository {
1417        paths: PrivateGitRepositoryPaths,
1418        root_identity: DirectoryFilesystemIdentity,
1419        owner_uid: u32,
1420        config: ExactPrivateFile,
1421        head: ExactPrivateFile,
1422        alternates: ExactPrivateFile,
1423        http_alternates: ExactPrivateFile,
1424        ssh_config: ExactPrivateFile,
1425        known_hosts: ExactPrivateFile,
1426    }
1427
1428    impl PrivateBareGitRepository {
1429        pub fn open(root: impl AsRef<Path>) -> Result<Self, PrivateGitRepositoryError> {
1430            let preparation = PrivateGitRepositoryPreparation::prepare(root)?;
1431            if preparation.needs_initialization() {
1432                return Err(PrivateGitRepositoryError::InitializationRequired);
1433            }
1434            preparation.finish()
1435        }
1436
1437        pub const fn paths(&self) -> &PrivateGitRepositoryPaths {
1438            &self.paths
1439        }
1440
1441        pub fn process_context(&self) -> Result<GitProcessContext, PrivateGitRepositoryError> {
1442            self.verify()?;
1443            GitProcessContext::new(
1444                &self.paths.root,
1445                Some(&self.paths.bare),
1446                &self.paths.home,
1447                &self.paths.xdg,
1448                &self.paths.temp,
1449            )
1450            .map_err(Into::into)
1451        }
1452
1453        pub fn verify(&self) -> Result<(), PrivateGitRepositoryError> {
1454            verify_directory_identity(&self.paths.root, &self.root_identity)
1455                .map_err(|_| PrivateGitRepositoryError::DirectoryIdentityChanged)?;
1456            for directory in [
1457                &self.paths.root,
1458                &self.paths.bare,
1459                &self.paths.home,
1460                &self.paths.xdg,
1461                &self.paths.temp,
1462                &self.paths.template,
1463                &self.paths.ssh,
1464            ] {
1465                canonical_private_directory(directory)?;
1466                if directory_owner(directory)? != self.owner_uid {
1467                    return Err(PrivateGitRepositoryError::UnsafeDirectory);
1468                }
1469            }
1470            for file in [
1471                &self.config,
1472                &self.head,
1473                &self.alternates,
1474                &self.http_alternates,
1475                &self.ssh_config,
1476                &self.known_hosts,
1477            ] {
1478                file.verify()?;
1479            }
1480            validate_generated_git_config(&self.config.expected)?;
1481            ensure_directory_empty(&self.paths.template)
1482        }
1483    }
1484
1485    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
1486    pub enum PrivateGitRepositoryError {
1487        UnsafeDirectory,
1488        DirectoryIdentityChanged,
1489        UnsafeControlFile,
1490        InvalidControlFile,
1491        ControlFileIdentityChanged,
1492        UnexpectedDirectoryEntry,
1493        PartialInitialization,
1494        AlreadyInitialized,
1495        InitializationRequired,
1496        InvalidGitConfig,
1497        ProcessPolicy(GitProcessPolicyError),
1498        ControlFileIo,
1499    }
1500
1501    impl fmt::Display for PrivateGitRepositoryError {
1502        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1503            match self {
1504                Self::UnsafeDirectory => formatter.write_str("private Git directory is unsafe"),
1505                Self::DirectoryIdentityChanged => {
1506                    formatter.write_str("private Git directory identity changed")
1507                }
1508                Self::UnsafeControlFile => {
1509                    formatter.write_str("private Git control file is unsafe")
1510                }
1511                Self::InvalidControlFile => {
1512                    formatter.write_str("private Git control file is invalid")
1513                }
1514                Self::ControlFileIdentityChanged => {
1515                    formatter.write_str("private Git control file identity changed")
1516                }
1517                Self::UnexpectedDirectoryEntry => {
1518                    formatter.write_str("private Git directory contains unexpected state")
1519                }
1520                Self::PartialInitialization => {
1521                    formatter.write_str("private bare Git repository is partially initialized")
1522                }
1523                Self::AlreadyInitialized => {
1524                    formatter.write_str("private Git repository already exists")
1525                }
1526                Self::InitializationRequired => {
1527                    formatter.write_str("private Git repository needs initialization")
1528                }
1529                Self::InvalidGitConfig => formatter.write_str("private Git config is invalid"),
1530                Self::ProcessPolicy(error) => {
1531                    write!(formatter, "private Git process policy failed: {error}")
1532                }
1533                Self::ControlFileIo => formatter.write_str("private Git control-file I/O failed"),
1534            }
1535        }
1536    }
1537
1538    impl Error for PrivateGitRepositoryError {}
1539
1540    impl From<GitProcessPolicyError> for PrivateGitRepositoryError {
1541        fn from(value: GitProcessPolicyError) -> Self {
1542            Self::ProcessPolicy(value)
1543        }
1544    }
1545
1546    fn create_or_open_private_directory(path: &Path) -> Result<(), PrivateGitRepositoryError> {
1547        match fs::symlink_metadata(path) {
1548            Ok(_) => {
1549                canonical_private_directory(path)?;
1550            }
1551            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1552                let mut builder = fs::DirBuilder::new();
1553                builder.mode(0o700);
1554                builder
1555                    .create(path)
1556                    .map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
1557                canonical_private_directory(path)?;
1558                sync_parent(path)?;
1559            }
1560            Err(_) => return Err(PrivateGitRepositoryError::UnsafeDirectory),
1561        }
1562        Ok(())
1563    }
1564
1565    fn canonical_private_directory(path: &Path) -> Result<PathBuf, PrivateGitRepositoryError> {
1566        if !is_absolute_normal(path) {
1567            return Err(PrivateGitRepositoryError::UnsafeDirectory);
1568        }
1569        let metadata =
1570            fs::symlink_metadata(path).map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
1571        if !metadata.is_dir()
1572            || metadata.file_type().is_symlink()
1573            || metadata.permissions().mode() & 0o077 != 0
1574        {
1575            return Err(PrivateGitRepositoryError::UnsafeDirectory);
1576        }
1577        fs::canonicalize(path).map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)
1578    }
1579
1580    fn ensure_directory_empty(path: &Path) -> Result<(), PrivateGitRepositoryError> {
1581        let mut entries =
1582            fs::read_dir(path).map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
1583        if entries.next().is_some() {
1584            return Err(PrivateGitRepositoryError::UnexpectedDirectoryEntry);
1585        }
1586        Ok(())
1587    }
1588
1589    fn ensure_exact_private_file(
1590        path: &Path,
1591        expected: &[u8],
1592    ) -> Result<(), PrivateGitRepositoryError> {
1593        match fs::symlink_metadata(path) {
1594            Ok(_) => ExactPrivateFile::open(path, expected).map(drop),
1595            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1596                let mut options = fs::OpenOptions::new();
1597                let mut file = options
1598                    .write(true)
1599                    .create_new(true)
1600                    .mode(0o600)
1601                    .open(path)
1602                    .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
1603                file.write_all(expected)
1604                    .and_then(|()| file.sync_all())
1605                    .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?;
1606                sync_parent(path)?;
1607                ExactPrivateFile::open(path, expected).map(drop)
1608            }
1609            Err(_) => Err(PrivateGitRepositoryError::UnsafeControlFile),
1610        }
1611    }
1612
1613    fn make_private_file(path: &Path) -> Result<(), PrivateGitRepositoryError> {
1614        let metadata =
1615            fs::symlink_metadata(path).map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
1616        if !metadata.is_file()
1617            || metadata.file_type().is_symlink()
1618            || metadata.uid() != private_parent_owner(path)?
1619            || metadata.nlink() != 1
1620        {
1621            return Err(PrivateGitRepositoryError::UnsafeControlFile);
1622        }
1623        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1624            .map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)
1625    }
1626
1627    fn make_private_directory(
1628        path: &Path,
1629        expected_owner: u32,
1630    ) -> Result<(), PrivateGitRepositoryError> {
1631        let metadata =
1632            fs::symlink_metadata(path).map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)?;
1633        if !metadata.is_dir()
1634            || metadata.file_type().is_symlink()
1635            || metadata.uid() != expected_owner
1636        {
1637            return Err(PrivateGitRepositoryError::UnsafeDirectory);
1638        }
1639        fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1640            .map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)
1641    }
1642
1643    fn read_control_path(path: &Path) -> Result<Vec<u8>, PrivateGitRepositoryError> {
1644        let file = File::open(path).map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
1645        read_bounded_file(&file)
1646    }
1647
1648    fn read_bounded_file(file: &File) -> Result<Vec<u8>, PrivateGitRepositoryError> {
1649        let length = usize::try_from(
1650            file.metadata()
1651                .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?
1652                .len(),
1653        )
1654        .map_err(|_| PrivateGitRepositoryError::InvalidControlFile)?;
1655        if length > MAX_CONTROL_FILE_BYTES {
1656            return Err(PrivateGitRepositoryError::InvalidControlFile);
1657        }
1658        let mut reader = file
1659            .try_clone()
1660            .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?;
1661        reader
1662            .seek(SeekFrom::Start(0))
1663            .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?;
1664        let mut bytes = Vec::with_capacity(length);
1665        reader
1666            .take(MAX_CONTROL_FILE_BYTES as u64 + 1)
1667            .read_to_end(&mut bytes)
1668            .map_err(|_| PrivateGitRepositoryError::ControlFileIo)?;
1669        if bytes.len() != length || bytes.len() > MAX_CONTROL_FILE_BYTES {
1670            return Err(PrivateGitRepositoryError::InvalidControlFile);
1671        }
1672        Ok(bytes)
1673    }
1674
1675    fn validate_generated_git_config(bytes: &[u8]) -> Result<(), PrivateGitRepositoryError> {
1676        let text =
1677            std::str::from_utf8(bytes).map_err(|_| PrivateGitRepositoryError::InvalidGitConfig)?;
1678        if text.is_empty()
1679            || text.len() > MAX_CONTROL_FILE_BYTES
1680            || text.bytes().any(|byte| {
1681                byte == 0
1682                    || byte == b'\r'
1683                    || byte.is_ascii_control() && !matches!(byte, b'\n' | b'\t')
1684            })
1685        {
1686            return Err(PrivateGitRepositoryError::InvalidGitConfig);
1687        }
1688        let mut in_core = false;
1689        let mut repository_format = false;
1690        let mut bare = false;
1691        let mut seen = BTreeSet::new();
1692        for line in text.lines() {
1693            let trimmed = line.trim();
1694            if trimmed.is_empty() {
1695                continue;
1696            }
1697            if trimmed == "[core]" {
1698                if in_core {
1699                    return Err(PrivateGitRepositoryError::InvalidGitConfig);
1700                }
1701                in_core = true;
1702                continue;
1703            }
1704            if !in_core || trimmed.starts_with('[') || trimmed.starts_with(['#', ';']) {
1705                return Err(PrivateGitRepositoryError::InvalidGitConfig);
1706            }
1707            let (key, value) = trimmed
1708                .split_once('=')
1709                .ok_or(PrivateGitRepositoryError::InvalidGitConfig)?;
1710            let key = key.trim().to_ascii_lowercase();
1711            let value = value.trim().to_ascii_lowercase();
1712            if !seen.insert(key.clone()) {
1713                return Err(PrivateGitRepositoryError::InvalidGitConfig);
1714            }
1715            match (key.as_str(), value.as_str()) {
1716                ("repositoryformatversion", "0") => repository_format = true,
1717                ("bare", "true") => bare = true,
1718                ("filemode" | "symlinks" | "ignorecase", "true" | "false")
1719                | ("logallrefupdates", "false") => {}
1720                _ => return Err(PrivateGitRepositoryError::InvalidGitConfig),
1721            }
1722        }
1723        if in_core && repository_format && bare {
1724            Ok(())
1725        } else {
1726            Err(PrivateGitRepositoryError::InvalidGitConfig)
1727        }
1728    }
1729
1730    fn sync_parent(path: &Path) -> Result<(), PrivateGitRepositoryError> {
1731        let parent = path
1732            .parent()
1733            .ok_or(PrivateGitRepositoryError::ControlFileIo)?;
1734        File::open(parent)
1735            .and_then(|directory| directory.sync_all())
1736            .map_err(|_| PrivateGitRepositoryError::ControlFileIo)
1737    }
1738
1739    fn directory_owner(path: &Path) -> Result<u32, PrivateGitRepositoryError> {
1740        fs::metadata(path)
1741            .map(|metadata| metadata.uid())
1742            .map_err(|_| PrivateGitRepositoryError::UnsafeDirectory)
1743    }
1744
1745    fn private_parent_owner(path: &Path) -> Result<u32, PrivateGitRepositoryError> {
1746        let parent = path
1747            .parent()
1748            .ok_or(PrivateGitRepositoryError::UnsafeControlFile)?;
1749        let metadata =
1750            fs::metadata(parent).map_err(|_| PrivateGitRepositoryError::UnsafeControlFile)?;
1751        if !metadata.is_dir() || metadata.permissions().mode() & 0o077 != 0 {
1752            return Err(PrivateGitRepositoryError::UnsafeControlFile);
1753        }
1754        Ok(metadata.uid())
1755    }
1756
1757    fn is_absolute_normal(path: &Path) -> bool {
1758        path.is_absolute()
1759            && path
1760                .components()
1761                .all(|component| !matches!(component, Component::CurDir | Component::ParentDir))
1762    }
1763
1764    #[cfg(test)]
1765    mod tests {
1766        use std::process::Stdio;
1767
1768        use super::*;
1769
1770        fn system_toolchain() -> Option<UnixGitToolchain> {
1771            match UnixGitToolchain::new("/usr/bin/git") {
1772                Ok(toolchain) => Some(toolchain),
1773                Err(GitProcessPolicyError::InvalidToolLayout) => None,
1774                Err(error) => panic!("system toolchain: {error:?}"),
1775            }
1776        }
1777
1778        #[test]
1779        fn private_bare_repository_is_offline_and_ignores_ambient_config() {
1780            if !Path::new("/usr/bin/git").is_file() || !Path::new("/usr/bin/ssh").is_file() {
1781                return;
1782            }
1783            let Some(toolchain) = system_toolchain() else {
1784                return;
1785            };
1786            let temporary = tempfile::tempdir().expect("fixture");
1787            let root = temporary.path().join("isolation");
1788            let mut builder = fs::DirBuilder::new();
1789            builder.mode(0o700).create(&root).expect("private root");
1790            let preparation =
1791                PrivateGitRepositoryPreparation::prepare(&root).expect("private layout");
1792            let mut command = preparation
1793                .initialization_spec(&toolchain)
1794                .expect("init spec")
1795                .command()
1796                .expect("init command");
1797            assert!(
1798                command
1799                    .stdin(Stdio::null())
1800                    .stdout(Stdio::null())
1801                    .stderr(Stdio::null())
1802                    .status()
1803                    .expect("git init")
1804                    .success()
1805            );
1806            let repository = preparation.finish().expect("sealed repository");
1807            repository.verify().expect("verified repository");
1808            let context = repository.process_context().expect("process context");
1809            let output = toolchain
1810                .process_spec(
1811                    &context,
1812                    GitNetworkPolicy::Offline,
1813                    ["config", "--local", "--no-includes", "--list"],
1814                )
1815                .expect("config spec")
1816                .command()
1817                .expect("config command")
1818                .output()
1819                .expect("config output");
1820            assert!(output.status.success());
1821            let output = String::from_utf8(output.stdout).expect("UTF-8 config");
1822            assert!(output.contains("core.bare=true"));
1823            for forbidden in [
1824                "credential.helper",
1825                "core.sshcommand",
1826                "url.",
1827                "http.proxy",
1828                "include.path",
1829            ] {
1830                assert!(!output.to_ascii_lowercase().contains(forbidden));
1831            }
1832            PrivateBareGitRepository::open(repository.paths().root())
1833                .expect("reopened repository")
1834                .verify()
1835                .expect("reopened verification");
1836        }
1837
1838        #[test]
1839        fn managed_ssh_config_resolves_exact_private_known_hosts() {
1840            if !Path::new("/usr/bin/git").is_file() || !Path::new("/usr/bin/ssh").is_file() {
1841                return;
1842            }
1843            let Some(toolchain) = system_toolchain() else {
1844                return;
1845            };
1846            let temporary = tempfile::tempdir().expect("fixture");
1847            let root = temporary.path().join("isolation");
1848            fs::DirBuilder::new()
1849                .mode(0o700)
1850                .create(&root)
1851                .expect("private root");
1852            let preparation =
1853                PrivateGitRepositoryPreparation::prepare(&root).expect("private layout");
1854            let mut command = preparation
1855                .initialization_spec(&toolchain)
1856                .expect("init spec")
1857                .command()
1858                .expect("init command");
1859            assert!(
1860                command
1861                    .stdin(Stdio::null())
1862                    .stdout(Stdio::null())
1863                    .stderr(Stdio::null())
1864                    .status()
1865                    .expect("git init")
1866                    .success()
1867            );
1868            let repository = preparation.finish().expect("sealed repository");
1869            let paths = repository.paths();
1870            let config = String::from_utf8(fs::read(paths.ssh_config()).expect("SSH config"))
1871                .expect("UTF-8 SSH config");
1872            assert!(!config.contains('~'));
1873
1874            let output = std::process::Command::new("/usr/bin/ssh")
1875                .args(["-G", "-F"])
1876                .arg(paths.ssh_config())
1877                .arg("github.com")
1878                .env_clear()
1879                .env("HOME", temporary.path().join("ambient-home"))
1880                .output()
1881                .expect("ssh -G");
1882            assert!(output.status.success());
1883            let resolved = String::from_utf8(output.stdout).expect("UTF-8 ssh -G output");
1884            let values = resolved
1885                .lines()
1886                .filter_map(|line| line.split_once(' '))
1887                .filter(|(key, _)| key.eq_ignore_ascii_case("userknownhostsfile"))
1888                .map(|(_, value)| value.trim())
1889                .collect::<Vec<_>>();
1890            let expected = paths.known_hosts().to_string_lossy().into_owned();
1891            assert_eq!(values, [expected]);
1892        }
1893    }
1894}
1895
1896#[cfg(unix)]
1897pub use unix::{
1898    GITHUB_KNOWN_HOSTS_V1, PrivateBareGitRepository, PrivateGitRepositoryError,
1899    PrivateGitRepositoryPaths, PrivateGitRepositoryPreparation,
1900};