File: Helpers\GitHttpServer.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
 
namespace Aspire.Cli.EndToEnd.Tests.Helpers;
 
internal sealed class GitHttpServer(WebApplication application) : IAsyncDisposable
{
    public string RepositoryUrl => $"{application.Urls.Single()}/template.git";
 
    public static async Task<GitHttpServer> StartAsync(string directory, CancellationToken cancellationToken)
    {
        var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [], ContentRootPath = directory });
        builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0));
        var application = builder.Build();
        application.MapGet("/template.git/info/refs", context => RunBackendAsync(context, directory));
        application.MapPost("/template.git/git-upload-pack", context => RunBackendAsync(context, directory));
 
        try
        {
            await application.StartAsync(cancellationToken);
            return new GitHttpServer(application);
        }
        catch
        {
            await application.DisposeAsync();
            throw;
        }
    }
 
    public ValueTask DisposeAsync() => application.DisposeAsync();
 
    private static async Task RunBackendAsync(HttpContext context, string directory)
    {
        var cancellationToken = context.RequestAborted;
        var startInfo = new ProcessStartInfo("git")
        {
            UseShellExecute = false,
            RedirectStandardInput = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true
        };
        // Docker can create the fixture under a different UID. Trust only this test's bare repository.
        startInfo.ArgumentList.Add("-c");
        startInfo.ArgumentList.Add($"safe.directory={Path.Combine(directory, "template.git")}");
        startInfo.ArgumentList.Add("http-backend");
        // Smart HTTP is required for the CLI's shallow fetches. These are the backend's CGI inputs:
        // https://git-scm.com/docs/git-http-backend#_environment
        startInfo.Environment["GIT_PROJECT_ROOT"] = directory;
        startInfo.Environment["GIT_HTTP_EXPORT_ALL"] = "1";
        startInfo.Environment["PATH_INFO"] = context.Request.Path.Value;
        startInfo.Environment["REQUEST_METHOD"] = context.Request.Method;
        startInfo.Environment["QUERY_STRING"] = context.Request.QueryString.Value?.TrimStart('?') ?? string.Empty;
        startInfo.Environment["CONTENT_TYPE"] = context.Request.ContentType ?? string.Empty;
        startInfo.Environment["HTTP_GIT_PROTOCOL"] = context.Request.Headers["Git-Protocol"].ToString();
 
        using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Could not start the Git HTTP backend.");
        try
        {
            using var response = new MemoryStream();
            var responseTask = process.StandardOutput.BaseStream.CopyToAsync(response, cancellationToken);
            var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
            await context.Request.Body.CopyToAsync(process.StandardInput.BaseStream, cancellationToken);
            process.StandardInput.Close();
            await Task.WhenAll(responseTask, errorTask, process.WaitForExitAsync(cancellationToken));
            if (process.ExitCode != 0)
            {
                throw new InvalidOperationException($"Git HTTP backend exited with code {process.ExitCode}: {await errorTask}");
            }
 
            // CGI emits "Content-Type: ...\r\n...\r\n\r\n<binary pack data>". Buffer this small fixture's
            // response as bytes so decoding the headers cannot corrupt the Git payload.
            var bytes = response.ToArray();
            var headerEnd = bytes.AsSpan().IndexOf("\r\n\r\n"u8);
            if (headerEnd < 0)
            {
                throw new InvalidDataException("Git HTTP backend returned an incomplete CGI response.");
            }
 
            foreach (var header in Encoding.ASCII.GetString(bytes, 0, headerEnd).Split("\r\n"))
            {
                var separator = header.IndexOf(':');
                if (separator < 1)
                {
                    throw new InvalidDataException($"Git HTTP backend returned an invalid CGI header: {header}");
                }
 
                var name = header[..separator];
                var value = header[(separator + 1)..].Trim();
                if (name.Equals("Status", StringComparison.OrdinalIgnoreCase))
                {
                    // CGI uses a header such as "Status: 404 Not Found" for non-success responses.
                    context.Response.StatusCode = int.Parse(value.AsSpan(0, 3), CultureInfo.InvariantCulture);
                }
                else
                {
                    context.Response.Headers[name] = value;
                }
            }
 
            await context.Response.Body.WriteAsync(bytes.AsMemory(headerEnd + 4), cancellationToken);
        }
        finally
        {
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
                await process.WaitForExitAsync(CancellationToken.None);
            }
        }
    }
}