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
9 changes: 7 additions & 2 deletions samples/GitHttpBackend.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();

Expand Down
26 changes: 23 additions & 3 deletions src/GitHttpBackend.AspNetCore/GitHttpBackendEndpointExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,37 @@
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));
}

/// <summary>
/// Maps Git Smart HTTP endpoints under <paramref name="prefix"/> using an invoker the
/// caller already built.
/// </summary>
/// <remarks>
/// Resolving <c>git-http-backend</c> starts a <c>git --exec-path</c> 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 <see cref="GitHttpBackendInvoker.BackendPath"/>, 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.
/// </remarks>
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)

Check failure on line 53 in src/GitHttpBackend.AspNetCore/GitHttpBackendEndpointExtensions.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Zefek_GitHttpBackend&issues=AaC7G8tN1GRd1-oeiuYx&open=AaC7G8tN1GRd1-oeiuYx&pullRequest=19
{
var options = invoker.Options;

var logger = ctx.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("GitHttpBackend.AspNetCore");

Expand Down
7 changes: 7 additions & 0 deletions src/GitHttpBackend/GitHttpBackendInvoker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ public GitHttpBackendInvoker(GitBackendOptions options)
/// <summary>The resolved path to the git-http-backend executable.</summary>
public string BackendPath => _backendPath;

/// <summary>
/// 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 <see cref="GitBackendOptions.Authorize"/>
/// hook.
/// </summary>
public GitBackendOptions Options => _options;

/// <summary>
/// Creates the target repository when <see cref="GitBackendOptions.AllowCreateOnPush"/>
/// is set, <paramref name="request"/> is a push, and the repository does not exist yet.
Expand Down
81 changes: 81 additions & 0 deletions tests/GitHttpBackend.AspNetCore.Tests/CallerOwnedInvokerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;

namespace GitHttpBackend.AspNetCore.Tests;

/// <summary>
/// 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.
/// </summary>
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<ArgumentNullException>(
() => app.MapGitHttpBackend("/", (GitBackendOptions)null!));
Assert.Throws<ArgumentNullException>(
() => app.MapGitHttpBackend("/", (GitHttpBackendInvoker)null!));
Assert.Throws<ArgumentNullException>(
() => ((IEndpointRouteBuilder)null!).MapGitHttpBackend("/", new GitBackendOptions
{
ProjectRoot = Path.GetTempPath(),
}));
}
}
14 changes: 12 additions & 2 deletions tests/GitHttpBackend.AspNetCore.Tests/GitTestServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,17 @@ sealed class GitTestServer : IAsyncDisposable
/// Starts a server over a fresh project root. <paramref name="configure"/> receives that
/// root and returns the options to map, so a test can set its own <c>Authorize</c> hook.
/// </summary>
public static async Task<GitTestServer> StartAsync(Func<string, GitBackendOptions> configure)
public static Task<GitTestServer> StartAsync(Func<string, GitBackendOptions> configure)
=> StartCoreAsync((app, root) => app.MapGitHttpBackend("/", configure(root)));

/// <summary>
/// Same, but the caller builds the invoker — the overload a host uses when it wants the
/// resolved backend path for itself.
/// </summary>
public static Task<GitTestServer> StartWithInvokerAsync(Func<string, GitHttpBackendInvoker> configure)
=> StartCoreAsync((app, root) => app.MapGitHttpBackend("/", configure(root)));

static async Task<GitTestServer> StartCoreAsync(Action<WebApplication, string> map)
{
var projectRoot = Path.Combine(
Path.GetTempPath(), "githttpbackend-tests", Guid.NewGuid().ToString("n"));
Expand All @@ -48,7 +58,7 @@ public static async Task<GitTestServer> StartAsync(Func<string, GitBackendOption
builder.Services.AddLogging();

app = builder.Build();
app.MapGitHttpBackend("/", configure(projectRoot));
map(app, projectRoot);
await app.StartAsync();

var address = app.Urls.First();
Expand Down
Loading