Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ PublishScripts/
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# and except a Unity project's Packages/, which is source: Unity's package manifest and its
# resolved lock file are both meant to be committed, and a NuGet restore folder never contains
# a file by either name.
!**/[Pp]ackages/manifest.json
!**/[Pp]ackages/packages-lock.json
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
Expand Down Expand Up @@ -651,3 +656,16 @@ Temporary Items

# ImGui.ini files
imgui.ini

# Game engine projects
#
# Godot: the import cache, and the mono/temp bin+obj a C# build writes.
.godot/

# Unity: .meta files are source, not the Visual Studio C++ build artifact that the `*.meta` rule
# further up targets. Unity generates one per asset and it carries the GUID that scenes, prefabs
# and serialized references point at, so ignoring them gives every clone fresh GUIDs and silently
# breaks those references - including for a plug-in whose .dll is itself a build output. This
# negation has to come after that rule to win, and is scoped to the asset tree so the Visual
# Studio artifact stays ignored everywhere else.
!**/[Aa]ssets/**/*.meta
90 changes: 85 additions & 5 deletions GitBranchStateCache.Tests/Integration/RepositoryIdentityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@
/// What counts as "the same repository" once a request has been accepted.
/// </summary>
/// <remarks>
/// The allow-list matches case insensitively on purpose, so two callers can address one repository by
/// two spellings and both be served. Everything derived afterwards — the mirror directory, the
/// coalescing key, the diff cache key, the admission key — compares ordinally, so unless the path is
/// canonicalised once at the point it is accepted, one repository quietly becomes two of everything.
/// That is the whole of this service's purpose inverted: it would do the work twice rather than once.
/// The upstream registry and the allow-list both match case insensitively on purpose, so two callers
/// can address one repository by two spellings — of the repository path, of the upstream key, or of
/// both — and both be served. Everything derived afterwards — the mirror directory, the coalescing
/// key, the diff cache key, the admission key — compares ordinally, so unless the key is canonicalised
/// once at the point it is accepted, one repository quietly becomes two of everything. That is the
/// whole of this service's purpose inverted: it would do the work twice rather than once.
/// </remarks>
[TestClass]
public class RepositoryIdentityTests
Expand All @@ -32,6 +33,12 @@
/// <summary>The same repository as a client that cloned it with different casing spells it.</summary>
private const string CallerSpelling = "/v1/github/Studio/Game.git/state";

/// <summary>
/// The same repository again, reached through the upstream key spelled the way a forge brands
/// itself rather than the way the configuration keys it.
/// </summary>
private const string UpstreamCallerSpelling = "/v1/GitHub/studio/game.git/state";

private const string Body = $$"""{"base":"{{ClientBase}}","branchPatterns":["origin/main"]}""";

private static void Seed(ScriptedGit git)
Expand Down Expand Up @@ -126,4 +133,77 @@
"game.git",
"mirror.git")));
}

[TestMethod]
public async Task TwoSpellingsOfOneUpstream_AreBothServed()
{
// The premise for the upstream key, matching the one above for the repository path: the registry
// and the allow-list both resolve it case insensitively, so a caller that writes the forge's own
// branding rather than the configuration key is still addressing the configured upstream.
await using ServiceFixture fixture = await ServiceFixture.StartAsync();
Seed(fixture.Git);

using HttpResponseMessage configured = await fixture.Client.SendAsync(Request(ConfiguredSpelling));

Check warning on line 146 in GitBranchStateCache.Tests/Integration/RepositoryIdentityTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaDPyKPbiqcEUY1ughY7&open=AaDPyKPbiqcEUY1ughY7&pullRequest=39
using HttpResponseMessage caller = await fixture.Client.SendAsync(Request(UpstreamCallerSpelling));

Check warning on line 147 in GitBranchStateCache.Tests/Integration/RepositoryIdentityTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaDPyKPbiqcEUY1ughY8&open=AaDPyKPbiqcEUY1ughY8&pullRequest=39

Assert.AreEqual(HttpStatusCode.OK, configured.StatusCode);
Assert.AreEqual(HttpStatusCode.OK, caller.StatusCode);
}

[TestMethod]
public async Task TwoSpellingsOfOneUpstream_ShareOneMirrorFetchDiffAndAdmission()
{
// The same duplication the repository-path case guards against, reached through the sibling half
// of the key instead. One repository, so one clone, one credential probe, and one diff.
await using ServiceFixture fixture = await ServiceFixture.StartAsync();
Seed(fixture.Git);

using HttpResponseMessage configured = await fixture.Client.SendAsync(Request(ConfiguredSpelling));

Check warning on line 161 in GitBranchStateCache.Tests/Integration/RepositoryIdentityTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaDPyKPbiqcEUY1ughY9&open=AaDPyKPbiqcEUY1ughY9&pullRequest=39
using HttpResponseMessage caller = await fixture.Client.SendAsync(Request(UpstreamCallerSpelling));

Check warning on line 162 in GitBranchStateCache.Tests/Integration/RepositoryIdentityTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaDPyKPbiqcEUY1ughY-&open=AaDPyKPbiqcEUY1ughY-&pullRequest=39

Assert.AreEqual(HttpStatusCode.OK, configured.StatusCode);
Assert.AreEqual(HttpStatusCode.OK, caller.StatusCode);

Assert.AreEqual(1, fixture.Git.CountOf("clone"));
Assert.AreEqual(1, fixture.Git.CountOf("ls-remote"));
Assert.AreEqual(1, fixture.Git.CountOf("diff-tree"));
}

[TestMethod]
public async Task TwoSpellingsOfOneUpstream_ProduceOneMirrorDirectory()
{
// Asserted against the volume, because an upstream key that is not canonicalised becomes a
// second top-level directory holding a complete duplicate of every repository under it.
await using ServiceFixture fixture = await ServiceFixture.StartAsync();
Seed(fixture.Git);

using HttpResponseMessage configured = await fixture.Client.SendAsync(Request(ConfiguredSpelling));

Check warning on line 180 in GitBranchStateCache.Tests/Integration/RepositoryIdentityTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaDPyKPbiqcEUY1ughY_&open=AaDPyKPbiqcEUY1ughY_&pullRequest=39
using HttpResponseMessage caller = await fixture.Client.SendAsync(Request(UpstreamCallerSpelling));

Check warning on line 181 in GitBranchStateCache.Tests/Integration/RepositoryIdentityTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaDPyKPbiqcEUY1ughZA&open=AaDPyKPbiqcEUY1ughZA&pullRequest=39

IDirectory directory = fixture.FileSystem.Directory;

Assert.HasCount(
1,
directory.GetDirectories(ServiceFixture.MirrorRoot, "mirror.git", SearchOption.AllDirectories));
}

[TestMethod]
public async Task ARequestWithTheUpstreamSpelledDifferently_MirrorsUnderTheCanonicalUpstream()
{
// The canonical form is lower case for the upstream key as much as for the path, so the volume
// carries one directory per configured upstream whatever casing reached the service.
await using ServiceFixture fixture = await ServiceFixture.StartAsync();
Seed(fixture.Git);

using HttpResponseMessage response = await fixture.Client.SendAsync(Request(UpstreamCallerSpelling));

Check warning on line 198 in GitBranchStateCache.Tests/Integration/RepositoryIdentityTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitBranchStateCache&issues=AaDPyKPbiqcEUY1ughZB&open=AaDPyKPbiqcEUY1ughZB&pullRequest=39

Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);

Assert.IsTrue(fixture.FileSystem.Directory.Exists(fixture.FileSystem.Path.Combine(
ServiceFixture.MirrorRoot,
"github",
"studio",
"game.git",
"mirror.git")));
}
}
39 changes: 25 additions & 14 deletions GitBranchStateCache/Endpoints/BranchStateHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
/// <param name="metrics">Service counters.</param>
/// <param name="options">The configured options.</param>
/// <param name="logger">Logger.</param>
internal sealed class BranchStateHandler(

Check warning on line 45 in GitBranchStateCache/Endpoints/BranchStateHandler.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Constructor has 11 parameters, which is greater than the 7 authorized.

Check warning on line 45 in GitBranchStateCache/Endpoints/BranchStateHandler.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Constructor has 11 parameters, which is greater than the 7 authorized.

Check warning on line 45 in GitBranchStateCache/Endpoints/BranchStateHandler.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Constructor has 11 parameters, which is greater than the 7 authorized.

Check warning on line 45 in GitBranchStateCache/Endpoints/BranchStateHandler.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Constructor has 11 parameters, which is greater than the 7 authorized.
IUpstreamRegistry registry,
IRepositoryAllowList allowList,
IMirrorStore mirrors,
Expand Down Expand Up @@ -407,7 +407,7 @@
return null;
}

MirrorKey key = new(route.Upstream, Canonicalize(route.RepositoryPath));
MirrorKey key = new(Canonicalize(route.Upstream), Canonicalize(route.RepositoryPath));

if (!mirrors.TryResolve(key, out string? directory)
|| !UpstreamUrl.TryCombine(upstreamBase!, route.RepositoryPath, out Uri? repositoryUrl))
Expand All @@ -419,26 +419,37 @@
}

/// <summary>
/// Reduces a repository path to the one spelling this service knows it by.
/// Reduces one half of a <see cref="MirrorKey"/> — the upstream key or the repository path — to the
/// one spelling this service knows it by.
/// </summary>
/// <remarks>
/// This exists because the allow-list immediately above it matches case insensitively, deliberately
/// and for good reasons of its own, while every identity derived from the path afterwards compares
/// ordinally: the mirror directory on a case-sensitive volume, the coalescing key that keeps
/// concurrent work on one repository to a single fetch, the diff cache key built from it, and the
/// admission key. Pass the caller's literal spelling on and one repository addressed two ways is
/// two mirrors on disk, fetched twice per heartbeat, diffed twice, and probed twice — which is the
/// duplication this service exists to remove, reintroduced by a difference the allow-list has
/// already ruled irrelevant. So it is canonicalised once, here, at the only point that decides what
/// a request is about. Removing this does not simplify anything; it silently doubles the cost of
/// every repository whose callers do not agree on casing.
/// This exists because the upstream resolution and the allow-list immediately above it both match
/// case insensitively, deliberately and for good reasons of their own, while every identity derived
/// from the key afterwards compares ordinally: the mirror directory on a case-sensitive volume, the
/// coalescing key that keeps concurrent work on one repository to a single fetch, the diff cache key
/// built from it, and the admission key. Pass the caller's literal spelling on and one repository
/// addressed two ways is two mirrors on disk, fetched twice per heartbeat, diffed twice, and probed
/// twice — which is the duplication this service exists to remove, reintroduced by a difference the
/// checks above have already ruled irrelevant. So it is canonicalised once, here, at the only point
/// that decides what a request is about. Removing this does not simplify anything; it silently
/// doubles the cost of every repository whose callers do not agree on casing.
/// <para>
/// Both halves of the key go through this, and for the same reason. Canonicalising only the
/// repository path leaves the identical duplication reachable through the upstream key instead,
/// because <see cref="IUpstreamRegistry.TryResolve"/> and <see cref="IRepositoryAllowList.IsAllowed"/>
/// accept <c>GitHub</c> and <c>github</c> as one configured upstream while everything downstream of
/// them tells the two apart.
/// </para>
/// <para>
/// Only this service's own bookkeeping is canonicalised. What is sent to the forge keeps the
/// caller's spelling, because the forge is the authority on how it spells its own repository names
/// and this service should not be rewriting a URL on its behalf.
/// and this service should not be rewriting a URL on its behalf. The upstream key never reaches the
/// forge at all: the base URL comes from configuration, not from the caller's segment.
/// </para>
/// </remarks>
private static string Canonicalize(string repositoryPath) => repositoryPath.ToLowerInvariant();
/// <param name="segment">The upstream key or repository path as the caller spelled it.</param>
/// <returns>The spelling this service records the request under.</returns>
private static string Canonicalize(string segment) => segment.ToLowerInvariant();

private static bool TryParsePatterns(
IReadOnlyList<string>? requested,
Expand Down
Loading