From fc6d52162a969767ac47fbffb9b844340a237fb0 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 10 Nov 2026 09:41:06 -0800 Subject: [PATCH 1/4] Resolve unit-test Git dependencies through PATH Find Git through PATH instead of a fixed system installation directory. Share the resolver across the Git-dependent test fixtures. Preserve missing-Git failure and skip policies. Keep metadata-only worktree tests on a mock Git path. Add filesystem-only tests for search order, quoted directories, empty entries, relative paths, and absent Git. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- .../Git/AuthenticationConfigurationTests.cs | 4 +- .../Common/Git/GitProcessCredentialTests.cs | 6 +- .../GVFS.UnitTests/Common/RetryConfigTests.cs | 6 +- .../Common/WorktreeEnlistmentTests.cs | 22 +++--- GVFS/GVFS.UnitTests/Git/GitProcessTests.cs | 12 +-- GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs | 78 +++++++++++++++++++ GVFS/GVFS.UnitTests/GitTestHelper.cs | 38 +++++++++ 7 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs create mode 100644 GVFS/GVFS.UnitTests/GitTestHelper.cs diff --git a/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs b/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs index 2a1b94835..835d29a9b 100644 --- a/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs +++ b/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs @@ -24,7 +24,7 @@ public class AuthenticationConfigurationTests [SetUp] public void Setup() { - this.gitPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Git", "cmd", "git.exe"); + this.gitPath = GitTestHelper.FindGitOnPath(); this.root = Path.Combine(Path.GetTempPath(), nameof(AuthenticationConfigurationTests) + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(this.root, ".git", "objects")); Directory.CreateDirectory(Path.Combine(this.root, ".git", "refs", "heads")); @@ -579,7 +579,7 @@ private EventMetadata Collect( { if (!File.Exists(this.gitPath)) { - Assert.Fail("The authentication configuration integration tests require Git for Windows."); + Assert.Fail("The authentication configuration integration tests require Git on PATH."); } GitProcess git = new GitProcess(this.gitPath, this.root); diff --git a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs index 39899ec0c..434af25e8 100644 --- a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs +++ b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs @@ -156,10 +156,10 @@ public void CredentialFillDoesNotRetryWhenTheTimeoutCannotCoverTheDelay() [TestCase(false, Timeout.Infinite)] public void CredentialFillHandlesARealLockedGitConfig(bool releaseAfterFirstAttempt, int timeoutMs) { - string gitPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Git", "cmd", "git.exe"); - if (!File.Exists(gitPath)) + string gitPath = GitTestHelper.FindGitOnPath(); + if (gitPath == null) { - Assert.Ignore("Git is not installed at the expected path."); + Assert.Ignore("Git was not found on PATH."); } string repoPath = Path.Combine(Path.GetTempPath(), nameof(GitProcessCredentialTests) + Guid.NewGuid().ToString("N")); diff --git a/GVFS/GVFS.UnitTests/Common/RetryConfigTests.cs b/GVFS/GVFS.UnitTests/Common/RetryConfigTests.cs index 64ab99190..e2d3c87c0 100644 --- a/GVFS/GVFS.UnitTests/Common/RetryConfigTests.cs +++ b/GVFS/GVFS.UnitTests/Common/RetryConfigTests.cs @@ -595,10 +595,10 @@ public void LibGit2ReadsConfigWithIncludeConditionsItEvaluates(string condition) private static string GetGitPathOrIgnore() { - string gitPath = "C:\\Program Files\\Git\\cmd\\git.exe"; - if (!File.Exists(gitPath)) + string gitPath = GitTestHelper.FindGitOnPath(); + if (gitPath == null) { - Assert.Ignore("Git not found at expected path — skipping integration test"); + Assert.Ignore("Git was not found on PATH."); } return gitPath; diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs index 04fc9038d..24064db64 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs @@ -77,10 +77,8 @@ public void TearDown() } } - private GVFSEnlistment CreateWorktreeEnlistment() + private GVFSEnlistment CreateWorktreeEnlistment(string gitBinPath = @"mock:\git") { - string gitBinPath = GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath() - ?? @"C:\Program Files\Git\cmd\git.exe"; return GVFSEnlistment.CreateForWorktree( this.primaryRoot, gitBinPath, authentication: null, GVFSEnlistment.TryGetWorktreeInfo(this.worktreePath), @@ -300,7 +298,7 @@ public void WorktreeMetadataCreatesEnlistmentIdWhenNoneIsGivenOrStored(string st [TestCase] public void RequiredSettingsEnableWorktreeConfigAndWritePerWorktreeValues() { - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); string error; RequiredGitConfig.TrySetRequiredSettings( @@ -323,7 +321,7 @@ public void RequiredSettingsRepairCopiedWorktreeValueAndSharedValueOfPrimary() { this.AppendToSharedConfig("[status]\n\tdeserializePath = C:/sibling/status.dat\n"); File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/sibling/status.dat\n"); - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); string error; RequiredGitConfig.TrySetRequiredSettings( @@ -340,7 +338,7 @@ public void RequiredSettingsDisableNullPerWorktreeValueInWorktreeConfig() { this.AppendToSharedConfig("[extensions]\n\tworktreeConfig = true\n[status]\n\tdeserializePath = C:/primary/status.dat\n"); File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/worktree/status.dat\n"); - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); string error; RequiredGitConfig.TrySetRequiredSettings( @@ -357,7 +355,7 @@ public void RequiredSettingsDisableNullPerWorktreeValueInWorktreeConfig() public void RequiredSettingsRemoveRequiredOverridesFromWorktreeConfig() { File.WriteAllText(this.WorktreeConfigPath, "[core]\n\tautocrlf = true\n[gvfs]\n\tmount-id = worktreemountid\n"); - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); string error; RequiredGitConfig.TrySetRequiredSettings( @@ -374,7 +372,7 @@ public void RequiredSettingsRemoveRequiredOverridesFromWorktreeConfig() public void RequiredSettingsReplaceDuplicateWorktreeConfigExtensionValues() { this.AppendToSharedConfig("[extensions]\n\tworktreeConfig = true\n\tworktreeConfig = false\n"); - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); string error; RequiredGitConfig.TrySetRequiredSettings(enlistment, new Dictionary(), out error).ShouldBeTrue(error); @@ -421,7 +419,7 @@ public void PrimaryRequiredSettingsDoNotEnableWorktreeConfigWithoutLinkedWorktre public void RequiredSettingsWriteNothingWhenAppliedAgain() { GVFSEnlistment primary = new GVFSEnlistment(this.primaryRoot, "https://mock/repo", GetGitBinPath(), authentication: null); - GVFSEnlistment worktree = this.CreateWorktreeEnlistment(); + GVFSEnlistment worktree = this.CreateWorktreeEnlistment(GetGitBinPath()); string error; RequiredGitConfig.TrySetRequiredSettings(primary, out error).ShouldBeTrue(error); @@ -441,7 +439,7 @@ public void RequiredSettingsWriteNothingWhenAppliedAgain() public void RequiredSettingsFailWhenWorktreeConfigIsCorrupt() { File.WriteAllText(this.WorktreeConfigPath, "[status\n"); - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); string error; RequiredGitConfig.TrySetRequiredSettings( @@ -462,7 +460,9 @@ public void RequiredSettingsFailWhenWorktreeConfigIsCorrupt() private static string GetGitBinPath() { - return GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath() ?? @"C:\Program Files\Git\cmd\git.exe"; + string gitPath = GitTestHelper.FindGitOnPath(); + Assert.That(gitPath, Is.Not.Null, "The required-settings tests require Git on PATH."); + return gitPath; } private static string GetRepoMetadataPath(string dotGVFSRoot) diff --git a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs index 4dd4b0fec..b401efd5b 100644 --- a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs @@ -555,10 +555,10 @@ public void QuoteGitPath(string input, string expected) [TestCase(Timeout.Infinite)] public void GitCommandWaitsForOutputCallbacks(int timeoutMs) { - string gitPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Git", "cmd", "git.exe"); - if (!File.Exists(gitPath)) + string gitPath = GitTestHelper.FindGitOnPath(); + if (gitPath == null) { - Assert.Ignore("Git is not installed at the expected path."); + Assert.Ignore("Git was not found on PATH."); } OutputCallbackGitProcess gitProcess = new OutputCallbackGitProcess(gitPath); @@ -605,10 +605,10 @@ public void QuoteGitPath_RoundTripThroughProcess() "path\\with\\backslashes\\file.txt", }; - string gitPath = "C:\\Program Files\\Git\\cmd\\git.exe"; - if (!System.IO.File.Exists(gitPath)) + string gitPath = GitTestHelper.FindGitOnPath(); + if (gitPath == null) { - Assert.Ignore("Git not found at expected path — skipping integration test"); + Assert.Ignore("Git was not found on PATH."); } foreach (string testPath in testPaths) diff --git a/GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs b/GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs new file mode 100644 index 000000000..310f5087f --- /dev/null +++ b/GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs @@ -0,0 +1,78 @@ +using NUnit.Framework; +using System; +using System.IO; + +namespace GVFS.UnitTests.Git +{ + [TestFixture] + [NonParallelizable] + public class GitTestHelperTests + { + private string root; + + [SetUp] + public void SetUp() + { + this.root = Path.Combine(Path.GetTempPath(), nameof(GitTestHelperTests) + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this.root); + } + + [TearDown] + public void TearDown() + { + Directory.Delete(this.root, recursive: true); + } + + [Test] + public void FindsTheFirstGitInQuotedPathEntriesWithSpaces() + { + string firstDirectory = Path.Combine(this.root, "First Git"); + string secondDirectory = Path.Combine(this.root, "Second Git"); + Directory.CreateDirectory(firstDirectory); + Directory.CreateDirectory(secondDirectory); + string expectedGit = Path.Combine(firstDirectory, "git.exe"); + File.WriteAllText(expectedGit, string.Empty); + File.WriteAllText(Path.Combine(secondDirectory, "git.exe"), string.Empty); + string searchPath = string.Join(Path.PathSeparator, + Path.Combine(this.root, "missing"), "\"" + firstDirectory + "\"", secondDirectory); + + Assert.That(GitTestHelper.FindGitOnPath(searchPath), Is.EqualTo(expectedGit)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(";;")] + [TestCase("\"\"; ;")] + public void EmptyPathEntriesDoNotSearchTheWorkingDirectory(string searchPath) + { + File.WriteAllText(Path.Combine(this.root, "git.exe"), string.Empty); + string originalDirectory = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = this.root; + + Assert.That(GitTestHelper.FindGitOnPath(searchPath), Is.Null); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + } + } + + [Test] + public void RelativePathEntriesReturnAnAbsolutePath() + { + string expectedGit = Path.Combine(this.root, "git.exe"); + File.WriteAllText(expectedGit, string.Empty); + string searchPath = Path.GetRelativePath(Environment.CurrentDirectory, this.root); + + Assert.That(GitTestHelper.FindGitOnPath(searchPath), Is.EqualTo(expectedGit)); + } + + [Test] + public void MissingGitReturnsNull() + { + Assert.That(GitTestHelper.FindGitOnPath(this.root), Is.Null); + } + } +} diff --git a/GVFS/GVFS.UnitTests/GitTestHelper.cs b/GVFS/GVFS.UnitTests/GitTestHelper.cs new file mode 100644 index 000000000..976c503b7 --- /dev/null +++ b/GVFS/GVFS.UnitTests/GitTestHelper.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; + +namespace GVFS.UnitTests +{ + internal static class GitTestHelper + { + internal static string FindGitOnPath() + { + return FindGitOnPath(Environment.GetEnvironmentVariable("PATH")); + } + + internal static string FindGitOnPath(string searchPath) + { + if (string.IsNullOrEmpty(searchPath)) + { + return null; + } + + foreach (string entry in searchPath.Split(Path.PathSeparator)) + { + string directory = entry.Trim('"'); + if (string.IsNullOrWhiteSpace(directory)) + { + continue; + } + + string gitPath = Path.Combine(directory, "git.exe"); + if (File.Exists(gitPath)) + { + return Path.GetFullPath(gitPath); + } + } + + return null; + } + } +} From f50985aa24b9b04c6efc367d78ef5864e950abc6 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 10 Nov 2026 09:57:09 -0800 Subject: [PATCH 2/4] Handle quoted PATH separators and missing Git diagnostics Keep semicolons inside quoted PATH directories. Add a filesystem-only regression case for that directory shape. Compare resolved Windows paths without case sensitivity. Use one Git prerequisite guard across authentication test entry points. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- .../Git/AuthenticationConfigurationTests.cs | 21 ++++++++++++------- GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs | 9 ++++---- GVFS/GVFS.UnitTests/GitTestHelper.cs | 20 ++++++++++++++++-- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs b/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs index 835d29a9b..aeb47628d 100644 --- a/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs +++ b/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs @@ -521,7 +521,7 @@ public void ProductionFillCarriesItsOwnEnvironmentIntoTheCollector() { File.AppendAllText(Path.Combine(this.root, ".git", "config"), "[credential]\nhelper =\ninteractive = never\n"); - PlumbingGitProcess git = new PlumbingGitProcess(this.gitPath, this.root); + PlumbingGitProcess git = new PlumbingGitProcess(this.GetGitPath(), this.root); MockTracer tracer = new MockTracer(); Assert.That(git.TryGetCredential(tracer, "https://dev.azure.com/org/repo", out _, out _, out _, timeoutMs: 5000), Is.False); Assert.That(git.CollectedEnvironment, Is.SameAs(git.ResultEnvironment)); @@ -551,7 +551,7 @@ public void FailedCredentialFillBeforeCloneCreatesTheWorkingDirectoryCollectsCon { "GIT_TERMINAL_PROMPT", "0" }, { "GCM_VALIDATE", "0" }, }; - PreCloneGitProcess git = new PreCloneGitProcess(this.gitPath, workingDirectory, environment); + PreCloneGitProcess git = new PreCloneGitProcess(this.GetGitPath(), workingDirectory, environment); MockTracer tracer = new MockTracer(); Assert.That(git.TryGetCredential(tracer, "https://dev.azure.com/org/repo", out _, out _, out _), Is.False); @@ -571,18 +571,23 @@ public void FailedCredentialFillBeforeCloneCreatesTheWorkingDirectoryCollectsCon this.AssertNoSecret(snapshot); } - private EventMetadata Collect( - string url, - Action customize = null, - Dictionary credentialFillEnvironment = null, - ITracer tracer = null) + private string GetGitPath() { if (!File.Exists(this.gitPath)) { Assert.Fail("The authentication configuration integration tests require Git on PATH."); } - GitProcess git = new GitProcess(this.gitPath, this.root); + return this.gitPath; + } + + private EventMetadata Collect( + string url, + Action customize = null, + Dictionary credentialFillEnvironment = null, + ITracer tracer = null) + { + GitProcess git = new GitProcess(this.GetGitPath(), this.root); return AuthenticationConfiguration.Collect(url, command => { Process process = git.GetGitProcess(command, this.root, Path.Combine(this.root, ".git"), false, null, false); diff --git a/GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs b/GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs index 310f5087f..607680b93 100644 --- a/GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs @@ -23,10 +23,11 @@ public void TearDown() Directory.Delete(this.root, recursive: true); } - [Test] - public void FindsTheFirstGitInQuotedPathEntriesWithSpaces() + [TestCase("First Git")] + [TestCase("First; Git")] + public void FindsTheFirstGitInQuotedPathEntriesWithSpaces(string firstDirectoryName) { - string firstDirectory = Path.Combine(this.root, "First Git"); + string firstDirectory = Path.Combine(this.root, firstDirectoryName); string secondDirectory = Path.Combine(this.root, "Second Git"); Directory.CreateDirectory(firstDirectory); Directory.CreateDirectory(secondDirectory); @@ -66,7 +67,7 @@ public void RelativePathEntriesReturnAnAbsolutePath() File.WriteAllText(expectedGit, string.Empty); string searchPath = Path.GetRelativePath(Environment.CurrentDirectory, this.root); - Assert.That(GitTestHelper.FindGitOnPath(searchPath), Is.EqualTo(expectedGit)); + Assert.That(GitTestHelper.FindGitOnPath(searchPath), Is.EqualTo(expectedGit).IgnoreCase); } [Test] diff --git a/GVFS/GVFS.UnitTests/GitTestHelper.cs b/GVFS/GVFS.UnitTests/GitTestHelper.cs index 976c503b7..6a947558b 100644 --- a/GVFS/GVFS.UnitTests/GitTestHelper.cs +++ b/GVFS/GVFS.UnitTests/GitTestHelper.cs @@ -17,9 +17,25 @@ internal static string FindGitOnPath(string searchPath) return null; } - foreach (string entry in searchPath.Split(Path.PathSeparator)) + int entryStart = 0; + bool quoted = false; + for (int index = 0; index <= searchPath.Length; ++index) { - string directory = entry.Trim('"'); + if (index < searchPath.Length) + { + if (searchPath[index] == '"') + { + quoted = !quoted; + } + + if (searchPath[index] != Path.PathSeparator || quoted) + { + continue; + } + } + + string directory = searchPath.Substring(entryStart, index - entryStart).Trim('"'); + entryStart = index + 1; if (string.IsNullOrWhiteSpace(directory)) { continue; From f4da25c958643b89cd9ea39bb1c36b3331f06da5 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 10 Nov 2026 10:18:55 -0800 Subject: [PATCH 3/4] Move dependency-bound unit coverage to functional components Preserve real Git, process, HTTP, and named-pipe checks in local functional fixtures. Keep isolated policy, parser, filesystem, native hook, and in-process Windows API coverage in the unit suite. Run local functional components in PR and release build jobs through the existing runner and service lifecycle. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- .azure-pipelines/release.yml | 7 + .github/workflows/build.yaml | 5 + CONTRIBUTING.md | 15 +- .../GVFSMountExitCodeTests.cs | 35 - GVFS/GVFS.Common/InternalsVisibleTo.cs | 1 + GVFS/GVFS.FunctionalTests/Settings.cs | 3 +- .../AuthenticationConfigurationTests.cs | 618 ++++++++++++++++++ .../LocalComponents/GVFSGitObjectsTests.cs | 253 +++++++ .../LocalComponents/GVFSMountExitCodeTests.cs | 59 ++ .../GitProcessCredentialTests.cs | 139 ++++ .../Tests/LocalComponents/GitProcessTests.cs | 135 ++++ .../HttpResponseStreamTests.cs | 470 +++++++++++++ .../HydrationStatusDiagnosticsTests.cs | 23 + .../LocalComponents/IsolatedGitEnvironment.cs | 45 ++ .../QueuedPipeStringWriterTests.cs | 248 +++++++ .../Tests/LocalComponents/RetryConfigTests.cs | 165 +++++ .../TelemetryDaemonEventListenerTests.cs | 455 +++++++++++++ .../WaitUntilMountedFailureReportingTests.cs | 47 +- .../WaitUntilMountedProcessTrackingTests.cs | 48 ++ .../WorktreeRequiredConfigTests.cs | 277 ++++++++ .../Git/AuthenticationConfigurationTests.cs | 589 ----------------- .../Common/Git/GitProcessCredentialTests.cs | 102 --- .../Common/HydrationStatusDiagnosticsTests.cs | 11 - .../GVFS.UnitTests/Common/RetryConfigTests.cs | 183 +----- .../WaitUntilMountedProcessTrackingTests.cs | 38 -- .../Common/WorktreeEnlistmentTests.cs | 190 ------ .../GVFS.UnitTests/Git/GVFSGitObjectsTests.cs | 213 ------ GVFS/GVFS.UnitTests/Git/GitProcessTests.cs | 116 ---- .../Http/HttpResponseStreamTestSupport.cs | 59 ++ .../Http/HttpResponseStreamTests.cs | 527 +-------------- GVFS/GVFS.UnitTests/InternalsVisibleTo.cs | 3 + .../Mock/Git/RetryConfigTestRepository.cs | 37 ++ .../Tracing/QueuedPipeStringWriterTests.cs | 331 ++-------- .../TelemetryDaemonEventListenerTests.cs | 384 ----------- GVFS/GVFS/InternalsVisibleTo.cs | 1 + scripts/RunFunctionalTests-Dev.ps1 | 2 +- scripts/RunLocalComponentTests-Dev.ps1 | 34 + 37 files changed, 3192 insertions(+), 2676 deletions(-) create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/AuthenticationConfigurationTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GVFSGitObjectsTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GVFSMountExitCodeTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessCredentialTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/HttpResponseStreamTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/HydrationStatusDiagnosticsTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/IsolatedGitEnvironment.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/QueuedPipeStringWriterTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/RetryConfigTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/TelemetryDaemonEventListenerTests.cs rename GVFS/{GVFS.UnitTests/Common => GVFS.FunctionalTests/Tests/LocalComponents}/WaitUntilMountedFailureReportingTests.cs (65%) create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WaitUntilMountedProcessTrackingTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WorktreeRequiredConfigTests.cs create mode 100644 GVFS/GVFS.UnitTests/Http/HttpResponseStreamTestSupport.cs create mode 100644 GVFS/GVFS.UnitTests/InternalsVisibleTo.cs create mode 100644 GVFS/GVFS.UnitTests/Mock/Git/RetryConfigTestRepository.cs create mode 100644 scripts/RunLocalComponentTests-Dev.ps1 diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 6cb88ee80..025629a33 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -214,6 +214,13 @@ extends: ${{ dim.arch }} displayName: 'Run unit tests' + - task: PowerShell@2 + displayName: 'Run local functional components' + inputs: + pwsh: true + filePath: '$(Build.SourcesDirectory)\scripts\RunLocalComponentTests-Dev.ps1' + arguments: '-Configuration $(BuildConfiguration) -Arch ${{ dim.arch }}' + # ESRP signing of the standalone binaries (Payload + FastFetch). # The installer hasn't been built yet, so it can be packaged from # signed binaries in a single Inno Setup pass. diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9bf18bc87..634a0dee5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -306,6 +306,11 @@ jobs: shell: cmd run: src\scripts\CreateBuildArtifacts.bat ${{ matrix.configuration }} artifacts ${{ matrix.architecture }} + - name: Run local functional components + if: steps.skip.outputs.result != 'true' + shell: pwsh + run: src\scripts\RunLocalComponentTests-Dev.ps1 -Configuration ${{ matrix.configuration }} -Arch ${{ matrix.architecture }} + - name: Upload functional tests drop if: steps.skip.outputs.result != 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 639e1cfeb..55de1a6b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -279,10 +279,19 @@ The design review process is as follows: Comprehensive tests are essential for maintaining the health and quality of the product. For more details on writing tests see [Authoring Tests](https://github.com/Microsoft/VFSForGit/blob/master/AuthoringTests.md). -- *Functional tests are black-box tests and should not build against any VFS4G product code* +- *Keep end-to-end functional tests black-box* Keeping the code separate helps ensure that bugs in the product code do not compromise the integrity of the functional tests. + Local component tests in `GVFS.FunctionalTests.Tests.LocalComponents` can call product APIs to verify real Git, process, HTTP, and named-pipe behavior. + These tests use the existing functional runner and service lifecycle, but do not require a mounted enlistment or a remote repository. + Keep their dependencies local and isolate temporary files, configuration, and environment changes. + + Unit tests must not start external programs or use real network or named-pipe connections. + Native hook executable tests are an exception. + Unit tests can use isolated temporary files and the repository-built libgit2 library. + Keep pure policy, parsing, and controlled-stream tests in the unit suite. + ### C# Unit Tests - *Add `ExceptionExpected` to unit tests that are expected to have exceptions* @@ -298,4 +307,6 @@ The design review process is as follows: - *Use a `mock` prefix for absolute file system paths and URLs* - The unit tests should not touch the real file system nor should they reach out to any real URLs. Using `mock:\\` and `mock://` ensures that any product code that was not properly mocked will not interact with the real file system or attempt to contact a real URL. + Use `mock:\\` and `mock://` when a test does not require real files. + This convention prevents unexpected file access or network requests when product code is not fully mocked. + Filesystem-only tests must use an isolated temporary directory and remove their files after each test. diff --git a/GVFS/GVFS.CommandLine.Tests/GVFSMountExitCodeTests.cs b/GVFS/GVFS.CommandLine.Tests/GVFSMountExitCodeTests.cs index ee89911be..d2ed09cc3 100644 --- a/GVFS/GVFS.CommandLine.Tests/GVFSMountExitCodeTests.cs +++ b/GVFS/GVFS.CommandLine.Tests/GVFSMountExitCodeTests.cs @@ -1,8 +1,6 @@ using System; using System.CommandLine; -using System.Diagnostics; using System.IO; -using System.Threading.Tasks; using GVFS.Common; using GVFS.PlatformLoader; using NUnit.Framework; @@ -28,38 +26,5 @@ public void InvalidEnlistment_ReturnsGenericErrorExitCode() Assert.That(exitCode, Is.EqualTo((int)ReturnCode.GenericError)); } - - [Test] - public void InvalidEnlistmentProcess_ReturnsGenericErrorExitCode() - { - string nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - string mountOutputDirectory = AppContext.BaseDirectory.Replace( - "GVFS.CommandLine.Tests", - "GVFS.Mount", - StringComparison.OrdinalIgnoreCase); - ProcessStartInfo processInfo = new ProcessStartInfo - { - FileName = Path.Combine(mountOutputDirectory, "GVFS.Mount.exe"), - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - UseShellExecute = false, - }; - processInfo.ArgumentList.Add(nonExistentPath); - - using (Process process = Process.Start(processInfo)) - { - Task output = process.StandardOutput.ReadToEndAsync(); - Task errors = process.StandardError.ReadToEndAsync(); - - process.WaitForExit(); - Task.WaitAll(output, errors); - - Assert.That( - process.ExitCode, - Is.EqualTo((int)ReturnCode.GenericError), - $"stdout: {output.Result}{Environment.NewLine}stderr: {errors.Result}"); - } - } } } diff --git a/GVFS/GVFS.Common/InternalsVisibleTo.cs b/GVFS/GVFS.Common/InternalsVisibleTo.cs index 0ba48d81b..349286368 100644 --- a/GVFS/GVFS.Common/InternalsVisibleTo.cs +++ b/GVFS/GVFS.Common/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("GVFS.UnitTests")] +[assembly: InternalsVisibleTo("GVFS.FunctionalTests")] diff --git a/GVFS/GVFS.FunctionalTests/Settings.cs b/GVFS/GVFS.FunctionalTests/Settings.cs index c0f788018..1d80ad03d 100644 --- a/GVFS/GVFS.FunctionalTests/Settings.cs +++ b/GVFS/GVFS.FunctionalTests/Settings.cs @@ -82,7 +82,8 @@ public static void Initialize() PathToGVFSService = @"C:\Program Files\VFS for Git\GVFS.Service.exe"; } - PathToGit = @"C:\Program Files\Git\cmd\git.exe"; + PathToGit = GVFS.UnitTests.GitTestHelper.FindGitOnPath() + ?? throw new FileNotFoundException("Functional tests require Git on PATH."); PathToBash = @"C:\Program Files\Git\bin\bash.exe"; } } diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/AuthenticationConfigurationTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/AuthenticationConfigurationTests.cs new file mode 100644 index 000000000..d12955d9d --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/AuthenticationConfigurationTests.cs @@ -0,0 +1,618 @@ +using GVFS.Common.Git; +using GVFS.UnitTests; +using GVFS.Common.Tracing; +using GVFS.UnitTests.Category; +using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Mock.Git; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class AuthenticationConfigurationTests + { + private const string Secret = "SecretSentinelNotForTelemetry"; + private string root; + private string gitPath; + private IsolatedGitEnvironment environment; + + [SetUp] + public void Setup() + { + this.environment = new IsolatedGitEnvironment(); + this.gitPath = GitTestHelper.FindGitOnPath(); + this.root = Path.Combine(Path.GetTempPath(), nameof(AuthenticationConfigurationTests) + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(this.root, ".git", "objects")); + Directory.CreateDirectory(Path.Combine(this.root, ".git", "refs", "heads")); + File.WriteAllText(Path.Combine(this.root, ".git", "HEAD"), "ref: refs/heads/main\n"); + File.WriteAllText(Path.Combine(this.root, ".git", "config"), "[core]\nrepositoryformatversion = 0\nbare = false\n"); + File.WriteAllText(Path.Combine(this.root, "global"), string.Empty); + } + + [TearDown] + public void TearDown() + { + try + { + Directory.Delete(this.root, recursive: true); + } + finally + { + this.environment.Dispose(); + } + } + + [TestCase("https://example.com/org/repo", new[] { "manager", "cache", "wincred", "store", "oauth", "osxkeychain" })] + [TestCase("https://example.com/org/other", new[] { "manager", "cache", "wincred", "store", "osxkeychain" })] + [TestCase("https://example.com/organization/repo", new[] { "manager", "cache", "wincred", "osxkeychain" })] + [TestCase("https://example.com:443/org/repo", new[] { "manager", "cache", "wincred", "store", "oauth", "osxkeychain" })] + [TestCase("https://example.com/%6frg/repo%2F", new[] { "manager", "cache", "wincred", "store", "oauth", "osxkeychain" })] + [TestCase("https://user@" + "example.com/org/repo", new[] { "manager", "cache", "wincred", "store", "oauth", "libsecret", "osxkeychain" })] + [TestCase("https://other.example.com/org/repo", new[] { "manager", "cache", "osxkeychain" })] + public void NativeGitMatchingPreservesAllApplicableHelpers(string url, string[] expected) + { + File.WriteAllText(Path.Combine(this.root, "global"), + "[credential]\nhelper = manager\nhelper = cache\n" + + "[credential \"https://example.com\"]\nhelper = wincred\n" + + "[credential \"https://example.com/org\"]\nhelper = store\n" + + "[credential \"https://example.com/org/repo\"]\nhelper = oauth\n" + + "[credential \"https://user@example.com/org\"]\nhelper = libsecret\n" + + "[credential]\nhelper = osxkeychain\n"); + + EventMetadata snapshot = this.Collect(url); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["HelperChain"], Is.EqualTo(string.Join(", ", expected))); + Assert.That(snapshot["UseHttpPath"], Is.EqualTo("false")); + } + + [Test] + public void IncludesLocalWorktreeAndCommandResetsPreserveOrderWithoutSecrets() + { + File.WriteAllText(Path.Combine(this.root, "included"), "[credential]\nhelper = cache\n"); + File.WriteAllText(Path.Combine(this.root, "global"), "[credential]\nhelper = manager\n[include]\npath = included\n"); + File.AppendAllText(Path.Combine(this.root, ".git", "config"), + "[extensions]\nworktreeConfig = true\n[credential \"https://example.com/org\"]\nhelper =\nhelper = wincred\n"); + File.WriteAllText(Path.Combine(this.root, ".git", "config.worktree"), "[credential]\nhelper = store\n"); + + EventMetadata snapshot = this.Collect("https://example.com/org/repo", startInfo => + { + startInfo.Environment["GIT_CONFIG_COUNT"] = "2"; + startInfo.Environment["GIT_CONFIG_KEY_0"] = "credential.helper"; + startInfo.Environment["GIT_CONFIG_VALUE_0"] = string.Empty; + startInfo.Environment["GIT_CONFIG_KEY_1"] = "credential.helper"; + startInfo.Environment["GIT_CONFIG_VALUE_1"] = "!echo " + Secret; + }); + + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["HelperChain"], Is.EqualTo("omitted:shell")); + string expectedOperations = string.Join(", ", new[] + { + "global:manager", "global:cache", "local:reset", "local:wincred", "worktree:store", "command:reset", "command:omitted:shell", + }); + using (JsonDocument payload = JsonDocument.Parse(EventMetadataConverter.SerializeToString(snapshot))) + { + Assert.That(payload.RootElement.GetProperty("HelperChain").GetString(), Is.EqualTo("omitted:shell")); + Assert.That(payload.RootElement.GetProperty("HelperOperations").GetString(), Is.EqualTo(expectedOperations)); + } + + this.AssertNoSecret(snapshot); + } + + [TestCase("", "absent")] + [TestCase("[credential]\nhelper = manager\nhelper =\n", "reset-empty")] + public void AbsenceAndResetAreDistinct(string config, string state) + { + File.WriteAllText(Path.Combine(this.root, "global"), config); + EventMetadata snapshot = this.Collect("https://example.com/org"); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["HelperState"], Is.EqualTo(state)); + Assert.That(snapshot["HelperChain"], Is.Empty); + } + + [Test] + public void ConfigReadErrorsDoNotProduceAnEmptySuccessfulSnapshot() + { + File.WriteAllText(Path.Combine(this.root, "global"), "[invalid\n" + Secret); + int reads = 0; + MockTracer tracer = new MockTracer(); + EventMetadata snapshot = this.Collect("https://example.com/org", info => ++reads, tracer: tracer); + Assert.That(snapshot["Status"], Is.EqualTo("config-query-error")); + Assert.That(snapshot.ContainsKey("HelperChain"), Is.False); + Assert.That(reads, Is.EqualTo(1)); + Assert.That(tracer.RelatedWarningEvents, Is.Empty); + this.AssertNoSecret(snapshot); + } + + [TestCase(AuthenticationConfiguration.ReadCommand, 1, 2, "collected", 1)] + [TestCase("config --system --null --name-only --list", 1, 2, "collected", 1)] + [TestCase("config --file - --no-includes --null --get-urlmatch authdiagnostic", 1, 2, "collected", 1)] + [TestCase(AuthenticationConfiguration.ReadCommand, 5, 5, "config-query-error", 5)] + public void DiagnosticReadsUseSharedRetriesWithoutTracingConfiguration( + string query, int failures, int expectedAttempts, string expectedStatus, int expectedWarnings) + { + File.WriteAllText(Path.Combine(this.root, "global"), "[credential \"https://example.com/org\"]\nhelper = manager\n"); + string systemConfig = Path.Combine(this.root, "system"); + File.WriteAllText(systemConfig, string.Empty); + int attempts = 0; + MockTracer tracer = new MockTracer(); + + EventMetadata snapshot = this.Collect("https://example.com/org", info => + { + info.Environment.Remove("GIT_CONFIG_NOSYSTEM"); + info.Environment["GIT_CONFIG_SYSTEM"] = systemConfig; + if (info.Arguments.Contains(query, StringComparison.Ordinal) && ++attempts <= failures) + { + info.FileName = Environment.GetEnvironmentVariable("ComSpec"); + info.Arguments = "/d /c \"findstr . >nul & echo warning: unable to access '" + Secret + "': Permission denied 1>&2 & exit /b 128\""; + } + }, tracer: tracer); + + Assert.That(attempts, Is.EqualTo(expectedAttempts)); + Assert.That(snapshot["Status"], Is.EqualTo(expectedStatus)); + Assert.That(tracer.TelemetryWarnings.Count, Is.EqualTo(expectedWarnings)); + Assert.That(string.Join("\n", tracer.RelatedWarningEvents), Does.Not.Contain(Secret)); + if (expectedStatus == "collected") + { + Assert.That(snapshot["HelperChain"], Is.EqualTo("manager")); + Assert.That(snapshot["SystemConfigState"], Is.EqualTo("readable-no-auth-settings")); + } + else + { + Assert.That(snapshot.ContainsKey("HelperChain"), Is.False); + } + + this.AssertNoSecret(snapshot); + } + + [Test] + public void OversizedConfigDoesNotProduceAPartialHelperChain() + { + File.WriteAllText(Path.Combine(this.root, "global"), "[credential]\nhelper = " + new string('x', 70000)); + EventMetadata snapshot = this.Collect("https://example.com/org"); + Assert.That(snapshot["Status"], Is.EqualTo("query-output-limit")); + Assert.That(snapshot.ContainsKey("HelperChain"), Is.False); + } + + [Test] + public void SystemConfigAbsenceIsReportedSeparately() + { + EventMetadata snapshot = this.Collect("https://example.com/org", info => + { + info.Environment.Remove("GIT_CONFIG_NOSYSTEM"); + info.Environment["GIT_CONFIG_SYSTEM"] = Path.Combine(this.root, "missing-system-config"); + info.Environment["LC_ALL"] = "fr_FR.UTF-8"; + }); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["HelperState"], Is.EqualTo("absent")); + Assert.That(snapshot["SystemConfigState"], Is.EqualTo("missing")); + } + + [Test] + public void CommandOverridesCannotInjectSyntheticMatcherKeys() + { + File.WriteAllText(Path.Combine(this.root, "global"), "[credential \"https://other.example.com\"]\nhelper = manager\n"); + EventMetadata snapshot = this.Collect("https://example.com/org", info => + { + info.Environment["GIT_CONFIG_COUNT"] = "1"; + info.Environment["GIT_CONFIG_KEY_0"] = "authdiagnostic.entry0"; + info.Environment["GIT_CONFIG_VALUE_0"] = "true"; + }); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["HelperState"], Is.EqualTo("absent")); + } + + [Test] + public void InvalidAndValuelessPreferencesAreNotReportedAsSafeValues() + { + File.WriteAllText(Path.Combine(this.root, "global"), + "[credential]\nhelper\nuseHttpPath = " + Secret + "\nmsauthUseBroker = " + Secret + "\nazreposCredentialType = " + Secret + "\n"); + EventMetadata snapshot = this.Collect("https://example.com/org"); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["HelperChain"], Is.EqualTo("omitted:valueless")); + Assert.That(snapshot["CredentialConfigState"], Is.EqualTo("invalid-valueless-setting")); + Assert.That(snapshot["UseHttpPath"], Is.EqualTo("omitted:invalid")); + Assert.That(snapshot["Gcm_msauthusebroker"], Is.EqualTo("unavailable")); + Assert.That(snapshot["Gcm_azreposcredentialtype"], Is.EqualTo("unavailable")); + this.AssertNoSecret(snapshot); + } + + [Test] + public void GcmInvalidPreferencesAreOmittedWithoutDefaultingToSafeValues() + { + File.WriteAllText(Path.Combine(this.root, "global"), + "[credential]\nmsauthUseBroker = " + Secret + "\nazreposCredentialType = " + Secret + "\ninteractive = NEVER\n"); + EventMetadata snapshot = this.Collect("https://example.com/org"); + Assert.That(snapshot["Gcm_msauthusebroker"], Is.EqualTo("omitted:invalid")); + Assert.That(snapshot["Gcm_azreposcredentialtype"], Is.EqualTo("omitted:invalid")); + Assert.That(snapshot["Gcm_interactive"], Is.EqualTo("never")); + Assert.That(snapshot["GitInteractive"], Is.EqualTo("omitted:invalid"), "Git's special 'never' value is case-sensitive."); + this.AssertNoSecret(snapshot); + } + + [Test] + [Category(CategoryConstants.ExceptionExpected)] + public void DiagnosticQueriesHaveOneTimeoutBudgetAndStopTheirOwnedProcess() + { + using ManualResetEventSlim exited = new ManualResetEventSlim(); + Stopwatch timer = Stopwatch.StartNew(); + int attempts = 0; + MockTracer tracer = new MockTracer(); + EventMetadata snapshot = AuthenticationConfiguration.Collect("https://example.com/org", command => + { + ++attempts; + Process process = new Process + { + StartInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("ComSpec"), + attempts == 1 ? + "/d /c \"echo warning: unable to access 'config': Permission denied 1>&2 & exit /b 128\"" : + "/d /c ping -n 30 127.0.0.1 >nul") + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + EnableRaisingEvents = true, + }; + if (attempts > 1) + { + process.Exited += (sender, args) => exited.Set(); + } + + return process; + }, tracer: tracer); + Assert.That(snapshot["Status"], Is.EqualTo("query-timeout")); + Assert.That(attempts, Is.EqualTo(2)); + Assert.That(tracer.TelemetryWarnings.Count, Is.EqualTo(1)); + Assert.That(timer.Elapsed, Is.LessThan(TimeSpan.FromSeconds(8))); + Assert.That(exited.Wait(TimeSpan.FromSeconds(1)), Is.True); + } + + [Test] + public void CredentialFillCommandScopedUseHttpPathOverrideIsCaptured() + { + File.WriteAllText(Path.Combine(this.root, "global"), + "[credential]\nuseHttpPath = false\n[credential \"https://dev.azure.com/org\"]\nazreposCredentialType = oauth\n"); + EventMetadata snapshot = this.Collect("https://dev.azure.com/org/repo", info => + { + info.Arguments = "-c credential.\"https://dev.azure.com\".useHttpPath=true " + info.Arguments; + }); + Assert.That(snapshot["UseHttpPath"], Is.EqualTo("true")); + Assert.That(snapshot["Gcm_azreposcredentialtype"], Is.EqualTo("oauth")); + } + + [Test] + public void DiagnosticsReuseTheFailedCredentialProcessEnvironment() + { + Dictionary environment = new Dictionary + { + { "SystemRoot", Environment.GetEnvironmentVariable("SystemRoot") }, + { "GIT_CONFIG_NOSYSTEM", "1" }, + { "GIT_CONFIG_GLOBAL", Path.Combine(this.root, "global") }, + { "GIT_CONFIG_COUNT", "1" }, + { "GIT_CONFIG_KEY_0", "credential.helper" }, + { "GIT_CONFIG_VALUE_0", "manager-core" }, + { "GCM_MSAUTH_USEBROKER", "true" }, + }; + EventMetadata snapshot = this.Collect( + "https://example.com/org", + info => info.Environment["GCM_MSAUTH_USEBROKER"] = "false", + environment); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["EnvironmentTiming"], Is.EqualTo("credential-fill-process")); + Assert.That(snapshot["HelperChain"], Is.EqualTo("manager-core")); + Assert.That(snapshot["Gcm_msauthusebroker"], Is.EqualTo("true")); + } + + [Test] + public void GcmPreferencesUsePathParentHostAndEnvironmentButGitInteractiveIsUnscoped() + { + File.WriteAllText(Path.Combine(this.root, "global"), + "[credential]\ninteractive = never\nmsauthUseBroker = false\n" + + "[credential \"example.com\"]\nazreposCredentialType = pat\n" + + "[credential \"https://child.example.com/org\"]\nazreposCredentialType = oauth\ninteractive = true\n" + + "[credential \"https://child.example.com\"]\nuseHttpPath = true\n"); + EventMetadata snapshot = this.Collect("https://child.example.com/org/repo"); + Assert.That(snapshot["Gcm_azreposcredentialtype"], Is.EqualTo("oauth")); + Assert.That(snapshot["Gcm_msauthusebroker"], Is.EqualTo("false")); + Assert.That(snapshot["Gcm_interactive"], Is.EqualTo("true")); + Assert.That(snapshot["GitInteractive"], Is.EqualTo("never")); + + EventMetadata inherited = this.Collect("https://child.example.com/other", info => + { + info.Environment["GCM_MSAUTH_USEBROKER"] = "true"; + info.Environment["GCM_INTERACTIVE"] = "never"; + }); + Assert.That(inherited["Gcm_azreposcredentialtype"], Is.EqualTo("pat")); + Assert.That(inherited["Gcm_msauthusebroker"], Is.EqualTo("true")); + Assert.That(inherited["Gcm_msauthusebroker_Source"], Is.EqualTo("environment")); + Assert.That(inherited["Gcm_interactive"], Is.EqualTo("never")); + } + + [Test] + public void UnsafeValuesAndUrlPasswordsNeverEnterLocalOrRemoteMetadata() + { + File.WriteAllText(Path.Combine(this.root, "global"), + "[credential \"https://example.com/" + Secret + "\"]\nhelper = !echo " + Secret + "\n" + + "azreposCredentialType = " + Secret + "\nmsauthUseBroker = " + Secret + "\n"); + EventMetadata snapshot = this.Collect("https://user:" + Secret + "@example.com/" + Secret, info => + { + info.Environment["GCM_INTERACTIVE"] = Secret; + info.Environment["GIT_TRACE2_EVENT"] = Path.Combine(this.root, "trace"); + }); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["Gcm_interactive"], Is.EqualTo("omitted:invalid")); + Assert.That(snapshot["Gcm_azreposcredentialtype"], Is.EqualTo("unset"), "GCM receives no path when useHttpPath is false."); + Assert.That(snapshot["HelperChain"], Is.EqualTo("omitted:shell")); + Assert.That(File.Exists(Path.Combine(this.root, "trace")), Is.False); + this.AssertNoSecret(snapshot); + } + + [TestCase("example.com", "https://example.com/org", true)] + [TestCase("/org", "https://example.com/org", true)] + [TestCase("/org", "https://example.com/org/repo", false)] + [TestCase("https://", "https://example.com/org", true)] + [TestCase("example.com", "https://other.example.com/org", false)] + public void CredentialPartialUrlFallbackUsesExactComponents(string scope, string url, bool applies) + { + File.WriteAllText(Path.Combine(this.root, "global"), "[credential \"" + scope + "\"]\nhelper = manager\n"); + EventMetadata snapshot = this.Collect(url); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["HelperChain"], Is.EqualTo(applies ? "manager" : string.Empty)); + } + + [Test] + public void ValidEncodedSeparatorScopeDoesNotApplyItsResetThroughPartialFallback() + { + File.WriteAllText(Path.Combine(this.root, "global"), + "[credential]\nhelper = manager\n[credential \"https://example.com/team%2Frepo\"]\nhelper =\n"); + EventMetadata snapshot = this.Collect("https://example.com/team/repo"); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["HelperChain"], Is.EqualTo("manager")); + Assert.That(snapshot["HelperOperations"], Is.EqualTo("global:manager")); + } + + [TestCase("https://example.com/%zz", "https://example.com/%25zz")] + [TestCase("https://user:pass%zz@example.com/org", "https://user@example.com/org/repo")] + public void NonNormalizableFullUrlScopeReportsAnUnsupportedStateInsteadOfGuessing(string scope, string url) + { + File.WriteAllText(Path.Combine(this.root, "global"), + "[credential \"" + scope + "\"]\nhelper = manager\n"); + EventMetadata snapshot = this.Collect(url); + Assert.That(snapshot["Status"], Is.EqualTo("unsupported-config-url")); + Assert.That(snapshot.ContainsKey("HelperChain"), Is.False); + } + + [Test] + public void UsernameTokensRetainMatchingWithoutEnteringProcessArgumentsOrTrace2() + { + string trace = Path.Combine(this.root, "trace"); + List processes = new List(); + File.WriteAllText(Path.Combine(this.root, "global"), + "[trace2]\neventTarget = " + trace.Replace('\\', '/') + "\n" + + "[credential \"https://" + Secret + ":password@example.com/org\"]\nhelper = manager\n" + + "[credential \"https://other@example.com/org\"]\nhelper = store\n"); + EventMetadata snapshot = this.Collect("https://" + Secret + "@example.com/org", processes.Add); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["HelperChain"], Is.EqualTo("manager")); + Assert.That(processes.All(info => !info.Arguments.Contains(Secret, StringComparison.Ordinal)), Is.True); + Assert.That(File.Exists(trace), Is.False); + Assert.That(AuthenticationConfiguration.CredentialUrl.TryParse("https://" + Secret + "@example.com/org", out var url), Is.True); + Assert.That(url.Format(redactUsername: true), Is.EqualTo("https://authdiagnostic-user@example.com/org")); + this.AssertNoSecret(snapshot); + } + + [TestCase("https://example.com/org?token=" + Secret)] + [TestCase("https://example.com/org#token=" + Secret)] + [TestCase("https://user:p@ss@" + Secret + "/org")] + public void SensitiveRepositoryUrlsReportAnExplicitUnsupportedState(string url) + { + File.WriteAllText(Path.Combine(this.root, "global"), "[credential \"https://example.com\"]\nhelper = manager\n"); + EventMetadata snapshot = this.Collect(url); + Assert.That(snapshot["Status"], Is.EqualTo("unsupported-sensitive-repository-url")); + Assert.That(snapshot.ContainsKey("HelperChain"), Is.False); + this.AssertNoSecret(snapshot); + } + + [Test] + public void ConfigCommandOnlyRedirectDoesNotReplaceTheCredentialConfiguration() + { + File.WriteAllText(Path.Combine(this.root, "global"), "[credential]\nhelper = manager\n"); + string decoy = Path.Combine(this.root, "decoy"); + File.WriteAllText(decoy, "[credential]\nhelper = store\n"); + EventMetadata snapshot = this.Collect("https://example.com/org", info => info.Environment["GIT_CONFIG"] = decoy); + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["HelperChain"], Is.EqualTo("manager")); + } + + [Test] + public void EmptyGcmInteractiveAllowsInteractionButGitBooleanIsFalse() + { + File.WriteAllText(Path.Combine(this.root, "global"), "[credential]\ninteractive =\n"); + EventMetadata snapshot = this.Collect("https://example.com/org"); + Assert.That(snapshot["GitInteractive"], Is.EqualTo("false")); + Assert.That(snapshot["Gcm_interactive"], Is.EqualTo("true")); + snapshot = this.Collect("https://example.com/org", info => info.Environment["GCM_INTERACTIVE"] = string.Empty); + Assert.That(snapshot["Gcm_interactive"], Is.EqualTo("true")); + Assert.That(snapshot["Gcm_interactive_Source"], Is.EqualTo("environment")); + } + + [Test] + public void FailedProcessStartReportsOnlyItsStatus() + { + EventMetadata snapshot = AuthenticationConfiguration.Collect("https://example.com/org", command => + new Process { StartInfo = new ProcessStartInfo(Path.Combine(this.root, Secret + ".exe")) }); + Assert.That(snapshot["Status"], Is.EqualTo("query-process-error")); + this.AssertNoSecret(snapshot); + } + + [Test] + public void ProductionFillCarriesItsOwnEnvironmentIntoTheCollector() + { + File.AppendAllText(Path.Combine(this.root, ".git", "config"), + "[credential]\nhelper =\ninteractive = never\n"); + PlumbingGitProcess git = new PlumbingGitProcess(this.GetGitPath(), this.root); + MockTracer tracer = new MockTracer(); + Assert.That(git.TryGetCredential(tracer, "https://dev.azure.com/org/repo", out _, out _, out _, timeoutMs: 5000), Is.False); + Assert.That(git.CollectedEnvironment, Is.SameAs(git.ResultEnvironment)); + Assert.That(git.CollectedEnvironment["GIT_TERMINAL_PROMPT"], Is.EqualTo("0")); + Assert.That(git.CollectedEnvironment["GCM_VALIDATE"], Is.EqualTo("0")); + EventMetadata snapshot = tracer.RelatedEventMetadata[tracer.RelatedEventNames.IndexOf("AuthenticationConfiguration")]; + Assert.That(snapshot["Status"], Is.EqualTo("collected")); + Assert.That(snapshot["EnvironmentTiming"], Is.EqualTo("credential-fill-process")); + Assert.That(snapshot["UseHttpPath"], Is.EqualTo("true")); + Assert.That(snapshot["HelperState"], Is.EqualTo("reset-empty")); + } + + [Test] + public void FailedCredentialFillBeforeCloneCreatesTheWorkingDirectoryCollectsConfiguration() + { + string workingDirectory = Path.Combine(this.root, "enlistment", "src"); + string systemConfig = Path.Combine(this.root, "system"); + File.WriteAllText(systemConfig, string.Empty); + File.WriteAllText(Path.Combine(this.root, "global"), + "[credential]\nhelper = manager\n" + + "[credential \"https://dev.azure.com/org\"]\nhelper =\nhelper = store --file=" + Secret + "\n"); + Dictionary environment = new Dictionary + { + { "SystemRoot", Environment.GetEnvironmentVariable("SystemRoot") }, + { "GIT_CONFIG_SYSTEM", systemConfig }, + { "GIT_CONFIG_GLOBAL", Path.Combine(this.root, "global") }, + { "GIT_TERMINAL_PROMPT", "0" }, + { "GCM_VALIDATE", "0" }, + }; + PreCloneGitProcess git = new PreCloneGitProcess(this.GetGitPath(), workingDirectory, environment); + MockTracer tracer = new MockTracer(); + + Assert.That(git.TryGetCredential(tracer, "https://dev.azure.com/org/repo", out _, out _, out _), Is.False); + Assert.That(Directory.Exists(workingDirectory), Is.False); + EventMetadata snapshot = tracer.RelatedEventMetadata[tracer.RelatedEventNames.IndexOf("AuthenticationConfiguration")]; + using (JsonDocument payload = JsonDocument.Parse(EventMetadataConverter.SerializeToString(snapshot))) + { + Assert.That(payload.RootElement.GetProperty("Status").GetString(), Is.EqualTo("collected")); + Assert.That(payload.RootElement.GetProperty("EnvironmentTiming").GetString(), Is.EqualTo("credential-fill-process")); + Assert.That(payload.RootElement.GetProperty("HelperChain").GetString(), Is.EqualTo("store:omitted-arguments")); + Assert.That(payload.RootElement.GetProperty("HelperOperations").GetString(), + Is.EqualTo("global:manager, global:reset, global:store:omitted-arguments")); + Assert.That(payload.RootElement.GetProperty("SystemConfigState").GetString(), Is.EqualTo("readable-no-auth-settings")); + Assert.That(payload.RootElement.GetProperty("UseHttpPath").GetString(), Is.EqualTo("true")); + } + + this.AssertNoSecret(snapshot); + } + + private string GetGitPath() + { + if (!File.Exists(this.gitPath)) + { + Assert.Fail("The authentication configuration integration tests require Git on PATH."); + } + + return this.gitPath; + } + + private EventMetadata Collect( + string url, + Action customize = null, + Dictionary credentialFillEnvironment = null, + ITracer tracer = null) + { + GitProcess git = new GitProcess(this.GetGitPath(), this.root); + return AuthenticationConfiguration.Collect(url, command => + { + Process process = git.GetGitProcess(command, this.root, Path.Combine(this.root, ".git"), false, null, false); + foreach (string key in process.StartInfo.Environment.Keys.Where(k => + k.StartsWith("GIT_CONFIG", StringComparison.OrdinalIgnoreCase) || k.StartsWith("GCM_", StringComparison.OrdinalIgnoreCase)).ToArray()) + { + process.StartInfo.Environment.Remove(key); + } + + process.StartInfo.Environment["GIT_CONFIG_NOSYSTEM"] = "1"; + process.StartInfo.Environment["GIT_CONFIG_GLOBAL"] = Path.Combine(this.root, "global"); + customize?.Invoke(process.StartInfo); + return process; + }, credentialFillEnvironment, tracer); + } + + private void AssertNoSecret(EventMetadata metadata) + { + Assert.That(EventMetadataConverter.SerializeToString(metadata), Does.Not.Contain(Secret).And.Not.Contain("System.String[]")); + } + + private sealed class PreCloneGitProcess : GitProcess + { + private readonly Dictionary environment; + + internal PreCloneGitProcess(string gitPath, string root, Dictionary environment) + : base(gitPath, root) + { + this.environment = environment; + } + + protected override Result InvokeGitImpl( + string command, + string workingDirectory, + string dotGitDirectory, + bool useReadObjectHook, + Action writeStdIn, + Action parseStdOutLine, + int timeoutMs, + string gitObjectsDirectory = null, + bool usePreCommandHook = true, + bool mayRequireAuth = false, + bool? hasVisibleConsoleWindow = null) + { + Assert.That(command, Does.EndWith("credential fill")); + return new Result(string.Empty, "Credential fill failed", 128) + { + CredentialFillEnvironment = this.environment, + }; + } + } + + private sealed class PlumbingGitProcess : GitProcess + { + internal PlumbingGitProcess(string gitPath, string root) + : base(gitPath, root) + { + } + + internal Dictionary ResultEnvironment { get; private set; } + internal Dictionary CollectedEnvironment { get; private set; } + + protected override EventMetadata GetAuthenticationConfiguration(string repoUrl, Dictionary credentialFillEnvironment, ITracer tracer) + { + this.CollectedEnvironment = credentialFillEnvironment; + return base.GetAuthenticationConfiguration(repoUrl, credentialFillEnvironment, tracer); + } + + protected override Result InvokeGitImpl( + string command, + string workingDirectory, + string dotGitDirectory, + bool useReadObjectHook, + Action writeStdIn, + Action parseStdOutLine, + int timeoutMs, + string gitObjectsDirectory = null, + bool usePreCommandHook = true, + bool mayRequireAuth = false, + bool? hasVisibleConsoleWindow = null) + { + Result result = base.InvokeGitImpl(command, workingDirectory, dotGitDirectory, useReadObjectHook, writeStdIn, + parseStdOutLine, timeoutMs, gitObjectsDirectory, usePreCommandHook, mayRequireAuth, hasVisibleConsoleWindow); + this.ResultEnvironment = result.CredentialFillEnvironment; + return result; + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GVFSGitObjectsTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GVFSGitObjectsTests.cs new file mode 100644 index 000000000..4f9a969f0 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GVFSGitObjectsTests.cs @@ -0,0 +1,253 @@ +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.Common.Http; +using GVFS.Common.Tracing; +using GVFS.Tests.Should; +using GVFS.UnitTests.Category; +using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Mock.FileSystem; +using GVFS.UnitTests.Mock.Git; +using NUnit.Framework; +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class GVFSGitObjectsTests + { + private readonly byte[] validTestObjectFileContents = new byte[] + { + 0x78, 0x01, 0x4B, 0xCA, 0xC9, 0x4F, 0x52, 0x30, 0x62, + 0x48, 0xE4, 0x02, 0x00, 0x0E, 0x64, 0x02, 0x5D + }; + + [SetUp] + public void SetUp() + { + RetryCircuitBreaker.Reset(); + } + + [TearDown] + public void TearDown() + { + RetryCircuitBreaker.Reset(); + } + + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [Category(CategoryConstants.ExceptionExpected)] + public async Task CoalescedWaiterDoesNotInheritTheDownloadOwnersCancellation(bool namedPipeWaiter, bool cancelWaiter) + { + const string ObjectSha = "78981922613b2afb6025042ff6bd878ac1994e85"; + using CancellationTokenSource ownerCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using CancellationTokenSource waiterCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using CancellationTokenSource serverCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using ManualResetEventSlim bodyStarted = new ManualResetEventSlim(); + using CoalescedDownloadTracer tracer = new CoalescedDownloadTracer(); + using ManualResetEventSlim waiterJoined = tracer.WaiterJoined; + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + string endpoint = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; + MockGVFSEnlistment enlistment = new MockGVFSEnlistment( + "mock:\\src", endpoint, "mock:\\git", new MockGitProcess()); + enlistment.InitializeCachePathsFromKey("mock:\\.gvfs", "mock:\\.gvfs\\gitObjectCache"); + using GitObjectsHttpRequestor requestor = new GitObjectsHttpRequestor( + tracer, enlistment, new CacheServerInfo(endpoint, "loopback"), + new RetryConfig(0, TimeSpan.FromSeconds(5))); + int objectAvailable = 0; + MockFileSystemWithCallbacks fileSystem = new MockFileSystemWithCallbacks + { + OnFileExists = path => false, + OnMoveFile = (source, target) => Volatile.Write(ref objectAvailable, 1), + OnOpenFileStream = (path, mode, access) => access == FileAccess.Read + ? new MemoryStream(this.validTestObjectFileContents) + : new BodyReadMemoryStream(bodyStarted), + }; + using GitRepo repository = new DownloadedBlobRepo(tracer, () => Volatile.Read(ref objectAvailable) != 0); + GVFSGitObjects objects = new GVFSGitObjects( + new GVFSContext(tracer, fileSystem, repository, enlistment), requestor); + int requests = 0; + Task server = this.ServeCanceledThenSuccessfulObject( + listener, ObjectSha, () => Interlocked.Increment(ref requests), serverCancellation.Token); + Task owner = null; + Task waiter = null; + try + { + owner = Task.Run(() => objects.TryCopyBlobContentStream( + ObjectSha, ownerCancellation.Token, GVFSGitObjects.RequestSource.FileStreamCallback, + (stream, length) => Assert.Fail("Canceled owner must not copy the blob."), out _)); + bodyStarted.Wait(TimeSpan.FromSeconds(5)).ShouldEqual(true); + waiter = Task.Run(() => + { + if (namedPipeWaiter) + { + return objects.TryDownloadAndSaveObject( + ObjectSha, GVFSGitObjects.RequestSource.NamedPipeMessage) + == GitObjects.DownloadAndSaveObjectResult.Success; + } + + return objects.TryCopyBlobContentStream( + ObjectSha, waiterCancellation.Token, GVFSGitObjects.RequestSource.FileStreamCallback, + (stream, length) => + { + length.ShouldEqual(2L); + stream.ReadByte().ShouldEqual((int)'a'); + }, out _); + }); + tracer.WaiterJoined.Wait(TimeSpan.FromSeconds(5)).ShouldEqual(true); + if (cancelWaiter) + { + waiterCancellation.Cancel(); + } + + ownerCancellation.Cancel(); + Assert.CatchAsync(async () => await owner.WaitAsync(TimeSpan.FromSeconds(5))); + if (cancelWaiter) + { + Assert.CatchAsync(async () => await waiter.WaitAsync(TimeSpan.FromSeconds(5))); + } + else + { + (await waiter.WaitAsync(TimeSpan.FromSeconds(5))).ShouldEqual(true); + } + + requests.ShouldEqual(cancelWaiter ? 1 : 2); + objects.inflightDownloads.Count.ShouldEqual(0); + RetryCircuitBreaker.ConsecutiveFailures.ShouldEqual(0); + tracer.SnapshotRelatedEvents().Count( + record => record.Name == "TryDownloadAndSaveObject_CoalescedCancellation") + .ShouldEqual(cancelWaiter ? 0 : 1); + } + finally + { + ownerCancellation.Cancel(); + waiterCancellation.Cancel(); + serverCancellation.Cancel(); + listener.Stop(); + await Task.WhenAll( + ObserveCanceledDownloadTask(owner ?? Task.CompletedTask), + ObserveCanceledDownloadTask(waiter ?? Task.CompletedTask), + ObserveCanceledDownloadTask(server)); + } + } + + private async Task ServeCanceledThenSuccessfulObject( + TcpListener listener, string objectSha, Action onRequest, CancellationToken cancellationToken) + { + for (int attempt = 0; attempt < 2; ++attempt) + { + using TcpClient client = await listener.AcceptTcpClientAsync(cancellationToken); + using NetworkStream stream = client.GetStream(); + using (StreamReader reader = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true)) + { + string request = await reader.ReadLineAsync(cancellationToken); + request.ShouldContain(objectSha); + string line; + do + { + line = await reader.ReadLineAsync(cancellationToken); + } + while (!string.IsNullOrEmpty(line)); + } + + onRequest(); + byte[] headers = Encoding.ASCII.GetBytes( + $"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: application/x-git-loose-object\r\nContent-Length: {this.validTestObjectFileContents.Length}\r\n\r\n"); + await stream.WriteAsync(headers.AsMemory(), cancellationToken); + if (attempt == 0) + { + await stream.WriteAsync(this.validTestObjectFileContents.AsMemory(0, 8), cancellationToken); + try + { + (await stream.ReadAsync(new byte[1].AsMemory(), cancellationToken)).ShouldEqual(0); + } + catch (IOException e) when (e.InnerException is SocketException socketError && + socketError.SocketErrorCode == SocketError.ConnectionReset) + { + // A canceled HTTP response can close the socket with a reset. + } + } + else + { + await stream.WriteAsync(this.validTestObjectFileContents.AsMemory(), cancellationToken); + } + } + } + + private static async Task ObserveCanceledDownloadTask(Task task) + { + try + { + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (OperationCanceledException) + { + } + } + + private sealed class CoalescedDownloadTracer : MockTracer + { + public ManualResetEventSlim WaiterJoined { get; } = new ManualResetEventSlim(); + + public override void RelatedEvent(EventLevel level, string name, EventMetadata metadata) + { + base.RelatedEvent(level, name, metadata); + if (name == "TryDownloadAndSaveObject_CoalescedRequest") + { + this.WaiterJoined.Set(); + } + } + } + + private sealed class BodyReadMemoryStream : MemoryStream + { + private readonly ManualResetEventSlim bodyStarted; + + public BodyReadMemoryStream(ManualResetEventSlim bodyStarted) + { + this.bodyStarted = bodyStarted; + } + + public override void Write(byte[] buffer, int offset, int count) + { + base.Write(buffer, offset, count); + if (count > 0) + { + this.bodyStarted.Set(); + } + } + } + + private sealed class DownloadedBlobRepo : GitRepo + { + private readonly Func objectAvailable; + + public DownloadedBlobRepo(ITracer tracer, Func objectAvailable) + : base(tracer) + { + this.objectAvailable = objectAvailable; + } + + public override bool TryCopyBlobContentStream(string blobSha, Action writeAction) + { + if (!this.objectAvailable()) + { + return false; + } + + using MemoryStream stream = new MemoryStream(new byte[] { (byte)'a', (byte)'\n' }); + writeAction(stream, stream.Length); + return true; + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GVFSMountExitCodeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GVFSMountExitCodeTests.cs new file mode 100644 index 000000000..27de72be0 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GVFSMountExitCodeTests.cs @@ -0,0 +1,59 @@ +using GVFS.Common; +using GVFS.FunctionalTests.Properties; +using GVFS.Tests.Should; +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + public class GVFSMountExitCodeTests + { + [TestCase] + public void InvalidEnlistmentProcess_ReturnsGenericErrorExitCode() + { + Settings.Default.PathToGVFS.ShouldNotBeNull("The functional runner must select a GVFS payload."); + string mountPath = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(Settings.Default.PathToGVFS)), "GVFS.Mount.exe"); + File.Exists(mountPath).ShouldBeTrue("The selected GVFS payload must contain " + mountPath); + + string nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + ProcessStartInfo processInfo = new ProcessStartInfo + { + FileName = mountPath, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + processInfo.ArgumentList.Add(nonExistentPath); + + using (Process process = Process.Start(processInfo)) + { + process.ShouldNotBeNull("GVFS.Mount did not start."); + Task output = process.StandardOutput.ReadToEndAsync(); + Task errors = process.StandardError.ReadToEndAsync(); + try + { + process.WaitForExit(30000).ShouldBeTrue("GVFS.Mount did not exit within 30 seconds."); + Task.WaitAll(new Task[] { output, errors }, 10000).ShouldBeTrue("GVFS.Mount output did not close."); + + Assert.That( + process.ExitCode, + Is.EqualTo((int)ReturnCode.GenericError), + $"stdout: {output.Result}{Environment.NewLine}stderr: {errors.Result}"); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(10000).ShouldBeTrue("GVFS.Mount did not exit after cleanup."); + } + } + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessCredentialTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessCredentialTests.cs new file mode 100644 index 000000000..854123236 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessCredentialTests.cs @@ -0,0 +1,139 @@ +using GVFS.Common.Git; +using GVFS.Common.Tracing; +using GVFS.Tests.Should; +using GVFS.UnitTests; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class GitProcessCredentialTests + { + private const string SecretValue = "S3cretSentinelValueThatMustNotBeTraced"; + private const int GitFatalExitCode = 128; + + [TestCase(true, 30000)] + [TestCase(false, 30000)] + [TestCase(true, Timeout.Infinite)] + [TestCase(false, Timeout.Infinite)] + public void CredentialFillHandlesARealLockedGitConfig(bool releaseAfterFirstAttempt, int timeoutMs) + { + string gitPath = GitTestHelper.FindGitOnPath(); + gitPath.ShouldNotBeNull("Git must be available on PATH."); + + using IsolatedGitEnvironment environment = new IsolatedGitEnvironment(); + string repoPath = Path.Combine(environment.RootDirectory, "repo"); + string dotGit = Path.Combine(repoPath, ".git"); + Directory.CreateDirectory(Path.Combine(dotGit, "objects")); + Directory.CreateDirectory(Path.Combine(dotGit, "refs", "heads")); + File.WriteAllText(Path.Combine(dotGit, "HEAD"), "ref: refs/heads/main\n"); + File.WriteAllText(Path.Combine(dotGit, "config"), "[core]\n\trepositoryformatversion = 0\n\tbare = false\n"); + LockedConfigGitProcess gitProcess = new LockedConfigGitProcess(gitPath, repoPath, releaseAfterFirstAttempt); + using MockTracer tracer = new MockTracer(); + + bool success = gitProcess.TryGetCredential(tracer, "mock://repoUrl", out string username, out string password, out string error, timeoutMs: timeoutMs); + + success.ShouldEqual(releaseAfterFirstAttempt, error); + gitProcess.Results.Count.ShouldEqual(releaseAfterFirstAttempt ? 2 : 5); + gitProcess.Results[0].ExitCode.ShouldEqual(GitFatalExitCode); + gitProcess.Results[0].Errors.ShouldContain("Permission denied"); + gitProcess.Results[0].Errors.ShouldContain("unknown error occurred while reading the configuration files"); + if (releaseAfterFirstAttempt) + { + username.ShouldEqual("someone"); + password.ShouldEqual(SecretValue); + } + else + { + error.Contains(SecretValue).ShouldBeFalse("The returned error must not contain stderr: " + error); + error.ShouldEqual("Git credential fill failed with exit code " + GitFatalExitCode + "; see the log for its error output"); + } + + AssertNoSecret(tracer); + } + + private static void AssertNoSecret(MockTracer tracer) + { + foreach (MockTracer source in new[] { tracer, tracer.StartActivityTracer }) + { + foreach (string traced in source.RelatedInfoEvents + .Concat(source.RelatedWarningEvents) + .Concat(source.RelatedErrorEvents)) + { + traced.Contains(SecretValue).ShouldBeFalse("Credential output must not be traced: " + traced); + } + + foreach (EventMetadata metadata in source.TelemetryWarnings + .Concat(source.RelatedEventMetadata) + .Concat(source.StopMetadata) + .Where(item => item != null)) + { + foreach (object value in metadata.Values) + { + string text = value?.ToString() ?? string.Empty; + text.Contains(SecretValue).ShouldBeFalse("Credential output must not be traced: " + text); + } + } + } + } + + private sealed class LockedConfigGitProcess : GitProcess + { + private readonly string configPath; + private readonly bool releaseAfterFirstAttempt; + + public LockedConfigGitProcess(string gitPath, string repoPath, bool releaseAfterFirstAttempt) + : base(gitPath, repoPath) + { + this.configPath = Path.Combine(repoPath, ".git", "config"); + this.releaseAfterFirstAttempt = releaseAfterFirstAttempt; + } + + public List Results { get; } = new List(); + + protected override Result InvokeGitImpl( + string command, + string workingDirectory, + string dotGitDirectory, + bool useReadObjectHook, + Action writeStdIn, + Action parseStdOutLine, + int timeoutMs, + string gitObjectsDirectory = null, + bool usePreCommandHook = true, + bool mayRequireAuth = false, + bool? hasVisibleConsoleWindow = null) + { + // Replace all credential helpers with a deterministic local helper. No credentials leave the test. + string isolatedCommand = "-c credential.helper= -c credential.helper=\"!f() { echo username=someone; echo password=" + + SecretValue + "; }; f\" " + command; + using (FileStream configLock = this.Results.Count == 0 || !this.releaseAfterFirstAttempt + ? File.Open(this.configPath, FileMode.Open, FileAccess.Read, FileShare.None) + : null) + { + Result result = base.InvokeGitImpl( + isolatedCommand, + workingDirectory, + dotGitDirectory, + useReadObjectHook, + writeStdIn, + parseStdOutLine, + timeoutMs, + gitObjectsDirectory, + usePreCommandHook, + mayRequireAuth, + hasVisibleConsoleWindow); + this.Results.Add(result); + return result; + } + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessTests.cs new file mode 100644 index 000000000..0c5293694 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessTests.cs @@ -0,0 +1,135 @@ +using GVFS.Common.Git; +using GVFS.Tests.Should; +using GVFS.UnitTests; +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class GitProcessTests + { + [TestCase(30000)] + [TestCase(Timeout.Infinite)] + public void GitCommandWaitsForOutputCallbacks(int timeoutMs) + { + string gitPath = GitTestHelper.FindGitOnPath(); + gitPath.ShouldNotBeNull("Git must be available on PATH."); + + using IsolatedGitEnvironment environment = new IsolatedGitEnvironment(); + OutputCallbackGitProcess gitProcess = new OutputCallbackGitProcess(gitPath); + using (ManualResetEventSlim callbackStarted = new ManualResetEventSlim()) + using (ManualResetEventSlim releaseCallback = new ManualResetEventSlim()) + { + bool callbackCompleted = false; + Task command = Task.Run(() => gitProcess.GetVersion( + line => + { + callbackStarted.Set(); + callbackCompleted = releaseCallback.Wait(TimeSpan.FromSeconds(10)); + }, + timeoutMs)); + + try + { + callbackStarted.Wait(TimeSpan.FromSeconds(10)).ShouldBeTrue("Git did not produce output"); + command.Wait(TimeSpan.FromSeconds(1)).ShouldBeFalse("The command returned before its output callback finished"); + } + finally + { + releaseCallback.Set(); + command.Wait(TimeSpan.FromSeconds(10)).ShouldBeTrue("The command did not finish after its output callback was released"); + } + + command.Result.ExitCode.ShouldEqual(0); + callbackCompleted.ShouldBeTrue(); + } + } + + [TestCase] + public void QuoteGitPath_RoundTripThroughProcess() + { + string[] testPaths = new[] + { + "simple/path.txt", + "path with spaces/file name.txt", + "path\\with\\backslashes\\file.txt", + }; + + string gitPath = GitTestHelper.FindGitOnPath(); + gitPath.ShouldNotBeNull("Git must be available on PATH."); + + using IsolatedGitEnvironment environment = new IsolatedGitEnvironment(); + foreach (string testPath in testPaths) + { + string quoted = GitProcess.QuoteGitPath(testPath); + ProcessStartInfo processInfo = new ProcessStartInfo(gitPath) + { + Arguments = "rev-parse --sq-quote " + quoted, + WorkingDirectory = environment.RootDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + using (Process process = Process.Start(processInfo)) + { + process.ShouldNotBeNull("Git did not start."); + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorsTask = process.StandardError.ReadToEndAsync(); + try + { + process.WaitForExit(30000).ShouldBeTrue("Git did not exit within 30 seconds."); + Task.WaitAll(new Task[] { outputTask, errorsTask }, 10000).ShouldBeTrue("Git output did not close."); + process.ExitCode.ShouldEqual(0, errorsTask.Result); + string output = outputTask.Result.Trim(); + + // Git wraps --sq-quote output in single quotes. + if (output.StartsWith("'") && output.EndsWith("'")) + { + output = output.Substring(1, output.Length - 2); + } + + output.ShouldEqual( + testPath, + $"Path round-trip failed for: {testPath} (quoted as: {quoted})"); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(10000).ShouldBeTrue("Git did not exit after cleanup."); + } + } + } + } + } + + private sealed class OutputCallbackGitProcess : GitProcess + { + public OutputCallbackGitProcess(string gitPath) + : base(gitPath, workingDirectoryRoot: null) + { + } + + public Result GetVersion(Action onOutput, int timeoutMs) + { + return this.InvokeGitImpl( + "--version", + workingDirectory: null, + dotGitDirectory: null, + useReadObjectHook: false, + writeStdIn: null, + parseStdOutLine: onOutput, + timeoutMs: timeoutMs, + usePreCommandHook: false); + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/HttpResponseStreamTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/HttpResponseStreamTests.cs new file mode 100644 index 000000000..34a3dc47e --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/HttpResponseStreamTests.cs @@ -0,0 +1,470 @@ +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.Common.Http; +using GVFS.Common.Tracing; +using GVFS.Tests.Should; +using GVFS.UnitTests.Category; +using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Mock.Git; +using NUnit.Framework; +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using static GVFS.UnitTests.Http.HttpResponseStreamTestSupport; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class HttpResponseStreamTests + { + [SetUp] + public void SetUp() + { + RetryCircuitBreaker.Reset(); + } + + [TearDown] + public void TearDown() + { + RetryCircuitBreaker.Reset(); + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public async Task RealHttpBodyReadTimesOutAfterHeadersAndPartialData() + { + using MockTracer tracer = new MockTracer(); + using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + using HttpClient client = new HttpClient(); + listener.Start(); + Task server = ServeStalledResponse(listener, cancellation.Token); + try + { + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + using (HttpResponseMessage response = await client.GetAsync( + $"http://127.0.0.1:{port}/body", + HttpCompletionOption.ResponseHeadersRead, + cancellation.Token).WaitAsync(TimeSpan.FromSeconds(5))) + using (HttpResponseStream stream = CreateStream(await response.Content.ReadAsStreamAsync(cancellation.Token), tracer)) + { + stream.ReadByte().ShouldEqual((int)'x'); + Assert.ThrowsAsync( + async () => await stream.ReadAsync(new byte[8].AsMemory()).AsTask().WaitAsync(TimeSpan.FromSeconds(5))); + stream.ReadFailed.ShouldEqual(true); + LastBodyEvent(tracer, "TimedOut").Name.ShouldEqual("NetworkBodyReadTimeout"); + } + } + finally + { + cancellation.Cancel(); + listener.Stop(); + await ObserveServerCancellation(server, cancellation.Token); + } + } + + [TestCase(HttpStatusCode.OK)] + [TestCase(HttpStatusCode.ServiceUnavailable)] + [TestCase(HttpStatusCode.Unauthorized)] + [TestCase(HttpStatusCode.NotFound)] + [Category(CategoryConstants.ExceptionExpected)] + public async Task RequestorAppliesConfiguredReadTimeoutToSuccessAndErrorBodies(HttpStatusCode status) + { + using MockTracer tracer = new MockTracer(); + using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + Task server = ServeStalledResponse(listener, cancellation.Token, status); + try + { + using LoopbackHttpRequestor requestor = new LoopbackHttpRequestor(tracer); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + Uri uri = new Uri($"http://127.0.0.1:{port}/body?query-marker=not-for-telemetry"); + if (status == HttpStatusCode.OK) + { + using (GitEndPointResponseData response = requestor.Get(uri)) + { + response.Stream.ReadByte().ShouldEqual((int)'x'); + Assert.Throws(() => response.Stream.ReadByte()); + response.StreamReadFailed.ShouldEqual(true); + response.ResponseId.ShouldEqual((string)LastBodyEvent(tracer, "TimedOut").Metadata["ResponseId"]); + } + } + else + { + using (GitEndPointResponseData response = requestor.Get(uri)) + { + response.StatusCode.ShouldEqual(status); + ((GitObjectsHttpException)response.Error).StatusCode.ShouldEqual(status); + response.Error.Message.ShouldContain("response body"); + response.Error.Message.ShouldContain("No response body data arrived"); + response.ShouldRetry.ShouldEqual(status == HttpStatusCode.ServiceUnavailable); + } + } + + EventMetadata timeout = LastBodyEvent(tracer, "TimedOut").Metadata; + timeout["RequestId"].ShouldEqual(42L); + timeout["StatusCode"].ShouldEqual(status); + timeout["CacheName"].ShouldEqual("test-cache"); + timeout["RequestAuthority"].ShouldEqual($"127.0.0.1:{port}"); + timeout["BytesRead"].ShouldEqual(1L); + timeout["ReadTimeoutMs"].ShouldEqual(300D); + timeout.ContainsKey("ContentType").ShouldEqual(false); + GVFSJsonOptions.Serialize(timeout).Contains("query-marker", StringComparison.Ordinal).ShouldEqual(false); + } + finally + { + cancellation.Cancel(); + listener.Stop(); + await ObserveServerCancellation(server, cancellation.Token); + } + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public async Task StalledErrorBodyRetriesWithTheStatusAndReleasesItsConnectionPermit() + { + using MockTracer tracer = new MockTracer(); + using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + string endpoint = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; + Task server = ServeRetriedError(listener, cancellation.Token); + try + { + using GitObjectsHttpRequestor requestor = new GitObjectsHttpRequestor( + tracer, new MockGVFSEnlistment(), new CacheServerInfo(endpoint, "test"), + new RetryConfig(1, TimeSpan.FromSeconds(5)) { ReadTimeout = TimeSpan.FromMilliseconds(100) }); + RetryWrapper.InvocationResult result = + requestor.TrySendProtocolRequest( + 123, + (attempt, response) => + { + response.Stream.ReadByte().ShouldEqual(-1); + return new RetryWrapper.CallbackResult( + new GitObjectsHttpRequestor.GitObjectTaskResult(true)); + }, + onFailure: null, + method: HttpMethod.Get, + endPointGenerator: () => new Uri(endpoint), + requestBodyGenerator: () => null, + cancellationToken: cancellation.Token); + result.Succeeded.ShouldEqual(true); + result.Attempts.ShouldEqual(2); + EventMetadata[] responses = tracer.SnapshotRelatedEvents().Select(record => record.Metadata) + .Where(metadata => metadata.ContainsKey("availableConnections")).ToArray(); + responses.Length.ShouldEqual(2); + responses[0]["StatusCode"].ShouldEqual(HttpStatusCode.ServiceUnavailable); + responses[1]["StatusCode"].ShouldEqual(HttpStatusCode.OK); + responses[1]["availableConnections"].ShouldEqual(responses[0]["availableConnections"]); + } + finally + { + cancellation.Cancel(); + listener.Stop(); + await ObserveServerCancellation(server, cancellation.Token); + } + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public async Task ConfigProbeRetainsUnauthorizedStatusWhenItsBodyStalls() + { + using MockTracer tracer = new MockTracer(); + using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + string endpoint = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; + Task server = ServeStalledResponse(listener, cancellation.Token, HttpStatusCode.Unauthorized); + try + { + using ConfigHttpRequestor requestor = new ConfigHttpRequestor( + tracer, new MockGVFSEnlistment("mock:\\repo", endpoint, "mock:\\git", new MockGitProcess()), + new RetryConfig(0, TimeSpan.FromSeconds(5)) { ReadTimeout = TimeSpan.FromMilliseconds(100) }); + requestor.TryQueryGVFSConfig(false, out ServerGVFSConfig _, out HttpStatusCode? status, out string error) + .ShouldEqual(false); + status.ShouldEqual(HttpStatusCode.Unauthorized); + error.ShouldContain("401"); + error.ShouldContain("No response body data arrived"); + } + finally + { + cancellation.Cancel(); + listener.Stop(); + await ObserveServerCancellation(server, cancellation.Token); + } + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public async Task FinalResponseDiagnosticFailureReleasesThePreparedResponse() + { + using CallbackTracer tracer = new CallbackTracer(); + bool fail = true; + tracer.Callback = name => + { + if (name == "NetworkResponse" && fail) + { + fail = false; + throw new InvalidOperationException("diagnostic failure"); + } + }; + using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + Uri endpoint = new Uri($"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"); + Task server = ServeRetriedError(listener, cancellation.Token); + try + { + using LoopbackHttpRequestor requestor = new LoopbackHttpRequestor(tracer); + Assert.Throws(() => requestor.Get(endpoint)); + using (GitEndPointResponseData response = requestor.Get(endpoint)) + { + response.StatusCode.ShouldEqual(HttpStatusCode.OK); + response.Stream.ReadByte().ShouldEqual(-1); + } + + MockTracer.RecordedEvent[] responses = tracer.SnapshotRelatedEvents() + .Where(record => record.Name == "NetworkResponse").ToArray(); + responses.Length.ShouldEqual(2); + responses[1].Metadata["availableConnections"].ShouldEqual(responses[0].Metadata["availableConnections"]); + } + finally + { + cancellation.Cancel(); + listener.Stop(); + await ObserveServerCancellation(server, cancellation.Token); + } + } + + [TestCase] + public async Task AnonymousUnauthorizedBodyDoesNotReachTheErrorTelemetry() + { + using MockTracer tracer = new MockTracer(); + using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + Uri endpoint = new Uri($"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"); + Task server = ServeCompleteResponses(listener, cancellation.Token, + (HttpStatusCode.Unauthorized, "\u001b[31mRAW-BODY-MARKER", "text/plain; charset=utf-8")); + try + { + using GitObjectsHttpRequestor requestor = new GitObjectsHttpRequestor( + tracer, new MockGVFSEnlistment(), new CacheServerInfo(endpoint.AbsoluteUri, "test"), + new RetryConfig(0, TimeSpan.FromSeconds(5))); + RetryWrapper.InvocationResult result = + requestor.TrySendProtocolRequest( + 123, + (attempt, response) => throw new InvalidOperationException("Unexpected success"), + RetryWrapper.StandardErrorHandler(tracer, 123, "test"), + HttpMethod.Get, + () => endpoint, + () => null, + cancellation.Token); + result.Succeeded.ShouldEqual(false); + result.Error.Message.ShouldEqual("Anonymous request was rejected with a 401"); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + tracer.RelatedErrorEvents[0].ShouldContain("Anonymous request was rejected with a 401"); + tracer.RelatedErrorEvents[0].Contains("RAW-BODY-MARKER", StringComparison.Ordinal).ShouldEqual(false); + } + finally + { + cancellation.Cancel(); + listener.Stop(); + await ObserveServerCancellation(server, cancellation.Token); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task CompletedBadRequestBodyPreservesCharsetAndCredentialPolicy(bool authRequired) + { + using MockTracer tracer = new MockTracer(); + using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + string endpoint = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; + string body = authRequired ? HttpRequestor.CacheServerAuthRequiredBadRequestMessage : "denied: caf\u00e9"; + Task server = ServeCompleteResponses(listener, cancellation.Token, + (HttpStatusCode.Unauthorized, "denied", "text/plain"), + (HttpStatusCode.OK, "{}", "application/json"), + (HttpStatusCode.BadRequest, body, "text/plain; charset=utf-8")); + try + { + MockGitProcess git = CreateCredentialGitProcess(endpoint); + GitAuthentication authentication = new GitAuthentication(git, endpoint); + MockGVFSEnlistment enlistment = new MockGVFSEnlistment( + "mock:\\repo", endpoint, "mock:\\git", git, authentication); + using LoopbackHttpRequestor requestor = new LoopbackHttpRequestor(tracer, enlistment); + authentication.TryInitializeAndQueryGVFSConfig( + tracer, enlistment, new RetryConfig(0, TimeSpan.FromSeconds(5)), + out _, out string error, out _).ShouldEqual(true, error); + authentication.IsAnonymous.ShouldEqual(false); + using (GitEndPointResponseData response = requestor.Get(new Uri(endpoint))) + { + response.StatusCode.ShouldEqual(HttpStatusCode.BadRequest); + response.ShouldRetry.ShouldEqual(false); + response.Error.Message.ShouldContain(body); + } + + bool rejected = git.CredentialRejections.TryGetValue(endpoint, out System.Collections.Generic.List rejections); + rejected.ShouldEqual(authRequired); + if (authRequired) + { + rejections.Count.ShouldEqual(1); + } + } + finally + { + cancellation.Cancel(); + listener.Stop(); + await ObserveServerCancellation(server, cancellation.Token); + } + } + + [TestCase] + public async Task AuthenticationInitializationLoadsAllRetrySettings() + { + using MockTracer tracer = new MockTracer(); + using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + string endpoint = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; + Task server = ServeCompleteResponses(listener, cancellation.Token, + (HttpStatusCode.OK, "{}", "application/json")); + try + { + MockGitProcess git = CreateCredentialGitProcess(endpoint); + git.SetExpectedCommandResult("config gvfs.max-retries", () => new GitProcess.Result("0", string.Empty, 0)); + git.SetExpectedCommandResult("config gvfs.timeout-seconds", () => new GitProcess.Result("2", string.Empty, 0)); + git.SetExpectedCommandResult("config gvfs.read-timeout-seconds", () => new GitProcess.Result("1", string.Empty, 0)); + GitAuthentication authentication = new GitAuthentication(git, endpoint); + MockGVFSEnlistment enlistment = new MockGVFSEnlistment( + "mock:\\repo", endpoint, "mock:\\git", git, authentication); + + // Both config readers must use the same isolated settings. + using IsolatedLibGit2Config machineConfig = new IsolatedLibGit2Config(); + File.WriteAllText(machineConfig.GlobalConfigPath, "[gvfs]\n\tmax-retries = 0\n\ttimeout-seconds = 2\n\tread-timeout-seconds = 1\n"); + authentication.TryInitialize(tracer, enlistment, out string error).ShouldEqual(true, error); + EventMetadata loaded = tracer.RelatedEventMetadata[tracer.RelatedEventNames.IndexOf("RetryConfig_LoadedRetryConfig")]; + loaded["MaxRetries"].ShouldEqual(0); + loaded["Timeout"].ShouldEqual(TimeSpan.FromSeconds(2)); + loaded["ReadTimeout"].ShouldEqual(TimeSpan.FromSeconds(1)); + } + finally + { + cancellation.Cancel(); + listener.Stop(); + await ObserveServerCancellation(server, cancellation.Token); + } + } + + private static MockGitProcess CreateCredentialGitProcess(string endpoint) + { + MockGitProcess git = new MockGitProcess(); + git.SetExpectedCommandResult("config --get-urlmatch http " + endpoint, + () => new GitProcess.Result(string.Empty, string.Empty, 0)); + git.SetExpectedCommandResult("config gvfs.max-http-connections", + () => new GitProcess.Result(string.Empty, string.Empty, 1)); + const string Prefix = "-c credential.\"https://dev.azure.com\".useHttpPath=true credential "; + git.SetExpectedCommandResult(Prefix + "fill", + () => new GitProcess.Result("username=test-user\npassword=test-password\n", string.Empty, 0)); + git.SetExpectedCommandResult(Prefix + "approve", () => new GitProcess.Result(string.Empty, string.Empty, 0)); + git.SetExpectedCommandResult(Prefix + "reject", () => new GitProcess.Result(string.Empty, string.Empty, 0)); + return git; + } + + private static async Task ServeCompleteResponses(TcpListener listener, CancellationToken cancellationToken, + params (HttpStatusCode Status, string Body, string ContentType)[] responses) + { + foreach ((HttpStatusCode status, string body, string contentType) in responses) + { + using TcpClient client = await listener.AcceptTcpClientAsync(cancellationToken); + using NetworkStream stream = client.GetStream(); + await ReadRequestHeaders(stream, cancellationToken); + byte[] data = Encoding.UTF8.GetBytes(body); + byte[] headers = Encoding.ASCII.GetBytes( + $"HTTP/1.1 {(int)status} Test\r\nConnection: close\r\nContent-Type: {contentType}\r\nContent-Length: {data.Length}\r\n\r\n"); + await stream.WriteAsync(headers.AsMemory(), cancellationToken); + await stream.WriteAsync(data.AsMemory(), cancellationToken); + } + } + + private static async Task ObserveServerCancellation(Task server, CancellationToken cancellationToken) + { + try + { + await server.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private static async Task ServeRetriedError(TcpListener listener, CancellationToken cancellationToken) + { + for (int attempt = 0; attempt < 2; ++attempt) + { + using TcpClient client = await listener.AcceptTcpClientAsync(cancellationToken); + using NetworkStream stream = client.GetStream(); + await ReadRequestHeaders(stream, cancellationToken); + byte[] response = Encoding.ASCII.GetBytes(attempt == 0 + ? "HTTP/1.1 503 Unavailable\r\nContent-Length: 2\r\n\r\nx" + : "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + await stream.WriteAsync(response.AsMemory(), cancellationToken); + if (attempt == 0) + { + (await stream.ReadAsync(new byte[1].AsMemory(), cancellationToken)).ShouldEqual(0); + } + } + } + + private static async Task ServeStalledResponse( + TcpListener listener, + CancellationToken cancellationToken, + HttpStatusCode status = HttpStatusCode.OK) + { + using TcpClient connection = await listener.AcceptTcpClientAsync(cancellationToken); + using NetworkStream stream = connection.GetStream(); + await ReadRequestHeaders(stream, cancellationToken); + byte[] response = Encoding.ASCII.GetBytes( + $"HTTP/1.1 {(int)status} Test\r\nContent-Length: 2\r\nX-Cache-Name: test-cache\r\n\r\nx"); + await stream.WriteAsync(response.AsMemory(), cancellationToken); + await Task.Delay(Timeout.Infinite, cancellationToken); + } + + private static async Task ReadRequestHeaders(NetworkStream stream, CancellationToken cancellationToken) + { + using StreamReader reader = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true); + string line; + do + { + line = await reader.ReadLineAsync(cancellationToken); + } + while (!string.IsNullOrEmpty(line)); + } + + private class LoopbackHttpRequestor : HttpRequestor + { + public LoopbackHttpRequestor(MockTracer tracer, Enlistment enlistment = null) + : base( + tracer, + new RetryConfig(0, TimeSpan.FromSeconds(5)) { ReadTimeout = TimeSpan.FromMilliseconds(300) }, + enlistment ?? new MockGVFSEnlistment()) + { + } + + public GitEndPointResponseData Get(Uri uri) + { + return this.SendRequest(42, uri, HttpMethod.Get, null, CancellationToken.None); + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/HydrationStatusDiagnosticsTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/HydrationStatusDiagnosticsTests.cs new file mode 100644 index 000000000..12bc9c248 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/HydrationStatusDiagnosticsTests.cs @@ -0,0 +1,23 @@ +using GVFS.Common.NamedPipes; +using GVFS.UnitTests.Category; +using NUnit.Framework; +using System; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class HydrationStatusDiagnosticsTests + { + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void RealPipeConnectionReturnsItsTimeoutCause() + { + using (NamedPipeClient client = new NamedPipeClient("GVFS.FunctionalTests.Missing." + Guid.NewGuid().ToString("N"))) + { + Assert.IsFalse(client.Connect(25, out Exception error)); + Assert.IsInstanceOf(error); + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/IsolatedGitEnvironment.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/IsolatedGitEnvironment.cs new file mode 100644 index 000000000..ac9a77f80 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/IsolatedGitEnvironment.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + // Callers must be non-parallel because Git inherits these process-wide environment variables. + internal sealed class IsolatedGitEnvironment : IDisposable + { + private readonly Dictionary originalValues = new Dictionary(); + + public IsolatedGitEnvironment() + { + this.RootDirectory = Path.Combine(Path.GetTempPath(), "LocalComponents_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this.RootDirectory); + + foreach (string variable in new[] + { + "HOME", "XDG_CONFIG_HOME", "GIT_CONFIG", "GIT_CONFIG_COUNT", "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_SYSTEM", + "GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", + }) + { + this.originalValues.Add(variable, Environment.GetEnvironmentVariable(variable)); + Environment.SetEnvironmentVariable(variable, null); + } + + Environment.SetEnvironmentVariable("HOME", this.RootDirectory); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", Path.Combine(this.RootDirectory, "xdg")); + Environment.SetEnvironmentVariable("GIT_CONFIG_NOSYSTEM", "1"); + } + + public string RootDirectory { get; } + + public void Dispose() + { + foreach (KeyValuePair variable in this.originalValues) + { + Environment.SetEnvironmentVariable(variable.Key, variable.Value); + } + + Directory.Delete(this.RootDirectory, recursive: true); + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/QueuedPipeStringWriterTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/QueuedPipeStringWriterTests.cs new file mode 100644 index 000000000..95ce652a7 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/QueuedPipeStringWriterTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Diagnostics; +using System.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; +using GVFS.Common.Tracing; +using GVFS.Tests.Should; +using Moq; +using NUnit.Framework; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class QueuedPipeStringWriterTests + { + [TestCase] + public async Task GoodPipe_WritesDataAndRaisesStateHealthy() + { + const string inputMessage = "FooBar"; + byte[] expectedData = + { + 0x46, 0x6F, 0x6F, 0x42, 0x61, 0x72, (byte)'\n', // "FooBar\n" + 0x46, 0x6F, 0x6F, 0x42, 0x61, 0x72, (byte)'\n', // "FooBar\n" + 0x46, 0x6F, 0x6F, 0x42, 0x61, 0x72, (byte)'\n', // "FooBar\n" + }; + + string pipeName = Guid.NewGuid().ToString("N"); + + // Capture event invocations + Mock eventSink = new Mock(); + + using (TestPipeReaderWorker pipeWorker = new TestPipeReaderWorker(pipeName, PipeTransmissionMode.Byte)) + { + // Start the pipe reader worker first and wait until the pipe server has been stood-up + // before starting the pipe writer/enqueuing messages because the writer does not wait + // for the pipe to be ready to accept (it returns and drops messages immediately). + pipeWorker.Start(); + pipeWorker.WaitForReadyToAccept(); + QueuedPipeStringWriter writer = new QueuedPipeStringWriter( + () => new NamedPipeClientStream(".", pipeName, PipeDirection.Out), + eventSink.Object); + writer.Start(); + Task stop = null; + try + { + bool queueOk1 = writer.TryEnqueue(inputMessage); + bool queueOk2 = writer.TryEnqueue(inputMessage); + bool queueOk3 = writer.TryEnqueue(inputMessage); + + pipeWorker.WaitForRecievedBytes(count: expectedData.Length); + stop = Task.Run(writer.Stop); + await stop.WaitAsync(TimeSpan.FromSeconds(5)); + pipeWorker.Stop(); + + queueOk1.ShouldBeTrue(); + queueOk2.ShouldBeTrue(); + queueOk3.ShouldBeTrue(); + + byte[] actualData = pipeWorker.GetReceivedDataSnapshot(); + CollectionAssert.AreEqual(expectedData, actualData); + + eventSink.Verify( + x => x.OnStateChanged(writer, QueuedPipeStringWriterState.Healthy, It.IsAny()), + Times.Once); + eventSink.Verify( + x => x.OnStateChanged(writer, QueuedPipeStringWriterState.Stopped, It.IsAny()), + Times.Once); + } + finally + { + try + { + pipeWorker.Stop(); + } + finally + { + try + { + if (stop != null) + { + await stop.WaitAsync(TimeSpan.FromSeconds(5)); + } + } + finally + { + await Task.Run(writer.Dispose).WaitAsync(TimeSpan.FromSeconds(5)); + } + } + } + } + } + + private class TestPipeReaderWorker : IDisposable + { + private readonly NamedPipeServerStream pipe; + private readonly CancellationTokenSource shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + private readonly AutoResetEvent receivedData = new AutoResetEvent(initialState: false); + private readonly TaskCompletionSource readyToAccept = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + private int bufferLength = 0; + private readonly byte[] buffer = new byte[16 * 1024]; + private readonly Lock bufferLock = new Lock(); + private Task worker; + private bool isRunning; + private bool isDisposed; + + public TestPipeReaderWorker(string pipeName, PipeTransmissionMode transmissionMode) + { + this.pipe = new NamedPipeServerStream( + pipeName, PipeDirection.In, 1, transmissionMode, PipeOptions.Asynchronous); + } + + public void Start() + { + if (!this.isRunning) + { + this.isRunning = true; + this.worker = this.ReadAsync(); + } + } + + public void WaitForReadyToAccept() + { + this.readyToAccept.Task.WaitAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + } + + public void WaitForRecievedBytes(int count) + { + if (!this.isRunning) + { + throw new InvalidOperationException("The pipe worker is stopped."); + } + + int length; + + Stopwatch elapsed = Stopwatch.StartNew(); + while (true) + { + // Since the buffer can only grow (and we only care about waiting for a minimum length), we + // don't care that the length could increase after we've released the lock. + lock (this.bufferLock) + { + length = this.bufferLength; + } + + if (length >= count) + { + break; + } + + TimeSpan remaining = TimeSpan.FromSeconds(5) - elapsed.Elapsed; + if (remaining <= TimeSpan.Zero || !this.receivedData.WaitOne(remaining)) + { + throw new TimeoutException("The pipe worker did not receive all expected bytes."); + } + } + } + + public byte[] GetReceivedDataSnapshot() + { + if (this.isRunning) + { + throw new InvalidOperationException("Should stop the test pipe worker first"); + } + + if (this.isDisposed) + { + throw new ObjectDisposedException($"{nameof(TestPipeReaderWorker)}"); + } + + byte[] snapshot; + + lock (this.bufferLock) + { + snapshot = new byte[this.bufferLength]; + Array.Copy(this.buffer, snapshot, snapshot.Length); + } + + return snapshot; + } + + public void Stop() + { + if (this.isRunning) + { + this.isRunning = false; + this.shutdown.Cancel(); + this.pipe.Dispose(); + try + { + this.worker.WaitAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) when (this.shutdown.IsCancellationRequested) + { + } + catch (ObjectDisposedException) when (this.shutdown.IsCancellationRequested) + { + } + } + } + + public void Dispose() + { + if (this.isDisposed) + { + return; + } + + try + { + this.Stop(); + } + finally + { + this.pipe.Dispose(); + this.shutdown.Dispose(); + this.receivedData.Dispose(); + this.isDisposed = true; + } + } + + private async Task ReadAsync() + { + this.readyToAccept.TrySetResult(true); + await this.pipe.WaitForConnectionAsync(this.shutdown.Token); + byte[] readBuffer = new byte[1024]; + while (true) + { + int count = await this.pipe.ReadAsync(readBuffer.AsMemory(), this.shutdown.Token); + if (count == 0) + { + return; + } + + lock (this.bufferLock) + { + Array.Copy(readBuffer, 0, this.buffer, this.bufferLength, count); + this.bufferLength += count; + } + + this.receivedData.Set(); + } + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/RetryConfigTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/RetryConfigTests.cs new file mode 100644 index 000000000..5c66866b9 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/RetryConfigTests.cs @@ -0,0 +1,165 @@ +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.Tests.Should; +using GVFS.UnitTests; +using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Mock.Git; +using NUnit.Framework; +using System; +using System.IO; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class RetryConfigTests + { + private IsolatedGitEnvironment environment; + private string gitPath; + + [SetUp] + public void SetUp() + { + this.gitPath = GitTestHelper.FindGitOnPath(); + this.gitPath.ShouldNotBeNull("Git must be available on PATH."); + this.environment = new IsolatedGitEnvironment(); + } + + [TearDown] + public void TearDown() + { + this.environment?.Dispose(); + this.environment = null; + } + + [TestCase] + public void UserConfigThatGitDoesNotReadIsReadWithGit() + { + string[] variables = { "HOMEDRIVE", "HOMEPATH", "USERPROFILE" }; + string[] originalValues = Array.ConvertAll(variables, Environment.GetEnvironmentVariable); + using (IsolatedLibGit2Config machineConfig = new IsolatedLibGit2Config()) + { + // Git reads only HOME\.gitconfig. libgit2 also checks USERPROFILE\.gitconfig. + string emptyHome = Path.Combine(machineConfig.Directory, "home"); + string profile = Path.Combine(machineConfig.Directory, "profile"); + Directory.CreateDirectory(emptyHome); + Directory.CreateDirectory(profile); + File.WriteAllText(Path.Combine(profile, ".gitconfig"), "[gvfs]\n\ttimeout-seconds = 1\n"); + try + { + Environment.SetEnvironmentVariable("HOME", emptyHome); + Environment.SetEnvironmentVariable("HOMEDRIVE", null); + Environment.SetEnvironmentVariable("HOMEPATH", null); + Environment.SetEnvironmentVariable("USERPROFILE", profile); + IsolatedLibGit2Config.ResetUserSearchPathsFromEnvironment(); + + string missingWorkingDirectory = Path.Combine(machineConfig.Directory, "missing"); + using MockTracer tracer = new MockTracer(); + RetryConfig.ConfigStringReader readWithLibGit2 = RetryConfig.CreateLibGit2Reader(tracer, missingWorkingDirectory); + GitProcess git = new GitProcess(this.gitPath, workingDirectoryRoot: null); + RetryConfig.ConfigStringReader readWithGit = (string name, out string value, out string error) => + git.GetFromConfig(name).TryParseAsString(out value, out error); + + readWithLibGit2("gvfs.timeout-seconds", out string _, out string libGit2Error).ShouldBeFalse(); + libGit2Error.ShouldContain("libgit2 reads the global config file", Path.Combine(profile, ".gitconfig")); + readWithGit("gvfs.timeout-seconds", out string gitValue, out string gitError).ShouldBeTrue(gitError); + gitValue.ShouldBeNull(); + + RetryConfig config = RetryConfig.LoadFromGitConfig(tracer, RetryConfig.WithGitFallback(tracer, readWithLibGit2, readWithGit)); + config.Timeout.ShouldEqual(TimeSpan.FromSeconds(RetryConfig.DefaultTimeoutSeconds)); + } + finally + { + for (int i = 0; i < variables.Length; ++i) + { + Environment.SetEnvironmentVariable(variables[i], originalValues[i]); + } + } + } + } + + [TestCase] + public void LibGit2AndGitReadTheSameRepositoryValues() + { + using (RetryConfigTestRepository repo = new RetryConfigTestRepository("[gvfs]\n\tmax-retries = 7\n\tmax-retries = 8\n\ttimeout-seconds = 31\n\tread-timeout-seconds = 32\n")) + { + using MockTracer tracer = new MockTracer(); + RetryConfig libGit2Config = RetryConfig.LoadFromGitConfig(tracer, RetryConfig.CreateLibGit2Reader(tracer, repo.WorkingDirectory)); + RetryConfig gitConfig = RetryConfig.LoadFromGitConfig(tracer, new GitProcess(this.gitPath, repo.WorkingDirectory)); + + libGit2Config.MaxRetries.ShouldEqual(8); + gitConfig.MaxRetries.ShouldEqual(8); + libGit2Config.Timeout.ShouldEqual(TimeSpan.FromSeconds(31)); + gitConfig.Timeout.ShouldEqual(TimeSpan.FromSeconds(31)); + libGit2Config.ReadTimeout.ShouldEqual(TimeSpan.FromSeconds(32)); + gitConfig.ReadTimeout.ShouldEqual(TimeSpan.FromSeconds(32)); + } + } + + [TestCase] + public void ConfigWithAnIncludeConditionLibGit2IgnoresIsReadWithGit() + { + using (RetryConfigTestRepository repo = new RetryConfigTestRepository(string.Empty)) + { + string includedPath = Path.Combine(repo.WorkingDirectory, "included.gitconfig"); + File.WriteAllText(includedPath, "[gvfs]\n\tmax-retries = 12\n"); + File.AppendAllText( + Path.Combine(repo.WorkingDirectory, ".git", "config"), + "[remote \"origin\"]\n\turl = https://example.com/repo\n" + + "[includeIf \"hasconfig:remote.*.url:https://example.com/**\"]\n\tpath = " + includedPath.Replace('\\', '/') + "\n"); + + using MockTracer tracer = new MockTracer(); + RetryConfig.ConfigStringReader readWithLibGit2 = RetryConfig.CreateLibGit2Reader(tracer, repo.WorkingDirectory); + GitProcess git = new GitProcess(this.gitPath, repo.WorkingDirectory); + RetryConfig.ConfigStringReader readWithGit = (string name, out string value, out string error) => + git.GetFromConfig(name).TryParseAsString(out value, out error); + + readWithLibGit2("gvfs.max-retries", out string _, out string libGit2Error).ShouldBeFalse(); + libGit2Error.ShouldContain("libgit2 does not evaluate like git: includeif.hasconfig:remote.*.url:https://example.com/**.path"); + + RetryConfig config = RetryConfig.LoadFromGitConfig(tracer, RetryConfig.WithGitFallback(tracer, readWithLibGit2, readWithGit)); + config.MaxRetries.ShouldEqual(12); + tracer.RelatedWarningEvents.Count.ShouldEqual(0); + } + } + + // Each layout makes libgit2 evaluate the include differently from Git. + [TestCase("onbranch:main", false)] + [TestCase("onbranch:main", true)] + [TestCase("gitdir:", false)] + [TestCase("gitdir/i:", false)] + public void IncludeLayoutsLibGit2EvaluatesDifferentlyAreReadWithGit(string condition, bool headOnBranch) + { + using (RetryConfigTestRepository repo = new RetryConfigTestRepository(string.Empty)) + { + if (!headOnBranch) + { + File.WriteAllText(Path.Combine(repo.WorkingDirectory, ".git", "HEAD"), new string('1', 40) + "\n"); + } + + string gitDir = Path.Combine(repo.WorkingDirectory, ".git").Replace('\\', '/') + "/"; + string includedPath = Path.Combine(repo.WorkingDirectory, "included.gitconfig"); + File.WriteAllText(includedPath, "[gvfs]\n\tmax-retries = 13\n"); + File.AppendAllText( + Path.Combine(repo.WorkingDirectory, ".git", "config"), + "[includeIf \"" + condition.Replace("", gitDir.Substring(2)) + "\"]\n\tpath = " + + includedPath.Replace('\\', '/') + "\n"); + + using MockTracer tracer = new MockTracer(); + RetryConfig.ConfigStringReader readWithLibGit2 = RetryConfig.CreateLibGit2Reader(tracer, repo.WorkingDirectory); + GitProcess git = new GitProcess(this.gitPath, repo.WorkingDirectory); + RetryConfig.ConfigStringReader readWithGit = (string name, out string value, out string error) => + git.GetFromConfig(name).TryParseAsString(out value, out error); + + readWithLibGit2("gvfs.max-retries", out string _, out string libGit2Error).ShouldBeFalse(); + libGit2Error.ShouldContain("libgit2 does not evaluate like git"); + + readWithGit("gvfs.max-retries", out string gitValue, out string gitError).ShouldBeTrue(gitError); + RetryConfig config = RetryConfig.LoadFromGitConfig(tracer, RetryConfig.WithGitFallback(tracer, readWithLibGit2, readWithGit)); + config.MaxRetries.ShouldEqual(gitValue == null ? RetryConfig.DefaultMaxRetries : int.Parse(gitValue)); + config.MaxRetries.ShouldEqual(headOnBranch && condition.StartsWith("onbranch:") ? 13 : RetryConfig.DefaultMaxRetries); + tracer.RelatedWarningEvents.Count.ShouldEqual(0); + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/TelemetryDaemonEventListenerTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/TelemetryDaemonEventListenerTests.cs new file mode 100644 index 000000000..ef170027a --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/TelemetryDaemonEventListenerTests.cs @@ -0,0 +1,455 @@ +using System; +using System.Linq; +using System.Text.Json; +using GVFS.Common.Tracing; +using NUnit.Framework; +using GVFS.Common; +using System.IO; +using System.IO.Pipes; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GVFS.UnitTests.Mock.Common.Tracing; +using GVFS.CommandLine; +using GVFS.Common.FileSystem; +using GVFS.Common.NamedPipes; +using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Category; +using GVFS.UnitTests.Tracing; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class TelemetryDaemonEventListenerTests + { + [TestCase(false)] + [TestCase(true)] + [Category(CategoryConstants.ExceptionExpected)] + public async Task HydrationMetadataReachesTheCollectorWithNullsAndNestedCounts(bool cacheHit) + { + string pipeName = "GVFS.FunctionalTests.Hydration." + Guid.NewGuid().ToString("N"); + using (CancellationTokenSource deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10))) + using (NamedPipeServerStream server = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous)) + { + Task received = ReadTelemetryAsync(server, deadline.Token); + try + { + using (JsonTracer tracer = new JsonTracer("test", "health", disableTelemetry: true)) + using (StringWriter output = new StringWriter()) + { + tracer.AddEventListener(new TelemetryDaemonEventListener("test", "test-enlistment", "test-mount", pipeName, eventSink: null)); + CollectorHealthVerb verb = new CollectorHealthVerb { StatusOnly = true, CacheHit = cacheHit, Output = output }; + verb.ExecuteWithCompletion(null, tracer, Guid.NewGuid()); + } + + string[] lines = (await received.WaitAsync(deadline.Token)).Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.AreEqual(cacheHit ? 1 : 2, lines.Length); + TelemetryDaemonEventListener.PipeMessage health = TelemetryDaemonEventListener.PipeMessage.FromJson(lines.Last()); + Assert.AreEqual("health", health.EventName); + Assert.AreEqual("test-enlistment", health.Payload.EnlistmentId); + using (JsonDocument json = JsonDocument.Parse(health.Payload.Json)) + { + JsonElement root = json.RootElement; + Assert.AreEqual(cacheHit ? "Success" : "Failed", root.GetProperty("Outcome").GetString()); + if (cacheHit) + { + Assert.AreEqual(0, root.GetProperty("Cache").GetProperty("Counts").GetProperty("ModifiedFileCount").GetInt32()); + Assert.IsFalse(root.TryGetProperty("Summary", out _)); + } + else + { + JsonElement summary = root.GetProperty("Summary"); + Assert.AreEqual(100, summary.GetProperty("TotalFileCount").GetInt32()); + Assert.AreEqual(JsonValueKind.Null, summary.GetProperty("ModifiedFileCount").ValueKind); + Assert.AreEqual(JsonValueKind.Null, summary.GetProperty("TotalFolderCount").ValueKind); + Assert.AreEqual("Failed", summary.GetProperty("Outcome").GetString()); + TelemetryDaemonEventListener.PipeMessage duration = TelemetryDaemonEventListener.PipeMessage.FromJson(lines[0]); + Assert.AreEqual("HydrationSummaryDuration", duration.EventName); + using (JsonDocument calculation = JsonDocument.Parse(duration.Payload.Json)) + { + Assert.AreEqual(summary.GetRawText(), calculation.RootElement.GetRawText()); + Assert.AreEqual(root.GetProperty("CommandId").GetString(), summary.GetProperty("CommandId").GetString()); + } + } + } + + Assert.That(string.Join("\n", lines), Does.Not.Contain("private")); + } + finally + { + deadline.Cancel(); + try + { + await received.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + } + } + } + } + + private sealed class CollectorHealthVerb : HealthVerb + { + public bool CacheHit { get; set; } + + internal override CacheLookupResult TryGetCachedHydrationStatus(GVFSEnlistment enlistment) + { + return ParseCachedResponse(this.CacheHit + ? new NamedPipeMessages.Message("S", "0,0,0,0,100,10") + : new NamedPipeMessages.Message("NA", null)); + } + + internal override EnlistmentHydrationSummary CreateHydrationSummary(GVFSEnlistment enlistment, ITracer tracer, HydrationSummaryDiagnostics diagnostics) + { + return EnlistmentHydrationSummary.CreateSummary(new MockGVFSEnlistment(), new HeaderFileSystem(), tracer, + () => 10, new FailingPlaceholderPaths(), diagnostics: diagnostics); + } + } + + private sealed class HeaderFileSystem : PhysicalFileSystem + { + public override Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare shareMode, FileOptions options, bool flushesToDisk) + { + return new MemoryStream(new byte[] { 68, 73, 82, 67, 0, 0, 0, 4, 0, 0, 0, 100 }); + } + } + + private sealed class FailingPlaceholderPaths : EnlistmentPathData + { + public override void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment, HydrationSummaryDiagnostics diagnostics = null) + { + throw new IOException("mock:\\private\\database"); + } + } + + private static async Task ReadTelemetryAsync(NamedPipeServerStream server, CancellationToken cancellationToken) + { + await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); + using (StreamReader reader = new StreamReader(server)) + { + return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + } + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public async Task RemoteBoundsSurviveARealPipeReconnectAndChildActivities() + { + string pipeName = "gvfs-telemetry-test-" + Guid.NewGuid().ToString("N"); + TelemetryAggregatorTests.TestClock clock = new TelemetryAggregatorTests.TestClock(); + using (CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15))) + using (ListenerFailureSink sink = new ListenerFailureSink()) + using (JsonTracer tracer = new JsonTracer("Microsoft-GVFS-Test", "Root", disableTelemetry: true)) + using (MockListener local = new MockListener(EventLevel.Verbose, Keywords.Any)) + using (TelemetryDaemonEventListener remote = new TelemetryDaemonEventListener("Microsoft.Git.GVFS", "synthetic-enlistment", "synthetic-mount", pipeName, sink, clock)) + { + tracer.AddEventListener(local); + tracer.AddEventListener(remote); + using (NamedPipeServerStream server = CreateServer(pipeName)) + using (StreamReader reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) + { + using PendingPipeConnection connection = new PendingPipeConnection(server, timeout.Token); + TraceRejection(tracer); + await connection.Connected; + Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("NetworkError")); + + using (ITracer child = tracer.StartActivity("Child", EventLevel.Informational)) + { + TraceRejection(child); + } + + tracer.RelatedEvent(EventLevel.Informational, "BeforeDisconnect", null, Keywords.Telemetry); + Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("BeforeDisconnect")); + } + + tracer.RelatedEvent(EventLevel.Informational, "DetectBrokenPipe", null, Keywords.Telemetry); + Assert.That(sink.Failed.Wait(TimeSpan.FromSeconds(5)), Is.True); + + using (NamedPipeServerStream server = CreateServer(pipeName)) + using (StreamReader reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) + { + using PendingPipeConnection connection = new PendingPipeConnection(server, timeout.Token); + tracer.RelatedEvent(EventLevel.Informational, "Reconnected", null, Keywords.Telemetry); + await connection.Connected; + Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("Reconnected")); + TraceRejection(tracer); + clock.Advance(TimeSpan.FromSeconds(60)); + tracer.RelatedEvent(EventLevel.Informational, "Flush", null, Keywords.Telemetry); + TelemetryDaemonEventListener.PipeMessage summary = await ReadMessage(reader, timeout.Token); + Assert.That(summary.EventName, Is.EqualTo("TelemetrySummary")); + Assert.That(summary.Payload.EnlistmentId, Is.EqualTo("synthetic-enlistment")); + Assert.That(summary.Payload.MountId, Is.EqualTo("synthetic-mount")); + using (JsonDocument payload = JsonDocument.Parse(summary.Payload.Json)) + { + Assert.That(payload.RootElement.GetProperty("ObservedCount").GetInt64(), Is.EqualTo(3)); + Assert.That(payload.RootElement.GetProperty("SampleCount").GetInt64(), Is.EqualTo(1)); + Assert.That(payload.RootElement.GetProperty("SuppressedCount").GetInt64(), Is.EqualTo(2)); + } + + Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("Flush")); + Assert.That(local.EventNamesRead.Count(name => name == "NetworkError"), Is.EqualTo(3)); + remote.Dispose(); + } + } + } + + [TestCase] + public async Task DisposalDeliversPendingSummaryAndPreservesLegacyResult() + { + string pipeName = "gvfs-telemetry-test-" + Guid.NewGuid().ToString("N"); + using (CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15))) + using (NamedPipeServerStream server = CreateServer(pipeName)) + using (StreamReader reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) + using (JsonTracer tracer = new JsonTracer("Microsoft-GVFS-Test", "Root", disableTelemetry: true)) + using (TelemetryDaemonEventListener remote = new TelemetryDaemonEventListener("Microsoft.Git.GVFS", "synthetic-enlistment", "synthetic-mount", pipeName, null)) + { + tracer.AddEventListener(remote); + using PendingPipeConnection connection = new PendingPipeConnection(server, timeout.Token); + for (int i = 0; i < 2; ++i) + { + EventMetadata metadata = new EventMetadata { { "result", "0xC0000467" } }; + metadata.SetTelemetryAggregation("StartDirectoryEnumerationAsyncHandler", TelemetryFailureCategory.SizesUnavailable, "Failed"); + tracer.RelatedError(metadata, "Synthetic size unavailable"); + } + + await connection.Connected; + TelemetryDaemonEventListener.PipeMessage sample = await ReadMessage(reader, timeout.Token); + using (JsonDocument payload = JsonDocument.Parse(sample.Payload.Json)) + { + Assert.That(payload.RootElement.GetProperty("result").GetString(), Is.EqualTo("0xC0000467")); + Assert.That(payload.RootElement.GetProperty("AggregationResult").GetString(), Is.EqualTo("Failed")); + Assert.That(payload.RootElement.GetProperty("TelemetrySchemaVersion").GetInt32(), Is.EqualTo(3)); + Assert.That(payload.RootElement.GetProperty("TelemetryAggregation").GetString(), Is.EqualTo("Sample")); + Assert.That(payload.RootElement.TryGetProperty("Result", out _), Is.False); + } + + Task disposal = Task.Run(remote.Dispose); + try + { + TelemetryDaemonEventListener.PipeMessage summary = await ReadMessage(reader, timeout.Token); + Assert.That(summary.EventName, Is.EqualTo("TelemetrySummary")); + using (JsonDocument payload = JsonDocument.Parse(summary.Payload.Json)) + { + Assert.That(payload.RootElement.GetProperty("ObservedCount").GetInt64(), Is.EqualTo(2)); + Assert.That(payload.RootElement.GetProperty("SampleCount").GetInt64(), Is.EqualTo(1)); + Assert.That(payload.RootElement.GetProperty("SuppressedCount").GetInt64(), Is.EqualTo(1)); + Assert.That(payload.RootElement.GetProperty("AggregationResult").GetString(), Is.EqualTo("Failed")); + } + + await disposal.WaitAsync(timeout.Token); + } + finally + { + server.Dispose(); + await disposal.WaitAsync(TimeSpan.FromSeconds(5)); + } + + remote.Dispose(); + } + } + + [TestCase] + public async Task CrossCommandSummaryOmitsSessionButSamplesRetainIt() + { + string pipeName = "gvfs-telemetry-test-" + Guid.NewGuid().ToString("N"); + TelemetryAggregatorTests.TestClock clock = new TelemetryAggregatorTests.TestClock(); + using (CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15))) + using (NamedPipeServerStream server = CreateServer(pipeName)) + using (StreamReader reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) + using (JsonTracer tracer = new JsonTracer("Microsoft-GVFS-Test", "Root", disableTelemetry: true)) + using (TelemetryDaemonEventListener remote = new TelemetryDaemonEventListener("Microsoft.Git.GVFS", "synthetic-enlistment", "synthetic-mount", pipeName, null, clock)) + { + tracer.AddEventListener(remote); + using PendingPipeConnection connection = new PendingPipeConnection(server, timeout.Token); + tracer.SetGitCommandSessionId("session-A"); + TraceRejection(tracer); + await connection.Connected; + Assert.That((await ReadMessage(reader, timeout.Token)).Payload.GitCommandSessionId, Is.EqualTo("session-A")); + + tracer.SetGitCommandSessionId("session-B"); + TraceRejection(tracer); + tracer.SetGitCommandSessionId("session-C"); + clock.Advance(TimeSpan.FromSeconds(60)); + tracer.RelatedEvent(EventLevel.Informational, "HealthyEvent", null, Keywords.Telemetry); + string summaryJson = await reader.ReadLineAsync(timeout.Token); + using (JsonDocument envelope = JsonDocument.Parse(summaryJson)) + { + Assert.That(envelope.RootElement.GetProperty("payload").TryGetProperty("gitCommandSessionId", out _), Is.False); + } + + TelemetryDaemonEventListener.PipeMessage summary = TelemetryDaemonEventListener.PipeMessage.FromJson(summaryJson); + Assert.That(summary.EventName, Is.EqualTo("TelemetrySummary")); + Assert.That(summary.Payload.EnlistmentId, Is.EqualTo("synthetic-enlistment")); + Assert.That(summary.Payload.MountId, Is.EqualTo("synthetic-mount")); + using (JsonDocument payload = JsonDocument.Parse(summary.Payload.Json)) + { + Assert.That(payload.RootElement.GetProperty("ObservedCount").GetInt64(), Is.EqualTo(2)); + Assert.That(payload.RootElement.GetProperty("SampleCount").GetInt64(), Is.EqualTo(1)); + Assert.That(payload.RootElement.GetProperty("SuppressedCount").GetInt64(), Is.EqualTo(1)); + } + + TelemetryDaemonEventListener.PipeMessage healthy = await ReadMessage(reader, timeout.Token); + Assert.That(healthy.EventName, Is.EqualTo("HealthyEvent")); + Assert.That(healthy.Payload.GitCommandSessionId, Is.EqualTo("session-C")); + } + } + + [TestCase] + public async Task BreakerRecoverySampleSurvivesInitialAcceptingAndRepeatedTransitions() + { + RetryCircuitBreaker.Reset(); + try + { + string pipeName = "gvfs-telemetry-test-" + Guid.NewGuid().ToString("N"); + TelemetryAggregatorTests.TestClock clock = new TelemetryAggregatorTests.TestClock(); + using (CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15))) + using (NamedPipeServerStream server = CreateServer(pipeName)) + using (StreamReader reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) + using (JsonTracer tracer = new JsonTracer("Microsoft-GVFS-Test", "Root", disableTelemetry: true)) + using (TelemetryDaemonEventListener remote = new TelemetryDaemonEventListener("Microsoft.Git.GVFS", "synthetic-enlistment", "synthetic-mount", pipeName, null, clock)) + { + tracer.AddEventListener(remote); + using PendingPipeConnection connection = new PendingPipeConnection(server, timeout.Token); + RetryWrapper wrapper = new RetryWrapper(1, CancellationToken.None, tracer: tracer); + wrapper.Invoke(attempt => new RetryWrapper.CallbackResult(true)); + wrapper.Invoke(attempt => + { + RetryCircuitBreaker.Configure(1, 30000); + return new RetryWrapper.CallbackResult(new IOException("Synthetic failure"), shouldRetry: true); + }); + RetryCircuitBreaker.Configure(1, 0); + wrapper.Invoke(attempt => new RetryWrapper.CallbackResult(true)); + await connection.Connected; + + string[] previousStates = { "Unknown", "Accepting", "Rejecting", "CooldownElapsed" }; + string[] states = { "Accepting", "Rejecting", "CooldownElapsed", "Accepting" }; + for (int i = 0; i < states.Length; ++i) + { + TelemetryDaemonEventListener.PipeMessage sample = await ReadMessage(reader, timeout.Token); + Assert.That(sample.EventName, Is.EqualTo("RetryCircuitBreakerState")); + using (JsonDocument payload = JsonDocument.Parse(sample.Payload.Json)) + { + Assert.That(payload.RootElement.GetProperty("PreviousState").GetString(), Is.EqualTo(previousStates[i])); + Assert.That(payload.RootElement.GetProperty("State").GetString(), Is.EqualTo(states[i])); + Assert.That(payload.RootElement.GetProperty("AggregationResult").GetString(), Is.EqualTo(states[i])); + } + } + + clock.Advance(TimeSpan.FromSeconds(10)); + wrapper.Invoke(attempt => + { + RetryCircuitBreaker.Configure(1, 30000); + return new RetryWrapper.CallbackResult(new IOException("Synthetic failure"), shouldRetry: true); + }); + RetryCircuitBreaker.Configure(1, 0); + clock.Advance(TimeSpan.FromSeconds(2)); + wrapper.Invoke(attempt => + { + clock.Advance(TimeSpan.FromSeconds(1)); + return new RetryWrapper.CallbackResult(true); + }); + tracer.RelatedEvent(EventLevel.Informational, "RepeatedTransitionsComplete", null, Keywords.Telemetry); + Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("RepeatedTransitionsComplete")); + + clock.Advance(TimeSpan.FromSeconds(60)); + tracer.RelatedEvent(EventLevel.Informational, "Flush", null, Keywords.Telemetry); + for (int i = 0; i < states.Length; ++i) + { + TelemetryDaemonEventListener.PipeMessage summary = await ReadMessage(reader, timeout.Token); + Assert.That(summary.EventName, Is.EqualTo("TelemetrySummary")); + using (JsonDocument payload = JsonDocument.Parse(summary.Payload.Json)) + { + Assert.That(payload.RootElement.GetProperty("PreviousState").GetString(), Is.EqualTo(previousStates[i])); + Assert.That(payload.RootElement.GetProperty("AggregationResult").GetString(), Is.EqualTo(states[i])); + Assert.That(payload.RootElement.GetProperty("ObservedCount").GetInt64(), Is.EqualTo(i == 0 ? 1 : 2)); + Assert.That(payload.RootElement.GetProperty("SampleCount").GetInt64(), Is.EqualTo(1)); + Assert.That(payload.RootElement.GetProperty("SuppressedCount").GetInt64(), Is.EqualTo(i == 0 ? 0 : 1)); + int[] lastSeconds = { 0, 10, 12, 13 }; + Assert.That(payload.RootElement.GetProperty("LastObservedAtUtc").GetDateTime(), Is.EqualTo(clock.UtcStart.AddSeconds(lastSeconds[i]).UtcDateTime)); + } + } + + Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("Flush")); + } + } + finally + { + RetryCircuitBreaker.Reset(); + } + } + + private static NamedPipeServerStream CreateServer(string pipeName) + { + return new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + } + + private sealed class PendingPipeConnection : IDisposable + { + private readonly NamedPipeServerStream server; + + public PendingPipeConnection(NamedPipeServerStream server, CancellationToken cancellationToken) + { + this.server = server; + this.Connected = server.WaitForConnectionAsync(cancellationToken); + } + + public Task Connected { get; } + + public void Dispose() + { + bool aborted = !this.Connected.IsCompleted; + if (aborted) + { + this.server.Dispose(); + } + + try + { + this.Connected.WaitAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) when (aborted || this.Connected.IsCanceled) + { + } + catch (ObjectDisposedException) when (aborted) + { + } + catch (IOException) when (aborted) + { + } + } + } + + private static async Task ReadMessage(StreamReader reader, CancellationToken token) + { + return TelemetryDaemonEventListener.PipeMessage.FromJson(await reader.ReadLineAsync(token)); + } + + private static void TraceRejection(ITracer tracer) + { + EventMetadata metadata = new EventMetadata(); + metadata.SetTelemetryAggregation("Download", TelemetryFailureCategory.CircuitBreakerOpen, "Rejected"); + tracer.RelatedError(metadata, "Local rejection", Keywords.Network); + } + + private sealed class ListenerFailureSink : IEventListenerEventSink, IDisposable + { + public ManualResetEventSlim Failed { get; } = new ManualResetEventSlim(); + + public void OnListenerFailure(EventListener listener, string errorMessage) + { + this.Failed.Set(); + } + + public void OnListenerRecovery(EventListener listener) + { + } + + public void Dispose() + { + this.Failed.Dispose(); + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WaitUntilMountedFailureReportingTests.cs similarity index 65% rename from GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs rename to GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WaitUntilMountedFailureReportingTests.cs index 6f28e51b8..f81dc6a5f 100644 --- a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WaitUntilMountedFailureReportingTests.cs @@ -4,12 +4,15 @@ using GVFS.UnitTests.Mock.Common; using NUnit.Framework; using System; +using System.IO; using System.IO.Pipes; +using System.Threading; using System.Threading.Tasks; -namespace GVFS.UnitTests.Common +namespace GVFS.FunctionalTests.Tests.LocalComponents { [TestFixture] + [NonParallelizable] public class WaitUntilMountedFailureReportingTests { private const string EnlistmentRoot = "mock:\\fake\\root"; @@ -38,12 +41,6 @@ public void FallsBackToGenericMessageWhenNoMountErrorIsSent() /// Stands in for GVFS.Mount: answers a single GetStatus request with MountFailed, /// then returns the error message WaitUntilMounted produced. /// - /// - /// Uses a raw rather than - /// , because the latter goes through - /// GVFSPlatform.CreatePipeByName, which the unit-test mock platform does not - /// support. - /// private static string RunAgainstMountFailedServer(string mountError) { string pipeName = "GVFS_test_mount_failed_" + Guid.NewGuid().ToString("N"); @@ -57,44 +54,58 @@ private static string RunAgainstMountFailedServer(string mountError) string responseJson = response.ToJson(); + using (CancellationTokenSource deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10))) using (NamedPipeServerStream serverStream = new NamedPipeServerStream( pipeName, PipeDirection.InOut, - maxNumberOfServerInstances: 1)) + maxNumberOfServerInstances: 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous)) { - Task serverTask = Task.Run(() => + using CancellationTokenRegistration abort = deadline.Token.Register(() => serverStream.Dispose()); + Task serverTask = Task.Run(async () => { - serverStream.WaitForConnection(); + await serverStream.WaitForConnectionAsync(deadline.Token); NamedPipeStreamReader reader = new NamedPipeStreamReader(serverStream); NamedPipeStreamWriter writer = new NamedPipeStreamWriter(serverStream); - // WaitUntilMounted sends GetStatus and stops on the first - // MountFailed response, so one exchange is enough. reader.ReadMessage(); writer.WriteMessage(responseJson); }); try { + using MockTracer tracer = new MockTracer(); string errorMessage; bool result = GVFSEnlistment.WaitUntilMounted( - new MockTracer(), + tracer, pipeName, EnlistmentRoot, unattended: false, out errorMessage); result.ShouldBeFalse(); + serverTask.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue( + "The stand-in mount server must answer and complete."); return errorMessage; } finally { - // Surfaces a hung server task instead of silently disposing the stream - // out from under it, which would fault an unobserved task and hide the - // real cause of a failure. - serverTask.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue( - "The stand-in mount server should have answered and completed."); + deadline.Cancel(); + try + { + serverTask.WaitAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + } + catch (ObjectDisposedException) when (deadline.IsCancellationRequested) + { + } + catch (IOException) when (deadline.IsCancellationRequested) + { + } } } } diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WaitUntilMountedProcessTrackingTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WaitUntilMountedProcessTrackingTests.cs new file mode 100644 index 000000000..a052124a7 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WaitUntilMountedProcessTrackingTests.cs @@ -0,0 +1,48 @@ +using GVFS.Common; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System; +using System.Threading; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class WaitUntilMountedProcessTrackingTests + { + [TestCase] + public void DetectsLateProcessExitWhilePipeNeverAppears() + { + const int FakePid = 24680; + const int FakeExitCode = -1; + int snapshotCallCount = 0; + Func snapshot = () => + { + int count = Interlocked.Increment(ref snapshotCallCount); + bool exited = count >= 2; + return new GVFSEnlistment.MountProcessSnapshot( + FakePid, + hasExited: exited, + exitCode: exited ? FakeExitCode : 0); + }; + + using MockTracer tracer = new MockTracer(); + string errorMessage; + DateTime start = DateTime.UtcNow; + bool result = GVFSEnlistment.WaitUntilMounted( + tracer, + pipeName: "GVFS_no_such_pipe_for_test_" + Guid.NewGuid().ToString("N"), + enlistmentRoot: "C:\\fake\\root", + unattended: false, + mountProcessStatus: snapshot, + out errorMessage); + TimeSpan elapsed = DateTime.UtcNow - start; + + result.ShouldBeFalse(); + errorMessage.ShouldContain(FakePid.ToString()); + errorMessage.ShouldContain(FakeExitCode.ToString()); + Assert.That(elapsed.TotalSeconds, Is.LessThan(10), "WaitUntilMounted must detect late process exit within a few connect retries"); + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WorktreeRequiredConfigTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WorktreeRequiredConfigTests.cs new file mode 100644 index 000000000..d0b695401 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WorktreeRequiredConfigTests.cs @@ -0,0 +1,277 @@ +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.FunctionalTests.Tools; +using GVFS.Tests.Should; +using GVFS.UnitTests; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; +using GitProcess = GVFS.FunctionalTests.Tools.GitProcess; +using ProcessResult = GVFS.FunctionalTests.Tools.ProcessResult; + +namespace GVFS.FunctionalTests.Tests.LocalComponents +{ + [TestFixture] + [NonParallelizable] + public class WorktreeRequiredConfigTests + { + private string testRoot; + private string primaryRoot; + private string sharedGitDir; + private string worktreePath; + private string worktreeGitDir; + private string unattendedValue; + private IsolatedGitEnvironment environment; + + [SetUp] + public void SetUp() + { + this.environment = new IsolatedGitEnvironment(); + // The required settings depend on GVFS_UNATTENDED, and git config file + // arguments must handle a path with a space. + this.unattendedValue = Environment.GetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable); + Environment.SetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable, null); + + this.testRoot = Path.Combine(Path.GetTempPath(), "GVFS WT Enl Tests_" + Path.GetRandomFileName()); + this.primaryRoot = Path.Combine(this.testRoot, "enlistment"); + string primarySrc = Path.Combine(this.primaryRoot, "src"); + this.sharedGitDir = Path.Combine(primarySrc, ".git"); + this.worktreePath = Path.Combine(this.testRoot, "agent-wt-1"); + this.worktreeGitDir = Path.Combine(this.sharedGitDir, "worktrees", "agent-wt-1"); + + Directory.CreateDirectory(this.sharedGitDir); + Directory.CreateDirectory(Path.Combine(this.sharedGitDir, "objects")); + Directory.CreateDirectory(Path.Combine(this.sharedGitDir, "refs")); + Directory.CreateDirectory(this.worktreeGitDir); + Directory.CreateDirectory(this.worktreePath); + Directory.CreateDirectory(Path.Combine(this.primaryRoot, ".gvfs")); + + File.WriteAllText( + Path.Combine(this.sharedGitDir, "config"), + "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = https://mock/repo\n"); + File.WriteAllText( + Path.Combine(this.sharedGitDir, "HEAD"), + "ref: refs/heads/main\n"); + File.WriteAllText( + Path.Combine(this.worktreePath, ".git"), + "gitdir: " + this.worktreeGitDir); + File.WriteAllText( + Path.Combine(this.worktreeGitDir, "commondir"), + "../.."); + File.WriteAllText( + Path.Combine(this.worktreeGitDir, "HEAD"), + "ref: refs/heads/agent-wt-1\n"); + File.WriteAllText( + Path.Combine(this.worktreeGitDir, "gitdir"), + Path.Combine(this.worktreePath, ".git")); + } + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable, this.unattendedValue); + try + { + if (Directory.Exists(this.testRoot)) + { + Directory.Delete(this.testRoot, recursive: true); + } + } + finally + { + this.environment.Dispose(); + } + } + + private GVFSEnlistment CreateWorktreeEnlistment(string gitBinPath) + { + return GVFSEnlistment.CreateForWorktree( + this.primaryRoot, gitBinPath, authentication: null, + GVFSEnlistment.TryGetWorktreeInfo(this.worktreePath), + repoUrl: "https://mock/repo"); + } + + [TestCase] + public void RequiredSettingsEnableWorktreeConfigAndWritePerWorktreeValues() + { + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); + + string error; + RequiredGitConfig.TrySetRequiredSettings( + enlistment, + new Dictionary + { + { "status.deserializePath", "C:/worktree/status.dat" }, + { "core.autocrlf", "false" }, + }, + out error).ShouldBeTrue(error); + + this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual("true"); + this.GetConfigValues(this.SharedConfigPath, "core.autocrlf").ShouldEqual("false"); + this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual(this.PrimaryStatusCachePath); + this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual("C:/worktree/status.dat"); + } + + [TestCase] + public void RequiredSettingsRepairCopiedWorktreeValueAndSharedValueOfPrimary() + { + this.AppendToSharedConfig("[status]\n\tdeserializePath = C:/sibling/status.dat\n"); + File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/sibling/status.dat\n"); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); + + string error; + RequiredGitConfig.TrySetRequiredSettings( + enlistment, + new Dictionary { { "status.deserializePath", "C:/worktree/status.dat" } }, + out error).ShouldBeTrue(error); + + this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual("C:/worktree/status.dat"); + this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual(this.PrimaryStatusCachePath); + } + + [TestCase] + public void RequiredSettingsDisableNullPerWorktreeValueInWorktreeConfig() + { + this.AppendToSharedConfig("[extensions]\n\tworktreeConfig = true\n[status]\n\tdeserializePath = C:/primary/status.dat\n"); + File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/worktree/status.dat\n"); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); + + string error; + RequiredGitConfig.TrySetRequiredSettings( + enlistment, + new Dictionary { { "status.deserializePath", null } }, + out error).ShouldBeTrue(error); + + this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual(string.Empty); + File.ReadAllText(this.WorktreeConfigPath).ShouldContain("deserializePath"); + this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual("C:/primary/status.dat"); + } + + [TestCase] + public void RequiredSettingsRemoveRequiredOverridesFromWorktreeConfig() + { + File.WriteAllText(this.WorktreeConfigPath, "[core]\n\tautocrlf = true\n[gvfs]\n\tmount-id = worktreemountid\n"); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); + + string error; + RequiredGitConfig.TrySetRequiredSettings( + enlistment, + new Dictionary { { "core.autocrlf", "false" } }, + out error).ShouldBeTrue(error); + + this.GetConfigValues(this.WorktreeConfigPath, "core.autocrlf").ShouldEqual(string.Empty); + this.GetConfigValues(this.WorktreeConfigPath, "gvfs.mount-id").ShouldEqual("worktreemountid"); + this.GetConfigValues(this.SharedConfigPath, "core.autocrlf").ShouldEqual("false"); + } + + [TestCase] + public void RequiredSettingsReplaceDuplicateWorktreeConfigExtensionValues() + { + this.AppendToSharedConfig("[extensions]\n\tworktreeConfig = true\n\tworktreeConfig = false\n"); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); + + string error; + RequiredGitConfig.TrySetRequiredSettings(enlistment, new Dictionary(), out error).ShouldBeTrue(error); + + this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual("true"); + } + + [TestCase] + public void PrimaryRequiredSettingsEnableWorktreeConfigAndRemoveOverridesWhenLinkedWorktreeHasConfig() + { + File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/worktree/status.dat\n"); + string primaryWorktreeConfigPath = Path.Combine(this.sharedGitDir, "config.worktree"); + File.WriteAllText(primaryWorktreeConfigPath, "[core]\n\tautocrlf = true\n"); + GVFSEnlistment primary = new GVFSEnlistment(this.primaryRoot, "https://mock/repo", GetGitBinPath(), authentication: null); + + string error; + RequiredGitConfig.TrySetRequiredSettings( + primary, + new Dictionary { { "core.autocrlf", "false" } }, + out error).ShouldBeTrue(error); + + this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual("true"); + this.GetConfigValues(primaryWorktreeConfigPath, "core.autocrlf").ShouldEqual(string.Empty); + this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual("C:/worktree/status.dat"); + } + + [TestCase] + public void PrimaryRequiredSettingsDoNotEnableWorktreeConfigWithoutLinkedWorktreeConfig() + { + GVFSEnlistment primary = new GVFSEnlistment(this.primaryRoot, "https://mock/repo", GetGitBinPath(), authentication: null); + + string error; + RequiredGitConfig.TrySetRequiredSettings( + primary, + new Dictionary { { "status.deserializePath", "C:/primary/status.dat" } }, + out error).ShouldBeTrue(error); + + this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual(string.Empty); + this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual("C:/primary/status.dat"); + File.Exists(Path.Combine(this.sharedGitDir, "config.worktree")).ShouldBeFalse(); + } + + [TestCase] + public void RequiredSettingsWriteNothingWhenAppliedAgain() + { + GVFSEnlistment primary = new GVFSEnlistment(this.primaryRoot, "https://mock/repo", GetGitBinPath(), authentication: null); + GVFSEnlistment worktree = this.CreateWorktreeEnlistment(GetGitBinPath()); + + string error; + RequiredGitConfig.TrySetRequiredSettings(primary, out error).ShouldBeTrue(error); + RequiredGitConfig.TrySetRequiredSettings(worktree, out error).ShouldBeTrue(error); + DateTime sharedConfigWriteTime = File.GetLastWriteTimeUtc(this.SharedConfigPath); + DateTime worktreeConfigWriteTime = File.GetLastWriteTimeUtc(this.WorktreeConfigPath); + + RequiredGitConfig.TrySetRequiredSettings(primary, out error).ShouldBeTrue(error); + RequiredGitConfig.TrySetRequiredSettings(worktree, out error).ShouldBeTrue(error); + + File.GetLastWriteTimeUtc(this.SharedConfigPath).ShouldEqual(sharedConfigWriteTime, "A second application wrote the shared config"); + File.GetLastWriteTimeUtc(this.WorktreeConfigPath).ShouldEqual(worktreeConfigWriteTime, "A second application wrote config.worktree"); + this.GetConfigValues(this.SharedConfigPath, "credential.https://dev.azure.com.useHttpPath").ShouldEqual("true"); + } + + [TestCase] + public void RequiredSettingsFailWhenWorktreeConfigIsCorrupt() + { + File.WriteAllText(this.WorktreeConfigPath, "[status\n"); + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); + + string error; + RequiredGitConfig.TrySetRequiredSettings( + enlistment, + new Dictionary { { "status.deserializePath", "C:/worktree/status.dat" } }, + out error).ShouldBeFalse(); + + error.ShouldContain("config.worktree"); + } + + private string PrimaryDotGVFSRoot => Path.Combine(this.primaryRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); + + private string PrimaryStatusCachePath => Path.Combine(this.PrimaryDotGVFSRoot, "gitStatusCache", "GitStatusCache.dat").Replace('\\', '/'); + + private string SharedConfigPath => Path.Combine(this.sharedGitDir, "config"); + + private string WorktreeConfigPath => Path.Combine(this.worktreeGitDir, "config.worktree"); + + private static string GetGitBinPath() + { + string gitPath = GitTestHelper.FindGitOnPath(); + Assert.That(gitPath, Is.Not.Null, "The required-settings tests require Git on PATH."); + return gitPath; + } + + private string GetConfigValues(string configFile, string key) + { + ProcessResult result = GitProcess.InvokeProcess(this.worktreePath, $"config --file \"{configFile}\" --get-all {key}"); + (result.ExitCode == 0 || result.ExitCode == 1).ShouldBeTrue(result.Errors); + return result.Output.Trim(); + } + + private void AppendToSharedConfig(string text) + { + File.AppendAllText(this.SharedConfigPath, text); + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs b/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs index aeb47628d..41ec375f3 100644 --- a/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs +++ b/GVFS/GVFS.UnitTests/Common/Git/AuthenticationConfigurationTests.cs @@ -6,11 +6,7 @@ using NUnit.Framework; using System; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Linq; -using System.Text.Json; -using System.Threading; namespace GVFS.UnitTests.Common.Git { @@ -18,26 +14,6 @@ namespace GVFS.UnitTests.Common.Git public class AuthenticationConfigurationTests { private const string Secret = "SecretSentinelNotForTelemetry"; - private string root; - private string gitPath; - - [SetUp] - public void Setup() - { - this.gitPath = GitTestHelper.FindGitOnPath(); - this.root = Path.Combine(Path.GetTempPath(), nameof(AuthenticationConfigurationTests) + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Path.Combine(this.root, ".git", "objects")); - Directory.CreateDirectory(Path.Combine(this.root, ".git", "refs", "heads")); - File.WriteAllText(Path.Combine(this.root, ".git", "HEAD"), "ref: refs/heads/main\n"); - File.WriteAllText(Path.Combine(this.root, ".git", "config"), "[core]\nrepositoryformatversion = 0\nbare = false\n"); - File.WriteAllText(Path.Combine(this.root, "global"), string.Empty); - } - - [TearDown] - public void TearDown() - { - Directory.Delete(this.root, recursive: true); - } [TestCase("manager", "manager")] [TestCase("", "reset")] @@ -57,326 +33,6 @@ public void HelpersExposeOnlyClosedDescriptions(string value, string expected) Assert.That(AuthenticationConfiguration.DescribeHelper(value), Is.EqualTo(expected)); } - [TestCase("https://example.com/org/repo", new[] { "manager", "cache", "wincred", "store", "oauth", "osxkeychain" })] - [TestCase("https://example.com/org/other", new[] { "manager", "cache", "wincred", "store", "osxkeychain" })] - [TestCase("https://example.com/organization/repo", new[] { "manager", "cache", "wincred", "osxkeychain" })] - [TestCase("https://example.com:443/org/repo", new[] { "manager", "cache", "wincred", "store", "oauth", "osxkeychain" })] - [TestCase("https://example.com/%6frg/repo%2F", new[] { "manager", "cache", "wincred", "store", "oauth", "osxkeychain" })] - [TestCase("https://user@" + "example.com/org/repo", new[] { "manager", "cache", "wincred", "store", "oauth", "libsecret", "osxkeychain" })] - [TestCase("https://other.example.com/org/repo", new[] { "manager", "cache", "osxkeychain" })] - public void NativeGitMatchingPreservesAllApplicableHelpers(string url, string[] expected) - { - File.WriteAllText(Path.Combine(this.root, "global"), - "[credential]\nhelper = manager\nhelper = cache\n" + - "[credential \"https://example.com\"]\nhelper = wincred\n" + - "[credential \"https://example.com/org\"]\nhelper = store\n" + - "[credential \"https://example.com/org/repo\"]\nhelper = oauth\n" + - "[credential \"https://user@example.com/org\"]\nhelper = libsecret\n" + - "[credential]\nhelper = osxkeychain\n"); - - EventMetadata snapshot = this.Collect(url); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["HelperChain"], Is.EqualTo(string.Join(", ", expected))); - Assert.That(snapshot["UseHttpPath"], Is.EqualTo("false")); - } - - [Test] - public void IncludesLocalWorktreeAndCommandResetsPreserveOrderWithoutSecrets() - { - File.WriteAllText(Path.Combine(this.root, "included"), "[credential]\nhelper = cache\n"); - File.WriteAllText(Path.Combine(this.root, "global"), "[credential]\nhelper = manager\n[include]\npath = included\n"); - File.AppendAllText(Path.Combine(this.root, ".git", "config"), - "[extensions]\nworktreeConfig = true\n[credential \"https://example.com/org\"]\nhelper =\nhelper = wincred\n"); - File.WriteAllText(Path.Combine(this.root, ".git", "config.worktree"), "[credential]\nhelper = store\n"); - - EventMetadata snapshot = this.Collect("https://example.com/org/repo", startInfo => - { - startInfo.Environment["GIT_CONFIG_COUNT"] = "2"; - startInfo.Environment["GIT_CONFIG_KEY_0"] = "credential.helper"; - startInfo.Environment["GIT_CONFIG_VALUE_0"] = string.Empty; - startInfo.Environment["GIT_CONFIG_KEY_1"] = "credential.helper"; - startInfo.Environment["GIT_CONFIG_VALUE_1"] = "!echo " + Secret; - }); - - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["HelperChain"], Is.EqualTo("omitted:shell")); - string expectedOperations = string.Join(", ", new[] - { - "global:manager", "global:cache", "local:reset", "local:wincred", "worktree:store", "command:reset", "command:omitted:shell", - }); - using (JsonDocument payload = JsonDocument.Parse(EventMetadataConverter.SerializeToString(snapshot))) - { - Assert.That(payload.RootElement.GetProperty("HelperChain").GetString(), Is.EqualTo("omitted:shell")); - Assert.That(payload.RootElement.GetProperty("HelperOperations").GetString(), Is.EqualTo(expectedOperations)); - } - - this.AssertNoSecret(snapshot); - } - - [TestCase("", "absent")] - [TestCase("[credential]\nhelper = manager\nhelper =\n", "reset-empty")] - public void AbsenceAndResetAreDistinct(string config, string state) - { - File.WriteAllText(Path.Combine(this.root, "global"), config); - EventMetadata snapshot = this.Collect("https://example.com/org"); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["HelperState"], Is.EqualTo(state)); - Assert.That(snapshot["HelperChain"], Is.Empty); - } - - [Test] - public void ConfigReadErrorsDoNotProduceAnEmptySuccessfulSnapshot() - { - File.WriteAllText(Path.Combine(this.root, "global"), "[invalid\n" + Secret); - int reads = 0; - MockTracer tracer = new MockTracer(); - EventMetadata snapshot = this.Collect("https://example.com/org", info => ++reads, tracer: tracer); - Assert.That(snapshot["Status"], Is.EqualTo("config-query-error")); - Assert.That(snapshot.ContainsKey("HelperChain"), Is.False); - Assert.That(reads, Is.EqualTo(1)); - Assert.That(tracer.RelatedWarningEvents, Is.Empty); - this.AssertNoSecret(snapshot); - } - - [TestCase(AuthenticationConfiguration.ReadCommand, 1, 2, "collected", 1)] - [TestCase("config --system --null --name-only --list", 1, 2, "collected", 1)] - [TestCase("config --file - --no-includes --null --get-urlmatch authdiagnostic", 1, 2, "collected", 1)] - [TestCase(AuthenticationConfiguration.ReadCommand, 5, 5, "config-query-error", 5)] - public void DiagnosticReadsUseSharedRetriesWithoutTracingConfiguration( - string query, int failures, int expectedAttempts, string expectedStatus, int expectedWarnings) - { - File.WriteAllText(Path.Combine(this.root, "global"), "[credential \"https://example.com/org\"]\nhelper = manager\n"); - string systemConfig = Path.Combine(this.root, "system"); - File.WriteAllText(systemConfig, string.Empty); - int attempts = 0; - MockTracer tracer = new MockTracer(); - - EventMetadata snapshot = this.Collect("https://example.com/org", info => - { - info.Environment.Remove("GIT_CONFIG_NOSYSTEM"); - info.Environment["GIT_CONFIG_SYSTEM"] = systemConfig; - if (info.Arguments.Contains(query, StringComparison.Ordinal) && ++attempts <= failures) - { - info.FileName = Environment.GetEnvironmentVariable("ComSpec"); - info.Arguments = "/d /c \"findstr . >nul & echo warning: unable to access '" + Secret + "': Permission denied 1>&2 & exit /b 128\""; - } - }, tracer: tracer); - - Assert.That(attempts, Is.EqualTo(expectedAttempts)); - Assert.That(snapshot["Status"], Is.EqualTo(expectedStatus)); - Assert.That(tracer.TelemetryWarnings.Count, Is.EqualTo(expectedWarnings)); - Assert.That(string.Join("\n", tracer.RelatedWarningEvents), Does.Not.Contain(Secret)); - if (expectedStatus == "collected") - { - Assert.That(snapshot["HelperChain"], Is.EqualTo("manager")); - Assert.That(snapshot["SystemConfigState"], Is.EqualTo("readable-no-auth-settings")); - } - else - { - Assert.That(snapshot.ContainsKey("HelperChain"), Is.False); - } - - this.AssertNoSecret(snapshot); - } - - [Test] - public void OversizedConfigDoesNotProduceAPartialHelperChain() - { - File.WriteAllText(Path.Combine(this.root, "global"), "[credential]\nhelper = " + new string('x', 70000)); - EventMetadata snapshot = this.Collect("https://example.com/org"); - Assert.That(snapshot["Status"], Is.EqualTo("query-output-limit")); - Assert.That(snapshot.ContainsKey("HelperChain"), Is.False); - } - - [Test] - public void SystemConfigAbsenceIsReportedSeparately() - { - EventMetadata snapshot = this.Collect("https://example.com/org", info => - { - info.Environment.Remove("GIT_CONFIG_NOSYSTEM"); - info.Environment["GIT_CONFIG_SYSTEM"] = Path.Combine(this.root, "missing-system-config"); - info.Environment["LC_ALL"] = "fr_FR.UTF-8"; - }); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["HelperState"], Is.EqualTo("absent")); - Assert.That(snapshot["SystemConfigState"], Is.EqualTo("missing")); - } - - [Test] - public void CommandOverridesCannotInjectSyntheticMatcherKeys() - { - File.WriteAllText(Path.Combine(this.root, "global"), "[credential \"https://other.example.com\"]\nhelper = manager\n"); - EventMetadata snapshot = this.Collect("https://example.com/org", info => - { - info.Environment["GIT_CONFIG_COUNT"] = "1"; - info.Environment["GIT_CONFIG_KEY_0"] = "authdiagnostic.entry0"; - info.Environment["GIT_CONFIG_VALUE_0"] = "true"; - }); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["HelperState"], Is.EqualTo("absent")); - } - - [Test] - public void InvalidAndValuelessPreferencesAreNotReportedAsSafeValues() - { - File.WriteAllText(Path.Combine(this.root, "global"), - "[credential]\nhelper\nuseHttpPath = " + Secret + "\nmsauthUseBroker = " + Secret + "\nazreposCredentialType = " + Secret + "\n"); - EventMetadata snapshot = this.Collect("https://example.com/org"); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["HelperChain"], Is.EqualTo("omitted:valueless")); - Assert.That(snapshot["CredentialConfigState"], Is.EqualTo("invalid-valueless-setting")); - Assert.That(snapshot["UseHttpPath"], Is.EqualTo("omitted:invalid")); - Assert.That(snapshot["Gcm_msauthusebroker"], Is.EqualTo("unavailable")); - Assert.That(snapshot["Gcm_azreposcredentialtype"], Is.EqualTo("unavailable")); - this.AssertNoSecret(snapshot); - } - - [Test] - public void GcmInvalidPreferencesAreOmittedWithoutDefaultingToSafeValues() - { - File.WriteAllText(Path.Combine(this.root, "global"), - "[credential]\nmsauthUseBroker = " + Secret + "\nazreposCredentialType = " + Secret + "\ninteractive = NEVER\n"); - EventMetadata snapshot = this.Collect("https://example.com/org"); - Assert.That(snapshot["Gcm_msauthusebroker"], Is.EqualTo("omitted:invalid")); - Assert.That(snapshot["Gcm_azreposcredentialtype"], Is.EqualTo("omitted:invalid")); - Assert.That(snapshot["Gcm_interactive"], Is.EqualTo("never")); - Assert.That(snapshot["GitInteractive"], Is.EqualTo("omitted:invalid"), "Git's special 'never' value is case-sensitive."); - this.AssertNoSecret(snapshot); - } - - [Test] - [Category(CategoryConstants.ExceptionExpected)] - public void DiagnosticQueriesHaveOneTimeoutBudgetAndStopTheirOwnedProcess() - { - using ManualResetEventSlim exited = new ManualResetEventSlim(); - Stopwatch timer = Stopwatch.StartNew(); - int attempts = 0; - MockTracer tracer = new MockTracer(); - EventMetadata snapshot = AuthenticationConfiguration.Collect("https://example.com/org", command => - { - ++attempts; - Process process = new Process - { - StartInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("ComSpec"), - attempts == 1 ? - "/d /c \"echo warning: unable to access 'config': Permission denied 1>&2 & exit /b 128\"" : - "/d /c ping -n 30 127.0.0.1 >nul") - { - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }, - EnableRaisingEvents = true, - }; - if (attempts > 1) - { - process.Exited += (sender, args) => exited.Set(); - } - - return process; - }, tracer: tracer); - Assert.That(snapshot["Status"], Is.EqualTo("query-timeout")); - Assert.That(attempts, Is.EqualTo(2)); - Assert.That(tracer.TelemetryWarnings.Count, Is.EqualTo(1)); - Assert.That(timer.Elapsed, Is.LessThan(TimeSpan.FromSeconds(8))); - Assert.That(exited.Wait(TimeSpan.FromSeconds(1)), Is.True); - } - - [Test] - public void CredentialFillCommandScopedUseHttpPathOverrideIsCaptured() - { - File.WriteAllText(Path.Combine(this.root, "global"), - "[credential]\nuseHttpPath = false\n[credential \"https://dev.azure.com/org\"]\nazreposCredentialType = oauth\n"); - EventMetadata snapshot = this.Collect("https://dev.azure.com/org/repo", info => - { - info.Arguments = "-c credential.\"https://dev.azure.com\".useHttpPath=true " + info.Arguments; - }); - Assert.That(snapshot["UseHttpPath"], Is.EqualTo("true")); - Assert.That(snapshot["Gcm_azreposcredentialtype"], Is.EqualTo("oauth")); - } - - [Test] - public void DiagnosticsReuseTheFailedCredentialProcessEnvironment() - { - Dictionary environment = new Dictionary - { - { "SystemRoot", Environment.GetEnvironmentVariable("SystemRoot") }, - { "GIT_CONFIG_NOSYSTEM", "1" }, - { "GIT_CONFIG_GLOBAL", Path.Combine(this.root, "global") }, - { "GIT_CONFIG_COUNT", "1" }, - { "GIT_CONFIG_KEY_0", "credential.helper" }, - { "GIT_CONFIG_VALUE_0", "manager-core" }, - { "GCM_MSAUTH_USEBROKER", "true" }, - }; - EventMetadata snapshot = this.Collect( - "https://example.com/org", - info => info.Environment["GCM_MSAUTH_USEBROKER"] = "false", - environment); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["EnvironmentTiming"], Is.EqualTo("credential-fill-process")); - Assert.That(snapshot["HelperChain"], Is.EqualTo("manager-core")); - Assert.That(snapshot["Gcm_msauthusebroker"], Is.EqualTo("true")); - } - - [Test] - public void GcmPreferencesUsePathParentHostAndEnvironmentButGitInteractiveIsUnscoped() - { - File.WriteAllText(Path.Combine(this.root, "global"), - "[credential]\ninteractive = never\nmsauthUseBroker = false\n" + - "[credential \"example.com\"]\nazreposCredentialType = pat\n" + - "[credential \"https://child.example.com/org\"]\nazreposCredentialType = oauth\ninteractive = true\n" + - "[credential \"https://child.example.com\"]\nuseHttpPath = true\n"); - EventMetadata snapshot = this.Collect("https://child.example.com/org/repo"); - Assert.That(snapshot["Gcm_azreposcredentialtype"], Is.EqualTo("oauth")); - Assert.That(snapshot["Gcm_msauthusebroker"], Is.EqualTo("false")); - Assert.That(snapshot["Gcm_interactive"], Is.EqualTo("true")); - Assert.That(snapshot["GitInteractive"], Is.EqualTo("never")); - - EventMetadata inherited = this.Collect("https://child.example.com/other", info => - { - info.Environment["GCM_MSAUTH_USEBROKER"] = "true"; - info.Environment["GCM_INTERACTIVE"] = "never"; - }); - Assert.That(inherited["Gcm_azreposcredentialtype"], Is.EqualTo("pat")); - Assert.That(inherited["Gcm_msauthusebroker"], Is.EqualTo("true")); - Assert.That(inherited["Gcm_msauthusebroker_Source"], Is.EqualTo("environment")); - Assert.That(inherited["Gcm_interactive"], Is.EqualTo("never")); - } - - [Test] - public void UnsafeValuesAndUrlPasswordsNeverEnterLocalOrRemoteMetadata() - { - File.WriteAllText(Path.Combine(this.root, "global"), - "[credential \"https://example.com/" + Secret + "\"]\nhelper = !echo " + Secret + "\n" + - "azreposCredentialType = " + Secret + "\nmsauthUseBroker = " + Secret + "\n"); - EventMetadata snapshot = this.Collect("https://user:" + Secret + "@example.com/" + Secret, info => - { - info.Environment["GCM_INTERACTIVE"] = Secret; - info.Environment["GIT_TRACE2_EVENT"] = Path.Combine(this.root, "trace"); - }); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["Gcm_interactive"], Is.EqualTo("omitted:invalid")); - Assert.That(snapshot["Gcm_azreposcredentialtype"], Is.EqualTo("unset"), "GCM receives no path when useHttpPath is false."); - Assert.That(snapshot["HelperChain"], Is.EqualTo("omitted:shell")); - Assert.That(File.Exists(Path.Combine(this.root, "trace")), Is.False); - this.AssertNoSecret(snapshot); - } - - [TestCase("example.com", "https://example.com/org", true)] - [TestCase("/org", "https://example.com/org", true)] - [TestCase("/org", "https://example.com/org/repo", false)] - [TestCase("https://", "https://example.com/org", true)] - [TestCase("example.com", "https://other.example.com/org", false)] - public void CredentialPartialUrlFallbackUsesExactComponents(string scope, string url, bool applies) - { - File.WriteAllText(Path.Combine(this.root, "global"), "[credential \"" + scope + "\"]\nhelper = manager\n"); - EventMetadata snapshot = this.Collect(url); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["HelperChain"], Is.EqualTo(applies ? "manager" : string.Empty)); - } - [Test] public void OnlyFailedAttemptsCollectAndRepeatedFailuresAreThrottled() { @@ -396,82 +52,6 @@ public void OnlyFailedAttemptsCollectAndRepeatedFailuresAreThrottled() Assert.That(tracer.RelatedEventKeywords[index], Is.EqualTo(Keywords.Network | Keywords.Telemetry)); } - [Test] - public void ValidEncodedSeparatorScopeDoesNotApplyItsResetThroughPartialFallback() - { - File.WriteAllText(Path.Combine(this.root, "global"), - "[credential]\nhelper = manager\n[credential \"https://example.com/team%2Frepo\"]\nhelper =\n"); - EventMetadata snapshot = this.Collect("https://example.com/team/repo"); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["HelperChain"], Is.EqualTo("manager")); - Assert.That(snapshot["HelperOperations"], Is.EqualTo("global:manager")); - } - - [TestCase("https://example.com/%zz", "https://example.com/%25zz")] - [TestCase("https://user:pass%zz@example.com/org", "https://user@example.com/org/repo")] - public void NonNormalizableFullUrlScopeReportsAnUnsupportedStateInsteadOfGuessing(string scope, string url) - { - File.WriteAllText(Path.Combine(this.root, "global"), - "[credential \"" + scope + "\"]\nhelper = manager\n"); - EventMetadata snapshot = this.Collect(url); - Assert.That(snapshot["Status"], Is.EqualTo("unsupported-config-url")); - Assert.That(snapshot.ContainsKey("HelperChain"), Is.False); - } - - [Test] - public void UsernameTokensRetainMatchingWithoutEnteringProcessArgumentsOrTrace2() - { - string trace = Path.Combine(this.root, "trace"); - List processes = new List(); - File.WriteAllText(Path.Combine(this.root, "global"), - "[trace2]\neventTarget = " + trace.Replace('\\', '/') + "\n" + - "[credential \"https://" + Secret + ":password@example.com/org\"]\nhelper = manager\n" + - "[credential \"https://other@example.com/org\"]\nhelper = store\n"); - EventMetadata snapshot = this.Collect("https://" + Secret + "@example.com/org", processes.Add); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["HelperChain"], Is.EqualTo("manager")); - Assert.That(processes.All(info => !info.Arguments.Contains(Secret, StringComparison.Ordinal)), Is.True); - Assert.That(File.Exists(trace), Is.False); - Assert.That(AuthenticationConfiguration.CredentialUrl.TryParse("https://" + Secret + "@example.com/org", out var url), Is.True); - Assert.That(url.Format(redactUsername: true), Is.EqualTo("https://authdiagnostic-user@example.com/org")); - this.AssertNoSecret(snapshot); - } - - [TestCase("https://example.com/org?token=" + Secret)] - [TestCase("https://example.com/org#token=" + Secret)] - [TestCase("https://user:p@ss@" + Secret + "/org")] - public void SensitiveRepositoryUrlsReportAnExplicitUnsupportedState(string url) - { - File.WriteAllText(Path.Combine(this.root, "global"), "[credential \"https://example.com\"]\nhelper = manager\n"); - EventMetadata snapshot = this.Collect(url); - Assert.That(snapshot["Status"], Is.EqualTo("unsupported-sensitive-repository-url")); - Assert.That(snapshot.ContainsKey("HelperChain"), Is.False); - this.AssertNoSecret(snapshot); - } - - [Test] - public void ConfigCommandOnlyRedirectDoesNotReplaceTheCredentialConfiguration() - { - File.WriteAllText(Path.Combine(this.root, "global"), "[credential]\nhelper = manager\n"); - string decoy = Path.Combine(this.root, "decoy"); - File.WriteAllText(decoy, "[credential]\nhelper = store\n"); - EventMetadata snapshot = this.Collect("https://example.com/org", info => info.Environment["GIT_CONFIG"] = decoy); - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["HelperChain"], Is.EqualTo("manager")); - } - - [Test] - public void EmptyGcmInteractiveAllowsInteractionButGitBooleanIsFalse() - { - File.WriteAllText(Path.Combine(this.root, "global"), "[credential]\ninteractive =\n"); - EventMetadata snapshot = this.Collect("https://example.com/org"); - Assert.That(snapshot["GitInteractive"], Is.EqualTo("false")); - Assert.That(snapshot["Gcm_interactive"], Is.EqualTo("true")); - snapshot = this.Collect("https://example.com/org", info => info.Environment["GCM_INTERACTIVE"] = string.Empty); - Assert.That(snapshot["Gcm_interactive"], Is.EqualTo("true")); - Assert.That(snapshot["Gcm_interactive_Source"], Is.EqualTo("environment")); - } - [Test] [Category(CategoryConstants.ExceptionExpected)] public void TreeKillFailuresCannotEscapeTheCancellationCallback() @@ -481,15 +61,6 @@ public void TreeKillFailuresCannotEscapeTheCancellationCallback() Assert.That(GitProcessConfigReader.TryKillProcess(() => throw new InvalidOperationException()), Is.True); } - [Test] - public void FailedProcessStartReportsOnlyItsStatus() - { - EventMetadata snapshot = AuthenticationConfiguration.Collect("https://example.com/org", command => - new Process { StartInfo = new ProcessStartInfo(Path.Combine(this.root, Secret + ".exe")) }); - Assert.That(snapshot["Status"], Is.EqualTo("query-process-error")); - this.AssertNoSecret(snapshot); - } - [Test] public void MalformedAndExcessConfigEntriesAreRejected() { @@ -516,99 +87,6 @@ public void ExitZeroWithoutPasswordCollectsButCertificateFailureDoesNot() Assert.That(git.Collections, Is.Zero); } - [Test] - public void ProductionFillCarriesItsOwnEnvironmentIntoTheCollector() - { - File.AppendAllText(Path.Combine(this.root, ".git", "config"), - "[credential]\nhelper =\ninteractive = never\n"); - PlumbingGitProcess git = new PlumbingGitProcess(this.GetGitPath(), this.root); - MockTracer tracer = new MockTracer(); - Assert.That(git.TryGetCredential(tracer, "https://dev.azure.com/org/repo", out _, out _, out _, timeoutMs: 5000), Is.False); - Assert.That(git.CollectedEnvironment, Is.SameAs(git.ResultEnvironment)); - Assert.That(git.CollectedEnvironment["GIT_TERMINAL_PROMPT"], Is.EqualTo("0")); - Assert.That(git.CollectedEnvironment["GCM_VALIDATE"], Is.EqualTo("0")); - EventMetadata snapshot = tracer.RelatedEventMetadata[tracer.RelatedEventNames.IndexOf("AuthenticationConfiguration")]; - Assert.That(snapshot["Status"], Is.EqualTo("collected")); - Assert.That(snapshot["EnvironmentTiming"], Is.EqualTo("credential-fill-process")); - Assert.That(snapshot["UseHttpPath"], Is.EqualTo("true")); - Assert.That(snapshot["HelperState"], Is.EqualTo("reset-empty")); - } - - [Test] - public void FailedCredentialFillBeforeCloneCreatesTheWorkingDirectoryCollectsConfiguration() - { - string workingDirectory = Path.Combine(this.root, "enlistment", "src"); - string systemConfig = Path.Combine(this.root, "system"); - File.WriteAllText(systemConfig, string.Empty); - File.WriteAllText(Path.Combine(this.root, "global"), - "[credential]\nhelper = manager\n" + - "[credential \"https://dev.azure.com/org\"]\nhelper =\nhelper = store --file=" + Secret + "\n"); - Dictionary environment = new Dictionary - { - { "SystemRoot", Environment.GetEnvironmentVariable("SystemRoot") }, - { "GIT_CONFIG_SYSTEM", systemConfig }, - { "GIT_CONFIG_GLOBAL", Path.Combine(this.root, "global") }, - { "GIT_TERMINAL_PROMPT", "0" }, - { "GCM_VALIDATE", "0" }, - }; - PreCloneGitProcess git = new PreCloneGitProcess(this.GetGitPath(), workingDirectory, environment); - MockTracer tracer = new MockTracer(); - - Assert.That(git.TryGetCredential(tracer, "https://dev.azure.com/org/repo", out _, out _, out _), Is.False); - Assert.That(Directory.Exists(workingDirectory), Is.False); - EventMetadata snapshot = tracer.RelatedEventMetadata[tracer.RelatedEventNames.IndexOf("AuthenticationConfiguration")]; - using (JsonDocument payload = JsonDocument.Parse(EventMetadataConverter.SerializeToString(snapshot))) - { - Assert.That(payload.RootElement.GetProperty("Status").GetString(), Is.EqualTo("collected")); - Assert.That(payload.RootElement.GetProperty("EnvironmentTiming").GetString(), Is.EqualTo("credential-fill-process")); - Assert.That(payload.RootElement.GetProperty("HelperChain").GetString(), Is.EqualTo("store:omitted-arguments")); - Assert.That(payload.RootElement.GetProperty("HelperOperations").GetString(), - Is.EqualTo("global:manager, global:reset, global:store:omitted-arguments")); - Assert.That(payload.RootElement.GetProperty("SystemConfigState").GetString(), Is.EqualTo("readable-no-auth-settings")); - Assert.That(payload.RootElement.GetProperty("UseHttpPath").GetString(), Is.EqualTo("true")); - } - - this.AssertNoSecret(snapshot); - } - - private string GetGitPath() - { - if (!File.Exists(this.gitPath)) - { - Assert.Fail("The authentication configuration integration tests require Git on PATH."); - } - - return this.gitPath; - } - - private EventMetadata Collect( - string url, - Action customize = null, - Dictionary credentialFillEnvironment = null, - ITracer tracer = null) - { - GitProcess git = new GitProcess(this.GetGitPath(), this.root); - return AuthenticationConfiguration.Collect(url, command => - { - Process process = git.GetGitProcess(command, this.root, Path.Combine(this.root, ".git"), false, null, false); - foreach (string key in process.StartInfo.Environment.Keys.Where(k => - k.StartsWith("GIT_CONFIG", StringComparison.OrdinalIgnoreCase) || k.StartsWith("GCM_", StringComparison.OrdinalIgnoreCase)).ToArray()) - { - process.StartInfo.Environment.Remove(key); - } - - process.StartInfo.Environment["GIT_CONFIG_NOSYSTEM"] = "1"; - process.StartInfo.Environment["GIT_CONFIG_GLOBAL"] = Path.Combine(this.root, "global"); - customize?.Invoke(process.StartInfo); - return process; - }, credentialFillEnvironment, tracer); - } - - private void AssertNoSecret(EventMetadata metadata) - { - Assert.That(EventMetadataConverter.SerializeToString(metadata), Does.Not.Contain(Secret).And.Not.Contain("System.String[]")); - } - private sealed class RecordingGitProcess : MockGitProcess { internal int Collections { get; private set; } @@ -619,72 +97,5 @@ protected override EventMetadata GetAuthenticationConfiguration(string repoUrl, return new EventMetadata { { "Status", "config-query-error" } }; } } - - private sealed class PreCloneGitProcess : GitProcess - { - private readonly Dictionary environment; - - internal PreCloneGitProcess(string gitPath, string root, Dictionary environment) - : base(gitPath, root) - { - this.environment = environment; - } - - protected override Result InvokeGitImpl( - string command, - string workingDirectory, - string dotGitDirectory, - bool useReadObjectHook, - Action writeStdIn, - Action parseStdOutLine, - int timeoutMs, - string gitObjectsDirectory = null, - bool usePreCommandHook = true, - bool mayRequireAuth = false, - bool? hasVisibleConsoleWindow = null) - { - Assert.That(command, Does.EndWith("credential fill")); - return new Result(string.Empty, "Credential fill failed", 128) - { - CredentialFillEnvironment = this.environment, - }; - } - } - - private sealed class PlumbingGitProcess : GitProcess - { - internal PlumbingGitProcess(string gitPath, string root) - : base(gitPath, root) - { - } - - internal Dictionary ResultEnvironment { get; private set; } - internal Dictionary CollectedEnvironment { get; private set; } - - protected override EventMetadata GetAuthenticationConfiguration(string repoUrl, Dictionary credentialFillEnvironment, ITracer tracer) - { - this.CollectedEnvironment = credentialFillEnvironment; - return base.GetAuthenticationConfiguration(repoUrl, credentialFillEnvironment, tracer); - } - - protected override Result InvokeGitImpl( - string command, - string workingDirectory, - string dotGitDirectory, - bool useReadObjectHook, - Action writeStdIn, - Action parseStdOutLine, - int timeoutMs, - string gitObjectsDirectory = null, - bool usePreCommandHook = true, - bool mayRequireAuth = false, - bool? hasVisibleConsoleWindow = null) - { - Result result = base.InvokeGitImpl(command, workingDirectory, dotGitDirectory, useReadObjectHook, writeStdIn, - parseStdOutLine, timeoutMs, gitObjectsDirectory, usePreCommandHook, mayRequireAuth, hasVisibleConsoleWindow); - this.ResultEnvironment = result.CredentialFillEnvironment; - return result; - } - } } } diff --git a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs index 434af25e8..1d7cc5621 100644 --- a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs +++ b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs @@ -4,9 +4,7 @@ using GVFS.UnitTests.Mock.Common; using GVFS.UnitTests.Mock.Git; using NUnit.Framework; -using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading; @@ -150,54 +148,6 @@ public void CredentialFillDoesNotRetryWhenTheTimeoutCannotCoverTheDelay() ConfigReadRetryTelemetry(tracer).Single()["Attempts"].ShouldEqual(1); } - [TestCase(true, 30000)] - [TestCase(false, 30000)] - [TestCase(true, Timeout.Infinite)] - [TestCase(false, Timeout.Infinite)] - public void CredentialFillHandlesARealLockedGitConfig(bool releaseAfterFirstAttempt, int timeoutMs) - { - string gitPath = GitTestHelper.FindGitOnPath(); - if (gitPath == null) - { - Assert.Ignore("Git was not found on PATH."); - } - - string repoPath = Path.Combine(Path.GetTempPath(), nameof(GitProcessCredentialTests) + Guid.NewGuid().ToString("N")); - string dotGit = Path.Combine(repoPath, ".git"); - Directory.CreateDirectory(Path.Combine(dotGit, "objects")); - Directory.CreateDirectory(Path.Combine(dotGit, "refs", "heads")); - try - { - File.WriteAllText(Path.Combine(dotGit, "HEAD"), "ref: refs/heads/main\n"); - File.WriteAllText(Path.Combine(dotGit, "config"), "[core]\n\trepositoryformatversion = 0\n\tbare = false\n"); - LockedConfigGitProcess gitProcess = new LockedConfigGitProcess(gitPath, repoPath, releaseAfterFirstAttempt); - MockTracer tracer = new MockTracer(); - - bool success = gitProcess.TryGetCredential(tracer, "mock://repoUrl", out string username, out string password, out string error, timeoutMs: timeoutMs); - - success.ShouldEqual(releaseAfterFirstAttempt, error); - gitProcess.Results.Count.ShouldEqual(releaseAfterFirstAttempt ? 2 : 5); - gitProcess.Results[0].ExitCode.ShouldEqual(GitFatalExitCode); - gitProcess.Results[0].Errors.ShouldContain("Permission denied"); - gitProcess.Results[0].Errors.ShouldContain("unknown error occurred while reading the configuration files"); - if (releaseAfterFirstAttempt) - { - username.ShouldEqual("someone"); - password.ShouldEqual(SecretValue); - } - else - { - AssertErrorMessageHasNoStderr(error); - } - - AssertNoSecret(tracer); - } - finally - { - Directory.Delete(repoPath, recursive: true); - } - } - [TestCase] public void TryGetCredentialDoesNotTraceSecretWhenThePasswordIsMissing() { @@ -466,58 +416,6 @@ private static MockGitProcess CreateGitProcess( return gitProcess; } - private sealed class LockedConfigGitProcess : GitProcess - { - private readonly string configPath; - private readonly bool releaseAfterFirstAttempt; - - public LockedConfigGitProcess(string gitPath, string repoPath, bool releaseAfterFirstAttempt) - : base(gitPath, repoPath) - { - this.configPath = Path.Combine(repoPath, ".git", "config"); - this.releaseAfterFirstAttempt = releaseAfterFirstAttempt; - } - - public List Results { get; } = new List(); - - protected override Result InvokeGitImpl( - string command, - string workingDirectory, - string dotGitDirectory, - bool useReadObjectHook, - Action writeStdIn, - Action parseStdOutLine, - int timeoutMs, - string gitObjectsDirectory = null, - bool usePrecommandHook = true, - bool mayRequireAuth = false, - bool? hasVisibleConsoleWindow = null) - { - // Replace all credential helpers with a local, deterministic helper. No credentials leave the test. - string isolatedCommand = "-c credential.helper= -c credential.helper=\"!f() { echo username=someone; echo password=" + - SecretValue + "; }; f\" " + command; - using (FileStream configLock = this.Results.Count == 0 || !this.releaseAfterFirstAttempt - ? File.Open(this.configPath, FileMode.Open, FileAccess.Read, FileShare.None) - : null) - { - Result result = base.InvokeGitImpl( - isolatedCommand, - workingDirectory, - dotGitDirectory, - useReadObjectHook, - writeStdIn, - parseStdOutLine, - timeoutMs, - gitObjectsDirectory, - usePrecommandHook, - mayRequireAuth, - hasVisibleConsoleWindow); - this.Results.Add(result); - return result; - } - } - } - private static void AssertErrorMessageHasNoStderr(string errorMessage) { // The caller puts this message in an exception, and the retry handler reports exceptions diff --git a/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs b/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs index d1ed003b5..2fb061f36 100644 --- a/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs +++ b/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs @@ -270,17 +270,6 @@ public void RealWorkerRecordsTransportFailureAndPhase(string failure, string pha Assert.That(EventMetadataConverter.SerializeToString(result.GetMetadata()), Does.Not.Contain("private")); } - [TestCase] - [Category(CategoryConstants.ExceptionExpected)] - public void RealPipeConnectionReturnsItsTimeoutCause() - { - using (NamedPipeClient client = new NamedPipeClient("GVFS.UnitTests.Missing." + Guid.NewGuid().ToString("N"))) - { - Assert.IsFalse(client.Connect(25, out Exception error)); - Assert.IsInstanceOf(error); - } - } - [TestCase] public void LateWorkerDoesNotMutateTimeoutMetadata() { diff --git a/GVFS/GVFS.UnitTests/Common/RetryConfigTests.cs b/GVFS/GVFS.UnitTests/Common/RetryConfigTests.cs index e2d3c87c0..5696cdebd 100644 --- a/GVFS/GVFS.UnitTests/Common/RetryConfigTests.cs +++ b/GVFS/GVFS.UnitTests/Common/RetryConfigTests.cs @@ -198,7 +198,7 @@ public void CompleteLoaderRejectsMissingDependencies() [TestCase] public void LibGit2ReaderReadsRepositoryValues() { - using (TestRepository repo = new TestRepository("[gvfs]\n\tmax-retries = 7\n\ttimeout-seconds = 31\n\tread-timeout-seconds = 0\n")) + using (RetryConfigTestRepository repo = new RetryConfigTestRepository("[gvfs]\n\tmax-retries = 7\n\ttimeout-seconds = 31\n\tread-timeout-seconds = 0\n")) { MockTracer tracer = new MockTracer(); RetryConfig config = RetryConfig.LoadFromGitConfig(tracer, RetryConfig.CreateLibGit2Reader(tracer, repo.WorkingDirectory)); @@ -213,7 +213,7 @@ public void LibGit2ReaderReadsRepositoryValues() [TestCase] public void LibGit2ReaderUsesDefaultValuesWhenEntriesAreBlank() { - using (TestRepository repo = new TestRepository("[gvfs]\n\tmax-retries =\n\ttimeout-seconds\n")) + using (RetryConfigTestRepository repo = new RetryConfigTestRepository("[gvfs]\n\tmax-retries =\n\ttimeout-seconds\n")) { MockTracer tracer = new MockTracer(); RetryConfig config = RetryConfig.LoadFromGitConfig(tracer, RetryConfig.CreateLibGit2Reader(tracer, repo.WorkingDirectory)); @@ -230,7 +230,7 @@ public void LibGit2ReaderUsesDefaultValuesWhenEntriesAreBlank() [TestCase("read-timeout-seconds = 2147484", "gvfs.read-timeout-seconds", "Invalid value 2147484 for setting gvfs.read-timeout-seconds, value must be between 0 and 2147483")] public void LibGit2ReaderReportsTheSameInvalidValueErrorsAsGit(string setting, string configName, string expectedError) { - using (TestRepository repo = new TestRepository("[gvfs]\n\t" + setting + "\n")) + using (RetryConfigTestRepository repo = new RetryConfigTestRepository("[gvfs]\n\t" + setting + "\n")) { MockTracer tracer = new MockTracer(); RetryConfig.LoadFromGitConfig(tracer, RetryConfig.CreateLibGit2Reader(tracer, repo.WorkingDirectory)); @@ -243,7 +243,7 @@ public void LibGit2ReaderReportsTheSameInvalidValueErrorsAsGit(string setting, s [TestCase] public void LibGit2ReaderUsesDefaultsWhenConfigIsCorrupt() { - using (TestRepository repo = new TestRepository("[gvfs\n")) + using (RetryConfigTestRepository repo = new RetryConfigTestRepository("[gvfs\n")) { MockTracer tracer = new MockTracer(); RetryConfig config = RetryConfig.LoadFromGitConfig(tracer, RetryConfig.CreateLibGit2Reader(tracer, repo.WorkingDirectory)); @@ -395,53 +395,6 @@ public void LibGit2ReaderReadsGlobalConfigBeforeTheRepositoryExists() } } - [TestCase] - public void UserConfigThatGitDoesNotReadIsReadWithGit() - { - string gitPath = GetGitPathOrIgnore(); - string[] variables = { "HOMEDRIVE", "HOMEPATH", "USERPROFILE" }; - string[] originalValues = Array.ConvertAll(variables, Environment.GetEnvironmentVariable); - using (IsolatedLibGit2Config machineConfig = new IsolatedLibGit2Config()) - { - // git reads only HOME\.gitconfig. libgit2 also falls back to USERPROFILE\.gitconfig. - string emptyHome = Path.Combine(machineConfig.Directory, "home"); - string profile = Path.Combine(machineConfig.Directory, "profile"); - Directory.CreateDirectory(emptyHome); - Directory.CreateDirectory(profile); - File.WriteAllText(Path.Combine(profile, ".gitconfig"), "[gvfs]\n\ttimeout-seconds = 1\n"); - try - { - Environment.SetEnvironmentVariable("HOME", emptyHome); - Environment.SetEnvironmentVariable("HOMEDRIVE", null); - Environment.SetEnvironmentVariable("HOMEPATH", null); - Environment.SetEnvironmentVariable("USERPROFILE", profile); - IsolatedLibGit2Config.ResetUserSearchPathsFromEnvironment(); - - string missingWorkingDirectory = Path.Combine(Path.GetTempPath(), "RetryConfigTests_" + Guid.NewGuid().ToString("N")); - MockTracer tracer = new MockTracer(); - RetryConfig.ConfigStringReader readWithLibGit2 = RetryConfig.CreateLibGit2Reader(tracer, missingWorkingDirectory); - GitProcess git = new GitProcess(gitPath, workingDirectoryRoot: null); - RetryConfig.ConfigStringReader readWithGit = (string name, out string value, out string error) => - git.GetFromConfig(name).TryParseAsString(out value, out error); - - readWithLibGit2("gvfs.timeout-seconds", out string _, out string libGit2Error).ShouldBeFalse(); - libGit2Error.ShouldContain("libgit2 reads the global config file", Path.Combine(profile, ".gitconfig")); - readWithGit("gvfs.timeout-seconds", out string gitValue, out string gitError).ShouldBeTrue(gitError); - gitValue.ShouldBeNull(); - - RetryConfig config = RetryConfig.LoadFromGitConfig(tracer, RetryConfig.WithGitFallback(tracer, readWithLibGit2, readWithGit)); - config.Timeout.ShouldEqual(TimeSpan.FromSeconds(RetryConfig.DefaultTimeoutSeconds)); - } - finally - { - for (int i = 0; i < variables.Length; ++i) - { - Environment.SetEnvironmentVariable(variables[i], originalValues[i]); - } - } - } - } - [TestCase] public void DisposingARepositoryWithoutNativeStateKeepsTheIsolatedConfig() { @@ -476,98 +429,12 @@ public void LibGit2ReaderRejectsAGlobalIncludeConditionItIgnores() } } - [TestCase] - public void LibGit2AndGitReadTheSameRepositoryValues() - { - string gitPath = GetGitPathOrIgnore(); - using (TestRepository repo = new TestRepository("[gvfs]\n\tmax-retries = 7\n\tmax-retries = 8\n\ttimeout-seconds = 31\n\tread-timeout-seconds = 32\n")) - { - RetryConfig libGit2Config = RetryConfig.LoadFromGitConfig(new MockTracer(), RetryConfig.CreateLibGit2Reader(new MockTracer(), repo.WorkingDirectory)); - RetryConfig gitConfig = RetryConfig.LoadFromGitConfig(new MockTracer(), new GitProcess(gitPath, repo.WorkingDirectory)); - - libGit2Config.MaxRetries.ShouldEqual(8); - gitConfig.MaxRetries.ShouldEqual(8); - libGit2Config.Timeout.ShouldEqual(TimeSpan.FromSeconds(31)); - gitConfig.Timeout.ShouldEqual(TimeSpan.FromSeconds(31)); - libGit2Config.ReadTimeout.ShouldEqual(TimeSpan.FromSeconds(32)); - gitConfig.ReadTimeout.ShouldEqual(TimeSpan.FromSeconds(32)); - } - } - - [TestCase] - public void ConfigWithAnIncludeConditionLibGit2IgnoresIsReadWithGit() - { - string gitPath = GetGitPathOrIgnore(); - using (TestRepository repo = new TestRepository(string.Empty)) - { - string includedPath = Path.Combine(repo.WorkingDirectory, "included.gitconfig"); - File.WriteAllText(includedPath, "[gvfs]\n\tmax-retries = 12\n"); - File.AppendAllText( - Path.Combine(repo.WorkingDirectory, ".git", "config"), - "[remote \"origin\"]\n\turl = https://example.com/repo\n" + - "[includeIf \"hasconfig:remote.*.url:https://example.com/**\"]\n\tpath = " + includedPath.Replace('\\', '/') + "\n"); - - MockTracer tracer = new MockTracer(); - RetryConfig.ConfigStringReader readWithLibGit2 = RetryConfig.CreateLibGit2Reader(tracer, repo.WorkingDirectory); - GitProcess git = new GitProcess(gitPath, repo.WorkingDirectory); - RetryConfig.ConfigStringReader readWithGit = (string name, out string value, out string error) => - git.GetFromConfig(name).TryParseAsString(out value, out error); - - readWithLibGit2("gvfs.max-retries", out string _, out string libGit2Error).ShouldBeFalse(); - libGit2Error.ShouldContain("libgit2 does not evaluate like git: includeif.hasconfig:remote.*.url:https://example.com/**.path"); - - RetryConfig config = RetryConfig.LoadFromGitConfig(tracer, RetryConfig.WithGitFallback(tracer, readWithLibGit2, readWithGit)); - config.MaxRetries.ShouldEqual(12); - tracer.RelatedWarningEvents.Count.ShouldEqual(0); - } - } - - // In each layout, libgit2 evaluates the include differently from git.exe. - [TestCase("onbranch:main", false)] - [TestCase("onbranch:main", true)] - [TestCase("gitdir:", false)] - [TestCase("gitdir/i:", false)] - public void IncludeLayoutsLibGit2EvaluatesDifferentlyAreReadWithGit(string condition, bool headOnBranch) - { - string gitPath = GetGitPathOrIgnore(); - using (TestRepository repo = new TestRepository(string.Empty)) - { - if (!headOnBranch) - { - File.WriteAllText(Path.Combine(repo.WorkingDirectory, ".git", "HEAD"), new string('1', 40) + "\n"); - } - - string gitDir = Path.Combine(repo.WorkingDirectory, ".git").Replace('\\', '/') + "/"; - string includedPath = Path.Combine(repo.WorkingDirectory, "included.gitconfig"); - File.WriteAllText(includedPath, "[gvfs]\n\tmax-retries = 13\n"); - File.AppendAllText( - Path.Combine(repo.WorkingDirectory, ".git", "config"), - "[includeIf \"" + condition.Replace("", gitDir.Substring(2)) + "\"]\n\tpath = " + - includedPath.Replace('\\', '/') + "\n"); - - MockTracer tracer = new MockTracer(); - RetryConfig.ConfigStringReader readWithLibGit2 = RetryConfig.CreateLibGit2Reader(tracer, repo.WorkingDirectory); - GitProcess git = new GitProcess(gitPath, repo.WorkingDirectory); - RetryConfig.ConfigStringReader readWithGit = (string name, out string value, out string error) => - git.GetFromConfig(name).TryParseAsString(out value, out error); - - readWithLibGit2("gvfs.max-retries", out string _, out string libGit2Error).ShouldBeFalse(); - libGit2Error.ShouldContain("libgit2 does not evaluate like git"); - - readWithGit("gvfs.max-retries", out string gitValue, out string gitError).ShouldBeTrue(gitError); - RetryConfig config = RetryConfig.LoadFromGitConfig(tracer, RetryConfig.WithGitFallback(tracer, readWithLibGit2, readWithGit)); - config.MaxRetries.ShouldEqual(gitValue == null ? RetryConfig.DefaultMaxRetries : int.Parse(gitValue)); - config.MaxRetries.ShouldEqual(headOnBranch && condition.StartsWith("onbranch:") ? 13 : RetryConfig.DefaultMaxRetries); - tracer.RelatedWarningEvents.Count.ShouldEqual(0); - } - } - // git.exe expands %(prefix) to its installation directory. libgit2 skips the include. [TestCase("[include]\n\tpath = %(prefix)/etc/gvfs.gitconfig\n")] [TestCase("[includeIf \"gitdir:**/\"]\n\tpath = %(prefix)/etc/gvfs.gitconfig\n")] public void IncludePathsWithAGitPrefixAreReadWithGit(string includeConfig) { - using (TestRepository repo = new TestRepository(includeConfig)) + using (RetryConfigTestRepository repo = new RetryConfigTestRepository(includeConfig)) { RetryConfig.CreateLibGit2Reader(new MockTracer(), repo.WorkingDirectory)("gvfs.max-retries", out string _, out string error) .ShouldBeFalse(); @@ -579,7 +446,7 @@ public void IncludePathsWithAGitPrefixAreReadWithGit(string includeConfig) [TestCase("gitdir/i:**/")] public void LibGit2ReadsConfigWithIncludeConditionsItEvaluates(string condition) { - using (TestRepository repo = new TestRepository(string.Empty)) + using (RetryConfigTestRepository repo = new RetryConfigTestRepository(string.Empty)) { string includedPath = Path.Combine(repo.WorkingDirectory, "included.gitconfig"); File.WriteAllText(includedPath, "[gvfs]\n\tmax-retries = 13\n"); @@ -593,16 +460,6 @@ public void LibGit2ReadsConfigWithIncludeConditionsItEvaluates(string condition) } } - private static string GetGitPathOrIgnore() - { - string gitPath = GitTestHelper.FindGitOnPath(); - if (gitPath == null) - { - Assert.Ignore("Git was not found on PATH."); - } - - return gitPath; - } private class RetryConfigVerb : GVFSVerb { public override string EnlistmentRootPathParameter { get; set; } @@ -659,33 +516,5 @@ public override ConfigResult GetFromConfig( return base.GetFromConfig(settingName, forceOutsideEnlistment, fileSystem); } } - - // A minimal repository that libgit2 can open without git.exe. libgit2 does not read the - // machine's config while the repository exists. The non-ASCII path checks the UTF-8 - // marshalling of the repository path. - private sealed class TestRepository : IDisposable - { - private readonly IsolatedLibGit2Config machineConfig = new IsolatedLibGit2Config(); - - public TestRepository(string extraConfig) - { - this.WorkingDirectory = Path.Combine(Path.GetTempPath(), "RetryConfigTests_ü_" + Guid.NewGuid().ToString("N")); - string dotGit = Path.Combine(this.WorkingDirectory, ".git"); - Directory.CreateDirectory(Path.Combine(dotGit, "objects")); - Directory.CreateDirectory(Path.Combine(dotGit, "refs", "heads")); - File.WriteAllText(Path.Combine(dotGit, "HEAD"), "ref: refs/heads/main\n"); - File.WriteAllText( - Path.Combine(dotGit, "config"), - "[core]\n\trepositoryformatversion = 0\n\tbare = false\n" + extraConfig); - } - - public string WorkingDirectory { get; } - - public void Dispose() - { - Directory.Delete(this.WorkingDirectory, recursive: true); - this.machineConfig.Dispose(); - } - } } } diff --git a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedProcessTrackingTests.cs b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedProcessTrackingTests.cs index cf04194eb..ba9fdbc31 100644 --- a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedProcessTrackingTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedProcessTrackingTests.cs @@ -46,43 +46,5 @@ public void ReturnsImmediatelyWhenMountProcessSnapshotReportsExited() Assert.That(elapsed.TotalSeconds, Is.LessThan(5), "WaitUntilMounted should bail out quickly when the snapshot reports the mount process exited"); } - [TestCase] - public void DetectsLateProcessExitWhilePipeNeverAppears() - { - const int FakePid = 24680; - const int FakeExitCode = -1; - int snapshotCallCount = 0; - Func snapshot = () => - { - int count = Interlocked.Increment(ref snapshotCallCount); - - // Pretend the mount process is still running for the first - // couple of polls, then suddenly report it exited. - bool exited = count >= 2; - return new GVFSEnlistment.MountProcessSnapshot( - FakePid, - hasExited: exited, - exitCode: exited ? FakeExitCode : 0); - }; - - string errorMessage; - DateTime start = DateTime.UtcNow; - bool result = GVFSEnlistment.WaitUntilMounted( - new MockTracer(), - pipeName: "GVFS_no_such_pipe_for_test_" + Guid.NewGuid().ToString("N"), - enlistmentRoot: "C:\\fake\\root", - unattended: false, - mountProcessStatus: snapshot, - out errorMessage); - TimeSpan elapsed = DateTime.UtcNow - start; - - result.ShouldBeFalse(); - errorMessage.ShouldContain(FakePid.ToString()); - errorMessage.ShouldContain(FakeExitCode.ToString()); - - // Per-attempt connect timeout is 500ms; we expect to discover the - // exit on the second poll, well within a few seconds. - Assert.That(elapsed.TotalSeconds, Is.LessThan(10), "WaitUntilMounted should detect late process exit within a few connect retries"); - } } } diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs index 24064db64..90c786dee 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs @@ -7,7 +7,6 @@ using NUnit.Framework; using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; namespace GVFS.UnitTests.Common @@ -295,176 +294,10 @@ public void WorktreeMetadataCreatesEnlistmentIdWhenNoneIsGivenOrStored(string st }); } - [TestCase] - public void RequiredSettingsEnableWorktreeConfigAndWritePerWorktreeValues() - { - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); - - string error; - RequiredGitConfig.TrySetRequiredSettings( - enlistment, - new Dictionary - { - { "status.deserializePath", "C:/worktree/status.dat" }, - { "core.autocrlf", "false" }, - }, - out error).ShouldBeTrue(error); - - this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual("true"); - this.GetConfigValues(this.SharedConfigPath, "core.autocrlf").ShouldEqual("false"); - this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual(this.PrimaryStatusCachePath); - this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual("C:/worktree/status.dat"); - } - - [TestCase] - public void RequiredSettingsRepairCopiedWorktreeValueAndSharedValueOfPrimary() - { - this.AppendToSharedConfig("[status]\n\tdeserializePath = C:/sibling/status.dat\n"); - File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/sibling/status.dat\n"); - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); - - string error; - RequiredGitConfig.TrySetRequiredSettings( - enlistment, - new Dictionary { { "status.deserializePath", "C:/worktree/status.dat" } }, - out error).ShouldBeTrue(error); - - this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual("C:/worktree/status.dat"); - this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual(this.PrimaryStatusCachePath); - } - - [TestCase] - public void RequiredSettingsDisableNullPerWorktreeValueInWorktreeConfig() - { - this.AppendToSharedConfig("[extensions]\n\tworktreeConfig = true\n[status]\n\tdeserializePath = C:/primary/status.dat\n"); - File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/worktree/status.dat\n"); - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); - - string error; - RequiredGitConfig.TrySetRequiredSettings( - enlistment, - new Dictionary { { "status.deserializePath", null } }, - out error).ShouldBeTrue(error); - - this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual(string.Empty); - File.ReadAllText(this.WorktreeConfigPath).ShouldContain("deserializePath"); - this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual("C:/primary/status.dat"); - } - - [TestCase] - public void RequiredSettingsRemoveRequiredOverridesFromWorktreeConfig() - { - File.WriteAllText(this.WorktreeConfigPath, "[core]\n\tautocrlf = true\n[gvfs]\n\tmount-id = worktreemountid\n"); - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); - - string error; - RequiredGitConfig.TrySetRequiredSettings( - enlistment, - new Dictionary { { "core.autocrlf", "false" } }, - out error).ShouldBeTrue(error); - - this.GetConfigValues(this.WorktreeConfigPath, "core.autocrlf").ShouldEqual(string.Empty); - this.GetConfigValues(this.WorktreeConfigPath, "gvfs.mount-id").ShouldEqual("worktreemountid"); - this.GetConfigValues(this.SharedConfigPath, "core.autocrlf").ShouldEqual("false"); - } - - [TestCase] - public void RequiredSettingsReplaceDuplicateWorktreeConfigExtensionValues() - { - this.AppendToSharedConfig("[extensions]\n\tworktreeConfig = true\n\tworktreeConfig = false\n"); - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); - - string error; - RequiredGitConfig.TrySetRequiredSettings(enlistment, new Dictionary(), out error).ShouldBeTrue(error); - - this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual("true"); - } - - [TestCase] - public void PrimaryRequiredSettingsEnableWorktreeConfigAndRemoveOverridesWhenLinkedWorktreeHasConfig() - { - File.WriteAllText(this.WorktreeConfigPath, "[status]\n\tdeserializePath = C:/worktree/status.dat\n"); - string primaryWorktreeConfigPath = Path.Combine(this.sharedGitDir, "config.worktree"); - File.WriteAllText(primaryWorktreeConfigPath, "[core]\n\tautocrlf = true\n"); - GVFSEnlistment primary = new GVFSEnlistment(this.primaryRoot, "https://mock/repo", GetGitBinPath(), authentication: null); - - string error; - RequiredGitConfig.TrySetRequiredSettings( - primary, - new Dictionary { { "core.autocrlf", "false" } }, - out error).ShouldBeTrue(error); - - this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual("true"); - this.GetConfigValues(primaryWorktreeConfigPath, "core.autocrlf").ShouldEqual(string.Empty); - this.GetConfigValues(this.WorktreeConfigPath, "status.deserializePath").ShouldEqual("C:/worktree/status.dat"); - } - - [TestCase] - public void PrimaryRequiredSettingsDoNotEnableWorktreeConfigWithoutLinkedWorktreeConfig() - { - GVFSEnlistment primary = new GVFSEnlistment(this.primaryRoot, "https://mock/repo", GetGitBinPath(), authentication: null); - - string error; - RequiredGitConfig.TrySetRequiredSettings( - primary, - new Dictionary { { "status.deserializePath", "C:/primary/status.dat" } }, - out error).ShouldBeTrue(error); - - this.GetConfigValues(this.SharedConfigPath, "extensions.worktreeConfig").ShouldEqual(string.Empty); - this.GetConfigValues(this.SharedConfigPath, "status.deserializePath").ShouldEqual("C:/primary/status.dat"); - File.Exists(Path.Combine(this.sharedGitDir, "config.worktree")).ShouldBeFalse(); - } - - [TestCase] - public void RequiredSettingsWriteNothingWhenAppliedAgain() - { - GVFSEnlistment primary = new GVFSEnlistment(this.primaryRoot, "https://mock/repo", GetGitBinPath(), authentication: null); - GVFSEnlistment worktree = this.CreateWorktreeEnlistment(GetGitBinPath()); - - string error; - RequiredGitConfig.TrySetRequiredSettings(primary, out error).ShouldBeTrue(error); - RequiredGitConfig.TrySetRequiredSettings(worktree, out error).ShouldBeTrue(error); - DateTime sharedConfigWriteTime = File.GetLastWriteTimeUtc(this.SharedConfigPath); - DateTime worktreeConfigWriteTime = File.GetLastWriteTimeUtc(this.WorktreeConfigPath); - - RequiredGitConfig.TrySetRequiredSettings(primary, out error).ShouldBeTrue(error); - RequiredGitConfig.TrySetRequiredSettings(worktree, out error).ShouldBeTrue(error); - - File.GetLastWriteTimeUtc(this.SharedConfigPath).ShouldEqual(sharedConfigWriteTime, "A second application wrote the shared config"); - File.GetLastWriteTimeUtc(this.WorktreeConfigPath).ShouldEqual(worktreeConfigWriteTime, "A second application wrote config.worktree"); - this.GetConfigValues(this.SharedConfigPath, "credential.https://dev.azure.com.useHttpPath").ShouldEqual("true"); - } - - [TestCase] - public void RequiredSettingsFailWhenWorktreeConfigIsCorrupt() - { - File.WriteAllText(this.WorktreeConfigPath, "[status\n"); - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(GetGitBinPath()); - - string error; - RequiredGitConfig.TrySetRequiredSettings( - enlistment, - new Dictionary { { "status.deserializePath", "C:/worktree/status.dat" } }, - out error).ShouldBeFalse(); - - error.ShouldContain("config.worktree"); - } - private string PrimaryDotGVFSRoot => Path.Combine(this.primaryRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - private string PrimaryStatusCachePath => Path.Combine(this.PrimaryDotGVFSRoot, "gitStatusCache", "GitStatusCache.dat").Replace('\\', '/'); - private string SharedConfigPath => Path.Combine(this.sharedGitDir, "config"); - private string WorktreeConfigPath => Path.Combine(this.worktreeGitDir, "config.worktree"); - - private static string GetGitBinPath() - { - string gitPath = GitTestHelper.FindGitOnPath(); - Assert.That(gitPath, Is.Not.Null, "The required-settings tests require Git on PATH."); - return gitPath; - } - private static string GetRepoMetadataPath(string dotGVFSRoot) { return Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.Databases.RepoMetadata); @@ -484,29 +317,6 @@ private static void EditRepoMetadata(string dotGVFSRoot, Action ac } } - /// - /// Returns every value of in , - /// one per line, or an empty string when the key is missing. - /// - private string GetConfigValues(string configFile, string key) - { - ProcessStartInfo startInfo = new ProcessStartInfo(GetGitBinPath(), $"config --file \"{configFile}\" --get-all {key}") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - - using (Process process = Process.Start(startInfo)) - { - string output = process.StandardOutput.ReadToEnd(); - string errors = process.StandardError.ReadToEnd(); - process.WaitForExit(); - (process.ExitCode == 0 || process.ExitCode == 1).ShouldBeTrue(errors); - return output.Trim(); - } - } - private void AppendToSharedConfig(string text) { File.AppendAllText(this.SharedConfigPath, text); diff --git a/GVFS/GVFS.UnitTests/Git/GVFSGitObjectsTests.cs b/GVFS/GVFS.UnitTests/Git/GVFSGitObjectsTests.cs index 069189a12..12fc72e60 100644 --- a/GVFS/GVFS.UnitTests/Git/GVFSGitObjectsTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GVFSGitObjectsTests.cs @@ -15,11 +15,8 @@ using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Sockets; using System.Reflection; -using System.Text; using System.Threading; -using System.Threading.Tasks; namespace GVFS.UnitTests.Git { @@ -1058,216 +1055,6 @@ private void AssertMalformedShaHydrationFailsCleanly(string malformedSha) tracer.RelatedEventNames.ShouldContain(e => e == "TryCopyBlobContentStream_MalformedBlobSha"); } - [TestCase(false, false)] - [TestCase(true, false)] - [TestCase(false, true)] - [Category(CategoryConstants.ExceptionExpected)] - public async Task CoalescedWaiterDoesNotInheritTheDownloadOwnersCancellation(bool namedPipeWaiter, bool cancelWaiter) - { - const string ObjectSha = "78981922613b2afb6025042ff6bd878ac1994e85"; - using CancellationTokenSource ownerCancellation = new CancellationTokenSource(); - using CancellationTokenSource waiterCancellation = new CancellationTokenSource(); - using CancellationTokenSource serverCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - using ManualResetEventSlim bodyStarted = new ManualResetEventSlim(); - using CoalescedDownloadTracer tracer = new CoalescedDownloadTracer(); - using ManualResetEventSlim waiterJoined = tracer.WaiterJoined; - using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - string endpoint = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; - int requests = 0; - Task server = this.ServeCanceledThenSuccessfulObject( - listener, ObjectSha, () => Interlocked.Increment(ref requests), serverCancellation.Token); - MockGVFSEnlistment enlistment = new MockGVFSEnlistment( - TestEnlistmentRoot, endpoint, "mock:\\git", new MockGitProcess()); - enlistment.InitializeCachePathsFromKey(TestLocalCacheRoot, TestObjectRoot); - using GitObjectsHttpRequestor requestor = new GitObjectsHttpRequestor( - tracer, enlistment, new CacheServerInfo(endpoint, "loopback"), new RetryConfig(0)); - int objectAvailable = 0; - MockFileSystemWithCallbacks fileSystem = new MockFileSystemWithCallbacks - { - OnFileExists = path => false, - OnMoveFile = (source, target) => Volatile.Write(ref objectAvailable, 1), - OnOpenFileStream = (path, mode, access) => access == FileAccess.Read - ? new MemoryStream(this.validTestObjectFileContents) - : new BodyReadMemoryStream(bodyStarted), - }; - using GitRepo repository = new DownloadedBlobRepo(tracer, () => Volatile.Read(ref objectAvailable) != 0); - GVFSGitObjects objects = new GVFSGitObjects( - new GVFSContext(tracer, fileSystem, repository, enlistment), requestor); - Task owner = Task.Run(() => objects.TryCopyBlobContentStream( - ObjectSha, ownerCancellation.Token, GVFSGitObjects.RequestSource.FileStreamCallback, - (stream, length) => Assert.Fail("Canceled owner must not copy the blob."), out _)); - Task waiter = null; - try - { - bodyStarted.Wait(TimeSpan.FromSeconds(5)).ShouldEqual(true); - waiter = Task.Run(() => - { - if (namedPipeWaiter) - { - return objects.TryDownloadAndSaveObject( - ObjectSha, GVFSGitObjects.RequestSource.NamedPipeMessage) - == GitObjects.DownloadAndSaveObjectResult.Success; - } - - return objects.TryCopyBlobContentStream( - ObjectSha, waiterCancellation.Token, GVFSGitObjects.RequestSource.FileStreamCallback, - (stream, length) => - { - length.ShouldEqual(2L); - stream.ReadByte().ShouldEqual((int)'a'); - }, out _); - }); - tracer.WaiterJoined.Wait(TimeSpan.FromSeconds(5)).ShouldEqual(true); - if (cancelWaiter) - { - waiterCancellation.Cancel(); - } - - ownerCancellation.Cancel(); - Assert.CatchAsync(async () => await owner.WaitAsync(TimeSpan.FromSeconds(5))); - if (cancelWaiter) - { - Assert.CatchAsync(async () => await waiter.WaitAsync(TimeSpan.FromSeconds(5))); - } - else - { - (await waiter.WaitAsync(TimeSpan.FromSeconds(5))).ShouldEqual(true); - } - - requests.ShouldEqual(cancelWaiter ? 1 : 2); - objects.inflightDownloads.Count.ShouldEqual(0); - RetryCircuitBreaker.ConsecutiveFailures.ShouldEqual(0); - tracer.SnapshotRelatedEvents().Count( - record => record.Name == "TryDownloadAndSaveObject_CoalescedCancellation") - .ShouldEqual(cancelWaiter ? 0 : 1); - } - finally - { - ownerCancellation.Cancel(); - waiterCancellation.Cancel(); - serverCancellation.Cancel(); - listener.Stop(); - await ObserveCanceledDownloadTask(owner); - if (waiter != null) - { - await ObserveCanceledDownloadTask(waiter); - } - - await ObserveCanceledDownloadTask(server); - } - } - - private async Task ServeCanceledThenSuccessfulObject( - TcpListener listener, string objectSha, Action onRequest, CancellationToken cancellationToken) - { - for (int attempt = 0; attempt < 2; ++attempt) - { - using TcpClient client = await listener.AcceptTcpClientAsync(cancellationToken); - using NetworkStream stream = client.GetStream(); - using (StreamReader reader = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true)) - { - string request = await reader.ReadLineAsync(cancellationToken); - request.ShouldContain(objectSha); - string line; - do - { - line = await reader.ReadLineAsync(cancellationToken); - } - while (!string.IsNullOrEmpty(line)); - } - - onRequest(); - byte[] headers = Encoding.ASCII.GetBytes( - $"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: application/x-git-loose-object\r\nContent-Length: {this.validTestObjectFileContents.Length}\r\n\r\n"); - await stream.WriteAsync(headers.AsMemory(), cancellationToken); - if (attempt == 0) - { - await stream.WriteAsync(this.validTestObjectFileContents.AsMemory(0, 8), cancellationToken); - try - { - (await stream.ReadAsync(new byte[1].AsMemory(), cancellationToken)).ShouldEqual(0); - } - catch (IOException e) when (e.InnerException is SocketException socketError && - socketError.SocketErrorCode == SocketError.ConnectionReset) - { - // A canceled HTTP response can close the socket with a reset. - } - } - else - { - await stream.WriteAsync(this.validTestObjectFileContents.AsMemory(), cancellationToken); - } - } - } - - private static async Task ObserveCanceledDownloadTask(Task task) - { - try - { - await task.WaitAsync(TimeSpan.FromSeconds(5)); - } - catch (OperationCanceledException) - { - } - } - - private sealed class CoalescedDownloadTracer : MockTracer - { - public ManualResetEventSlim WaiterJoined { get; } = new ManualResetEventSlim(); - - public override void RelatedEvent(EventLevel level, string name, EventMetadata metadata) - { - base.RelatedEvent(level, name, metadata); - if (name == "TryDownloadAndSaveObject_CoalescedRequest") - { - this.WaiterJoined.Set(); - } - } - } - - private sealed class BodyReadMemoryStream : MemoryStream - { - private readonly ManualResetEventSlim bodyStarted; - - public BodyReadMemoryStream(ManualResetEventSlim bodyStarted) - { - this.bodyStarted = bodyStarted; - } - - public override void Write(byte[] buffer, int offset, int count) - { - base.Write(buffer, offset, count); - if (count > 0) - { - this.bodyStarted.Set(); - } - } - } - - private sealed class DownloadedBlobRepo : GitRepo - { - private readonly Func objectAvailable; - - public DownloadedBlobRepo(ITracer tracer, Func objectAvailable) - : base(tracer) - { - this.objectAvailable = objectAvailable; - } - - public override bool TryCopyBlobContentStream(string blobSha, Action writeAction) - { - if (!this.objectAvailable()) - { - return false; - } - - using MemoryStream stream = new MemoryStream(new byte[] { (byte)'a', (byte)'\n' }); - writeAction(stream, stream.Length); - return true; - } - } - private GVFSGitObjects CreateTestableGVFSGitObjects(GitObjectsHttpRequestor httpObjects, MockFileSystemWithCallbacks fileSystem) { return this.CreateTestableGVFSGitObjects(httpObjects, fileSystem, out _); diff --git a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs index b401efd5b..28ea8409c 100644 --- a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs @@ -5,10 +5,7 @@ using NUnit.Framework; using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; -using System.Threading; -using System.Threading.Tasks; namespace GVFS.UnitTests.Git { @@ -551,98 +548,6 @@ public void QuoteGitPath(string input, string expected) GitProcess.QuoteGitPath(input).ShouldEqual(expected); } - [TestCase(30000)] - [TestCase(Timeout.Infinite)] - public void GitCommandWaitsForOutputCallbacks(int timeoutMs) - { - string gitPath = GitTestHelper.FindGitOnPath(); - if (gitPath == null) - { - Assert.Ignore("Git was not found on PATH."); - } - - OutputCallbackGitProcess gitProcess = new OutputCallbackGitProcess(gitPath); - using (ManualResetEventSlim callbackStarted = new ManualResetEventSlim()) - using (ManualResetEventSlim releaseCallback = new ManualResetEventSlim()) - { - bool callbackCompleted = false; - Task command = Task.Run(() => gitProcess.GetVersion( - line => - { - callbackStarted.Set(); - callbackCompleted = releaseCallback.Wait(TimeSpan.FromSeconds(10)); - }, - timeoutMs)); - - try - { - callbackStarted.Wait(TimeSpan.FromSeconds(10)).ShouldBeTrue("Git did not produce output"); - command.Wait(TimeSpan.FromSeconds(1)).ShouldBeFalse("The command returned before its output callback finished"); - } - finally - { - releaseCallback.Set(); - command.Wait(TimeSpan.FromSeconds(10)).ShouldBeTrue("The command did not finish after its output callback was released"); - } - - command.Result.ExitCode.ShouldEqual(0); - callbackCompleted.ShouldBeTrue(); - } - } - - [TestCase] - [Description("Integration test: verify QuoteGitPath produces arguments that git actually receives correctly")] - public void QuoteGitPath_RoundTripThroughProcess() - { - // Test that paths with special characters survive the - // ProcessStartInfo.Arguments → Windows CRT argument parsing → git round-trip. - // We use "git rev-parse --sq-quote " which echoes the path back - // in shell-quoted form, proving git received it correctly. - string[] testPaths = new[] - { - "simple/path.txt", - "path with spaces/file name.txt", - "path\\with\\backslashes\\file.txt", - }; - - string gitPath = GitTestHelper.FindGitOnPath(); - if (gitPath == null) - { - Assert.Ignore("Git was not found on PATH."); - } - - foreach (string testPath in testPaths) - { - string quoted = GitProcess.QuoteGitPath(testPath); - ProcessStartInfo psi = new ProcessStartInfo(gitPath) - { - Arguments = "rev-parse --sq-quote " + quoted, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - using (Process proc = Process.Start(psi)) - { - string output = proc.StandardOutput.ReadToEnd().Trim(); - proc.WaitForExit(); - - // git sq-quote wraps in single quotes and escapes single quotes - // For a simple path "foo/bar.txt" → output is "'foo/bar.txt'" - // Strip the outer single quotes to get the raw path back - if (output.StartsWith("'") && output.EndsWith("'")) - { - output = output.Substring(1, output.Length - 2); - } - - output.ShouldEqual( - testPath, - $"Path round-trip failed for: {testPath} (quoted as: {quoted})"); - } - } - } - private MockGitProcess CreateProcessWithTransientThenSuccess(string command, string successOutput, out Func getDelayCount) { int delayCount = 0; @@ -654,26 +559,5 @@ private MockGitProcess CreateProcessWithTransientThenSuccess(string command, str getDelayCount = () => delayCount; return process; } - - private sealed class OutputCallbackGitProcess : GitProcess - { - public OutputCallbackGitProcess(string gitPath) - : base(gitPath, workingDirectoryRoot: null) - { - } - - public Result GetVersion(Action onOutput, int timeoutMs) - { - return this.InvokeGitImpl( - "--version", - workingDirectory: null, - dotGitDirectory: null, - useReadObjectHook: false, - writeStdIn: null, - parseStdOutLine: onOutput, - timeoutMs: timeoutMs, - usePreCommandHook: false); - } - } } } diff --git a/GVFS/GVFS.UnitTests/Http/HttpResponseStreamTestSupport.cs b/GVFS/GVFS.UnitTests/Http/HttpResponseStreamTestSupport.cs new file mode 100644 index 000000000..afbd4e9ff --- /dev/null +++ b/GVFS/GVFS.UnitTests/Http/HttpResponseStreamTestSupport.cs @@ -0,0 +1,59 @@ +using GVFS.Common.Http; +using GVFS.Common.Tracing; +using GVFS.UnitTests.Mock.Common; +using System; +using System.IO; +using System.Linq; +using System.Threading; + +namespace GVFS.UnitTests.Http +{ + internal static class HttpResponseStreamTestSupport + { + internal static HttpResponseStream CreateStream( + Stream innerStream, + MockTracer tracer = null, + TimeSpan? readTimeout = null) + { + return new HttpResponseStream( + innerStream, + readTimeout ?? TimeSpan.FromMilliseconds(300), + CancellationToken.None, + tracer, + new EventMetadata + { + { "RequestId", 42L }, + { "CacheName", "test-cache" }, + { "RequestAuthority", "cache.example:443" }, + }, + progressInterval: TimeSpan.FromMilliseconds(30)); + } + + internal static MockTracer.RecordedEvent LastBodyEvent(MockTracer tracer, string outcome) + { + return tracer.SnapshotRelatedEvents() + .Where(record => record.Metadata != null && + record.Metadata.TryGetValue("Outcome", out object value) && (string)value == outcome) + .OrderBy(record => (long)record.Metadata["SampleSequence"]) + .Last(); + } + + internal class CallbackTracer : MockTracer + { + public Action Callback { get; set; } + public Action EventCallback { get; set; } + + public override void RelatedEvent(EventLevel level, string eventName, EventMetadata metadata) + { + this.RelatedEvent(level, eventName, metadata, Keywords.None); + } + + public override void RelatedEvent(EventLevel level, string eventName, EventMetadata metadata, Keywords keyword) + { + base.RelatedEvent(level, eventName, metadata, keyword); + this.EventCallback?.Invoke(eventName, metadata); + this.Callback?.Invoke(eventName); + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Http/HttpResponseStreamTests.cs b/GVFS/GVFS.UnitTests/Http/HttpResponseStreamTests.cs index 1ee094a44..db11417ad 100644 --- a/GVFS/GVFS.UnitTests/Http/HttpResponseStreamTests.cs +++ b/GVFS/GVFS.UnitTests/Http/HttpResponseStreamTests.cs @@ -1,11 +1,9 @@ using GVFS.Common; -using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Common.Tracing; using GVFS.Tests.Should; using GVFS.UnitTests.Category; using GVFS.UnitTests.Mock.Common; -using GVFS.UnitTests.Mock.Git; using Moq; using NUnit.Framework; using System; @@ -13,10 +11,9 @@ using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Sockets; -using System.Text; using System.Threading; using System.Threading.Tasks; +using static GVFS.UnitTests.Http.HttpResponseStreamTestSupport; namespace GVFS.UnitTests.Http { @@ -283,382 +280,6 @@ public void FastTransfersDoNotEmitProgressEvents() tracer.SnapshotRelatedEvents().Length.ShouldEqual(0); } - [TestCase] - [Category(CategoryConstants.ExceptionExpected)] - public async Task RealHttpBodyReadTimesOutAfterHeadersAndPartialData() - { - using (MockTracer tracer = new MockTracer()) - using (CancellationTokenSource serverCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10))) - using (TcpListener listener = new TcpListener(IPAddress.Loopback, 0)) - using (HttpClient client = new HttpClient()) - { - listener.Start(); - Task server = ServeStalledResponse(listener, serverCancellation.Token); - try - { - int port = ((IPEndPoint)listener.LocalEndpoint).Port; - using (HttpResponseMessage response = await client.GetAsync( - $"http://127.0.0.1:{port}/body", - HttpCompletionOption.ResponseHeadersRead).WaitAsync(TimeSpan.FromSeconds(5))) - using (HttpResponseStream stream = CreateStream(await response.Content.ReadAsStreamAsync(), tracer)) - { - stream.ReadByte().ShouldEqual((int)'x'); - Assert.ThrowsAsync( - async () => await stream.ReadAsync(new byte[8].AsMemory()).AsTask().WaitAsync(TimeSpan.FromSeconds(5))); - stream.ReadFailed.ShouldEqual(true); - LastBodyEvent(tracer, "TimedOut").Name.ShouldEqual("NetworkBodyReadTimeout"); - } - } - finally - { - serverCancellation.Cancel(); - listener.Stop(); - try - { - await server.WaitAsync(TimeSpan.FromSeconds(5)); - } - catch (OperationCanceledException) when (serverCancellation.IsCancellationRequested) - { - } - } - } - } - - [TestCase(HttpStatusCode.OK)] - [TestCase(HttpStatusCode.ServiceUnavailable)] - [TestCase(HttpStatusCode.Unauthorized)] - [TestCase(HttpStatusCode.NotFound)] - [Category(CategoryConstants.ExceptionExpected)] - public async Task RequestorAppliesConfiguredReadTimeoutToSuccessAndErrorBodies(HttpStatusCode status) - { - using (MockTracer tracer = new MockTracer()) - using (CancellationTokenSource serverCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10))) - using (TcpListener listener = new TcpListener(IPAddress.Loopback, 0)) - using (LoopbackHttpRequestor requestor = new LoopbackHttpRequestor(tracer)) - { - listener.Start(); - Task server = ServeStalledResponse(listener, serverCancellation.Token, status); - try - { - int port = ((IPEndPoint)listener.LocalEndpoint).Port; - Uri uri = new Uri($"http://127.0.0.1:{port}/body?query-marker=not-for-telemetry"); - if (status == HttpStatusCode.OK) - { - using (GitEndPointResponseData response = requestor.Get(uri)) - { - response.Stream.ReadByte().ShouldEqual((int)'x'); - Assert.Throws(() => response.Stream.ReadByte()); - response.StreamReadFailed.ShouldEqual(true); - response.ResponseId.ShouldEqual((string)LastBodyEvent(tracer, "TimedOut").Metadata["ResponseId"]); - } - } - else - { - using (GitEndPointResponseData response = requestor.Get(uri)) - { - response.StatusCode.ShouldEqual(status); - ((GitObjectsHttpException)response.Error).StatusCode.ShouldEqual(status); - response.Error.Message.ShouldContain("response body"); - response.Error.Message.ShouldContain("No response body data arrived"); - response.ShouldRetry.ShouldEqual(status == HttpStatusCode.ServiceUnavailable); - } - } - - EventMetadata timeout = LastBodyEvent(tracer, "TimedOut").Metadata; - timeout["RequestId"].ShouldEqual(42L); - timeout["StatusCode"].ShouldEqual(status); - timeout["CacheName"].ShouldEqual("test-cache"); - timeout["RequestAuthority"].ShouldEqual($"127.0.0.1:{port}"); - timeout["BytesRead"].ShouldEqual(1L); - timeout["ReadTimeoutMs"].ShouldEqual(300D); - timeout.ContainsKey("ContentType").ShouldEqual(false); - GVFSJsonOptions.Serialize(timeout).Contains("query-marker", StringComparison.Ordinal).ShouldEqual(false); - } - finally - { - serverCancellation.Cancel(); - listener.Stop(); - try - { - await server.WaitAsync(TimeSpan.FromSeconds(5)); - } - catch (OperationCanceledException) when (serverCancellation.IsCancellationRequested) - { - } - } - } - } - - private static HttpResponseStream CreateStream( - Stream innerStream, - MockTracer tracer = null, - TimeSpan? readTimeout = null) - { - return new HttpResponseStream( - innerStream, - readTimeout ?? TimeSpan.FromMilliseconds(300), - CancellationToken.None, - tracer, - new EventMetadata - { - { "RequestId", 42L }, - { "CacheName", "test-cache" }, - { "RequestAuthority", "cache.example:443" }, - }, - progressInterval: TimeSpan.FromMilliseconds(30)); - } - - [TestCase] - [Category(CategoryConstants.ExceptionExpected)] - public async Task StalledErrorBodyRetriesWithTheStatusAndReleasesItsConnectionPermit() - { - using MockTracer tracer = new MockTracer(); - using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - string endpoint = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; - Task server = ServeRetriedError(listener, cancellation.Token); - using GitObjectsHttpRequestor requestor = new GitObjectsHttpRequestor( - tracer, new MockGVFSEnlistment(), new CacheServerInfo(endpoint, "test"), - new RetryConfig(1) { ReadTimeout = TimeSpan.FromMilliseconds(100) }); - try - { - RetryWrapper.InvocationResult result = - requestor.TrySendProtocolRequest( - 123, - (attempt, response) => - { - response.Stream.ReadByte().ShouldEqual(-1); - return new RetryWrapper.CallbackResult( - new GitObjectsHttpRequestor.GitObjectTaskResult(true)); - }, - onFailure: null, - method: HttpMethod.Get, - endPointGenerator: () => new Uri(endpoint), - requestBodyGenerator: () => null, - cancellationToken: CancellationToken.None); - result.Succeeded.ShouldEqual(true); - result.Attempts.ShouldEqual(2); - EventMetadata[] responses = tracer.SnapshotRelatedEvents().Select(record => record.Metadata) - .Where(metadata => metadata.ContainsKey("availableConnections")).ToArray(); - responses.Length.ShouldEqual(2); - responses[0]["StatusCode"].ShouldEqual(HttpStatusCode.ServiceUnavailable); - responses[1]["StatusCode"].ShouldEqual(HttpStatusCode.OK); - responses[1]["availableConnections"].ShouldEqual(responses[0]["availableConnections"]); - } - finally - { - cancellation.Cancel(); - listener.Stop(); - try - { - await server.WaitAsync(TimeSpan.FromSeconds(5)); - } - catch (OperationCanceledException) when (cancellation.IsCancellationRequested) - { - } - } - } - - [TestCase] - [Category(CategoryConstants.ExceptionExpected)] - public async Task ConfigProbeRetainsUnauthorizedStatusWhenItsBodyStalls() - { - using MockTracer tracer = new MockTracer(); - using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - string endpoint = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; - Task server = ServeStalledResponse(listener, cancellation.Token, HttpStatusCode.Unauthorized); - using ConfigHttpRequestor requestor = new ConfigHttpRequestor( - tracer, new MockGVFSEnlistment("mock:\\repo", endpoint, "mock:\\git", new MockGitProcess()), - new RetryConfig(0) { ReadTimeout = TimeSpan.FromMilliseconds(100) }); - try - { - requestor.TryQueryGVFSConfig(false, out ServerGVFSConfig _, out HttpStatusCode? status, out string error) - .ShouldEqual(false); - status.ShouldEqual(HttpStatusCode.Unauthorized); - error.ShouldContain("401"); - error.ShouldContain("No response body data arrived"); - } - finally - { - cancellation.Cancel(); - listener.Stop(); - try - { - await server.WaitAsync(TimeSpan.FromSeconds(5)); - } - catch (OperationCanceledException) when (cancellation.IsCancellationRequested) - { - } - } - } - - [TestCase] - [Category(CategoryConstants.ExceptionExpected)] - public async Task FinalResponseDiagnosticFailureReleasesThePreparedResponse() - { - using CallbackTracer tracer = new CallbackTracer(); - bool fail = true; - tracer.Callback = name => - { - if (name == "NetworkResponse" && fail) - { - fail = false; - throw new InvalidOperationException("diagnostic failure"); - } - }; - using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - Uri endpoint = new Uri($"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"); - Task server = ServeRetriedError(listener, cancellation.Token); - using LoopbackHttpRequestor requestor = new LoopbackHttpRequestor(tracer); - try - { - Assert.Throws(() => requestor.Get(endpoint)); - using (GitEndPointResponseData response = requestor.Get(endpoint)) - { - response.StatusCode.ShouldEqual(HttpStatusCode.OK); - response.Stream.ReadByte().ShouldEqual(-1); - } - - MockTracer.RecordedEvent[] responses = tracer.SnapshotRelatedEvents() - .Where(record => record.Name == "NetworkResponse").ToArray(); - responses.Length.ShouldEqual(2); - responses[1].Metadata["availableConnections"].ShouldEqual(responses[0].Metadata["availableConnections"]); - } - finally - { - cancellation.Cancel(); - listener.Stop(); - await ObserveServerCancellation(server, cancellation.Token); - } - } - - [TestCase] - public async Task AnonymousUnauthorizedBodyDoesNotReachTheErrorTelemetry() - { - using MockTracer tracer = new MockTracer(); - using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - Uri endpoint = new Uri($"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"); - Task server = ServeCompleteResponses(listener, cancellation.Token, - (HttpStatusCode.Unauthorized, "\u001b[31mRAW-BODY-MARKER", "text/plain; charset=utf-8")); - using GitObjectsHttpRequestor requestor = new GitObjectsHttpRequestor( - tracer, new MockGVFSEnlistment(), new CacheServerInfo(endpoint.AbsoluteUri, "test"), new RetryConfig(0)); - try - { - RetryWrapper.InvocationResult result = - requestor.TrySendProtocolRequest( - 123, - (attempt, response) => throw new InvalidOperationException("Unexpected success"), - RetryWrapper.StandardErrorHandler(tracer, 123, "test"), - HttpMethod.Get, - () => endpoint, - () => null, - CancellationToken.None); - result.Succeeded.ShouldEqual(false); - result.Error.Message.ShouldEqual("Anonymous request was rejected with a 401"); - tracer.RelatedErrorEvents.Count.ShouldEqual(1); - tracer.RelatedErrorEvents[0].ShouldContain("Anonymous request was rejected with a 401"); - tracer.RelatedErrorEvents[0].Contains("RAW-BODY-MARKER", StringComparison.Ordinal).ShouldEqual(false); - } - finally - { - cancellation.Cancel(); - listener.Stop(); - await ObserveServerCancellation(server, cancellation.Token); - } - } - - [TestCase(true)] - [TestCase(false)] - public async Task CompletedBadRequestBodyPreservesCharsetAndCredentialPolicy(bool authRequired) - { - using MockTracer tracer = new MockTracer(); - using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - string endpoint = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; - string body = authRequired ? HttpRequestor.CacheServerAuthRequiredBadRequestMessage : "denied: caf\u00e9"; - Task server = ServeCompleteResponses(listener, cancellation.Token, - (HttpStatusCode.Unauthorized, "denied", "text/plain"), - (HttpStatusCode.OK, "{}", "application/json"), - (HttpStatusCode.BadRequest, body, "text/plain; charset=utf-8")); - MockGitProcess git = CreateCredentialGitProcess(endpoint); - GitAuthentication authentication = new GitAuthentication(git, endpoint); - MockGVFSEnlistment enlistment = new MockGVFSEnlistment( - "mock:\\repo", endpoint, "mock:\\git", git, authentication); - using LoopbackHttpRequestor requestor = new LoopbackHttpRequestor(tracer, enlistment); - try - { - authentication.TryInitializeAndQueryGVFSConfig( - tracer, enlistment, new RetryConfig(0, TimeSpan.FromSeconds(5)), - out _, out string error, out _).ShouldEqual(true, error); - authentication.IsAnonymous.ShouldEqual(false); - using (GitEndPointResponseData response = requestor.Get(new Uri(endpoint))) - { - response.StatusCode.ShouldEqual(HttpStatusCode.BadRequest); - response.ShouldRetry.ShouldEqual(false); - response.Error.Message.ShouldContain(body); - } - - bool rejected = git.CredentialRejections.TryGetValue(endpoint, out System.Collections.Generic.List rejections); - rejected.ShouldEqual(authRequired); - if (authRequired) - { - rejections.Count.ShouldEqual(1); - } - } - finally - { - cancellation.Cancel(); - listener.Stop(); - await ObserveServerCancellation(server, cancellation.Token); - } - } - - [TestCase] - public async Task AuthenticationInitializationLoadsAllRetrySettings() - { - using MockTracer tracer = new MockTracer(); - using CancellationTokenSource cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - string endpoint = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; - Task server = ServeCompleteResponses(listener, cancellation.Token, - (HttpStatusCode.OK, "{}", "application/json")); - MockGitProcess git = CreateCredentialGitProcess(endpoint); - git.SetExpectedCommandResult("config gvfs.max-retries", () => new GitProcess.Result("0", string.Empty, 0)); - git.SetExpectedCommandResult("config gvfs.timeout-seconds", () => new GitProcess.Result("2", string.Empty, 0)); - git.SetExpectedCommandResult("config gvfs.read-timeout-seconds", () => new GitProcess.Result("1", string.Empty, 0)); - GitAuthentication authentication = new GitAuthentication(git, endpoint); - MockGVFSEnlistment enlistment = new MockGVFSEnlistment( - "mock:\\repo", endpoint, "mock:\\git", git, authentication); - - // libgit2 reads the settings from the isolated global config. When the environment - // changes Git config, git.exe reads the same values from the mock. - using IsolatedLibGit2Config machineConfig = new IsolatedLibGit2Config(); - File.WriteAllText(machineConfig.GlobalConfigPath, "[gvfs]\n\tmax-retries = 0\n\ttimeout-seconds = 2\n\tread-timeout-seconds = 1\n"); - try - { - authentication.TryInitialize(tracer, enlistment, out string error).ShouldEqual(true, error); - EventMetadata loaded = tracer.RelatedEventMetadata[tracer.RelatedEventNames.IndexOf("RetryConfig_LoadedRetryConfig")]; - loaded["MaxRetries"].ShouldEqual(0); - loaded["Timeout"].ShouldEqual(TimeSpan.FromSeconds(2)); - loaded["ReadTimeout"].ShouldEqual(TimeSpan.FromSeconds(1)); - } - finally - { - cancellation.Cancel(); - listener.Stop(); - await ObserveServerCancellation(server, cancellation.Token); - } - } - [TestCase] [Category(CategoryConstants.ExceptionExpected)] public void DisposedRootTracerDoesNotChangeTheReadFailureOrExitTheProcess() @@ -674,84 +295,6 @@ public void DisposedRootTracerDoesNotChangeTheReadFailureOrExitTheProcess() stream.ReadFailed.ShouldEqual(true); } - private static MockGitProcess CreateCredentialGitProcess(string endpoint) - { - MockGitProcess git = new MockGitProcess(); - git.SetExpectedCommandResult("config --get-urlmatch http " + endpoint, - () => new GitProcess.Result(string.Empty, string.Empty, 0)); - git.SetExpectedCommandResult("config gvfs.max-http-connections", - () => new GitProcess.Result(string.Empty, string.Empty, 1)); - const string Prefix = "-c credential.\"https://dev.azure.com\".useHttpPath=true credential "; - git.SetExpectedCommandResult(Prefix + "fill", - () => new GitProcess.Result("username=test-user\npassword=test-password\n", string.Empty, 0)); - git.SetExpectedCommandResult(Prefix + "approve", () => new GitProcess.Result(string.Empty, string.Empty, 0)); - git.SetExpectedCommandResult(Prefix + "reject", () => new GitProcess.Result(string.Empty, string.Empty, 0)); - return git; - } - - private static async Task ServeCompleteResponses(TcpListener listener, CancellationToken cancellationToken, - params (HttpStatusCode Status, string Body, string ContentType)[] responses) - { - foreach ((HttpStatusCode status, string body, string contentType) in responses) - { - using TcpClient client = await listener.AcceptTcpClientAsync(cancellationToken); - using NetworkStream stream = client.GetStream(); - using (StreamReader reader = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true)) - { - string line; - do - { - line = await reader.ReadLineAsync(cancellationToken); - } - while (!string.IsNullOrEmpty(line)); - } - - byte[] data = Encoding.UTF8.GetBytes(body); - byte[] headers = Encoding.ASCII.GetBytes( - $"HTTP/1.1 {(int)status} Test\r\nConnection: close\r\nContent-Type: {contentType}\r\nContent-Length: {data.Length}\r\n\r\n"); - await stream.WriteAsync(headers.AsMemory(), cancellationToken); - await stream.WriteAsync(data.AsMemory(), cancellationToken); - } - } - - private static async Task ObserveServerCancellation(Task server, CancellationToken cancellationToken) - { - try - { - await server.WaitAsync(TimeSpan.FromSeconds(5)); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - } - } - - private static async Task ServeRetriedError(TcpListener listener, CancellationToken cancellationToken) - { - for (int attempt = 0; attempt < 2; ++attempt) - { - using TcpClient client = await listener.AcceptTcpClientAsync(cancellationToken); - using NetworkStream stream = client.GetStream(); - using (StreamReader reader = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true)) - { - string line; - do - { - line = await reader.ReadLineAsync(cancellationToken); - } - while (!string.IsNullOrEmpty(line)); - } - - byte[] response = Encoding.ASCII.GetBytes(attempt == 0 - ? "HTTP/1.1 503 Unavailable\r\nContent-Length: 2\r\n\r\nx" - : "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); - await stream.WriteAsync(response.AsMemory(), cancellationToken); - if (attempt == 0) - { - (await stream.ReadAsync(new byte[1].AsMemory(), cancellationToken)).ShouldEqual(0); - } - } - } - [TestCase] [Category(CategoryConstants.ExceptionExpected)] public async Task DisabledReadTimeoutStillHonorsCallerCancellation() @@ -1041,33 +584,6 @@ public void ProgressReportsCountOnlyBytesSinceThePreviousReport() reports.OrderBy(report => (long)report["SampleSequence"]).Last()["Outcome"].ShouldEqual("Completed"); } - private static MockTracer.RecordedEvent LastBodyEvent(MockTracer tracer, string outcome) - { - return tracer.SnapshotRelatedEvents() - .Where(record => record.Metadata != null && - record.Metadata.TryGetValue("Outcome", out object value) && (string)value == outcome) - .OrderBy(record => (long)record.Metadata["SampleSequence"]) - .Last(); - } - - private class CallbackTracer : MockTracer - { - public Action Callback { get; set; } - public Action EventCallback { get; set; } - - public override void RelatedEvent(EventLevel level, string eventName, EventMetadata metadata) - { - this.RelatedEvent(level, eventName, metadata, Keywords.None); - } - - public override void RelatedEvent(EventLevel level, string eventName, EventMetadata metadata, Keywords keyword) - { - base.RelatedEvent(level, eventName, metadata, keyword); - this.EventCallback?.Invoke(eventName, metadata); - this.Callback?.Invoke(eventName); - } - } - private class FailingReadStream : MemoryStream { public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) @@ -1076,47 +592,6 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken } } - private static async Task ServeStalledResponse( - TcpListener listener, - CancellationToken cancellationToken, - HttpStatusCode status = HttpStatusCode.OK) - { - using (TcpClient connection = await listener.AcceptTcpClientAsync(cancellationToken)) - using (NetworkStream stream = connection.GetStream()) - { - using (StreamReader request = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true)) - { - string line; - do - { - line = await request.ReadLineAsync(cancellationToken); - } - while (!string.IsNullOrEmpty(line)); - } - - byte[] response = Encoding.ASCII.GetBytes( - $"HTTP/1.1 {(int)status} Test\r\nContent-Length: 2\r\nX-Cache-Name: test-cache\r\n\r\nx"); - await stream.WriteAsync(response.AsMemory(), cancellationToken); - await Task.Delay(Timeout.Infinite, cancellationToken); - } - } - - private class LoopbackHttpRequestor : HttpRequestor - { - public LoopbackHttpRequestor(MockTracer tracer, Enlistment enlistment = null) - : base( - tracer, - new RetryConfig(0, TimeSpan.FromSeconds(5)) { ReadTimeout = TimeSpan.FromMilliseconds(300) }, - enlistment ?? new MockGVFSEnlistment()) - { - } - - public GitEndPointResponseData Get(Uri uri) - { - return this.SendRequest(42, uri, HttpMethod.Get, null, CancellationToken.None); - } - } - private class BlockingReadStream : MemoryStream { public BlockingReadStream(byte[] prefix = null) diff --git a/GVFS/GVFS.UnitTests/InternalsVisibleTo.cs b/GVFS/GVFS.UnitTests/InternalsVisibleTo.cs new file mode 100644 index 000000000..1380e2b33 --- /dev/null +++ b/GVFS/GVFS.UnitTests/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GVFS.FunctionalTests")] diff --git a/GVFS/GVFS.UnitTests/Mock/Git/RetryConfigTestRepository.cs b/GVFS/GVFS.UnitTests/Mock/Git/RetryConfigTestRepository.cs new file mode 100644 index 000000000..1e7049c0f --- /dev/null +++ b/GVFS/GVFS.UnitTests/Mock/Git/RetryConfigTestRepository.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; + +namespace GVFS.UnitTests.Mock.Git +{ + // This repository requires no git.exe. The non-ASCII path checks libgit2's UTF-8 marshalling. + internal sealed class RetryConfigTestRepository : IDisposable + { + private readonly IsolatedLibGit2Config machineConfig = new IsolatedLibGit2Config(); + + public RetryConfigTestRepository(string extraConfig) + { + this.WorkingDirectory = Path.Combine(Path.GetTempPath(), "RetryConfigTests_ü_" + Guid.NewGuid().ToString("N")); + string dotGit = Path.Combine(this.WorkingDirectory, ".git"); + Directory.CreateDirectory(Path.Combine(dotGit, "objects")); + Directory.CreateDirectory(Path.Combine(dotGit, "refs", "heads")); + File.WriteAllText(Path.Combine(dotGit, "HEAD"), "ref: refs/heads/main\n"); + File.WriteAllText( + Path.Combine(dotGit, "config"), + "[core]\n\trepositoryformatversion = 0\n\tbare = false\n" + extraConfig); + } + + public string WorkingDirectory { get; } + + public void Dispose() + { + try + { + Directory.Delete(this.WorkingDirectory, recursive: true); + } + finally + { + this.machineConfig.Dispose(); + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Tracing/QueuedPipeStringWriterTests.cs b/GVFS/GVFS.UnitTests/Tracing/QueuedPipeStringWriterTests.cs index e3df9c0c4..f1793fcc5 100644 --- a/GVFS/GVFS.UnitTests/Tracing/QueuedPipeStringWriterTests.cs +++ b/GVFS/GVFS.UnitTests/Tracing/QueuedPipeStringWriterTests.cs @@ -1,273 +1,58 @@ -using System; -using System.IO.Pipes; -using System.Threading; -using GVFS.Common.Tracing; -using GVFS.Tests.Should; -using Moq; -using NUnit.Framework; - -namespace GVFS.UnitTests.Tracing -{ - [TestFixture] - public class QueuedPipeStringWriterTests - { - [TestCase] - public void Stop_RaisesStateStopped() - { - // Capture event invocations - Mock eventSink = new Mock(); - - // createPipeFunc returns `null` since the test will never enqueue any messaged to write - QueuedPipeStringWriter writer = new QueuedPipeStringWriter( - () => null, - eventSink.Object); - - // Try to write some dummy data - writer.Start(); - writer.Stop(); - - eventSink.Verify( - x => x.OnStateChanged(writer, QueuedPipeStringWriterState.Stopped, null), - Times.Once); - } - - [TestCase] - public void MissingPipe_RaisesStateFailing() - { - const string inputMessage = "FooBar"; - - // Capture event invocations - Mock eventSink = new Mock(); - - QueuedPipeStringWriter writer = new QueuedPipeStringWriter( - () => throw new Exception("Failing pipe connection"), - eventSink.Object); - - // Try to write some dummy data - writer.Start(); - bool queueOk = writer.TryEnqueue(inputMessage); - writer.Stop(); - - queueOk.ShouldBeTrue(); - eventSink.Verify( - x => x.OnStateChanged(writer, QueuedPipeStringWriterState.Failing, It.IsAny()), - Times.Once); - eventSink.Verify( - x => x.OnStateChanged(writer, QueuedPipeStringWriterState.Stopped, It.IsAny()), - Times.Once); - } - - [TestCase] - public void GoodPipe_WritesDataAndRaisesStateHealthy() - { - const string inputMessage = "FooBar"; - byte[] expectedData = - { - 0x46, 0x6F, 0x6F, 0x42, 0x61, 0x72, (byte)'\n', // "FooBar\n" - 0x46, 0x6F, 0x6F, 0x42, 0x61, 0x72, (byte)'\n', // "FooBar\n" - 0x46, 0x6F, 0x6F, 0x42, 0x61, 0x72, (byte)'\n', // "FooBar\n" - }; - - string pipeName = Guid.NewGuid().ToString("N"); - - // Capture event invocations - Mock eventSink = new Mock(); - - QueuedPipeStringWriter writer = new QueuedPipeStringWriter( - () => new NamedPipeClientStream(".", pipeName, PipeDirection.Out), - eventSink.Object); - - using (TestPipeReaderWorker pipeWorker = new TestPipeReaderWorker(pipeName, PipeTransmissionMode.Byte)) - { - // Start the pipe reader worker first and wait until the pipe server has been stood-up - // before starting the pipe writer/enqueuing messages because the writer does not wait - // for the pipe to be ready to accept (it returns and drops messages immediately). - pipeWorker.Start(); - pipeWorker.WaitForReadyToAccept(); - writer.Start(); - - // Try to write some dummy data - bool queueOk1 = writer.TryEnqueue(inputMessage); - bool queueOk2 = writer.TryEnqueue(inputMessage); - bool queueOk3 = writer.TryEnqueue(inputMessage); - - // Wait until we've received all the sent messages before shuting down - // the pipe worker thread. - pipeWorker.WaitForRecievedBytes(count: expectedData.Length); - pipeWorker.Stop(); - writer.Stop(); - - queueOk1.ShouldBeTrue(); - queueOk2.ShouldBeTrue(); - queueOk3.ShouldBeTrue(); - - byte[] actualData = pipeWorker.GetReceivedDataSnapshot(); - CollectionAssert.AreEqual(expectedData, actualData); - - // Should only receive one 'healthy' state change per successfully written message - eventSink.Verify( - x => x.OnStateChanged(writer, QueuedPipeStringWriterState.Healthy, It.IsAny()), - Times.Once); - eventSink.Verify( - x => x.OnStateChanged(writer, QueuedPipeStringWriterState.Stopped, It.IsAny()), - Times.Once); - } - } - - private class TestPipeReaderWorker : IDisposable - { - private readonly string pipeName; - private readonly PipeTransmissionMode transmissionMode; - private readonly AutoResetEvent receivedData = new AutoResetEvent(initialState: false); - private readonly ManualResetEvent readyToAccept = new ManualResetEvent(initialState: false); - private readonly ManualResetEvent shutdownEvent = new ManualResetEvent(initialState: false); - - private int bufferLength = 0; - private byte[] buffer = new byte[16*1024]; - private Lock bufferLock = new Lock(); - private Thread thread; - private bool isRunning; - private bool isDisposed; - - public TestPipeReaderWorker(string pipeName, PipeTransmissionMode transmissionMode) - { - this.pipeName = pipeName; - this.transmissionMode = transmissionMode; - } - - public void Start() - { - if (!this.isRunning) - { - this.isRunning = true; - this.thread = new Thread(this.ThreadProc) - { - Name = nameof(TestPipeReaderWorker), - IsBackground = true - }; - this.thread.Start(); - } - } - - public void WaitForReadyToAccept() - { - this.readyToAccept.WaitOne(); - } - - public void WaitForRecievedBytes(int count) - { - if (!this.isRunning) - { - throw new InvalidOperationException("Worker has been stopped so will never receieve new data"); - } - - int length; - - while (true) - { - // Since the buffer can only grow (and we only care about waiting for a minimum length), we - // don't care that the length could increase after we've released the lock. - lock (this.bufferLock) - { - length = this.bufferLength; - } - - if (length >= count) - { - break; - } - - // Wait for more data (the buffer will grow) - this.receivedData.WaitOne(); - } - } - - public byte[] GetReceivedDataSnapshot() - { - if (this.isRunning) - { - throw new InvalidOperationException("Should stop the test pipe worker first"); - } - - if (this.isDisposed) - { - throw new ObjectDisposedException($"{nameof(TestPipeReaderWorker)}"); - } - - byte[] snapshot; - - lock (this.bufferLock) - { - snapshot = new byte[this.bufferLength]; - Array.Copy(this.buffer, snapshot, snapshot.Length); - } - - return snapshot; - } - - public void Stop() - { - if (this.isRunning) - { - this.isRunning = false; - this.shutdownEvent.Set(); - this.thread.Join(); - } - } - - public void Dispose() - { - if (this.isDisposed) - { - return; - } - - this.Stop(); - this.isDisposed = true; - } - - private void ThreadProc() - { - using (NamedPipeServerStream pipe = new NamedPipeServerStream(this.pipeName, PipeDirection.In, -1, this.transmissionMode, PipeOptions.Asynchronous)) - { - // Signal that the pipe has been created and we're ready to accept clients - this.readyToAccept.Set(); - - pipe.WaitForConnection(); - - while (this.isRunning) - { - byte[] readBuffer = new byte[1024]; - - IAsyncResult asyncResult = pipe.BeginRead(readBuffer, offset: 0, count: readBuffer.Length, callback: null, state: null); - - // Wait for a read operation to complete, or until we're told to shutdown - WaitHandle.WaitAny(new[] { asyncResult.AsyncWaitHandle, this.shutdownEvent }); - - if (this.isRunning) - { - // Complete the read - int nr = pipe.EndRead(asyncResult); - if (nr > 0) - { - // We actually read some data so append this to the main buffer - lock (this.bufferLock) - { - Array.Copy(readBuffer, 0, this.buffer, this.bufferLength, nr); - this.bufferLength += nr; - } - - this.receivedData.Set(); - } - else - { - // We got here because the pipe has been closed. - // If we've been asked to shutdown we will break on the next while-loop evaluation. - } - } - } - } - } - } - } -} +using System; +using GVFS.Common.Tracing; +using GVFS.Tests.Should; +using Moq; +using NUnit.Framework; + +namespace GVFS.UnitTests.Tracing +{ + [TestFixture] + public class QueuedPipeStringWriterTests + { + [TestCase] + public void Stop_RaisesStateStopped() + { + // Capture event invocations + Mock eventSink = new Mock(); + + // createPipeFunc returns `null` since the test will never enqueue any messaged to write + QueuedPipeStringWriter writer = new QueuedPipeStringWriter( + () => null, + eventSink.Object); + + // Try to write some dummy data + writer.Start(); + writer.Stop(); + + eventSink.Verify( + x => x.OnStateChanged(writer, QueuedPipeStringWriterState.Stopped, null), + Times.Once); + } + + [TestCase] + public void MissingPipe_RaisesStateFailing() + { + const string inputMessage = "FooBar"; + + // Capture event invocations + Mock eventSink = new Mock(); + + QueuedPipeStringWriter writer = new QueuedPipeStringWriter( + () => throw new Exception("Failing pipe connection"), + eventSink.Object); + + // Try to write some dummy data + writer.Start(); + bool queueOk = writer.TryEnqueue(inputMessage); + writer.Stop(); + + queueOk.ShouldBeTrue(); + eventSink.Verify( + x => x.OnStateChanged(writer, QueuedPipeStringWriterState.Failing, It.IsAny()), + Times.Once); + eventSink.Verify( + x => x.OnStateChanged(writer, QueuedPipeStringWriterState.Stopped, It.IsAny()), + Times.Once); + } + } +} diff --git a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs index d1f38c6c4..742c1b490 100644 --- a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs +++ b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs @@ -1,398 +1,14 @@ -using System.Collections.Generic; using System.Linq; using System.Text.Json; using GVFS.Common.Tracing; using GVFS.Tests.Should; using NUnit.Framework; -using GVFS.Common; -using System; -using System.IO; -using System.IO.Pipes; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using GVFS.UnitTests.Mock.Common.Tracing; -using GVFS.CommandLine; -using GVFS.Common.FileSystem; -using GVFS.Common.NamedPipes; -using GVFS.UnitTests.Mock.Common; -using GVFS.UnitTests.Category; namespace GVFS.UnitTests.Tracing { [TestFixture] public class TelemetryDaemonEventListenerTests { - [TestCase(false)] - [TestCase(true)] - [Category(CategoryConstants.ExceptionExpected)] - public async Task HydrationMetadataReachesTheCollectorWithNullsAndNestedCounts(bool cacheHit) - { - string pipeName = "GVFS.UnitTests.Hydration." + Guid.NewGuid().ToString("N"); - using (CancellationTokenSource deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10))) - using (NamedPipeServerStream server = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous)) - { - Task received = ReadTelemetryAsync(server, deadline.Token); - using (JsonTracer tracer = new JsonTracer("test", "health", disableTelemetry: true)) - using (StringWriter output = new StringWriter()) - { - tracer.AddEventListener(new TelemetryDaemonEventListener("test", "test-enlistment", "test-mount", pipeName, eventSink: null)); - CollectorHealthVerb verb = new CollectorHealthVerb { StatusOnly = true, CacheHit = cacheHit, Output = output }; - verb.ExecuteWithCompletion(null, tracer, Guid.NewGuid()); - } - - string[] lines = (await received).Split('\n', StringSplitOptions.RemoveEmptyEntries); - Assert.AreEqual(cacheHit ? 1 : 2, lines.Length); - TelemetryDaemonEventListener.PipeMessage health = TelemetryDaemonEventListener.PipeMessage.FromJson(lines.Last()); - Assert.AreEqual("health", health.EventName); - Assert.AreEqual("test-enlistment", health.Payload.EnlistmentId); - using (JsonDocument json = JsonDocument.Parse(health.Payload.Json)) - { - JsonElement root = json.RootElement; - Assert.AreEqual(cacheHit ? "Success" : "Failed", root.GetProperty("Outcome").GetString()); - if (cacheHit) - { - Assert.AreEqual(0, root.GetProperty("Cache").GetProperty("Counts").GetProperty("ModifiedFileCount").GetInt32()); - Assert.IsFalse(root.TryGetProperty("Summary", out _)); - } - else - { - JsonElement summary = root.GetProperty("Summary"); - Assert.AreEqual(100, summary.GetProperty("TotalFileCount").GetInt32()); - Assert.AreEqual(JsonValueKind.Null, summary.GetProperty("ModifiedFileCount").ValueKind); - Assert.AreEqual(JsonValueKind.Null, summary.GetProperty("TotalFolderCount").ValueKind); - Assert.AreEqual("Failed", summary.GetProperty("Outcome").GetString()); - TelemetryDaemonEventListener.PipeMessage duration = TelemetryDaemonEventListener.PipeMessage.FromJson(lines[0]); - Assert.AreEqual("HydrationSummaryDuration", duration.EventName); - using (JsonDocument calculation = JsonDocument.Parse(duration.Payload.Json)) - { - Assert.AreEqual(summary.GetRawText(), calculation.RootElement.GetRawText()); - Assert.AreEqual(root.GetProperty("CommandId").GetString(), summary.GetProperty("CommandId").GetString()); - } - } - } - - Assert.That(string.Join("\n", lines), Does.Not.Contain("private")); - } - } - - private sealed class CollectorHealthVerb : HealthVerb - { - public bool CacheHit { get; set; } - - internal override CacheLookupResult TryGetCachedHydrationStatus(GVFSEnlistment enlistment) - { - return ParseCachedResponse(this.CacheHit - ? new NamedPipeMessages.Message("S", "0,0,0,0,100,10") - : new NamedPipeMessages.Message("NA", null)); - } - - internal override EnlistmentHydrationSummary CreateHydrationSummary(GVFSEnlistment enlistment, ITracer tracer, HydrationSummaryDiagnostics diagnostics) - { - return EnlistmentHydrationSummary.CreateSummary(new MockGVFSEnlistment(), new HeaderFileSystem(), tracer, - () => 10, new FailingPlaceholderPaths(), diagnostics: diagnostics); - } - } - - private sealed class HeaderFileSystem : PhysicalFileSystem - { - public override Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare shareMode, FileOptions options, bool flushesToDisk) - { - return new MemoryStream(new byte[] { 68, 73, 82, 67, 0, 0, 0, 4, 0, 0, 0, 100 }); - } - } - - private sealed class FailingPlaceholderPaths : EnlistmentPathData - { - public override void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment, HydrationSummaryDiagnostics diagnostics = null) - { - throw new IOException("mock:\\private\\database"); - } - } - - private static async Task ReadTelemetryAsync(NamedPipeServerStream server, CancellationToken cancellationToken) - { - await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); - using (StreamReader reader = new StreamReader(server)) - { - return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - } - } - - [TestCase] - [Category(CategoryConstants.ExceptionExpected)] - public async Task RemoteBoundsSurviveARealPipeReconnectAndChildActivities() - { - string pipeName = "gvfs-telemetry-test-" + Guid.NewGuid().ToString("N"); - TelemetryAggregatorTests.TestClock clock = new TelemetryAggregatorTests.TestClock(); - using (CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15))) - using (ListenerFailureSink sink = new ListenerFailureSink()) - using (JsonTracer tracer = new JsonTracer("Microsoft-GVFS-Test", "Root", disableTelemetry: true)) - using (MockListener local = new MockListener(EventLevel.Verbose, Keywords.Any)) - using (TelemetryDaemonEventListener remote = new TelemetryDaemonEventListener("Microsoft.Git.GVFS", "synthetic-enlistment", "synthetic-mount", pipeName, sink, clock)) - { - tracer.AddEventListener(local); - tracer.AddEventListener(remote); - using (NamedPipeServerStream server = CreateServer(pipeName)) - using (StreamReader reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) - { - Task connection = server.WaitForConnectionAsync(timeout.Token); - TraceRejection(tracer); - await connection; - Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("NetworkError")); - - using (ITracer child = tracer.StartActivity("Child", EventLevel.Informational)) - { - TraceRejection(child); - } - - tracer.RelatedEvent(EventLevel.Informational, "BeforeDisconnect", null, Keywords.Telemetry); - Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("BeforeDisconnect")); - } - - tracer.RelatedEvent(EventLevel.Informational, "DetectBrokenPipe", null, Keywords.Telemetry); - Assert.That(sink.Failed.Wait(TimeSpan.FromSeconds(5)), Is.True); - - using (NamedPipeServerStream server = CreateServer(pipeName)) - using (StreamReader reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) - { - Task connection = server.WaitForConnectionAsync(timeout.Token); - tracer.RelatedEvent(EventLevel.Informational, "Reconnected", null, Keywords.Telemetry); - await connection; - Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("Reconnected")); - TraceRejection(tracer); - clock.Advance(TimeSpan.FromSeconds(60)); - tracer.RelatedEvent(EventLevel.Informational, "Flush", null, Keywords.Telemetry); - TelemetryDaemonEventListener.PipeMessage summary = await ReadMessage(reader, timeout.Token); - Assert.That(summary.EventName, Is.EqualTo("TelemetrySummary")); - Assert.That(summary.Payload.EnlistmentId, Is.EqualTo("synthetic-enlistment")); - Assert.That(summary.Payload.MountId, Is.EqualTo("synthetic-mount")); - using (JsonDocument payload = JsonDocument.Parse(summary.Payload.Json)) - { - Assert.That(payload.RootElement.GetProperty("ObservedCount").GetInt64(), Is.EqualTo(3)); - Assert.That(payload.RootElement.GetProperty("SampleCount").GetInt64(), Is.EqualTo(1)); - Assert.That(payload.RootElement.GetProperty("SuppressedCount").GetInt64(), Is.EqualTo(2)); - } - - Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("Flush")); - Assert.That(local.EventNamesRead.Count(name => name == "NetworkError"), Is.EqualTo(3)); - remote.Dispose(); - } - } - } - - [TestCase] - public async Task DisposalDeliversPendingSummaryAndPreservesLegacyResult() - { - string pipeName = "gvfs-telemetry-test-" + Guid.NewGuid().ToString("N"); - using (CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15))) - using (NamedPipeServerStream server = CreateServer(pipeName)) - using (StreamReader reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) - using (JsonTracer tracer = new JsonTracer("Microsoft-GVFS-Test", "Root", disableTelemetry: true)) - using (TelemetryDaemonEventListener remote = new TelemetryDaemonEventListener("Microsoft.Git.GVFS", "synthetic-enlistment", "synthetic-mount", pipeName, null)) - { - tracer.AddEventListener(remote); - Task connection = server.WaitForConnectionAsync(timeout.Token); - for (int i = 0; i < 2; ++i) - { - EventMetadata metadata = new EventMetadata { { "result", "0xC0000467" } }; - metadata.SetTelemetryAggregation("StartDirectoryEnumerationAsyncHandler", TelemetryFailureCategory.SizesUnavailable, "Failed"); - tracer.RelatedError(metadata, "Synthetic size unavailable"); - } - - await connection; - TelemetryDaemonEventListener.PipeMessage sample = await ReadMessage(reader, timeout.Token); - using (JsonDocument payload = JsonDocument.Parse(sample.Payload.Json)) - { - Assert.That(payload.RootElement.GetProperty("result").GetString(), Is.EqualTo("0xC0000467")); - Assert.That(payload.RootElement.GetProperty("AggregationResult").GetString(), Is.EqualTo("Failed")); - Assert.That(payload.RootElement.GetProperty("TelemetrySchemaVersion").GetInt32(), Is.EqualTo(3)); - Assert.That(payload.RootElement.GetProperty("TelemetryAggregation").GetString(), Is.EqualTo("Sample")); - Assert.That(payload.RootElement.TryGetProperty("Result", out _), Is.False); - } - - Task disposal = Task.Run(remote.Dispose); - TelemetryDaemonEventListener.PipeMessage summary = await ReadMessage(reader, timeout.Token); - Assert.That(summary.EventName, Is.EqualTo("TelemetrySummary")); - using (JsonDocument payload = JsonDocument.Parse(summary.Payload.Json)) - { - Assert.That(payload.RootElement.GetProperty("ObservedCount").GetInt64(), Is.EqualTo(2)); - Assert.That(payload.RootElement.GetProperty("SampleCount").GetInt64(), Is.EqualTo(1)); - Assert.That(payload.RootElement.GetProperty("SuppressedCount").GetInt64(), Is.EqualTo(1)); - Assert.That(payload.RootElement.GetProperty("AggregationResult").GetString(), Is.EqualTo("Failed")); - } - - await disposal.WaitAsync(timeout.Token); - remote.Dispose(); - } - } - - [TestCase] - public async Task CrossCommandSummaryOmitsSessionButSamplesRetainIt() - { - string pipeName = "gvfs-telemetry-test-" + Guid.NewGuid().ToString("N"); - TelemetryAggregatorTests.TestClock clock = new TelemetryAggregatorTests.TestClock(); - using (CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15))) - using (NamedPipeServerStream server = CreateServer(pipeName)) - using (StreamReader reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) - using (JsonTracer tracer = new JsonTracer("Microsoft-GVFS-Test", "Root", disableTelemetry: true)) - using (TelemetryDaemonEventListener remote = new TelemetryDaemonEventListener("Microsoft.Git.GVFS", "synthetic-enlistment", "synthetic-mount", pipeName, null, clock)) - { - tracer.AddEventListener(remote); - Task connection = server.WaitForConnectionAsync(timeout.Token); - tracer.SetGitCommandSessionId("session-A"); - TraceRejection(tracer); - await connection; - Assert.That((await ReadMessage(reader, timeout.Token)).Payload.GitCommandSessionId, Is.EqualTo("session-A")); - - tracer.SetGitCommandSessionId("session-B"); - TraceRejection(tracer); - tracer.SetGitCommandSessionId("session-C"); - clock.Advance(TimeSpan.FromSeconds(60)); - tracer.RelatedEvent(EventLevel.Informational, "HealthyEvent", null, Keywords.Telemetry); - string summaryJson = await reader.ReadLineAsync(timeout.Token); - using (JsonDocument envelope = JsonDocument.Parse(summaryJson)) - { - Assert.That(envelope.RootElement.GetProperty("payload").TryGetProperty("gitCommandSessionId", out _), Is.False); - } - - TelemetryDaemonEventListener.PipeMessage summary = TelemetryDaemonEventListener.PipeMessage.FromJson(summaryJson); - Assert.That(summary.EventName, Is.EqualTo("TelemetrySummary")); - Assert.That(summary.Payload.EnlistmentId, Is.EqualTo("synthetic-enlistment")); - Assert.That(summary.Payload.MountId, Is.EqualTo("synthetic-mount")); - using (JsonDocument payload = JsonDocument.Parse(summary.Payload.Json)) - { - Assert.That(payload.RootElement.GetProperty("ObservedCount").GetInt64(), Is.EqualTo(2)); - Assert.That(payload.RootElement.GetProperty("SampleCount").GetInt64(), Is.EqualTo(1)); - Assert.That(payload.RootElement.GetProperty("SuppressedCount").GetInt64(), Is.EqualTo(1)); - } - - TelemetryDaemonEventListener.PipeMessage healthy = await ReadMessage(reader, timeout.Token); - Assert.That(healthy.EventName, Is.EqualTo("HealthyEvent")); - Assert.That(healthy.Payload.GitCommandSessionId, Is.EqualTo("session-C")); - } - } - - [TestCase] - public async Task BreakerRecoverySampleSurvivesInitialAcceptingAndRepeatedTransitions() - { - RetryCircuitBreaker.Reset(); - try - { - string pipeName = "gvfs-telemetry-test-" + Guid.NewGuid().ToString("N"); - TelemetryAggregatorTests.TestClock clock = new TelemetryAggregatorTests.TestClock(); - using (CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15))) - using (NamedPipeServerStream server = CreateServer(pipeName)) - using (StreamReader reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) - using (JsonTracer tracer = new JsonTracer("Microsoft-GVFS-Test", "Root", disableTelemetry: true)) - using (TelemetryDaemonEventListener remote = new TelemetryDaemonEventListener("Microsoft.Git.GVFS", "synthetic-enlistment", "synthetic-mount", pipeName, null, clock)) - { - tracer.AddEventListener(remote); - Task connection = server.WaitForConnectionAsync(timeout.Token); - RetryWrapper wrapper = new RetryWrapper(1, CancellationToken.None, tracer: tracer); - wrapper.Invoke(attempt => new RetryWrapper.CallbackResult(true)); - wrapper.Invoke(attempt => - { - RetryCircuitBreaker.Configure(1, 30000); - return new RetryWrapper.CallbackResult(new IOException("Synthetic failure"), shouldRetry: true); - }); - RetryCircuitBreaker.Configure(1, 0); - wrapper.Invoke(attempt => new RetryWrapper.CallbackResult(true)); - await connection; - - string[] previousStates = { "Unknown", "Accepting", "Rejecting", "CooldownElapsed" }; - string[] states = { "Accepting", "Rejecting", "CooldownElapsed", "Accepting" }; - for (int i = 0; i < states.Length; ++i) - { - TelemetryDaemonEventListener.PipeMessage sample = await ReadMessage(reader, timeout.Token); - Assert.That(sample.EventName, Is.EqualTo("RetryCircuitBreakerState")); - using (JsonDocument payload = JsonDocument.Parse(sample.Payload.Json)) - { - Assert.That(payload.RootElement.GetProperty("PreviousState").GetString(), Is.EqualTo(previousStates[i])); - Assert.That(payload.RootElement.GetProperty("State").GetString(), Is.EqualTo(states[i])); - Assert.That(payload.RootElement.GetProperty("AggregationResult").GetString(), Is.EqualTo(states[i])); - } - } - - clock.Advance(TimeSpan.FromSeconds(10)); - wrapper.Invoke(attempt => - { - RetryCircuitBreaker.Configure(1, 30000); - return new RetryWrapper.CallbackResult(new IOException("Synthetic failure"), shouldRetry: true); - }); - RetryCircuitBreaker.Configure(1, 0); - clock.Advance(TimeSpan.FromSeconds(2)); - wrapper.Invoke(attempt => - { - clock.Advance(TimeSpan.FromSeconds(1)); - return new RetryWrapper.CallbackResult(true); - }); - tracer.RelatedEvent(EventLevel.Informational, "RepeatedTransitionsComplete", null, Keywords.Telemetry); - Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("RepeatedTransitionsComplete")); - - clock.Advance(TimeSpan.FromSeconds(60)); - tracer.RelatedEvent(EventLevel.Informational, "Flush", null, Keywords.Telemetry); - for (int i = 0; i < states.Length; ++i) - { - TelemetryDaemonEventListener.PipeMessage summary = await ReadMessage(reader, timeout.Token); - Assert.That(summary.EventName, Is.EqualTo("TelemetrySummary")); - using (JsonDocument payload = JsonDocument.Parse(summary.Payload.Json)) - { - Assert.That(payload.RootElement.GetProperty("PreviousState").GetString(), Is.EqualTo(previousStates[i])); - Assert.That(payload.RootElement.GetProperty("AggregationResult").GetString(), Is.EqualTo(states[i])); - Assert.That(payload.RootElement.GetProperty("ObservedCount").GetInt64(), Is.EqualTo(i == 0 ? 1 : 2)); - Assert.That(payload.RootElement.GetProperty("SampleCount").GetInt64(), Is.EqualTo(1)); - Assert.That(payload.RootElement.GetProperty("SuppressedCount").GetInt64(), Is.EqualTo(i == 0 ? 0 : 1)); - int[] lastSeconds = { 0, 10, 12, 13 }; - Assert.That(payload.RootElement.GetProperty("LastObservedAtUtc").GetDateTime(), Is.EqualTo(clock.UtcStart.AddSeconds(lastSeconds[i]).UtcDateTime)); - } - } - - Assert.That((await ReadMessage(reader, timeout.Token)).EventName, Is.EqualTo("Flush")); - } - } - finally - { - RetryCircuitBreaker.Reset(); - } - } - - private static NamedPipeServerStream CreateServer(string pipeName) - { - return new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); - } - - private static async Task ReadMessage(StreamReader reader, CancellationToken token) - { - return TelemetryDaemonEventListener.PipeMessage.FromJson(await reader.ReadLineAsync(token)); - } - - private static void TraceRejection(ITracer tracer) - { - EventMetadata metadata = new EventMetadata(); - metadata.SetTelemetryAggregation("Download", TelemetryFailureCategory.CircuitBreakerOpen, "Rejected"); - tracer.RelatedError(metadata, "Local rejection", Keywords.Network); - } - - private sealed class ListenerFailureSink : IEventListenerEventSink, IDisposable - { - public ManualResetEventSlim Failed { get; } = new ManualResetEventSlim(); - - public void OnListenerFailure(EventListener listener, string errorMessage) - { - this.Failed.Set(); - } - - public void OnListenerRecovery(EventListener listener) - { - } - - public void Dispose() - { - this.Failed.Dispose(); - } - } - [TestCase] public void TraceMessageDataIsCorrectFormat() { diff --git a/GVFS/GVFS/InternalsVisibleTo.cs b/GVFS/GVFS/InternalsVisibleTo.cs index 248b151be..a3317c2a8 100644 --- a/GVFS/GVFS/InternalsVisibleTo.cs +++ b/GVFS/GVFS/InternalsVisibleTo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("GVFS.UnitTests")] [assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] +[assembly: InternalsVisibleTo("GVFS.FunctionalTests")] diff --git a/scripts/RunFunctionalTests-Dev.ps1 b/scripts/RunFunctionalTests-Dev.ps1 index 24692ed60..cc1f24ace 100644 --- a/scripts/RunFunctionalTests-Dev.ps1 +++ b/scripts/RunFunctionalTests-Dev.ps1 @@ -74,7 +74,7 @@ if (-not (Test-Path (Join-Path $payloadDir "GVFS.exe"))) { Write-Error "Payload GVFS.exe not found at $payloadDir. Has the solution been built for $Arch / $Configuration?" exit 1 } -$env:PATH = "$payloadDir;C:\Program Files\Git\cmd;$env:PATH" +$env:PATH = "$payloadDir;$env:PATH" Write-Host "============================================" Write-Host "GVFS Functional Tests - Dev Mode (no admin)" diff --git a/scripts/RunLocalComponentTests-Dev.ps1 b/scripts/RunLocalComponentTests-Dev.ps1 new file mode 100644 index 000000000..70ce1601c --- /dev/null +++ b/scripts/RunLocalComponentTests-Dev.ps1 @@ -0,0 +1,34 @@ +[CmdletBinding()] +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Debug", + [ValidateSet("x64", "arm64")] + [string]$Arch = "x64" +) + +$ErrorActionPreference = "Stop" +$enlistmentDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$resultPath = Join-Path $enlistmentDir "LocalComponentTestResult.xml" +if (Test-Path -LiteralPath $resultPath) { + Remove-Item -LiteralPath $resultPath +} + +& (Join-Path $PSScriptRoot "RunFunctionalTests-Dev.ps1") -Configuration $Configuration -Arch $Arch ` + -ExtraArgs "--test=GVFS.FunctionalTests.Tests.LocalComponents", "--result=$resultPath" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +[xml]$result = Get-Content -LiteralPath $resultPath -Raw +$cases = @($result.SelectNodes("//test-case")) +if ($cases.Count -eq 0) { + throw "No local component tests were selected." +} +foreach ($case in $cases) { + if (-not $case.fullname.StartsWith("GVFS.FunctionalTests.Tests.LocalComponents.", [StringComparison]::Ordinal)) { + throw "Unexpected test in the local component run: $($case.fullname)" + } + if ($case.result -ne "Passed") { + throw "Local component test did not pass: $($case.fullname) ($($case.result))" + } +} From 5011eb4f55d094954a218decc964344ec2659f61 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 10 Nov 2026 10:43:15 -0800 Subject: [PATCH 4/4] Align functional Git discovery with GVFS Use the product Git installation resolver for functional tests and derive Bash from the same installation. Initialize the platform before settings. Complete filesystem setup before environment mutation. Quote test result paths and preserve the surviving unit fixture encoding. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.FunctionalTests/Program.cs | 2 +- GVFS/GVFS.FunctionalTests/Settings.cs | 6 +++--- .../AuthenticationConfigurationTests.cs | 8 ++++---- .../LocalComponents/GitProcessCredentialTests.cs | 5 ++--- .../Tests/LocalComponents/GitProcessTests.cs | 8 +++----- .../Tests/LocalComponents/RetryConfigTests.cs | 5 ++--- .../LocalComponents/WorktreeRequiredConfigTests.cs | 13 +++++-------- .../Common/WorktreeEnlistmentTests.cs | 4 ++-- .../Tracing/TelemetryDaemonEventListenerTests.cs | 2 +- scripts/RunFunctionalTests-Dev.ps1 | 10 +--------- scripts/RunLocalComponentTests-Dev.ps1 | 2 +- 11 files changed, 25 insertions(+), 40 deletions(-) diff --git a/GVFS/GVFS.FunctionalTests/Program.cs b/GVFS/GVFS.FunctionalTests/Program.cs index d74c70eb8..34e785129 100644 --- a/GVFS/GVFS.FunctionalTests/Program.cs +++ b/GVFS/GVFS.FunctionalTests/Program.cs @@ -14,8 +14,8 @@ public class Program { public static void Main(string[] args) { - Properties.Settings.Default.Initialize(); GVFSPlatformLoader.Initialize(); + Properties.Settings.Default.Initialize(); GVFSTestConfig.IsDevMode = Environment.GetEnvironmentVariable("GVFS_FUNCTIONAL_TEST_DEV_MODE") == "1"; diff --git a/GVFS/GVFS.FunctionalTests/Settings.cs b/GVFS/GVFS.FunctionalTests/Settings.cs index 1d80ad03d..eacff8871 100644 --- a/GVFS/GVFS.FunctionalTests/Settings.cs +++ b/GVFS/GVFS.FunctionalTests/Settings.cs @@ -82,9 +82,9 @@ public static void Initialize() PathToGVFSService = @"C:\Program Files\VFS for Git\GVFS.Service.exe"; } - PathToGit = GVFS.UnitTests.GitTestHelper.FindGitOnPath() - ?? throw new FileNotFoundException("Functional tests require Git on PATH."); - PathToBash = @"C:\Program Files\Git\bin\bash.exe"; + PathToGit = GVFS.Common.GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath() + ?? throw new FileNotFoundException("Functional tests require the Git installation that GVFS uses."); + PathToBash = Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(PathToGit)), "bin", "bash.exe"); } } } diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/AuthenticationConfigurationTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/AuthenticationConfigurationTests.cs index d12955d9d..e46c8f4d6 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/AuthenticationConfigurationTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/AuthenticationConfigurationTests.cs @@ -1,5 +1,5 @@ using GVFS.Common.Git; -using GVFS.UnitTests; +using GVFS.FunctionalTests.Properties; using GVFS.Common.Tracing; using GVFS.UnitTests.Category; using GVFS.UnitTests.Mock.Common; @@ -27,14 +27,14 @@ public class AuthenticationConfigurationTests [SetUp] public void Setup() { - this.environment = new IsolatedGitEnvironment(); - this.gitPath = GitTestHelper.FindGitOnPath(); + this.gitPath = Settings.Default.PathToGit; this.root = Path.Combine(Path.GetTempPath(), nameof(AuthenticationConfigurationTests) + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(this.root, ".git", "objects")); Directory.CreateDirectory(Path.Combine(this.root, ".git", "refs", "heads")); File.WriteAllText(Path.Combine(this.root, ".git", "HEAD"), "ref: refs/heads/main\n"); File.WriteAllText(Path.Combine(this.root, ".git", "config"), "[core]\nrepositoryformatversion = 0\nbare = false\n"); File.WriteAllText(Path.Combine(this.root, "global"), string.Empty); + this.environment = new IsolatedGitEnvironment(); } [TearDown] @@ -514,7 +514,7 @@ private string GetGitPath() { if (!File.Exists(this.gitPath)) { - Assert.Fail("The authentication configuration integration tests require Git on PATH."); + Assert.Fail("The Git installation that GVFS uses is unavailable."); } return this.gitPath; diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessCredentialTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessCredentialTests.cs index 854123236..016e08ebb 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessCredentialTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessCredentialTests.cs @@ -1,7 +1,7 @@ using GVFS.Common.Git; using GVFS.Common.Tracing; using GVFS.Tests.Should; -using GVFS.UnitTests; +using GVFS.FunctionalTests.Properties; using GVFS.UnitTests.Mock.Common; using NUnit.Framework; using System; @@ -25,8 +25,7 @@ public class GitProcessCredentialTests [TestCase(false, Timeout.Infinite)] public void CredentialFillHandlesARealLockedGitConfig(bool releaseAfterFirstAttempt, int timeoutMs) { - string gitPath = GitTestHelper.FindGitOnPath(); - gitPath.ShouldNotBeNull("Git must be available on PATH."); + string gitPath = Settings.Default.PathToGit; using IsolatedGitEnvironment environment = new IsolatedGitEnvironment(); string repoPath = Path.Combine(environment.RootDirectory, "repo"); diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessTests.cs index 0c5293694..d49c57ea0 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessTests.cs @@ -1,6 +1,6 @@ using GVFS.Common.Git; using GVFS.Tests.Should; -using GVFS.UnitTests; +using GVFS.FunctionalTests.Properties; using NUnit.Framework; using System; using System.Diagnostics; @@ -17,8 +17,7 @@ public class GitProcessTests [TestCase(Timeout.Infinite)] public void GitCommandWaitsForOutputCallbacks(int timeoutMs) { - string gitPath = GitTestHelper.FindGitOnPath(); - gitPath.ShouldNotBeNull("Git must be available on PATH."); + string gitPath = Settings.Default.PathToGit; using IsolatedGitEnvironment environment = new IsolatedGitEnvironment(); OutputCallbackGitProcess gitProcess = new OutputCallbackGitProcess(gitPath); @@ -60,8 +59,7 @@ public void QuoteGitPath_RoundTripThroughProcess() "path\\with\\backslashes\\file.txt", }; - string gitPath = GitTestHelper.FindGitOnPath(); - gitPath.ShouldNotBeNull("Git must be available on PATH."); + string gitPath = Settings.Default.PathToGit; using IsolatedGitEnvironment environment = new IsolatedGitEnvironment(); foreach (string testPath in testPaths) diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/RetryConfigTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/RetryConfigTests.cs index 5c66866b9..3e26e0b28 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/RetryConfigTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/RetryConfigTests.cs @@ -1,7 +1,7 @@ using GVFS.Common; using GVFS.Common.Git; using GVFS.Tests.Should; -using GVFS.UnitTests; +using GVFS.FunctionalTests.Properties; using GVFS.UnitTests.Mock.Common; using GVFS.UnitTests.Mock.Git; using NUnit.Framework; @@ -20,8 +20,7 @@ public class RetryConfigTests [SetUp] public void SetUp() { - this.gitPath = GitTestHelper.FindGitOnPath(); - this.gitPath.ShouldNotBeNull("Git must be available on PATH."); + this.gitPath = Settings.Default.PathToGit; this.environment = new IsolatedGitEnvironment(); } diff --git a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WorktreeRequiredConfigTests.cs b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WorktreeRequiredConfigTests.cs index d0b695401..42b77c51a 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WorktreeRequiredConfigTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WorktreeRequiredConfigTests.cs @@ -2,7 +2,7 @@ using GVFS.Common.Git; using GVFS.FunctionalTests.Tools; using GVFS.Tests.Should; -using GVFS.UnitTests; +using GVFS.FunctionalTests.Properties; using NUnit.Framework; using System; using System.Collections.Generic; @@ -27,12 +27,8 @@ public class WorktreeRequiredConfigTests [SetUp] public void SetUp() { - this.environment = new IsolatedGitEnvironment(); // The required settings depend on GVFS_UNATTENDED, and git config file // arguments must handle a path with a space. - this.unattendedValue = Environment.GetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable); - Environment.SetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable, null); - this.testRoot = Path.Combine(Path.GetTempPath(), "GVFS WT Enl Tests_" + Path.GetRandomFileName()); this.primaryRoot = Path.Combine(this.testRoot, "enlistment"); string primarySrc = Path.Combine(this.primaryRoot, "src"); @@ -65,6 +61,9 @@ public void SetUp() File.WriteAllText( Path.Combine(this.worktreeGitDir, "gitdir"), Path.Combine(this.worktreePath, ".git")); + this.environment = new IsolatedGitEnvironment(); + this.unattendedValue = Environment.GetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable); + Environment.SetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable, null); } [TearDown] @@ -257,9 +256,7 @@ public void RequiredSettingsFailWhenWorktreeConfigIsCorrupt() private static string GetGitBinPath() { - string gitPath = GitTestHelper.FindGitOnPath(); - Assert.That(gitPath, Is.Not.Null, "The required-settings tests require Git on PATH."); - return gitPath; + return Settings.Default.PathToGit; } private string GetConfigValues(string configFile, string key) diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs index 90c786dee..d61fba0d3 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs @@ -76,10 +76,10 @@ public void TearDown() } } - private GVFSEnlistment CreateWorktreeEnlistment(string gitBinPath = @"mock:\git") + private GVFSEnlistment CreateWorktreeEnlistment() { return GVFSEnlistment.CreateForWorktree( - this.primaryRoot, gitBinPath, authentication: null, + this.primaryRoot, @"mock:\git", authentication: null, GVFSEnlistment.TryGetWorktreeInfo(this.worktreePath), repoUrl: "https://mock/repo"); } diff --git a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs index 742c1b490..45cb4e38f 100644 --- a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs +++ b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using System.Text.Json; using GVFS.Common.Tracing; using GVFS.Tests.Should; diff --git a/scripts/RunFunctionalTests-Dev.ps1 b/scripts/RunFunctionalTests-Dev.ps1 index cc1f24ace..a687eea41 100644 --- a/scripts/RunFunctionalTests-Dev.ps1 +++ b/scripts/RunFunctionalTests-Dev.ps1 @@ -94,14 +94,6 @@ if (-not $gvfsPath) { } Write-Host "gvfs location: $($gvfsPath.Source)" -$gitPath = Get-Command git -ErrorAction SilentlyContinue -if (-not $gitPath) { - Write-Error "Unable to locate git on the PATH." - exit 1 -} -Write-Host "git location: $($gitPath.Source)" -Write-Host "" - # Build test exe path. The FunctionalTests csproj is a regular AOT-published # executable, so it ends up under the standard publish layout. $testExe = Join-Path $outDir "GVFS.FunctionalTests\bin\$Configuration\net10.0-windows10.0.17763.0\win-$Arch\publish\GVFS.FunctionalTests.exe" @@ -111,7 +103,7 @@ if (-not (Test-Path $testExe)) { } # Build arguments -$testArgs = @("/result:$(Join-Path $enlistmentDir 'TestResult.xml')") +$testArgs = @("/result:`"$(Join-Path $enlistmentDir 'TestResult.xml')`"") if ($ExtraArgs) { $testArgs += $ExtraArgs } Write-Host "Running: $testExe" diff --git a/scripts/RunLocalComponentTests-Dev.ps1 b/scripts/RunLocalComponentTests-Dev.ps1 index 70ce1601c..346fc4e9f 100644 --- a/scripts/RunLocalComponentTests-Dev.ps1 +++ b/scripts/RunLocalComponentTests-Dev.ps1 @@ -14,7 +14,7 @@ if (Test-Path -LiteralPath $resultPath) { } & (Join-Path $PSScriptRoot "RunFunctionalTests-Dev.ps1") -Configuration $Configuration -Arch $Arch ` - -ExtraArgs "--test=GVFS.FunctionalTests.Tests.LocalComponents", "--result=$resultPath" + -ExtraArgs "--test=GVFS.FunctionalTests.Tests.LocalComponents", "--result=`"$resultPath`"" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }