From a5e262e1f164bcb5fdddfcfaf8ccc985e1337aa8 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 29 Jan 2026 15:56:57 -0800 Subject: [PATCH] Workaround libgit2 safe.directory case mismatch bug --- GVFS/GVFS.Common/Git/LibGit2Repo.cs | 159 +++++++++++++++++- .../GVFS.FunctionalTests.csproj | 1 + GVFS/GVFS.FunctionalTests/Program.cs | 7 + .../SafeDirectoryOwnershipTests.cs | 119 +++++++++++++ .../Tools/PrivilegeEnabler.cs | 84 +++++++++ 5 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerTestCase/SafeDirectoryOwnershipTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tools/PrivilegeEnabler.cs diff --git a/GVFS/GVFS.Common/Git/LibGit2Repo.cs b/GVFS/GVFS.Common/Git/LibGit2Repo.cs index 86415ea5c..a50727913 100644 --- a/GVFS/GVFS.Common/Git/LibGit2Repo.cs +++ b/GVFS/GVFS.Common/Git/LibGit2Repo.cs @@ -10,6 +10,8 @@ public class LibGit2Repo : IDisposable { private bool disposedValue = false; + public delegate void MultiVarConfigCallback(string value); + public LibGit2Repo(ITracer tracer, string repoPath) { this.Tracer = tracer; @@ -23,8 +25,12 @@ public LibGit2Repo(ITracer tracer, string repoPath) string message = "Couldn't open repo at " + repoPath + ": " + reason; tracer.RelatedWarning(message); - Native.Shutdown(); - throw new InvalidDataException(message); + if (!reason.EndsWith(" is not owned by current user") + || !CheckSafeDirectoryConfigForCaseSensitivityIssue(tracer, repoPath, out repoHandle)) + { + Native.Shutdown(); + throw new InvalidDataException(message); + } } this.RepoHandle = repoHandle; @@ -246,7 +252,64 @@ public virtual string GetConfigString(string name) { Native.Config.Free(configHandle); } + } + public void ForEachMultiVarConfig(string key, MultiVarConfigCallback callback) + { + if (Native.Config.GetConfig(out IntPtr configHandle, this.RepoHandle) != Native.ResultCode.Success) + { + throw new LibGit2Exception($"Failed to get config handle: {Native.GetLastError()}"); + } + try + { + ForEachMultiVarConfig(configHandle, key, callback); + } + finally + { + Native.Config.Free(configHandle); + } + } + + public static void ForEachMultiVarConfigInGlobalAndSystemConfig(string key, MultiVarConfigCallback callback) + { + if (Native.Config.GetGlobalAndSystemConfig(out IntPtr configHandle) != Native.ResultCode.Success) + { + throw new LibGit2Exception($"Failed to get global and system config handle: {Native.GetLastError()}"); + } + try + { + ForEachMultiVarConfig(configHandle, key, callback); + } + finally + { + Native.Config.Free(configHandle); + } + } + + private static void ForEachMultiVarConfig(IntPtr configHandle, string key, MultiVarConfigCallback callback) + { + Native.Config.GitConfigMultivarCallback nativeCallback = (entryPtr, payload) => + { + try + { + var entry = Marshal.PtrToStructure(entryPtr); + callback(entry.GetValue()); + } + catch (Exception) + { + return Native.ResultCode.Failure; + } + return 0; + }; + if (Native.Config.GetMultivarForeach( + configHandle, + key, + regex:"", + nativeCallback, + IntPtr.Zero) != Native.ResultCode.Success) + { + throw new LibGit2Exception($"Failed to get multivar config for '{key}': {Native.GetLastError()}"); + } } /// @@ -302,11 +365,48 @@ protected virtual void Dispose(bool disposing) } } + private bool CheckSafeDirectoryConfigForCaseSensitivityIssue(ITracer tracer, string repoPath, out IntPtr repoHandle) + { + /* Libgit2 has a bug where it is case sensitive for safe.directory (especially the + * drive letter) when git.exe isn't. Until a fix can be made and propagated, work + * around it by matching the repo path we request to the configured safe directory. + * + * See https://github.com/libgit2/libgit2/issues/7037 + */ + repoHandle = IntPtr.Zero; + + string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + string normalized = path.Replace('\\', '/').ToUpperInvariant(); + return normalized.TrimEnd('/'); + } + + string normalizedRequestedPath = NormalizePath(repoPath); + + string configuredMatchingDirectory = null; + ForEachMultiVarConfigInGlobalAndSystemConfig("safe.directory", (string value) => + { + string normalizedConfiguredPath = NormalizePath(value); + if (normalizedConfiguredPath == normalizedRequestedPath) + { + configuredMatchingDirectory = value; + } + }); + + return configuredMatchingDirectory != null && Native.Repo.Open(out repoHandle, configuredMatchingDirectory) == Native.ResultCode.Success; + } + public static class Native { public enum ResultCode : int { Success = 0, + Failure = -1, NotFound = -3, } @@ -370,9 +470,64 @@ public static class Config [DllImport(Git2NativeLibName, EntryPoint = "git_repository_config")] public static extern ResultCode GetConfig(out IntPtr configHandle, IntPtr repoHandle); + [DllImport(Git2NativeLibName, EntryPoint = "git_config_open_default")] + public static extern ResultCode GetGlobalAndSystemConfig(out IntPtr configHandle); + [DllImport(Git2NativeLibName, EntryPoint = "git_config_get_string")] public static extern ResultCode GetString(out string value, IntPtr configHandle, string name); + [DllImport(Git2NativeLibName, EntryPoint = "git_config_get_multivar_foreach")] + public static extern ResultCode GetMultivarForeach( + IntPtr configHandle, + string name, + string regex, + GitConfigMultivarCallback callback, + IntPtr payload); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate ResultCode GitConfigMultivarCallback( + IntPtr entryPtr, + IntPtr payload); + + [StructLayout(LayoutKind.Sequential)] + public struct GitConfigEntry + { + public IntPtr Name; + public IntPtr Value; + public IntPtr BackendType; + public IntPtr OriginPath; + public uint IncludeDepth; + public int Level; + + public string GetValue() + { + return Value != IntPtr.Zero ? MarshalUtf8String(Value) : null; + } + + public string GetName() + { + return Name != IntPtr.Zero ? MarshalUtf8String(Name) : null; + } + + private static string MarshalUtf8String(IntPtr ptr) + { + if (ptr == IntPtr.Zero) + { + return null; + } + + int length = 0; + while (Marshal.ReadByte(ptr, length) != 0) + { + length++; + } + + byte[] buffer = new byte[length]; + Marshal.Copy(ptr, buffer, 0, length); + return System.Text.Encoding.UTF8.GetString(buffer); + } + } + [DllImport(Git2NativeLibName, EntryPoint = "git_config_get_bool")] public static extern ResultCode GetBool(out bool value, IntPtr configHandle, string name); diff --git a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj index f170451f4..c777bdf84 100644 --- a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj +++ b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj @@ -26,6 +26,7 @@ false + PreserveNewest diff --git a/GVFS/GVFS.FunctionalTests/Program.cs b/GVFS/GVFS.FunctionalTests/Program.cs index f00d9496a..79d51f528 100644 --- a/GVFS/GVFS.FunctionalTests/Program.cs +++ b/GVFS/GVFS.FunctionalTests/Program.cs @@ -1,5 +1,6 @@ using GVFS.FunctionalTests.Properties; using GVFS.FunctionalTests.Tools; +using GVFS.PlatformLoader; using GVFS.Tests; using System; using System.Collections.Generic; @@ -13,6 +14,7 @@ public class Program public static void Main(string[] args) { Properties.Settings.Default.Initialize(); + GVFSPlatformLoader.Initialize(); Console.WriteLine("Settings.Default.CurrentDirectory: {0}", Settings.Default.CurrentDirectory); Console.WriteLine("Settings.Default.PathToGit: {0}", Settings.Default.PathToGit); Console.WriteLine("Settings.Default.PathToGVFS: {0}", Settings.Default.PathToGVFS); @@ -21,6 +23,11 @@ public static void Main(string[] args) NUnitRunner runner = new NUnitRunner(args); runner.AddGlobalSetupIfNeeded("GVFS.FunctionalTests.GlobalSetup"); + if (runner.HasCustomArg("--debug")) + { + Debugger.Launch(); + } + if (runner.HasCustomArg("--no-shared-gvfs-cache")) { Console.WriteLine("Running without a shared git object cache"); diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerTestCase/SafeDirectoryOwnershipTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerTestCase/SafeDirectoryOwnershipTests.cs new file mode 100644 index 000000000..bfe04fa64 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerTestCase/SafeDirectoryOwnershipTests.cs @@ -0,0 +1,119 @@ +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.Common.Tracing; +using GVFS.FunctionalTests.Tools; +using GVFS.UnitTests.Category; +using NUnit.Framework; +using System; +using System.IO; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace GVFS.FunctionalTests.Tests.EnlistmentPerTestCase +{ + [TestFixture] + /* Not inheriting from TestsWithEnlistmentPerTestCase because we don't need to mount + * the repo for this test. */ + public class SafeDirectoryOwnershipTests + { + private GVFSEnlistment Enlistment; + private static readonly SecurityIdentifier usersSid = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null); + + [SetUp] + public void SetUp() + { + var enlistmentRoot = GVFSFunctionalTestEnlistment.GetUniqueEnlistmentRoot(); + Enlistment = new GVFSEnlistment( + enlistmentRoot, + GVFSTestConfig.RepoToClone, + GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath(), + authentication: null); + var process = Enlistment.CreateGitProcess(); + Common.Git.GitProcess.Init(Enlistment); + } + + [TestCase] + public void RepoOpensIfSafeDirectoryConfigIsSet() + { + var repoDir = this.Enlistment.WorkingDirectoryBackingRoot; + using (var safeDirectoryConfig = WithSafeDirectoryConfig(repoDir)) + using (var enlistmentOwner = WithEnlistmentOwner(usersSid)) + using (LibGit2Repo repo = new LibGit2Repo(NullTracer.Instance, repoDir)) + { + // repo is opened in the constructor + } + } + + [TestCase(true)] + [TestCase(false)] + [Category(CategoryConstants.CaseInsensitiveFileSystemOnly)] + public void RepoOpensEvenIfSafeDirectoryConfigIsCaseMismatched(bool upperCase) + { + var repoDir = this.Enlistment.WorkingDirectoryBackingRoot; + + if (upperCase) + { + repoDir = repoDir.ToUpperInvariant(); + } + else + { + repoDir = repoDir.ToLowerInvariant(); + } + using (var safeDirectoryConfig = WithSafeDirectoryConfig(this.Enlistment.WorkingDirectoryBackingRoot)) + using (var enlistmentOwner = WithEnlistmentOwner(usersSid)) + using (LibGit2Repo repo = new LibGit2Repo(NullTracer.Instance, repoDir)) + { + // repo is opened in the constructor + } + } + + private class Disposable : IDisposable + { + private readonly Action onDispose; + + public Disposable(Action onDispose) + { + this.onDispose = onDispose; + } + + public void Dispose() + { + onDispose(); + } + } + + private IDisposable WithSafeDirectoryConfig(string repoDir) + { + Tools.GitProcess.Invoke(null, $"config --global --add safe.directory \"{repoDir}\""); + return new Disposable(() => + Tools.GitProcess.Invoke(null, $"config --global --unset safe.directory \"{repoDir}\"")); + } + + private IDisposable WithEnlistmentOwner(SecurityIdentifier newOwner) + { + var repoDir = this.Enlistment.WorkingDirectoryBackingRoot; + var currentOwner = GetDirectoryOwner(repoDir); + + SetDirectoryOwner(repoDir, newOwner); + var updatedOwner = GetDirectoryOwner(repoDir); + return new Disposable(() => + SetDirectoryOwner(repoDir, currentOwner)); + } + + private SecurityIdentifier GetDirectoryOwner(string directory) + { + DirectorySecurity repoSecurity = Directory.GetAccessControl(directory); + return (SecurityIdentifier)repoSecurity.GetOwner(typeof(SecurityIdentifier)); + } + + private void SetDirectoryOwner(string directory, SecurityIdentifier newOwner) + { + using (new PrivilegeEnabler(PrivilegeEnabler.AllowChangeOwnerToGroup)) + { + DirectorySecurity repoSecurity = Directory.GetAccessControl(directory); + repoSecurity.SetOwner(newOwner); + Directory.SetAccessControl(directory, repoSecurity); + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tools/PrivilegeEnabler.cs b/GVFS/GVFS.FunctionalTests/Tools/PrivilegeEnabler.cs new file mode 100644 index 000000000..448858aa7 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tools/PrivilegeEnabler.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace GVFS.FunctionalTests.Tools +{ + public class PrivilegeEnabler : IDisposable + { + public const string AllowChangeOwnerToGroup = "SeRestorePrivilege"; + + private const int SE_PRIVILEGE_ENABLED = 0x00000002; + private const int TOKEN_QUERY = 0x00000008; + private const int TOKEN_ADJUST_PRIVILEGES = 0x00000020; + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool OpenProcessToken(IntPtr ProcessHandle, int DesiredAccess, out IntPtr TokenHandle); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool LookupPrivilegeValue(string lpSystemName, string lpName, out LUID lpLuid); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool AdjustTokenPrivileges(IntPtr TokenHandle, bool DisableAllPrivileges, ref TOKEN_PRIVILEGES NewState, int BufferLength, IntPtr PreviousState, IntPtr ReturnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr hObject); + + [StructLayout(LayoutKind.Sequential)] + private struct LUID + { + public uint LowPart; + public int HighPart; + } + + [StructLayout(LayoutKind.Sequential)] + private struct TOKEN_PRIVILEGES + { + public int PrivilegeCount; + public LUID Luid; + public int Attributes; + } + + private IntPtr tokenHandle; + + public PrivilegeEnabler(string privilegeName) + { + if (!OpenProcessToken(System.Diagnostics.Process.GetCurrentProcess().Handle, TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, out tokenHandle)) + { + throw new InvalidOperationException("Failed to open process token"); + } + + LUID luid; + if (!LookupPrivilegeValue(null, privilegeName, out luid)) + { + CloseHandle(tokenHandle); + throw new InvalidOperationException($"Failed to lookup privilege: {privilegeName}"); + } + + TOKEN_PRIVILEGES tp = new TOKEN_PRIVILEGES + { + PrivilegeCount = 1, + Luid = luid, + Attributes = SE_PRIVILEGE_ENABLED + }; + + if (!AdjustTokenPrivileges(tokenHandle, false, ref tp, 0, IntPtr.Zero, IntPtr.Zero)) + { + CloseHandle(tokenHandle); + throw new InvalidOperationException($"Failed to enable privilege: {privilegeName}"); + } + } + + public void Dispose() + { + if (tokenHandle != IntPtr.Zero) + { + CloseHandle(tokenHandle); + tokenHandle = IntPtr.Zero; + } + } + } +}