diff --git a/GVFS/GVFS.Common/Git/LibGit2Repo.cs b/GVFS/GVFS.Common/Git/LibGit2Repo.cs index 86415ea5c..60e69450d 100644 --- a/GVFS/GVFS.Common/Git/LibGit2Repo.cs +++ b/GVFS/GVFS.Common/Git/LibGit2Repo.cs @@ -10,21 +10,27 @@ public class LibGit2Repo : IDisposable { private bool disposedValue = false; + public delegate void MultiVarConfigCallback(string value); + public LibGit2Repo(ITracer tracer, string repoPath) { this.Tracer = tracer; - Native.Init(); + InitNative(); IntPtr repoHandle; - if (Native.Repo.Open(out repoHandle, repoPath) != Native.ResultCode.Success) + if (TryOpenRepo(repoPath, out repoHandle) != Native.ResultCode.Success) { - string reason = Native.GetLastError(); + string reason = GetLastNativeError(); 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)) + { + ShutdownNative(); + throw new InvalidDataException(message); + } } this.RepoHandle = repoHandle; @@ -32,6 +38,7 @@ public LibGit2Repo(ITracer tracer, string repoPath) protected LibGit2Repo() { + this.Tracer = NullTracer.Instance; } ~LibGit2Repo() @@ -246,7 +253,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 +366,86 @@ protected virtual void Dispose(bool disposing) } } + /// + /// Normalize a path for case-insensitive safe.directory comparison: + /// replace backslashes with forward slashes, convert to upper-case, + /// and trim trailing slashes. + /// + internal static string NormalizePathForSafeDirectoryComparison(string path) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + string normalized = path.Replace('\\', '/').ToUpperInvariant(); + return normalized.TrimEnd('/'); + } + + /// + /// Retrieve all configured safe.directory values from global and system git config. + /// Virtual so tests can provide fake entries without touching real config. + /// + protected virtual void GetSafeDirectoryConfigEntries(MultiVarConfigCallback callback) + { + ForEachMultiVarConfigInGlobalAndSystemConfig("safe.directory", callback); + } + + /// + /// Try to open a repository at the given path. Virtual so tests can + /// avoid the native P/Invoke call. + /// + protected virtual Native.ResultCode TryOpenRepo(string path, out IntPtr repoHandle) + { + return Native.Repo.Open(out repoHandle, path); + } + + protected virtual void InitNative() + { + Native.Init(); + } + + protected virtual void ShutdownNative() + { + Native.Shutdown(); + } + + protected virtual string GetLastNativeError() + { + return Native.GetLastError(); + } + + protected 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 normalizedRequestedPath = NormalizePathForSafeDirectoryComparison(repoPath); + + string configuredMatchingDirectory = null; + GetSafeDirectoryConfigEntries((string value) => + { + string normalizedConfiguredPath = NormalizePathForSafeDirectoryComparison(value); + if (normalizedConfiguredPath == normalizedRequestedPath) + { + configuredMatchingDirectory = value; + } + }); + + return configuredMatchingDirectory != null && TryOpenRepo(configuredMatchingDirectory, out repoHandle) == Native.ResultCode.Success; + } + public static class Native { public enum ResultCode : int { Success = 0, + Failure = -1, NotFound = -3, } @@ -370,9 +509,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.UnitTests/Common/LibGit2RepoSafeDirectoryTests.cs b/GVFS/GVFS.UnitTests/Common/LibGit2RepoSafeDirectoryTests.cs new file mode 100644 index 000000000..47fd8acd7 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/LibGit2RepoSafeDirectoryTests.cs @@ -0,0 +1,283 @@ +using GVFS.Common.Git; +using GVFS.Common.Tracing; +using GVFS.Tests.Should; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class LibGit2RepoSafeDirectoryTests + { + // ─────────────────────────────────────────────── + // Layer 1 – NormalizePathForSafeDirectoryComparison (pure string tests) + // ─────────────────────────────────────────────── + + [TestCase(@"C:\Repos\Foo", "C:/REPOS/FOO")] + [TestCase(@"c:\repos\foo", "C:/REPOS/FOO")] + [TestCase("c:/repos/foo", "C:/REPOS/FOO")] + [TestCase("C:/Repos/Foo/", "C:/REPOS/FOO")] + [TestCase(@"C:\Repos\Foo\", "C:/REPOS/FOO")] + [TestCase("C:/Repos/Foo///", "C:/REPOS/FOO")] + [TestCase(@"C:\Repos/Mixed\Path", "C:/REPOS/MIXED/PATH")] + [TestCase("already/normalized", "ALREADY/NORMALIZED")] + public void NormalizePathForSafeDirectoryComparison_ProducesExpectedResult(string input, string expected) + { + LibGit2Repo.NormalizePathForSafeDirectoryComparison(input).ShouldEqual(expected); + } + + [TestCase(null)] + [TestCase("")] + public void NormalizePathForSafeDirectoryComparison_HandlesNullAndEmpty(string input) + { + LibGit2Repo.NormalizePathForSafeDirectoryComparison(input).ShouldEqual(input); + } + + [TestCase(@"C:\Repos\Foo", "c:/repos/foo")] + [TestCase(@"C:\Repos\Foo", @"c:\Repos\Foo")] + [TestCase("C:/Repos/Foo/", @"c:\repos\foo")] + public void NormalizePathForSafeDirectoryComparison_CaseInsensitiveMatch(string a, string b) + { + LibGit2Repo.NormalizePathForSafeDirectoryComparison(a).ShouldEqual(LibGit2Repo.NormalizePathForSafeDirectoryComparison(b)); + } + + // ─────────────────────────────────────────────── + // Layer 2 – Constructor control-flow tests via mock + // Tests go through the public LibGit2Repo(ITracer, string) + // constructor, which is the real entry point. + // ─────────────────────────────────────────────── + + [TestCase] + public void Constructor_OwnershipError_WithMatchingConfigEntry_OpensSuccessfully() + { + // First Open() fails with ownership error, config has a case-variant match, + // second Open() with the configured path succeeds → constructor completes. + string requestedPath = @"C:\Repos\MyProject"; + string configuredPath = @"c:\repos\myproject"; + + using (MockSafeDirectoryRepo repo = MockSafeDirectoryRepo.Create( + requestedPath, + safeDirectoryEntries: new[] { configuredPath }, + openableRepos: new HashSet(StringComparer.Ordinal) { configuredPath })) + { + // Constructor completed without throwing — the workaround succeeded. + repo.OpenedPaths.ShouldContain(p => p == configuredPath); + } + } + + [TestCase] + public void Constructor_OwnershipError_NoMatchingConfigEntry_Throws() + { + // Open() fails with ownership error, config has no matching entry → throws. + string requestedPath = @"C:\Repos\MyProject"; + + Assert.Throws(() => + { + MockSafeDirectoryRepo.Create( + requestedPath, + safeDirectoryEntries: new[] { @"D:\Other\Repo" }, + openableRepos: new HashSet(StringComparer.Ordinal)); + }); + } + + [TestCase] + public void Constructor_OwnershipError_MatchButOpenFails_Throws() + { + // Open() fails with ownership error, config entry matches but + // the retry also fails → throws. + string requestedPath = @"C:\Repos\MyProject"; + string configuredPath = @"c:\repos\myproject"; + + Assert.Throws(() => + { + MockSafeDirectoryRepo.Create( + requestedPath, + safeDirectoryEntries: new[] { configuredPath }, + openableRepos: new HashSet(StringComparer.Ordinal)); + }); + } + + [TestCase] + public void Constructor_OwnershipError_EmptyConfig_Throws() + { + string requestedPath = @"C:\Repos\MyProject"; + + Assert.Throws(() => + { + MockSafeDirectoryRepo.Create( + requestedPath, + safeDirectoryEntries: Array.Empty(), + openableRepos: new HashSet(StringComparer.Ordinal)); + }); + } + + [TestCase] + public void Constructor_OwnershipError_MultipleEntries_PicksCorrectMatch() + { + // Config has several entries; only one is a case-variant match. + string requestedPath = @"C:\Repos\Target"; + string correctConfigEntry = @"c:/repos/target"; + + using (MockSafeDirectoryRepo repo = MockSafeDirectoryRepo.Create( + requestedPath, + safeDirectoryEntries: new[] + { + @"D:\Other\Repo", + correctConfigEntry, + @"E:\Unrelated\Path", + }, + openableRepos: new HashSet(StringComparer.Ordinal) + { + correctConfigEntry, + })) + { + repo.OpenedPaths.ShouldContain(p => p == correctConfigEntry); + } + } + + [TestCase] + public void Constructor_NonOwnershipError_Throws() + { + // Open() fails with a different error (not ownership) → throws + // without attempting safe.directory workaround. + string requestedPath = @"C:\Repos\MyProject"; + + Assert.Throws(() => + { + MockSafeDirectoryRepo.Create( + requestedPath, + safeDirectoryEntries: new[] { requestedPath }, + openableRepos: new HashSet(StringComparer.Ordinal), + nativeError: "repository not found"); + }); + + MockSafeDirectoryRepo.LastCreatedInstance + .SafeDirectoryCheckAttempted + .ShouldBeFalse("Safe.directory workaround should not be attempted for non-ownership errors"); + } + + [TestCase] + public void Constructor_OpenSucceedsFirstTime_NoWorkaround() + { + // Open() succeeds immediately → no safe.directory logic triggered. + string requestedPath = @"C:\Repos\MyProject"; + + using (MockSafeDirectoryRepo repo = MockSafeDirectoryRepo.Create( + requestedPath, + safeDirectoryEntries: Array.Empty(), + openableRepos: new HashSet(StringComparer.Ordinal) { requestedPath })) + { + // Only one Open call (the initial one), no retry. + repo.OpenedPaths.Count.ShouldEqual(1); + repo.OpenedPaths.ShouldContain(p => p == requestedPath); + } + } + + /// + /// Mock that intercepts all native P/Invoke calls so the public + /// constructor can be exercised without touching libgit2. + /// Uses thread-static config to work around virtual-call-from- + /// constructor ordering (base ctor runs before derived fields init). + /// + private class MockSafeDirectoryRepo : LibGit2Repo + { + [ThreadStatic] + private static MockConfig pendingConfig; + + [ThreadStatic] + private static MockSafeDirectoryRepo lastCreatedInstance; + + private string[] safeDirectoryEntries; + private HashSet openableRepos; + private string nativeError; + + public List OpenedPaths { get; } = new List(); + public bool SafeDirectoryCheckAttempted { get; private set; } + + /// + /// Returns the most recently constructed instance on the current + /// thread, even if the constructor threw an exception. + /// + public static MockSafeDirectoryRepo LastCreatedInstance => lastCreatedInstance; + + private MockSafeDirectoryRepo(ITracer tracer, string repoPath) + : base(tracer, repoPath) + { + // Fields already populated from pendingConfig by the time + // virtual methods are called from base ctor. + } + + public static MockSafeDirectoryRepo Create( + string repoPath, + string[] safeDirectoryEntries, + HashSet openableRepos, + string nativeError = "repository path '/some/path' is not owned by current user") + { + pendingConfig = new MockConfig + { + SafeDirectoryEntries = safeDirectoryEntries, + OpenableRepos = openableRepos, + NativeError = nativeError, + }; + + try + { + return new MockSafeDirectoryRepo(NullTracer.Instance, repoPath); + } + finally + { + pendingConfig = null; + } + } + + protected override void InitNative() + { + // Grab config from thread-static before base ctor proceeds. + this.safeDirectoryEntries = pendingConfig.SafeDirectoryEntries; + this.openableRepos = pendingConfig.OpenableRepos; + this.nativeError = pendingConfig.NativeError; + lastCreatedInstance = this; + } + + protected override void ShutdownNative() + { + } + + protected override string GetLastNativeError() + { + return this.nativeError; + } + + protected override void GetSafeDirectoryConfigEntries(MultiVarConfigCallback callback) + { + this.SafeDirectoryCheckAttempted = true; + foreach (string entry in this.safeDirectoryEntries) + { + callback(entry); + } + } + + protected override Native.ResultCode TryOpenRepo(string path, out IntPtr repoHandle) + { + this.OpenedPaths.Add(path); + repoHandle = IntPtr.Zero; + return this.openableRepos.Contains(path) + ? Native.ResultCode.Success + : Native.ResultCode.Failure; + } + + protected override void Dispose(bool disposing) + { + } + + private class MockConfig + { + public string[] SafeDirectoryEntries; + public HashSet OpenableRepos; + public string NativeError; + } + } + } +}