What's wrong
The repository allow-list is deliberately case-insensitive. RepositoryPattern.Translate compiles every configured pattern with RegexOptions.IgnoreCase, and the type's own remarks say why: "Matching is case insensitive because forge repository names are, and a pattern that fails only because someone typed Studio is a support ticket rather than a control." So for a configured pattern studio/game.git, IRepositoryAllowList.IsAllowed("github", "Studio/Game.git") and IsAllowed("github", "studio/game.git") both return true.
However, every identity derived downstream from that same client-supplied repositoryPath (taken verbatim from the request URL by StateRouteParser.Parse) is case-sensitive:
MirrorStore.TryResolve builds the on-disk mirror directory by Path.Combine-ing the path segments verbatim, so on a case-sensitive filesystem (this ships as a Linux container per the README/Dockerfile) Studio/Game.git and studio/game.git resolve to two different mirror.git directories.
MirrorKey.ToFlightKey() ($"{Upstream}\n{RepositoryPath}") is used both for SingleFlight clone/fetch coalescing in MirrorFetcher, and — via repository.Key.ToFlightKey() in BranchStateHandler.ComputeAsync — as the Repository component of DiffKey, so the two spellings never share fetch coalescing or a DiffCache entry either.
CredentialAdmission.Key hashes $"{upstream}\n{repositoryPath}\n{authorization}" (ordinal), so the two spellings are admitted (and separately probed with git ls-remote) independently too.
Concrete failure scenario
Two callers (or the same caller at different times — e.g. a client whose local remote URL preserves whatever casing was used when the repository was cloned) address the same upstream repository as Studio/Game.git and studio/game.git. Both pass the allow-list because it treats them as equivalent, but the service ends up: cloning and keeping two full bare mirrors of the same repository on disk, fetching it twice on every polling heartbeat, computing and caching branch diffs twice, and probing ls-remote admission twice. This silently doubles disk, network and CPU cost for that repository and defeats the service's central design goal stated in the README — "does that work once, centrally, for many clients" — for any deployment where callers don't consistently use one casing.
Suggested fix / acceptance criteria
- Normalize
repositoryPath to one canonical case immediately after the allow-list check succeeds in BranchStateHandler.Resolve, before it is used to build the mirror directory, the SingleFlight key, the DiffKey, or the admission key — so every downstream identity is derived from the canonical form instead of the caller's literal spelling.
- Add a test asserting that two requests for the same repository differing only in path casing resolve to the same mirror directory, share one fetch/coalescing key, and share one
DiffCache entry.
What's wrong
The repository allow-list is deliberately case-insensitive.
RepositoryPattern.Translatecompiles every configured pattern withRegexOptions.IgnoreCase, and the type's own remarks say why: "Matching is case insensitive because forge repository names are, and a pattern that fails only because someone typedStudiois a support ticket rather than a control." So for a configured patternstudio/game.git,IRepositoryAllowList.IsAllowed("github", "Studio/Game.git")andIsAllowed("github", "studio/game.git")both returntrue.However, every identity derived downstream from that same client-supplied
repositoryPath(taken verbatim from the request URL byStateRouteParser.Parse) is case-sensitive:MirrorStore.TryResolvebuilds the on-disk mirror directory byPath.Combine-ing the path segments verbatim, so on a case-sensitive filesystem (this ships as a Linux container per the README/Dockerfile)Studio/Game.gitandstudio/game.gitresolve to two differentmirror.gitdirectories.MirrorKey.ToFlightKey()($"{Upstream}\n{RepositoryPath}") is used both forSingleFlightclone/fetch coalescing inMirrorFetcher, and — viarepository.Key.ToFlightKey()inBranchStateHandler.ComputeAsync— as theRepositorycomponent ofDiffKey, so the two spellings never share fetch coalescing or aDiffCacheentry either.CredentialAdmission.Keyhashes$"{upstream}\n{repositoryPath}\n{authorization}"(ordinal), so the two spellings are admitted (and separately probed withgit ls-remote) independently too.Concrete failure scenario
Two callers (or the same caller at different times — e.g. a client whose local remote URL preserves whatever casing was used when the repository was cloned) address the same upstream repository as
Studio/Game.gitandstudio/game.git. Both pass the allow-list because it treats them as equivalent, but the service ends up: cloning and keeping two full bare mirrors of the same repository on disk, fetching it twice on every polling heartbeat, computing and caching branch diffs twice, and probingls-remoteadmission twice. This silently doubles disk, network and CPU cost for that repository and defeats the service's central design goal stated in the README — "does that work once, centrally, for many clients" — for any deployment where callers don't consistently use one casing.Suggested fix / acceptance criteria
repositoryPathto one canonical case immediately after the allow-list check succeeds inBranchStateHandler.Resolve, before it is used to build the mirror directory, theSingleFlightkey, theDiffKey, or the admission key — so every downstream identity is derived from the canonical form instead of the caller's literal spelling.DiffCacheentry.