Skip to content

Do not fall back to anonymous when the /gvfs/config probe is indeterminate - #2104

Open
tyrielv wants to merge 2 commits into
microsoft:vnextfrom
tyrielv:tyrielv/fix-anon-auth-latch
Open

Do not fall back to anonymous when the /gvfs/config probe is indeterminate#2104
tyrielv wants to merge 2 commits into
microsoft:vnextfrom
tyrielv:tyrielv/fix-anon-auth-latch

Conversation

@tyrielv

@tyrielv tyrielv commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What this fixes

A mount can end up permanently unauthenticated, and only a remount clears it.

GitAuthentication.TryInitializeAndQueryGVFSConfig probes /gvfs/config without
credentials. The probe has three outcomes, but the code only handled two:

Probe outcome Meaning Old behavior
200 Server allows anonymous access IsAnonymous = true
401 Credentials required IsAnonymous = false
timeout / 5xx / socket error Unknown IsAnonymous left at its default true

The third case is indeterminate — it says nothing about authentication. Leaving
IsAnonymous at its true default silently downgrades the process to anonymous.

Why it never recovers

HttpRequestor.SendRequest short-circuits on IsAnonymous:

if (!this.authentication.IsAnonymous &&
    !this.authentication.TryGetCredentials(this.Tracer, out authString, out errorMessage))

So while IsAnonymous is true:

  1. No Authorization header is sent, and TryGetCredentials is never called
    git credential fill never runs, so no credential is ever fetched.
  2. The Azure DevOps cache server answers with 400 and the body
    A valid Basic Authorization header is required.
  3. A 400 is not retryable, so RetryWrapper aborts immediately.
  4. RejectCredentials is a no-op, because its guard requires a non-null cached
    credential and none was ever cached.
  5. MarkInitialized() already ran, so TryInitializeAndQueryGVFSConfig throws
    InvalidOperationException("Already initialized") if called again — the probe
    never re-runs.

Every recovery path is closed. The state persists for the life of the process.

User impact

Mount does not fail on this, because when a cache server is configured
InProcessMount deliberately continues ("Mount will proceed with fallback cache
server"). The repo mounts and looks healthy, but cannot download objects or blob
sizes. Directory enumeration fails with SizesUnavailableException, so
StartDirectoryEnumerationAsyncHandler returns an error and git reports tracked
files as deleted
. Each enumeration retries and fails identically, flooding the
mount log.

Observed on a large enlistment: the config query timed out at mount, then ~35,000
auth errors and ~29,600 SizesUnavailableException in roughly one hour, with the
mount log growing past 70 MB. git status showed millions of phantom deletions.
Zero TryGetCredential events appeared in the entire log, while a separate
gvfs prefetch process authenticated normally against the same repo at the same
time — the classic signature of the process being stuck anonymous. Remounting
fixed it immediately.

The fix

Anonymous access becomes an affirmative determination: it requires a successful
unauthenticated probe.

  • IsAnonymous now defaults to false.
  • The indeterminate branch sets IsAnonymous = false explicitly and says so in
    the warning it traces.

If the server really does allow anonymous access, it ignores the Authorization
header GVFS sends after an indeterminate probe. The failure mode is now a
possible credential prompt instead of a mount that cannot serve files.

The default flip also closes a related gap: a request issued before initialization
completes previously went out anonymous. It now goes through TryGetCredentials,
which already waits on initializationComplete.

Not a 2.0 regression

v1.0.26098.1 has the same !IsAnonymous && TryGetCredentials(...) short-circuit
in SendRequest and the same hasCacheServer fallback in mount, and its
indeterminate branch also never assigns IsAnonymous. 1.0 reaches the identical
stuck-anonymous state. This targets vnext rather than master, because it
changes behavior on the authentication path for a long-standing defect rather than
repairing something 2.0 broke.

Known tradeoff (reviewer input welcome)

After an indeterminate probe, a genuinely anonymous server is now latched into requiring credentials for the life of the process, because MarkInitialized prevents a re-probe. A server that permits anonymous access simply ignores the Authorization header, so the real exposure is narrow: an anonymous repo, with no cached credential, after a transient probe failure — which could newly prompt via GCM.

Tests

GitAuthenticationTests — 6 tests covering all three probe outcomes plus the probe's own anonymity:

  • InitialConfigProbeIsSentWithoutCredentialsthe regression test for the bug above. Mirrors SendRequest's credential gate exactly (forceAnonymous || IsAnonymous) and asserts no credential is requested during the probe. InitializationWaitTimeoutMs is lowered to 500 ms so a regression fails fast instead of hanging the suite.
  • AuthIsNotAnonymousBeforeInitialization — pins the new default.
  • IndeterminateConfigProbeDoesNotLeaveAuthAnonymous — five real [TestCase(...)] rows (RequestTimeout, InternalServerError, ServiceUnavailable, BadRequest, and no HTTP response) so each status reports independently.
  • IndeterminateConfigProbeStillAuthenticatesLaterRequests — credentials remain obtainable afterward.
  • AnonymousConfigProbeSuccessLeavesAuthAnonymous, UnauthorizedConfigProbeRequiresAuthentication — guards against over-correcting.

Mutation testing, corrected. The original description claimed each half of the fix independently failed three tests. That was wrong — it reflected a combined revert. Re-run per half:

Mutation Result
Remove forceAnonymous: true from the probe fails InitialConfigProbeIsSentWithoutCredentials
Revert IsAnonymous default to true fails AuthIsNotAnonymousBeforeInitialization (1 test, not 3)
Delete the explicit IsAnonymous = false on the indeterminate branch fails nothing

The third result is expected: with the default now false, that assignment cannot change behavior. It is kept as defensive symmetry so each branch states its own outcome, and is commented as such.

Full unit suite: 948 tests, 0 failed (11 pre-existing ignored). StyleCop clean.

Why no functional test

The repro needs /gvfs/config to fail transiently while the cache-server path stays reachable. The functional suite clones from a live server and has no HTTP fault-injection seam, and the config endpoint URL derives from the same RepoUrl as object downloads, so the two cannot be failed independently. A true end-to-end unit test is also not yet possible here: HttpRequestor has no HttpMessageHandler seam on vnext (#2082 adds one). The new test therefore mirrors the production gate rather than driving SendRequest directly.

…inate

Mount initializes authentication by querying /gvfs/config without credentials.
A success means the server allows anonymous access. A 401 means credentials are
required. Any other outcome - a timeout, a 5xx, or a socket error - says nothing
about authentication, but the code left IsAnonymous at its default of true and
marked initialization complete.

That state is unrecoverable for the life of the mount process. HttpRequestor
omits the Authorization header while IsAnonymous is true, and the short-circuit
in SendRequest means TryGetCredentials is never called, so no credential is ever
fetched. The Azure DevOps cache server answers such a request with 400 and the
body "A valid Basic Authorization header is required." A 400 is not retryable,
RejectCredentials is a no-op because no credential was ever cached, and
initialization is already latched, so the probe never runs again.

Mount proceeds past this failure when a cache server is configured, so the repo
stays mounted but cannot download objects or blob sizes. Directory enumeration
then fails with SizesUnavailableException and git reports tracked files as
deleted. Every enumeration retries and fails the same way, which floods the
mount log. Only a remount clears the state.

Default IsAnonymous to false and set it explicitly on the indeterminate branch.
Anonymous access is now an affirmative determination that requires a successful
unauthenticated probe. If the server does allow anonymous access, it ignores the
Authorization header that GVFS sends after an indeterminate probe.

Extract IGVFSConfigRequestor from ConfigHttpRequestor and add an internal factory
seam so tests can drive each probe outcome without a server. Five tests cover the
three branches; the three that pin the fix fail if either half of it is reverted.

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Review found that the previous commit broke the probe it depends on.

The probe that determines whether the server allows anonymous access was
unauthenticated only as a side effect of IsAnonymous defaulting to true.
Defaulting it to false removed that mechanism without replacing it, so
HttpRequestor.SendRequest saw IsAnonymous == false, called TryGetCredentials,
and waited on initializationComplete - the event that only the same call stack
can set. Each wait cost the full 120 second initialization timeout and returned
a retryable 401, so a mount stalled for roughly 14 minutes across the default
seven attempts and then wrongly concluded that credentials were required. This
happened on every mount. The mock-based tests never reached SendRequest, so
they could not catch it.

Make the probe explicitly unauthenticated instead of implicitly so. SendRequest
takes a forceAnonymous parameter, ConfigHttpRequestor.TryQueryGVFSConfig passes
it through, and the initial probe sets it. A request that carries no credentials
now also skips ApproveCredentials and keeps the existing anonymous handling of a
401, which is the definitive answer that the server requires authentication.

SendRequest also resolves the decision once into a local instead of reading
IsAnonymous four times. Another thread can change the property mid-request, and
the credential gate, the Authorization header, and the response handling must
agree on one value.

Trace the indeterminate branch with Keywords.Telemetry and structured metadata.
The plain RelatedWarning overload traces with Keywords.None, which the telemetry
listener filters out, so the affected population could not be measured.

Replace the ConfigRequestorFactory Func with a plain ConfigRequestorOverride
property, matching the settable-knob style this class already uses for
InitializationWaitTimeoutMs, and make IGVFSConfigRequestor internal since no
production code needs the polymorphism.

Add InitialConfigProbeIsSentWithoutCredentials, which mirrors the SendRequest
credential gate and fails fast if the probe ever waits on its own
initialization. Convert the indeterminate-status test to real TestCase rows so
each status reports independently.

Mutation testing, per half: removing forceAnonymous fails the new test;
reverting the default fails AuthIsNotAnonymousBeforeInitialization; deleting the
explicit IsAnonymous assignment on the indeterminate branch fails nothing, so
that line is kept only as defensive symmetry and is commented as such.

Full unit suite: 948 tests, 0 failed.

Assisted-by: Claude Opus 5
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
@tyrielv
tyrielv marked this pull request as ready for review September 2, 2026 21:00
@tyrielv
tyrielv enabled auto-merge September 2, 2026 21:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant