diff --git a/GVFS/GVFS.Common/GVFSConstants.cs b/GVFS/GVFS.Common/GVFSConstants.cs
index 3dd946bff..030b6119f 100644
--- a/GVFS/GVFS.Common/GVFSConstants.cs
+++ b/GVFS/GVFS.Common/GVFSConstants.cs
@@ -105,6 +105,14 @@ public static class Endpoints
public const string InfoRefs = "/info/refs?service=git-upload-pack";
}
+ public static class WellKnownObjects
+ {
+ // SKETCH (design proposal): the git empty-tree object. Its SHA is a fixed constant
+ // that every git server recognizes, so it is a safe, well-formed target for a
+ // credential probe - the probe URL never depends on possibly-corrupt request input.
+ public const string EmptyTreeSha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
+ }
+
public static class SpecialGitFiles
{
public const string GitAttributes = ".gitattributes";
diff --git a/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs b/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs
index 2cdffcb8d..38f52f57b 100644
--- a/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs
+++ b/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs
@@ -29,6 +29,58 @@ public GitObjectsHttpRequestor(ITracer tracer, Enlistment enlistment, CacheServe
public CacheServerInfo CacheServer { get; private set; }
+ ///
+ /// SKETCH (design proposal). Probe the cache server's own objects endpoint with a
+ /// known-good, well-formed SHA (the git empty tree). This exercises the SAME host and
+ /// auth path that returned the 400, so the probe's status cleanly separates
+ /// "credential is bad" (401/302) from "the request was malformed" (any other status).
+ /// A 404 here still proves auth succeeded - we reached "not found" past the auth gate.
+ ///
+ ///
+ /// SKETCH (design proposal). Probe the objects endpoint of the SAME host that returned the
+ /// 400 (cache server or origin) with a known-good, well-formed SHA (the git empty tree), so
+ /// the probe exercises the same auth path. A 404 here still proves auth succeeded - we
+ /// reached "not found" past the auth gate.
+ ///
+ protected override Uri GetCredentialProbeUri(Uri failedRequestUri)
+ {
+ string objectsEndpoint = null;
+
+ if (this.CacheServer != null &&
+ !string.IsNullOrEmpty(this.CacheServer.ObjectsEndpointUrl) &&
+ HostMatches(failedRequestUri, this.CacheServer.ObjectsEndpointUrl))
+ {
+ objectsEndpoint = this.CacheServer.ObjectsEndpointUrl;
+ }
+ else if (!string.IsNullOrEmpty(this.enlistment.RepoUrl))
+ {
+ // The 400 came from origin (or the host could not be matched to the cache server).
+ objectsEndpoint = this.enlistment.RepoUrl + GVFSConstants.Endpoints.GVFSObjects;
+ }
+
+ if (string.IsNullOrEmpty(objectsEndpoint))
+ {
+ return null;
+ }
+
+ try
+ {
+ return new Uri(objectsEndpoint.TrimEnd('/') + "/" + GVFSConstants.WellKnownObjects.EmptyTreeSha);
+ }
+ catch (UriFormatException)
+ {
+ // A malformed endpoint cannot be probed; caller treats null as "do not reject".
+ return null;
+ }
+ }
+
+ private static bool HostMatches(Uri uri, string candidateUrl)
+ {
+ return uri != null &&
+ Uri.TryCreate(candidateUrl, UriKind.Absolute, out Uri candidate) &&
+ string.Equals(uri.Host, candidate.Host, StringComparison.OrdinalIgnoreCase);
+ }
+
public virtual List QueryForFileSizes(IEnumerable objectIds, CancellationToken cancellationToken)
{
long requestId = HttpRequestor.GetNewRequestId();
diff --git a/GVFS/GVFS.Common/Http/HttpRequestor.cs b/GVFS/GVFS.Common/Http/HttpRequestor.cs
index 0f9767dde..a2ddf74c7 100644
--- a/GVFS/GVFS.Common/Http/HttpRequestor.cs
+++ b/GVFS/GVFS.Common/Http/HttpRequestor.cs
@@ -19,6 +19,15 @@ public abstract class HttpRequestor : IDisposable
private const int ConnectionPoolWaitTimeoutMs = 30_000;
private const int ConnectionPoolContentionThresholdMs = 100;
+ // SKETCH (design proposal): the credential probe is a diagnostic side-request; keep
+ // it short so a confirmed-bad credential is not delayed by a slow probe.
+ private static readonly TimeSpan CredentialProbeTimeout = TimeSpan.FromSeconds(15);
+
+ // SKETCH (design proposal): how long a probe result is reused for the same credential.
+ // Single-flighting plus this short cache stops a burst of concurrent 400s from fanning
+ // out into a burst of probes (and RejectCredentials calls).
+ private static readonly TimeSpan CredentialProbeResultTtl = TimeSpan.FromSeconds(30);
+
private static long requestCount = 0;
private static SemaphoreSlim availableConnections;
private static int connectionLimitConfigured = 0;
@@ -29,6 +38,19 @@ public abstract class HttpRequestor : IDisposable
private HttpClient client;
+ // SKETCH (design proposal): a separate client for the credential probe. The probe must
+ // OBSERVE a 302 sign-in redirect as its auth-failure signal, so unlike the main client it
+ // must NOT auto-follow redirects. Following a same-host redirect would also re-send the
+ // Basic auth header to an unintended endpoint. SSL config is applied identically.
+ private HttpClient probeClient;
+
+ // SKETCH (design proposal): single-flight + short-lived memoization of the probe result,
+ // keyed by the credential that was probed. Guards against a concurrent-400 probe/reject herd.
+ private readonly object credentialProbeLock = new object();
+ private string lastProbedAuthString;
+ private bool lastProbeRejectResult;
+ private DateTime lastProbeTimeUtc = DateTime.MinValue;
+
static HttpRequestor()
{
// HTTP downloads are I/O-bound, not CPU-bound, so we default to
@@ -77,6 +99,25 @@ protected HttpRequestor(ITracer tracer, RetryConfig retryConfig, Enlistment enli
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
+ // SKETCH (design proposal): dedicated probe client with redirects disabled so the
+ // probe can see a 302 instead of silently following it to a 200 sign-in page.
+ SocketsHttpHandler probeHandler = new SocketsHttpHandler()
+ {
+ MaxConnectionsPerServer = Environment.ProcessorCount,
+ PooledConnectionLifetime = Timeout.InfiniteTimeSpan,
+ PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5),
+ AllowAutoRedirect = false,
+ };
+
+ this.authentication.ConfigureSocketsHandlerSslIfNeeded(this.Tracer, probeHandler, enlistment.CreateGitProcess());
+
+ this.probeClient = new HttpClient(probeHandler)
+ {
+ Timeout = retryConfig.Timeout,
+ DefaultRequestVersion = HttpVersion.Version11,
+ DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
+ };
+
this.userAgentHeader = new ProductInfoHeaderValue(ProcessHelper.GetEntryClassName(), ProcessHelper.GetCurrentProcessVersion());
}
@@ -96,6 +137,12 @@ public void Dispose()
this.client.Dispose();
this.client = null;
}
+
+ if (this.probeClient != null)
+ {
+ this.probeClient.Dispose();
+ this.probeClient = null;
+ }
}
protected GitEndPointResponseData SendRequest(
@@ -176,6 +223,10 @@ protected GitEndPointResponseData SendRequest(
GitEndPointResponseData gitEndPointResponseData = null;
HttpResponseMessage response = null;
+ // SKETCH (design proposal): tracks whether the credential probe already released the
+ // logical connection slot, so the response-disposed handler does not double-release.
+ bool connectionSlotReleased = false;
+
try
{
requestStopwatch.Restart();
@@ -232,29 +283,71 @@ protected GitEndPointResponseData SendRequest(
shouldRetry = false;
errorMessage = "Anonymous request was rejected with a 401";
}
- else if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.BadRequest || response.StatusCode == HttpStatusCode.Redirect)
+ else
{
- this.authentication.RejectCredentials(this.Tracer, authString);
- if (!this.authentication.IsBackingOff)
+ // SKETCH (design proposal): a bare 400 is normally a request/formatting
+ // problem, NOT an expired credential (an expired/invalid credential
+ // returns 401 or 302). The one 400 that can mean "no credential reached
+ // the server" is the missing Basic-auth-header case. Before erasing a
+ // possibly-good credential we re-send the SAME credential to a known-good,
+ // auth-enforced endpoint; only a probe that ALSO fails auth (401/302)
+ // proves a real credential failure.
+ bool badRequestConfirmedByProbe = false;
+ if (response.StatusCode == HttpStatusCode.BadRequest &&
+ !this.authentication.IsAnonymous)
+ {
+ // Free the logical connection slot BEFORE the (up-to-15s) probe: the
+ // error body has already been fully read, so the outer request no
+ // longer needs the slot, and the probe uses its own HttpClient/handler
+ // so it does not contend for this pool. This prevents a burst of 400s
+ // from pinning every slot for the probe duration and starving others.
+ availableConnections.Release();
+ connectionSlotReleased = true;
+
+ badRequestConfirmedByProbe =
+ this.CredentialProbeConfirmsAuthFailure(requestId, requestUri, authString, cancellationToken);
+ }
+
+ if (ShouldRejectCredentials(response.StatusCode) || badRequestConfirmedByProbe)
{
- errorMessage = string.Format("Server returned error code {0} ({1}). Your PAT may be expired and we are asking for a new one. Original error message from server: {2}", statusInt, response.StatusCode, errorMessage);
+ // A probe-confirmed 400 is a real auth failure, so it must join the
+ // same reject-and-retry contract as a 401: reject the credential AND
+ // allow a retry so the caller re-authenticates. A bare 400 stays
+ // non-retryable (ShouldRetry is false for it) - only the confirmed
+ // case opts back into retry, otherwise the reject is a useless erase.
+ if (badRequestConfirmedByProbe)
+ {
+ shouldRetry = true;
+ }
+
+ this.authentication.RejectCredentials(this.Tracer, authString);
+ if (!this.authentication.IsBackingOff)
+ {
+ errorMessage = string.Format("Server returned error code {0} ({1}). Your PAT may be expired and we are asking for a new one. Original error message from server: {2}", statusInt, response.StatusCode, errorMessage);
+ }
+ else
+ {
+ errorMessage = string.Format("Server returned error code {0} ({1}) after successfully renewing your PAT. You may not have access to this repo. Original error message from server: {2}", statusInt, response.StatusCode, errorMessage);
+ }
}
else
{
- errorMessage = string.Format("Server returned error code {0} ({1}) after successfully renewing your PAT. You may not have access to this repo. Original error message from server: {2}", statusInt, response.StatusCode, errorMessage);
+ errorMessage = string.Format("Server returned error code {0} ({1}). Original error message from server: {2}", statusInt, response.StatusCode, errorMessage);
}
}
- else
- {
- errorMessage = string.Format("Server returned error code {0} ({1}). Original error message from server: {2}", statusInt, response.StatusCode, errorMessage);
- }
gitEndPointResponseData = new GitEndPointResponseData(
response.StatusCode,
new GitObjectsHttpException(response.StatusCode, errorMessage),
shouldRetry,
message: response,
- onResponseDisposed: () => availableConnections.Release());
+ onResponseDisposed: () =>
+ {
+ if (!connectionSlotReleased)
+ {
+ availableConnections.Release();
+ }
+ });
}
}
catch (TaskCanceledException)
@@ -326,6 +419,168 @@ private static bool ShouldRetry(HttpStatusCode statusCode)
return false;
}
+ ///
+ /// Determines whether an HTTP status code indicates an authentication failure
+ /// that warrants rejecting (erasing) the stored credential.
+ ///
+ ///
+ /// Only 401 (Unauthorized) and 302 (Redirect to the Azure DevOps sign-in page)
+ /// are genuine authentication failures. A 400 (Bad Request) is a request/formatting
+ /// problem, NOT an expired credential - an expired or invalid credential always
+ /// returns 401 or 302.
+ ///
+ internal static bool ShouldRejectCredentials(HttpStatusCode statusCode)
+ {
+ return statusCode == HttpStatusCode.Unauthorized ||
+ statusCode == HttpStatusCode.Redirect;
+ }
+
+ ///
+ /// SKETCH (design proposal). Returns the URI of a lightweight, auth-enforced,
+ /// guaranteed-well-formed endpoint that can be used to confirm whether the current
+ /// credential is still valid. Returns null when this requestor cannot probe (the
+ /// caller then treats an ambiguous 400 conservatively and does NOT reject).
+ ///
+ /// The URI of the request that returned the 400, so the
+ /// probe can target the SAME host (cache vs origin) and exercise the same auth path.
+ ///
+ /// The probe URI MUST be built from a constant we control - never from the request
+ /// input that produced the 400 (that input may be the corrupt value that caused it).
+ /// Derived requestors override this to point at a known-good object on the same host
+ /// that returned the 400.
+ ///
+ protected virtual Uri GetCredentialProbeUri(Uri failedRequestUri)
+ {
+ return null;
+ }
+
+ ///
+ /// SKETCH (design proposal). Re-sends the SAME credential to the known-good probe
+ /// endpoint to decide whether a 400 actually reflects a bad credential. Single-flighted
+ /// and memoized per credential for a short TTL so concurrent 400s do not fan out.
+ ///
+ ///
+ /// true only when the probe itself fails authentication (401/302) - i.e. the
+ /// credential really is bad and should be rejected. false when the probe succeeds,
+ /// returns any non-auth status (e.g. 200/404 - both prove auth passed), or cannot
+ /// run (no probe URI / transport error). The decisive signal is "did the probe get
+ /// past auth", so ANY response other than 401/302 means the credential is good.
+ ///
+ internal bool CredentialProbeConfirmsAuthFailure(long requestId, Uri failedRequestUri, string authString, CancellationToken cancellationToken)
+ {
+ lock (this.credentialProbeLock)
+ {
+ if (this.lastProbedAuthString == authString &&
+ DateTime.UtcNow - this.lastProbeTimeUtc < CredentialProbeResultTtl)
+ {
+ // Reuse the recent result for this exact credential (single-flight/memoize).
+ return this.lastProbeRejectResult;
+ }
+
+ bool reject = this.RunCredentialProbe(requestId, failedRequestUri, authString, cancellationToken);
+
+ this.lastProbedAuthString = authString;
+ this.lastProbeRejectResult = reject;
+ this.lastProbeTimeUtc = DateTime.UtcNow;
+ return reject;
+ }
+ }
+
+ private bool RunCredentialProbe(long requestId, Uri failedRequestUri, string authString, CancellationToken cancellationToken)
+ {
+ Uri probeUri = this.GetCredentialProbeUri(failedRequestUri);
+ if (probeUri == null)
+ {
+ // Cannot probe - be conservative and do NOT reject a possibly-good credential.
+ return false;
+ }
+
+ Stopwatch probeStopwatch = Stopwatch.StartNew();
+ bool probed = this.TryProbeCredential(probeUri, authString, cancellationToken, out HttpStatusCode probeStatus);
+ TimeSpan probeElapsed = probeStopwatch.Elapsed;
+
+ if (!probed)
+ {
+ // Transport failure probing - inconclusive, so do NOT reject.
+ return false;
+ }
+
+ bool reject = ShouldRejectCredentials(probeStatus);
+
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add("Area", "Authentication");
+ metadata.Add("RequestId", requestId);
+ metadata.Add(nameof(probeUri), probeUri.ToString());
+ metadata.Add(nameof(probeStatus), probeStatus.ToString());
+ metadata.Add("probeElapsedMS", $"{probeElapsed.TotalMilliseconds:F4}");
+ metadata.Add("rejectCredential", reject);
+
+ // A 200 or a 404 both prove auth passed (a 404 means we reached "object not found"
+ // past the auth gate). We deliberately KEEP the credential on any non-401/302 status,
+ // erring toward keeping a possibly-good credential over re-triggering a popup storm.
+ // Flag genuinely unexpected statuses so an endpoint that masks auth failures behind an
+ // unusual code is visible in telemetry rather than silently trusted.
+ if (!reject &&
+ probeStatus != HttpStatusCode.OK &&
+ probeStatus != HttpStatusCode.NotFound)
+ {
+ metadata.Add("probeStatusAmbiguous", true);
+ }
+
+ this.Tracer.RelatedInfo(metadata, "Credential probe after HTTP 400 completed");
+
+ return reject;
+ }
+
+ ///
+ /// SKETCH (design proposal). One-shot, no-retry GET to the probe endpoint carrying
+ /// the same Basic auth header as the original request. Reads only the status code.
+ /// Deliberately separate from so it never re-enters the
+ /// 400/401 handling (no recursion, no retry, no circuit-breaker interaction), and uses
+ /// the redirect-disabled probe client so a 302 sign-in redirect is observed, not followed.
+ /// Protected virtual as a test seam so the probe decision can be unit-tested without a
+ /// live network.
+ ///
+ protected virtual bool TryProbeCredential(Uri probeUri, string authString, CancellationToken cancellationToken, out HttpStatusCode probeStatus)
+ {
+ probeStatus = default(HttpStatusCode);
+
+ try
+ {
+ using (HttpRequestMessage probe = new HttpRequestMessage(HttpMethod.Get, probeUri))
+ {
+ probe.Headers.Add("X-TFS-FedAuthRedirect", "Suppress");
+ probe.Headers.UserAgent.Add(this.userAgentHeader);
+ if (!this.authentication.IsAnonymous)
+ {
+ probe.Headers.Authorization = new AuthenticationHeaderValue("Basic", authString);
+ }
+
+ // Bound the probe by its own timeout AND honor the caller's cancellation so a
+ // cancelled mount/prefetch is not held hostage by the probe.
+ using (CancellationTokenSource timeout = new CancellationTokenSource(CredentialProbeTimeout))
+ using (CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken))
+ using (HttpResponseMessage probeResponse = this.probeClient.SendAsync(
+ probe,
+ HttpCompletionOption.ResponseHeadersRead,
+ linked.Token).GetAwaiter().GetResult())
+ {
+ probeStatus = probeResponse.StatusCode;
+ return true;
+ }
+ }
+ }
+ catch (Exception e) when (e is HttpRequestException || e is TaskCanceledException || e is OperationCanceledException)
+ {
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add("Area", "Authentication");
+ metadata.Add(nameof(probeUri), probeUri.ToString());
+ metadata.Add("Exception", e.ToString());
+ this.Tracer.RelatedWarning(metadata, "Credential probe after HTTP 400 could not complete; treating as inconclusive");
+ return false;
+ }
+ }
+
private static string GetSingleHeaderOrEmpty(HttpHeaders headers, string headerName)
{
IEnumerable values;
diff --git a/GVFS/GVFS.UnitTests/Http/CredentialProbeBehaviorTests.cs b/GVFS/GVFS.UnitTests/Http/CredentialProbeBehaviorTests.cs
new file mode 100644
index 000000000..2a996a8d1
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Http/CredentialProbeBehaviorTests.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Net;
+using System.Threading;
+using GVFS.Common;
+using GVFS.Common.Git;
+using GVFS.Common.Http;
+using GVFS.Tests.Should;
+using GVFS.UnitTests.Mock;
+using GVFS.UnitTests.Mock.Common;
+using NUnit.Framework;
+
+namespace GVFS.UnitTests.Http
+{
+ ///
+ /// SKETCH (design proposal). Exercises the actual probe decision in
+ /// via a test seam that overrides
+ /// the network send, so the four decision paths (probe 401/302 = reject; probe 200/404 = keep;
+ /// no probe URI = keep; transport failure = keep) and the single-flight memoization are covered.
+ ///
+ [TestFixture]
+ public class CredentialProbeBehaviorTests
+ {
+ private static readonly Uri FailedRequestUri = new Uri("mock://repo/gvfs/objects/badsha");
+
+ [TestCase]
+ public void Probe401ConfirmsAuthFailure()
+ {
+ ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.Unauthorized);
+ dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None)
+ .ShouldEqual(true, "A probe that returns 401 confirms the credential is bad");
+ dut.ProbeCallCount.ShouldEqual(1);
+ }
+
+ [TestCase]
+ public void Probe302ConfirmsAuthFailure()
+ {
+ ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.Redirect);
+ dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None)
+ .ShouldEqual(true, "A probe that returns 302 (sign-in redirect) confirms the credential is bad");
+ }
+
+ [TestCase]
+ public void Probe200KeepsCredential()
+ {
+ ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.OK);
+ dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None)
+ .ShouldEqual(false, "A probe that returns 200 proves the credential is valid - keep it");
+ }
+
+ [TestCase]
+ public void Probe404KeepsCredential()
+ {
+ // A 404 proves auth passed: we reached "object not found" past the auth gate.
+ ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.NotFound);
+ dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None)
+ .ShouldEqual(false, "A probe that returns 404 proves auth passed - keep the credential");
+ }
+
+ [TestCase]
+ public void TransportFailureKeepsCredential()
+ {
+ // probeStatus null => TryProbeCredential returns false (inconclusive).
+ ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), probeStatus: null);
+ dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None)
+ .ShouldEqual(false, "An inconclusive probe (transport failure) must NOT reject the credential");
+ }
+
+ [TestCase]
+ public void NoProbeUriKeepsCredentialWithoutProbing()
+ {
+ ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(probeUri: null, probeStatus: HttpStatusCode.Unauthorized);
+ dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authString", CancellationToken.None)
+ .ShouldEqual(false, "With no probe URI the credential must be kept (conservative)");
+ dut.ProbeCallCount.ShouldEqual(0, "No probe should be sent when there is no probe URI");
+ }
+
+ [TestCase]
+ public void RepeatedProbesForSameCredentialAreSingleFlighted()
+ {
+ ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.NotFound);
+
+ bool first = dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "sameAuth", CancellationToken.None);
+ bool second = dut.CredentialProbeConfirmsAuthFailure(2, FailedRequestUri, "sameAuth", CancellationToken.None);
+
+ first.ShouldEqual(false);
+ second.ShouldEqual(false);
+ dut.ProbeCallCount.ShouldEqual(1, "The second 400 for the same credential should reuse the memoized probe result");
+ }
+
+ [TestCase]
+ public void DifferentCredentialTriggersFreshProbe()
+ {
+ ProbeTestableHttpGitObjects dut = new ProbeTestableHttpGitObjects(ProbeUri(), HttpStatusCode.NotFound);
+
+ dut.CredentialProbeConfirmsAuthFailure(1, FailedRequestUri, "authOne", CancellationToken.None);
+ dut.CredentialProbeConfirmsAuthFailure(2, FailedRequestUri, "authTwo", CancellationToken.None);
+
+ dut.ProbeCallCount.ShouldEqual(2, "A different credential must not reuse the previous credential's probe result");
+ }
+
+ private static Uri ProbeUri()
+ {
+ return new Uri("mock://cache/gvfs/objects/4b825dc642cb6eb9a060e54bf8d69288fbee4904");
+ }
+
+ private sealed class ProbeTestableHttpGitObjects : GitObjectsHttpRequestor
+ {
+ private readonly Uri probeUri;
+ private readonly HttpStatusCode? probeStatus;
+
+ public ProbeTestableHttpGitObjects(Uri probeUri, HttpStatusCode? probeStatus)
+ : base(new MockTracer(), new MockGVFSEnlistment(), new MockCacheServerInfo(), new RetryConfig(maxRetries: 1))
+ {
+ this.probeUri = probeUri;
+ this.probeStatus = probeStatus;
+ }
+
+ public int ProbeCallCount { get; private set; }
+
+ protected override Uri GetCredentialProbeUri(Uri failedRequestUri)
+ {
+ return this.probeUri;
+ }
+
+ protected override bool TryProbeCredential(Uri probeUri, string authString, CancellationToken cancellationToken, out HttpStatusCode probeStatus)
+ {
+ this.ProbeCallCount++;
+ if (this.probeStatus.HasValue)
+ {
+ probeStatus = this.probeStatus.Value;
+ return true;
+ }
+
+ probeStatus = default(HttpStatusCode);
+ return false;
+ }
+ }
+ }
+}
diff --git a/GVFS/GVFS.UnitTests/Http/CredentialProbeDecisionTests.cs b/GVFS/GVFS.UnitTests/Http/CredentialProbeDecisionTests.cs
new file mode 100644
index 000000000..032586f0c
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Http/CredentialProbeDecisionTests.cs
@@ -0,0 +1,53 @@
+using System.Net;
+using GVFS.Common.Http;
+using GVFS.Tests.Should;
+using NUnit.Framework;
+
+namespace GVFS.UnitTests.Http
+{
+ ///
+ /// SKETCH (design proposal). Covers the decisive-signal rule the credential probe relies
+ /// on: after a 400, we re-send the same credential to a known-good endpoint and reject the
+ /// credential ONLY when that probe itself fails authentication (401/302). Any other probe
+ /// status - including 200 and 404 - proves the credential got past auth and must be kept.
+ ///
+ [TestFixture]
+ public class CredentialProbeDecisionTests
+ {
+ [TestCase]
+ public void Probe401MeansRejectCredential()
+ {
+ HttpRequestor.ShouldRejectCredentials(HttpStatusCode.Unauthorized)
+ .ShouldEqual(true, "A 401 probe response is a real auth failure - reject the credential");
+ }
+
+ [TestCase]
+ public void Probe302MeansRejectCredential()
+ {
+ HttpRequestor.ShouldRejectCredentials(HttpStatusCode.Redirect)
+ .ShouldEqual(true, "A 302 probe response is the sign-in redirect - reject the credential");
+ }
+
+ [TestCase]
+ public void Probe200MeansKeepCredential()
+ {
+ HttpRequestor.ShouldRejectCredentials(HttpStatusCode.OK)
+ .ShouldEqual(false, "A 200 probe response proves the credential is valid - keep it");
+ }
+
+ [TestCase]
+ public void Probe404MeansKeepCredential()
+ {
+ // A 404 proves auth passed: we reached "object not found" past the auth gate.
+ HttpRequestor.ShouldRejectCredentials(HttpStatusCode.NotFound)
+ .ShouldEqual(false, "A 404 probe response proves auth passed - keep the credential");
+ }
+
+ [TestCase]
+ public void Probe400MeansKeepCredential()
+ {
+ HttpRequestor.ShouldRejectCredentials(HttpStatusCode.BadRequest)
+ .ShouldEqual(false, "Even a 400 probe response is not an auth failure - keep the credential");
+ }
+ }
+}