History

Merge pull request #2134 from EliahKagan/user-program-files

Extend `ALTERNATIVE_LOCATIONS` for per-user installations

Eliah Kagan committed 1 year ago

Commit 2fffc502 parents e365244 7e77d404 files · +396 −57Browse files

Unified · Split

4 files
  1. Cargo.lock +0 −10
  2. gix-path/Cargo.toml +5 −2
  3. gix-path/src/env/git/mod.rs +18 −2
  4. gix-path/src/env/git/tests.rs +373 −43
Cargo.lock+0−10
@@ -2134,7 +2134,6 @@ dependencies = [ · 2133 unmodified lines
21342134 "gix-trace",
21352135 "gix-validate",
21362136 "home",
2137− "known-folders",
21382137 "once_cell",
21392138 "serial_test",
21402139 "thiserror 2.0.12",
@@ -3220,15 +3219,6 @@ dependencies = [ · 1079 unmodified lines
32203219 "rayon",
32213220 ]
32223221
3223−[[package]]
3224−name = "known-folders"
3225−version = "1.3.1"
3226−source = "registry+https://github.com/rust-lang/crates.io-index"
3227−checksum = "c644f4623d1c55eb60a9dac35e0858a59f982fb87db6ce34c872372b0a5b728f"
3228−dependencies = [
3229− "windows-sys 0.60.2",
3230−]
3231−
32323222 [[package]]
32333223 name = "kstring"
32343224 version = "2.0.2"
gix-path/Cargo.toml+5−2
@@ -29,6 +29,9 @@ gix-testtools = { path = "../tests/tools" } · 28 unmodified lines
2929 serial_test = { version = "3.1.0", default-features = false }
3030
3131 [target.'cfg(windows)'.dev-dependencies]
32−known-folders = "1.3.1"
33−windows = { version = "0.61.3", features = ["Win32_System_Threading"] }
32+windows = { version = "0.61.3", features = [
33+ "Win32_System_Com",
34+ "Win32_System_Threading",
35+ "Win32_UI_Shell",
36+] }
3437 winreg = "0.55.0"
gix-path/src/env/git/mod.rs+18−2
@@ -38,13 +38,21 @@ where · 37 unmodified lines
3838 // known. So the situation where a process only passes down `ProgramFiles` sometimes happens.
3939 let varname_current = "ProgramFiles";
4040
41+ // Should give the user's local application data path on any system. If a user program files
42+ // directory exists for this user, then it should be the `Programs` subdirectory of this. If it
43+ // doesn't exist, or on a future or extremely strangely configured Windows setup where it is
44+ // somewhere else, it should still be safe to attempt to use it. (This differs from global
45+ // program files paths, which are usually subdirectories of the root of the system drive, which
46+ // limited user accounts can usually create their own arbitrarily named directories inside.)
47+ let varname_user_appdata_local = "LocalAppData";
48+
4149 // 64-bit relative bin dirs. So far, this is always `mingw64` or `clangarm64`, not `urct64` or
4250 // `clang64`. We check `clangarm64` before `mingw64`, because in the strange case that both are
4351 // available, we don't want to skip over a native ARM64 executable for an emulated x86_64 one.
44− let suffixes_64 = [r"Git\clangarm64\bin", r"Git\mingw64\bin"].as_slice();
52+ let suffixes_64 = &[r"Git\clangarm64\bin", r"Git\mingw64\bin"][..];
4553
4654 // 32-bit relative bin dirs. So far, this is only ever `mingw32`, not `clang32`.
47− let suffixes_32 = [r"Git\mingw32\bin"].as_slice();
55+ let suffixes_32 = &[r"Git\mingw32\bin"][..];
4856
4957 // Whichever of the 64-bit or 32-bit relative bin better matches this process's architecture.
5058 // Unlike the system architecture, the process architecture is always known at compile time.
@@ -53,7 +61,15 @@ where · 2 unmodified lines
5361 #[cfg(target_pointer_width = "32")]
5462 let suffixes_current = suffixes_32;
5563
64+ // Bin dirs relative to a user's local application data directory. We try each architecture.
65+ let suffixes_user = &[
66+ r"Programs\Git\clangarm64\bin",
67+ r"Programs\Git\mingw64\bin",
68+ r"Programs\Git\mingw32\bin",
69+ ][..];
70+
5671 let rules = [
72+ (varname_user_appdata_local, suffixes_user),
5773 (varname_64bit, suffixes_64),
5874 (varname_x86, suffixes_32),
5975 (varname_current, suffixes_current),
gix-path/src/env/git/tests.rs+373−43
@@ -3,15 +3,24 @@ use std::path::Path; · 2 unmodified lines
33 #[cfg(windows)]
44 mod locations {
55 use std::{
6− ffi::{OsStr, OsString},
6+ ffi::{c_void, OsStr, OsString},
77 io::ErrorKind,
8+ os::windows::ffi::OsStringExt,
89 path::{Path, PathBuf},
910 };
1011
11− use known_folders::{get_known_folder_path, KnownFolder};
1212 use windows::{
13− core::{Result as WindowsResult, BOOL},
14− Win32::System::Threading::{GetCurrentProcess, IsWow64Process},
13+ core::{Result as WindowsResult, BOOL, GUID, PWSTR},
14+ Win32::{
15+ System::{
16+ Com::CoTaskMemFree,
17+ Threading::{GetCurrentProcess, IsWow64Process},
18+ },
19+ UI::Shell::{
20+ FOLDERID_LocalAppData, FOLDERID_ProgramFiles, FOLDERID_ProgramFilesX86, FOLDERID_UserProgramFiles,
21+ SHGetKnownFolderPath, KF_FLAG_DEFAULT, KF_FLAG_DONT_VERIFY, KNOWN_FOLDER_FLAG,
22+ },
23+ },
1524 };
1625 use winreg::{
1726 enums::{HKEY_LOCAL_MACHINE, KEY_QUERY_VALUE},
@@ -55,7 +64,7 @@ mod locations { · 37 unmodified lines
5564 }
5665
5766 #[test]
58− fn locations_under_program_files_ordinary_values_current_var_only() {
67+ fn locations_under_program_files_global_only_ordinary_values_current_var_only() {
5968 assert_eq!(
6069 locations_from!(
6170 "ProgramFiles" => r"C:\Program Files",
@@ -72,7 +81,7 @@ mod locations { · 10 unmodified lines
7281 }
7382
7483 #[test]
75− fn locations_under_program_files_ordinary_values_all_vars() {
84+ fn locations_under_program_files_global_only_ordinary_values_all_vars() {
7685 assert_eq!(
7786 locations_from!(
7887 "ProgramFiles" => {
@@ -94,7 +103,7 @@ mod locations { · 15 unmodified lines
94103 }
95104
96105 #[test]
97− fn locations_under_program_files_strange_values_all_vars_distinct() {
106+ fn locations_under_program_files_global_only_strange_values_all_vars_distinct() {
98107 assert_eq!(
99108 locations_from!(
100109 "ProgramFiles" => r"X:\cur\rent",
@@ -121,7 +130,7 @@ mod locations { · 20 unmodified lines
121130 }
122131
123132 #[test]
124− fn locations_under_program_files_strange_values_64bit_var_only() {
133+ fn locations_under_program_files_global_only_strange_values_64bit_var_only() {
125134 assert_eq!(
126135 locations_from!(
127136 "ProgramW6432" => r"Z:\wi\de",
@@ -131,7 +140,7 @@ mod locations { · 3 unmodified lines
131140 }
132141
133142 #[test]
134− fn locations_under_program_files_strange_values_all_vars_path_cruft() {
143+ fn locations_under_program_files_global_only_strange_values_all_vars_path_cruft() {
135144 assert_eq!(
136145 locations_from!(
137146 "ProgramFiles" => r"Z:/wi//de/",
@@ -156,7 +165,7 @@ mod locations { · 18 unmodified lines
156165 }
157166
158167 #[test]
159− fn locations_under_program_files_strange_values_some_relative() {
168+ fn locations_under_program_files_global_only_strange_values_some_relative() {
160169 assert_eq!(
161170 locations_from!(
162171 "ProgramFiles" => r"foo\bar",
@@ -167,6 +176,235 @@ mod locations { · 4 unmodified lines
167176 );
168177 }
169178
179+ #[test]
180+ fn locations_under_program_files_local_only_ordinary_value() {
181+ assert_eq!(
182+ locations_from!(
183+ "LocalAppData" => r"C:\Users\alice\AppData\Local",
184+ ),
185+ pathbuf_vec![
186+ r"C:\Users\alice\AppData\Local\Programs\Git\clangarm64\bin",
187+ r"C:\Users\alice\AppData\Local\Programs\Git\mingw64\bin",
188+ r"C:\Users\alice\AppData\Local\Programs\Git\mingw32\bin",
189+ ],
190+ );
191+ }
192+
193+ #[test]
194+ fn locations_under_program_files_local_only_strange_value_path_cruft() {
195+ assert_eq!(
196+ locations_from!(
197+ "LocalAppData" => r"\\.\Q:\Documents and Settings/bob\/weird\.\sub//dir",
198+ ),
199+ pathbuf_vec![
200+ r"\\.\Q:\Documents and Settings\bob\weird\sub\dir\Programs\Git\clangarm64\bin",
201+ r"\\.\Q:\Documents and Settings\bob\weird\sub\dir\Programs\Git\mingw64\bin",
202+ r"\\.\Q:\Documents and Settings\bob\weird\sub\dir\Programs\Git\mingw32\bin",
203+ ],
204+ );
205+ }
206+
207+ #[test]
208+ fn locations_under_program_files_local_only_strange_value_empty() {
209+ assert_eq!(
210+ locations_from!(
211+ "LocalAppData" => "",
212+ ),
213+ Vec::<PathBuf>::new(),
214+ );
215+ }
216+
217+ #[test]
218+ fn locations_under_program_files_local_only_strange_value_relative_nonempty() {
219+ assert_eq!(
220+ locations_from!(
221+ "LocalAppData" => r"AppData\Local",
222+ ),
223+ Vec::<PathBuf>::new(),
224+ );
225+ }
226+
227+ #[test]
228+ fn locations_under_program_files_local_and_global_ordinary_values_limited_vars() {
229+ assert_eq!(
230+ locations_from!(
231+ "LocalAppData" => r"C:\Users\alice\AppData\Local",
232+ "ProgramFiles" => r"C:\Program Files",
233+ ),
234+ if cfg!(target_pointer_width = "64") {
235+ pathbuf_vec![
236+ r"C:\Users\alice\AppData\Local\Programs\Git\clangarm64\bin",
237+ r"C:\Users\alice\AppData\Local\Programs\Git\mingw64\bin",
238+ r"C:\Users\alice\AppData\Local\Programs\Git\mingw32\bin",
239+ r"C:\Program Files\Git\clangarm64\bin",
240+ r"C:\Program Files\Git\mingw64\bin",
241+ ]
242+ } else {
243+ pathbuf_vec![
244+ r"C:\Users\alice\AppData\Local\Programs\Git\clangarm64\bin",
245+ r"C:\Users\alice\AppData\Local\Programs\Git\mingw64\bin",
246+ r"C:\Users\alice\AppData\Local\Programs\Git\mingw32\bin",
247+ r"C:\Program Files\Git\mingw32\bin",
248+ ]
249+ },
250+ );
251+ }
252+
253+ #[test]
254+ fn locations_under_program_files_local_and_global_ordinary_values_all_vars() {
255+ assert_eq!(
256+ locations_from!(
257+ "LocalAppData" => r"C:\Users\bob\AppData\Local",
258+ "ProgramFiles" => {
259+ if cfg!(target_pointer_width = "64") {
260+ r"C:\Program Files"
261+ } else {
262+ r"C:\Program Files (x86)"
263+ }
264+ },
265+ "ProgramFiles(x86)" => r"C:\Program Files (x86)",
266+ "ProgramW6432" => r"C:\Program Files",
267+ ),
268+ pathbuf_vec![
269+ r"C:\Users\bob\AppData\Local\Programs\Git\clangarm64\bin",
270+ r"C:\Users\bob\AppData\Local\Programs\Git\mingw64\bin",
271+ r"C:\Users\bob\AppData\Local\Programs\Git\mingw32\bin",
272+ r"C:\Program Files\Git\clangarm64\bin",
273+ r"C:\Program Files\Git\mingw64\bin",
274+ r"C:\Program Files (x86)\Git\mingw32\bin",
275+ ],
276+ );
277+ }
278+
279+ #[test]
280+ fn locations_under_program_files_local_and_global_strange_values_all_vars_distinct() {
281+ assert_eq!(
282+ locations_from!(
283+ "LocalAppData" => r"W:\us\er",
284+ "ProgramFiles" => r"X:\cur\rent",
285+ "ProgramFiles(x86)" => r"Y:\nar\row",
286+ "ProgramW6432" => r"Z:\wi\de",
287+ ),
288+ if cfg!(target_pointer_width = "64") {
289+ pathbuf_vec![
290+ r"W:\us\er\Programs\Git\clangarm64\bin",
291+ r"W:\us\er\Programs\Git\mingw64\bin",
292+ r"W:\us\er\Programs\Git\mingw32\bin",
293+ r"Z:\wi\de\Git\clangarm64\bin",
294+ r"Z:\wi\de\Git\mingw64\bin",
295+ r"Y:\nar\row\Git\mingw32\bin",
296+ r"X:\cur\rent\Git\clangarm64\bin",
297+ r"X:\cur\rent\Git\mingw64\bin",
298+ ]
299+ } else {
300+ pathbuf_vec![
301+ r"W:\us\er\Programs\Git\clangarm64\bin",
302+ r"W:\us\er\Programs\Git\mingw64\bin",
303+ r"W:\us\er\Programs\Git\mingw32\bin",
304+ r"Z:\wi\de\Git\clangarm64\bin",
305+ r"Z:\wi\de\Git\mingw64\bin",
306+ r"Y:\nar\row\Git\mingw32\bin",
307+ r"X:\cur\rent\Git\mingw32\bin",
308+ ]
309+ },
310+ );
311+ }
312+
313+ #[test]
314+ fn locations_under_program_files_local_and_global_strange_values_limited_64bit_var() {
315+ assert_eq!(
316+ locations_from!(
317+ "LocalAppData" => r"W:\us\er",
318+ "ProgramW6432" => r"Z:\wi\de",
319+ ),
320+ pathbuf_vec![
321+ r"W:\us\er\Programs\Git\clangarm64\bin",
322+ r"W:\us\er\Programs\Git\mingw64\bin",
323+ r"W:\us\er\Programs\Git\mingw32\bin",
324+ r"Z:\wi\de\Git\clangarm64\bin",
325+ r"Z:\wi\de\Git\mingw64\bin",
326+ ],
327+ );
328+ }
329+
330+ #[test]
331+ fn locations_under_program_files_local_and_global_strange_values_crufty_cross_clash() {
332+ assert_eq!(
333+ locations_from!(
334+ "LocalAppData" => r"Y:\nar\row",
335+ "ProgramFiles" => r"Z:/wi//de/",
336+ "ProgramFiles(x86)" => r"Y:/\nar/row\Programs",
337+ "ProgramW6432" => r"Z:\wi\.\de",
338+ ),
339+ if cfg!(target_pointer_width = "64") {
340+ pathbuf_vec![
341+ r"Y:\nar\row\Programs\Git\clangarm64\bin",
342+ r"Y:\nar\row\Programs\Git\mingw64\bin",
343+ r"Y:\nar\row\Programs\Git\mingw32\bin",
344+ r"Z:\wi\de\Git\clangarm64\bin",
345+ r"Z:\wi\de\Git\mingw64\bin",
346+ ]
347+ } else {
348+ pathbuf_vec![
349+ r"Y:\nar\row\Programs\Git\clangarm64\bin",
350+ r"Y:\nar\row\Programs\Git\mingw64\bin",
351+ r"Y:\nar\row\Programs\Git\mingw32\bin",
352+ r"Z:\wi\de\Git\clangarm64\bin",
353+ r"Z:\wi\de\Git\mingw64\bin",
354+ r"Z:\wi\de\Git\mingw32\bin",
355+ ]
356+ },
357+ );
358+ }
359+
360+ #[test]
361+ fn locations_under_program_files_local_and_global_strange_values_some_relative() {
362+ assert_eq!(
363+ locations_from!(
364+ "LocalAppData" => "dir",
365+ "ProgramFiles" => r"foo\bar",
366+ "ProgramFiles(x86)" => r"\\host\share\subdir",
367+ "ProgramW6432" => r"",
368+ ),
369+ pathbuf_vec![r"\\host\share\subdir\Git\mingw32\bin"],
370+ );
371+ }
372+
373+ /// Owner of a null-terminated `PWSTR` that must be freed with `CoTaskMemFree`.
374+ struct CoStr {
375+ pwstr: PWSTR,
376+ }
377+
378+ impl CoStr {
379+ /// SAFETY: The caller must ensure `pwstr` is a non-null pointer to the beginning of a
380+ /// null-terminated (zero-codepoint-terminated) wide string releasable by `CoTaskMemFree`.
381+ unsafe fn new(pwstr: PWSTR) -> Self {
382+ Self { pwstr }
383+ }
384+
385+ fn to_os_string(&self) -> OsString {
386+ // SAFETY: We know `pwstr` is derefrenceable and the string is null-terminated.
387+ let wide = unsafe { self.pwstr.as_wide() };
388+ OsString::from_wide(wide)
389+ }
390+ }
391+
392+ impl Drop for CoStr {
393+ fn drop(&mut self) {
394+ // SAFETY: `pwstr` is allowed to be passed to `CoTaskMemFree`. (We happen to know it's
395+ // non-null, but `CoTaskMemFree` permits null as well, so the cast is doubly safe.)
396+ unsafe { CoTaskMemFree(Some(self.pwstr.as_ptr().cast::<c_void>())) };
397+ }
398+ }
399+
400+ fn get_known_folder_path_with_flag(id: GUID, flag: KNOWN_FOLDER_FLAG) -> WindowsResult<PathBuf> {
401+ // SAFETY: `SHGetKnownFolderPath` in the `windows` crate wraps the API function and returns
402+ // a non-null pointer to a null-terminated wide string, or an error, not a null pointer.
403+ // As in the wrapped API function, the pointer it returns can be passed to `CoTaskMemFree`.
404+ let costr = unsafe { CoStr::new(SHGetKnownFolderPath(&id, flag, None)?) };
405+ Ok(PathBuf::from(costr.to_os_string()))
406+ }
407+
170408 #[derive(Clone, Copy, Debug)]
171409 enum PlatformBitness {
172410 Is32on32,
@@ -205,9 +443,10 @@ mod locations { · 32 unmodified lines
205443 Some(folded_text.ends_with(&folded_pattern))
206444 }
207445
208− /// The most common global program files paths on this system, by process and system architecture.
446+ /// The most common program files paths, as they are available in this environment.
209447 ///
210− /// This omits the 32-bit ARM program files directory, as Git for Windows is never installed there.
448+ /// This omits the global 32-bit ARM program files directory, because Git for Windows is never
449+ /// installed there.
211450 #[derive(Clone, Debug)]
212451 struct ProgramFilesPaths {
213452 /// The program files directory used for whatever architecture this program was built for.
@@ -224,10 +463,13 @@ mod locations { · 10 unmodified lines
224463 /// AMD64 programs use the same program files directory while 32-bit x86 and 32-bit ARM
225464 /// programs use two others. Only a 32-bit system has no 64-bit program files directory.
226465 maybe_64bit: Option<PathBuf>,
466+
467+ /// The per-user `Programs` subdirectory of the user's local application data directory.
468+ user: PathBuf,
227469 }
228470
229471 impl ProgramFilesPaths {
230− /// Get the three most common kinds of global program files paths without environment variables.
472+ /// Get the four most common kinds of program files paths without environment variables.
231473 ///
232474 /// The idea here is to obtain this information, which the `alternative_locations()` unit
233475 /// test uses to learn the expected alternative locations, without duplicating *any* of the
@@ -235,9 +477,10 @@ mod locations { · 1 unmodified line
235477 /// here is also more reliable than using environment variables, but it is a bit more
236478 /// complex, and it requires either additional dependencies or the use of unsafe code.
237479 ///
238− /// This gets `pf_current` and `pf_x86` by the [known folders][known-folders] system. But
239− /// it gets `maybe_pf_64bit` from the registry, as the corresponding known folder is not
240− /// available to 32-bit processes. See the [`KNOWNFOLDDERID`][knownfolderid] documentation.
480+ /// This gets `pf_user`, `pf_current`, and `pf_x86` by the [known folders][known-folders]
481+ /// system. But it gets `maybe_pf_64bit` from the registry, as the corresponding known
482+ /// folder is not available to 32-bit processes. See the [`KNOWNFOLDDERID`][knownfolderid]
483+ /// documentation.
241484 ///
242485 /// If in the future the implementation of `ALTERNATIVE_LOCATIONS` uses these techniques,
243486 /// then this function can be changed to use environment variables and renamed accordingly.
@@ -245,10 +488,10 @@ mod locations { · 1 unmodified line
245488 /// [known-folders]: https://learn.microsoft.com/en-us/windows/win32/shell/known-folders
246489 /// [knownfolderid]: https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid#remarks
247490 fn obtain_envlessly() -> Self {
248− let pf_current = get_known_folder_path(KnownFolder::ProgramFiles)
491+ let pf_current = get_known_folder_path_with_flag(FOLDERID_ProgramFiles, KF_FLAG_DEFAULT)
249492 .expect("The process architecture specific program files folder is always available");
250493
251− let pf_x86 = get_known_folder_path(KnownFolder::ProgramFilesX86)
494+ let pf_x86 = get_known_folder_path_with_flag(FOLDERID_ProgramFilesX86, KF_FLAG_DEFAULT)
252495 .expect("The x86 program files folder will in practice always be available");
253496
254497 let maybe_pf_64bit = RegKey::predef(HKEY_LOCAL_MACHINE)
@@ -262,10 +505,14 @@ mod locations { · 7 unmodified lines
262505 })
263506 .ok();
264507
508+ let pf_user = get_known_folder_path_with_flag(FOLDERID_UserProgramFiles, KF_FLAG_DONT_VERIFY)
509+ .expect("The path where the user's local Programs folder is, or would be created, is known");
510+
265511 Self {
266512 current: pf_current,
267513 x86: pf_x86,
268514 maybe_64bit: maybe_pf_64bit,
515+ user: pf_user,
269516 }
270517 }
271518
@@ -274,6 +521,12 @@ mod locations { · 2 unmodified lines
274521 /// This checks that `obtain_envlessly()` returned paths that are likely to be correct and
275522 /// that satisfy the most important properties based on the current system and process.
276523 fn validated(self) -> Self {
524+ self.validate_global_folders();
525+ self.validate_user_folder();
526+ self
527+ }
528+
529+ fn validate_global_folders(&self) {
277530 match PlatformBitness::current().expect("Process and system 'bitness' should be available") {
278531 PlatformBitness::Is32on32 => {
279532 assert_eq!(
@@ -325,59 +578,114 @@ mod locations { · 45 unmodified lines
325578 );
326579 }
327580 }
581+ }
328582
329− self
583+ fn validate_user_folder(&self) {
584+ let expected = get_known_folder_path_with_flag(FOLDERID_LocalAppData, KF_FLAG_DEFAULT)
585+ .expect("The user's local application data directory is available")
586+ .join("Programs");
587+ assert_eq!(
588+ self.user, expected,
589+ "The user program files directory is Programs in the local application data directory.",
590+ );
330591 }
331592 }
332593
333− /// Paths relative to process architecture specific program files directories.
594+ /// Architecture-specific Git for Windows paths relative to the user program files directory.
334595 #[derive(Clone, Debug)]
335− struct RelativeGitBinPaths<'a> {
596+ struct RelativeUserGitBinPaths<'a> {
597+ x86: &'a Path,
598+ x64: &'a Path,
599+ arm64: &'a Path,
600+ }
601+
602+ impl<'a> RelativeUserGitBinPaths<'a> {
603+ /// Assert that `locations` leads with the given user path prefix, and extract the suffixes.
604+ fn assert_from(pf_user: &'a Path, locations: &'static [PathBuf]) -> Self {
605+ match locations {
606+ [path1, path2, path3, ..] => {
607+ let suffix_user_arm64 = path1
608+ .strip_prefix(pf_user)
609+ .expect("It gives a per-user 64-bit ARM64 path and lists it first");
610+ let suffix_user_x64 = path2
611+ .strip_prefix(pf_user)
612+ .expect("It gives a per-user 64-bit x86 path and lists it second");
613+ let suffix_user_x86 = path3
614+ .strip_prefix(pf_user)
615+ .expect("It gives a per-user 32-bit x86 path and lists it third");
616+ Self {
617+ x86: suffix_user_x86,
618+ x64: suffix_user_x64,
619+ arm64: suffix_user_arm64,
620+ }
621+ }
622+ other => panic!(
623+ "{:?} has length {}, so some expected leading user program files paths are absent",
624+ other,
625+ other.len()
626+ ),
627+ }
628+ }
629+
630+ /// Assert that suffixes are common Git install locations relative to a program files directory.
631+ fn assert_architectures(&self) {
632+ assert_eq!(self.x86, Path::new("Git/mingw32/bin"));
633+ assert_eq!(self.x64, Path::new("Git/mingw64/bin"));
634+ assert_eq!(self.arm64, Path::new("Git/clangarm64/bin"));
635+ }
636+ }
637+
638+ /// Architecture-specific Git for Windows paths relative to global program files directories.
639+ #[derive(Clone, Debug)]
640+ struct RelativeGlobalGitBinPaths<'a> {
336641 x86: &'a Path,
337642 maybe_x64: Option<&'a Path>,
338643 maybe_arm64: Option<&'a Path>,
339644 }
340645
341− impl<'a> RelativeGitBinPaths<'a> {
342− /// Assert that `locations` has the given path prefixes, and extract the suffixes.
646+ impl<'a> RelativeGlobalGitBinPaths<'a> {
647+ /// Assert that `locations` trails with the given global path prefixes, and extract the suffixes.
343648 fn assert_from(pf: &'a ProgramFilesPaths, locations: &'static [PathBuf]) -> Self {
344649 match locations {
345− [primary, secondary, tertiary] => {
650+ [_, _, _, path4, path5, path6] => {
346651 let prefix_64bit = pf
347652 .maybe_64bit
348653 .as_ref()
349− .expect("It gives three paths only if some can be 64-bit");
350− let suffix_arm64 = primary
654+ .expect("It gives 6 paths only if some global paths can be 64-bit");
655+ let suffix_global_arm64 = path4
351656 .strip_prefix(prefix_64bit)
352− .expect("It gives the 64-bit ARM64 path and lists it first");
353− let suffix_x64 = secondary
657+ .expect("It gives a global 64-bit ARM64 path and lists it fourth");
658+ let suffix_global_x64 = path5
354659 .strip_prefix(prefix_64bit)
355− .expect("It gives the 64-bit x86 path and lists it second");
356− let suffix_x86 = tertiary
357− .strip_prefix(pf.x86.as_path())
358− .expect("It gives the 32-bit path and lists it third");
660+ .expect("It gives a global 64-bit x86 path and lists it fifth");
661+ let suffix_global_x86 = path6
662+ .strip_prefix(&pf.x86)
663+ .expect("It gives a global 32-bit path and lists it sixth");
359664 Self {
360− x86: suffix_x86,
361− maybe_x64: Some(suffix_x64),
362− maybe_arm64: Some(suffix_arm64),
665+ x86: suffix_global_x86,
666+ maybe_x64: Some(suffix_global_x64),
667+ maybe_arm64: Some(suffix_global_arm64),
363668 }
364669 }
365− [only] => {
366− assert_eq!(pf.maybe_64bit, None, "It gives one path only if none can be 64-bit.");
367− let suffix_x86 = only
368− .strip_prefix(pf.x86.as_path())
369− .expect("The one path it gives is the 32-bit path");
670+ [_, _, _, path4] => {
671+ assert_eq!(
672+ pf.maybe_64bit, None,
673+ "It gives 4 paths only if no global paths can be 64-bit.",
674+ );
675+ let suffix_global_x86 = path4
676+ .strip_prefix(&pf.x86)
677+ .expect("It gives a global 32-bit path and lists it fourth");
370678 Self {
371− x86: suffix_x86,
679+ x86: suffix_global_x86,
372680 maybe_x64: None,
373681 maybe_arm64: None,
374682 }
375683 }
376− other => panic!("{:?} has length {}, expected 1 or 3.", other, other.len()),
684+ other => panic!("{:?} has length {}, expected 4 or 6.", other, other.len()),
377685 }
378686 }
379687
380− /// Assert that the suffixes (relative subdirectories) are the common per-architecture Git install locations.
688+ /// Assert that suffixes are common Git install locations relative to a program files directory.
381689 fn assert_architectures(&self) {
382690 assert_eq!(self.x86, Path::new("Git/mingw32/bin"));
383691
@@ -390,6 +698,28 @@ mod locations { · 6 unmodified lines
390698 }
391699 }
392700
701+ /// Architecture-specific Git for Windows paths relative to particular program files directories.
702+ #[derive(Clone, Debug)]
703+ struct RelativeGitBinPaths<'a> {
704+ global: RelativeGlobalGitBinPaths<'a>,
705+ user: RelativeUserGitBinPaths<'a>,
706+ }
707+
708+ impl<'a> RelativeGitBinPaths<'a> {
709+ /// Assert that `locations` has the given path prefixes, and extract the suffixes.
710+ fn assert_from(pf: &'a ProgramFilesPaths, locations: &'static [PathBuf]) -> Self {
711+ let user = RelativeUserGitBinPaths::assert_from(&pf.user, locations);
712+ let global = RelativeGlobalGitBinPaths::assert_from(pf, locations);
713+ Self { global, user }
714+ }
715+
716+ /// Assert that global and user suffixes (relative subdirectories) are common Git install locations.
717+ fn assert_architectures(&self) {
718+ self.global.assert_architectures();
719+ self.user.assert_architectures();
720+ }
721+ }
722+
393723 #[test]
394724 fn alternative_locations() {
395725 // Obtain program files directory paths by other means and check that they seem correct.