diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 6cb88ee80d..025629a338 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 9bf18bc873..634a0dee53 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 639e1cfeb1..55de1a6b80 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 ee89911be4..d2ed09cc30 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 0ba48d81be..3492863681 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/Program.cs b/GVFS/GVFS.FunctionalTests/Program.cs index d74c70eb80..34e7851297 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 c0f7880188..eacff88711 100644 --- a/GVFS/GVFS.FunctionalTests/Settings.cs +++ b/GVFS/GVFS.FunctionalTests/Settings.cs @@ -82,8 +82,9 @@ public static void Initialize() PathToGVFSService = @"C:\Program Files\VFS for Git\GVFS.Service.exe"; } - PathToGit = @"C:\Program Files\Git\cmd\git.exe"; - 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 new file mode 100644 index 0000000000..e46c8f4d67 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/AuthenticationConfigurationTests.cs @@ -0,0 +1,618 @@ +using GVFS.Common.Git; +using GVFS.FunctionalTests.Properties; +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.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] + 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 Git installation that GVFS uses is unavailable."); + } + + 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 0000000000..4f9a969f0f --- /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 0000000000..27de72be04 --- /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 0000000000..016e08ebbd --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessCredentialTests.cs @@ -0,0 +1,138 @@ +using GVFS.Common.Git; +using GVFS.Common.Tracing; +using GVFS.Tests.Should; +using GVFS.FunctionalTests.Properties; +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 = Settings.Default.PathToGit; + + 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 0000000000..d49c57ea00 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/GitProcessTests.cs @@ -0,0 +1,133 @@ +using GVFS.Common.Git; +using GVFS.Tests.Should; +using GVFS.FunctionalTests.Properties; +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 = Settings.Default.PathToGit; + + 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 = Settings.Default.PathToGit; + + 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 0000000000..34a3dc47ee --- /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 0000000000..12bc9c248d --- /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 0000000000..ac9a77f807 --- /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 0000000000..95ce652a7e --- /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 0000000000..3e26e0b283 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/RetryConfigTests.cs @@ -0,0 +1,164 @@ +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.Tests.Should; +using GVFS.FunctionalTests.Properties; +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 = Settings.Default.PathToGit; + 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 0000000000..ef170027ab --- /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 6f28e51b84..f81dc6a5f7 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 0000000000..a052124a7f --- /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 0000000000..42b77c51ad --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/LocalComponents/WorktreeRequiredConfigTests.cs @@ -0,0 +1,274 @@ +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.FunctionalTests.Tools; +using GVFS.Tests.Should; +using GVFS.FunctionalTests.Properties; +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() + { + // The required settings depend on GVFS_UNATTENDED, and git config file + // arguments must handle a path with a space. + 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")); + this.environment = new IsolatedGitEnvironment(); + this.unattendedValue = Environment.GetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable); + Environment.SetEnvironmentVariable(GVFSConstants.UnattendedEnvironmentVariable, null); + } + + [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() + { + return Settings.Default.PathToGit; + } + + 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 2a1b948353..41ec375f36 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 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Git", "cmd", "git.exe"); - 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,94 +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.gitPath, 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.gitPath, 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 EventMetadata Collect( - string url, - Action customize = null, - Dictionary credentialFillEnvironment = null, - ITracer tracer = null) - { - if (!File.Exists(this.gitPath)) - { - Assert.Fail("The authentication configuration integration tests require Git for Windows."); - } - - GitProcess git = new GitProcess(this.gitPath, 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; } @@ -614,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 39899ec0c0..1d7cc56219 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 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Git", "cmd", "git.exe"); - if (!File.Exists(gitPath)) - { - Assert.Ignore("Git is not installed at the expected 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 d1ed003b5e..2fb061f36a 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 64ab99190f..5696cdebd2 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 = "C:\\Program Files\\Git\\cmd\\git.exe"; - if (!File.Exists(gitPath)) - { - Assert.Ignore("Git not found at expected path — skipping integration test"); - } - - 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 cf04194ebe..ba9fdbc31b 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 04fc9038d3..d61fba0d31 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 @@ -79,10 +78,8 @@ public void TearDown() private GVFSEnlistment CreateWorktreeEnlistment() { - string gitBinPath = GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath() - ?? @"C:\Program Files\Git\cmd\git.exe"; return GVFSEnlistment.CreateForWorktree( - this.primaryRoot, gitBinPath, authentication: null, + this.primaryRoot, @"mock:\git", authentication: null, GVFSEnlistment.TryGetWorktreeInfo(this.worktreePath), repoUrl: "https://mock/repo"); } @@ -297,174 +294,10 @@ public void WorktreeMetadataCreatesEnlistmentIdWhenNoneIsGivenOrStored(string st }); } - [TestCase] - public void RequiredSettingsEnableWorktreeConfigAndWritePerWorktreeValues() - { - GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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() - { - return GVFSPlatform.Instance.GitInstallation.GetInstalledGitBinPath() ?? @"C:\Program Files\Git\cmd\git.exe"; - } - 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 069189a12b..12fc72e600 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 4dd4b0fec4..28ea8409ca 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 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Git", "cmd", "git.exe"); - if (!File.Exists(gitPath)) - { - Assert.Ignore("Git is not installed at the expected 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 = "C:\\Program Files\\Git\\cmd\\git.exe"; - if (!System.IO.File.Exists(gitPath)) - { - Assert.Ignore("Git not found at expected path — skipping integration test"); - } - - 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/Git/GitTestHelperTests.cs b/GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs new file mode 100644 index 0000000000..607680b933 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Git/GitTestHelperTests.cs @@ -0,0 +1,79 @@ +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); + } + + [TestCase("First Git")] + [TestCase("First; Git")] + public void FindsTheFirstGitInQuotedPathEntriesWithSpaces(string firstDirectoryName) + { + string firstDirectory = Path.Combine(this.root, firstDirectoryName); + 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).IgnoreCase); + } + + [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 0000000000..6a947558bf --- /dev/null +++ b/GVFS/GVFS.UnitTests/GitTestHelper.cs @@ -0,0 +1,54 @@ +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; + } + + int entryStart = 0; + bool quoted = false; + for (int index = 0; index <= searchPath.Length; ++index) + { + 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; + } + + string gitPath = Path.Combine(directory, "git.exe"); + if (File.Exists(gitPath)) + { + return Path.GetFullPath(gitPath); + } + } + + return null; + } + } +} diff --git a/GVFS/GVFS.UnitTests/Http/HttpResponseStreamTestSupport.cs b/GVFS/GVFS.UnitTests/Http/HttpResponseStreamTestSupport.cs new file mode 100644 index 0000000000..afbd4e9ffa --- /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 1ee094a44f..db11417ad0 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 0000000000..1380e2b338 --- /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 0000000000..1e7049c0fd --- /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 e3df9c0c48..f1793fcc58 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 d1f38c6c4c..45cb4e38f7 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.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 248b151be5..a3317c2a80 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 24692ed60c..a687eea418 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)" @@ -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 new file mode 100644 index 0000000000..346fc4e9f3 --- /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))" + } +}