1#[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 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 #[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 pub fn root(&self) -> &Path {
139 &self.root
140 }
141
142 pub fn bare(&self) -> &Path {
144 &self.bare
145 }
146
147 pub fn home(&self) -> &Path {
149 &self.home
150 }
151
152 pub fn xdg(&self) -> &Path {
154 &self.xdg
155 }
156
157 pub fn temp(&self) -> &Path {
159 &self.temp
160 }
161
162 pub fn template(&self) -> &Path {
164 &self.template
165 }
166
167 pub fn ssh_config(&self) -> &Path {
169 &self.ssh_config
170 }
171
172 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 #[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 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 pub const fn needs_initialization(&self) -> bool {
413 self.needs_initialization
414 }
415
416 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 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 #[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 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 pub const fn paths(&self) -> &PrivateGitRepositoryPaths {
540 &self.paths
541 }
542
543 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 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 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
586 pub enum PrivateGitRepositoryError {
587 UnsafeDirectory,
589 DirectoryIdentityChanged,
591 UnsafeControlFile,
593 InvalidControlFile,
595 ControlFileIdentityChanged,
597 UnexpectedDirectoryEntry,
599 PartialInitialization,
601 AlreadyInitialized,
603 InitializationRequired,
605 InvalidGitConfig,
607 ProcessPolicy(GitProcessPolicyError),
609 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};