diff --git a/samples/GitHttpBackend.Server/Program.cs b/samples/GitHttpBackend.Server/Program.cs index 6bac035..442c7d5 100644 --- a/samples/GitHttpBackend.Server/Program.cs +++ b/samples/GitHttpBackend.Server/Program.cs @@ -101,7 +101,12 @@ return Results.Content(RenderHomePage(projectRoot, ctx.Request, canAccess), "text/html; charset=utf-8"); }); -var endpoint = app.MapGitHttpBackend("/", options); +// Built here rather than inside MapGitHttpBackend so the resolved backend path is available +// for the startup log without resolving it a second time — and so a bad path fails before +// that line is written, not after it. +var invoker = new GitHttpBackendInvoker(options); + +var endpoint = app.MapGitHttpBackend("/", invoker); if (useBasic) { endpoint.RequireAuthorization(); @@ -110,7 +115,7 @@ app.Logger.LogInformation( "Serving git repositories from {ProjectRoot} (auth mode: {AuthMode}, git-http-backend: {BackendPath})", - projectRoot, useBasic ? "basic" : "none", new GitHttpBackendInvoker(options).BackendPath); + projectRoot, useBasic ? "basic" : "none", invoker.BackendPath); await app.RunAsync(); diff --git a/src/GitHttpBackend.AspNetCore/GitHttpBackendEndpointExtensions.cs b/src/GitHttpBackend.AspNetCore/GitHttpBackendEndpointExtensions.cs index 93df1b5..e847c3a 100644 --- a/src/GitHttpBackend.AspNetCore/GitHttpBackendEndpointExtensions.cs +++ b/src/GitHttpBackend.AspNetCore/GitHttpBackendEndpointExtensions.cs @@ -23,17 +23,37 @@ public static IEndpointConventionBuilder MapGitHttpBackend( ArgumentNullException.ThrowIfNull(options); // Constructed once: resolves and validates the backend path up front. - var invoker = new GitHttpBackendInvoker(options); + return endpoints.MapGitHttpBackend(prefix, new GitHttpBackendInvoker(options)); + } + + /// + /// Maps Git Smart HTTP endpoints under using an invoker the + /// caller already built. + /// + /// + /// Resolving git-http-backend starts a git --exec-path process and checks the + /// filesystem, so a host that also wants the resolved path — to log it at startup, say — + /// can build the invoker itself, read , and + /// hand the same instance here rather than paying for the lookup twice. It also means a + /// bad backend path fails before that log line rather than after it. + /// + public static IEndpointConventionBuilder MapGitHttpBackend( + this IEndpointRouteBuilder endpoints, string prefix, GitHttpBackendInvoker invoker) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(invoker); var normalizedPrefix = "/" + prefix.Trim('/'); var pattern = (normalizedPrefix == "/" ? "" : normalizedPrefix) + "/{**gitPath}"; return endpoints.MapMethods(pattern, new[] { HttpMethods.Get, HttpMethods.Post }, - (HttpContext ctx) => HandleAsync(ctx, invoker, options)); + (HttpContext ctx) => HandleAsync(ctx, invoker)); } - static async Task HandleAsync(HttpContext ctx, GitHttpBackendInvoker invoker, GitBackendOptions options) + static async Task HandleAsync(HttpContext ctx, GitHttpBackendInvoker invoker) { + var options = invoker.Options; + var logger = ctx.RequestServices.GetRequiredService() .CreateLogger("GitHttpBackend.AspNetCore"); diff --git a/src/GitHttpBackend/GitHttpBackendInvoker.cs b/src/GitHttpBackend/GitHttpBackendInvoker.cs index 0326cf3..63ea2eb 100644 --- a/src/GitHttpBackend/GitHttpBackendInvoker.cs +++ b/src/GitHttpBackend/GitHttpBackendInvoker.cs @@ -44,6 +44,13 @@ public GitHttpBackendInvoker(GitBackendOptions options) /// The resolved path to the git-http-backend executable. public string BackendPath => _backendPath; + /// + /// The options this invoker was constructed with, so a host that owns the invoker does not + /// have to carry the options alongside it to reach the + /// hook. + /// + public GitBackendOptions Options => _options; + /// /// Creates the target repository when /// is set, is a push, and the repository does not exist yet. diff --git a/tests/GitHttpBackend.AspNetCore.Tests/CallerOwnedInvokerTests.cs b/tests/GitHttpBackend.AspNetCore.Tests/CallerOwnedInvokerTests.cs new file mode 100644 index 0000000..9301ad0 --- /dev/null +++ b/tests/GitHttpBackend.AspNetCore.Tests/CallerOwnedInvokerTests.cs @@ -0,0 +1,81 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace GitHttpBackend.AspNetCore.Tests; + +/// +/// The overload that takes an invoker the host already built. What matters is that the +/// mapping uses that instance — not a second one built from the same options — so the +/// resolved backend path the host logged is the one actually serving requests. +/// +public class CallerOwnedInvokerTests +{ + [RequiresGitFact] + public void Options_expose_what_the_invoker_was_constructed_with() + { + var options = new GitBackendOptions { ProjectRoot = Path.GetTempPath() }; + var invoker = new GitHttpBackendInvoker(options); + + Assert.Same(options, invoker.Options); + Assert.True(File.Exists(invoker.BackendPath)); + } + + [RequiresGitFact] + public async Task A_caller_owned_invoker_serves_the_repository() + { + using var git = new GitClient(); + await using var server = await GitTestServer.StartWithInvokerAsync( + root => new GitHttpBackendInvoker(new GitBackendOptions { ProjectRoot = root })); + + var work = git.CreateWorkingRepository("work"); + var bare = Path.Combine(server.ProjectRoot, "projekt.git"); + git.RunOk(server.ProjectRoot, "init", "--bare", "--", bare); + git.RunOk(work, "push", bare, "main"); + git.RunOk(bare, "symbolic-ref", "HEAD", "refs/heads/main"); + + var clone = Path.Combine(git.Root, "clone"); + git.RunOk(git.Root, "clone", new Uri(server.Client.BaseAddress!, "projekt.git").ToString(), clone); + + Assert.True(File.Exists(Path.Combine(clone, "README.md"))); + } + + [RequiresGitFact] + public async Task The_mapping_uses_the_invokers_own_options() + { + // The hook only runs if HandleAsync read the options off the instance it was handed, + // which is what stops the two overloads from drifting apart. + var authorizeCalls = 0; + await using var server = await GitTestServer.StartWithInvokerAsync( + root => new GitHttpBackendInvoker(new GitBackendOptions + { + ProjectRoot = root, + Authorize = _ => + { + Interlocked.Increment(ref authorizeCalls); + return ValueTask.FromResult(false); + }, + })); + + var response = await server.Client.GetAsync("/projekt.git/info/refs?service=git-upload-pack"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(1, authorizeCalls); + } + + [Fact] + public async Task Both_overloads_reject_a_null_argument() + { + await using var app = WebApplication.CreateSlimBuilder().Build(); + + Assert.Throws( + () => app.MapGitHttpBackend("/", (GitBackendOptions)null!)); + Assert.Throws( + () => app.MapGitHttpBackend("/", (GitHttpBackendInvoker)null!)); + Assert.Throws( + () => ((IEndpointRouteBuilder)null!).MapGitHttpBackend("/", new GitBackendOptions + { + ProjectRoot = Path.GetTempPath(), + })); + } +} diff --git a/tests/GitHttpBackend.AspNetCore.Tests/GitTestServer.cs b/tests/GitHttpBackend.AspNetCore.Tests/GitTestServer.cs index 25f2894..4f95762 100644 --- a/tests/GitHttpBackend.AspNetCore.Tests/GitTestServer.cs +++ b/tests/GitHttpBackend.AspNetCore.Tests/GitTestServer.cs @@ -33,7 +33,17 @@ sealed class GitTestServer : IAsyncDisposable /// Starts a server over a fresh project root. receives that /// root and returns the options to map, so a test can set its own Authorize hook. /// - public static async Task StartAsync(Func configure) + public static Task StartAsync(Func configure) + => StartCoreAsync((app, root) => app.MapGitHttpBackend("/", configure(root))); + + /// + /// Same, but the caller builds the invoker — the overload a host uses when it wants the + /// resolved backend path for itself. + /// + public static Task StartWithInvokerAsync(Func configure) + => StartCoreAsync((app, root) => app.MapGitHttpBackend("/", configure(root))); + + static async Task StartCoreAsync(Action map) { var projectRoot = Path.Combine( Path.GetTempPath(), "githttpbackend-tests", Guid.NewGuid().ToString("n")); @@ -48,7 +58,7 @@ public static async Task StartAsync(Func