File: Scripts\Common\ScriptHostFixture.cs
Web Access
Project: src\tests\Aspire.Acquisition.Tests\Aspire.Acquisition.Tests.csproj (Aspire.Acquisition.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.Net;
using Aspire.Templates.Tests;
using Xunit;
 
namespace Aspire.Acquisition.Tests.Scripts;
 
/// <summary>
/// xUnit class fixture that hosts the CLI acquisition scripts over HTTP on a random localhost port.
/// This enables testing the documented <c>curl | bash -s</c> and <c>irm | iex</c> piped install
/// patterns against a real HTTP server, closely matching production behavior.
/// </summary>
public sealed class ScriptHostFixture : IAsyncLifetime
{
    private HttpListener? _listener;
    private CancellationTokenSource? _cts;
    private Task? _serverTask;
    private string? _scriptsDirectory;
 
    /// <summary>
    /// Gets the TCP port the HTTP server is listening on.
    /// </summary>
    public int Port { get; private set; }
 
    /// <summary>
    /// Gets the base URL for the HTTP server (e.g., <c>http://localhost:12345</c>).
    /// </summary>
    public string BaseUrl => $"http://localhost:{Port}";
 
    public async ValueTask InitializeAsync()
    {
        // Resolve the scripts directory from the repo root
        var repoRoot = TestUtils.FindRepoRoot()?.FullName
            ?? throw new InvalidOperationException("Could not find repository root");
        _scriptsDirectory = Path.Combine(repoRoot, "eng", "scripts");
 
        if (!Directory.Exists(_scriptsDirectory))
        {
            throw new DirectoryNotFoundException($"Scripts directory not found: {_scriptsDirectory}");
        }
 
        // Retry binding to avoid TOCTOU port races: another process can claim the
        // probed port between TcpListener.Stop() and HttpListener.Start().
        const int maxRetries = 5;
        for (var attempt = 0; attempt < maxRetries; attempt++)
        {
            // Find a free port by binding to port 0
            using (var portFinder = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0))
            {
                portFinder.Start();
                Port = ((IPEndPoint)portFinder.LocalEndpoint).Port;
                portFinder.Stop();
            }
 
            _cts = new CancellationTokenSource();
            _listener = new HttpListener();
            _listener.Prefixes.Add($"http://localhost:{Port}/");
 
            try
            {
                _listener.Start();
                break;
            }
            catch (HttpListenerException) when (attempt < maxRetries - 1)
            {
                _listener.Close();
                _cts.Dispose();
                _listener = null;
                _cts = null;
            }
        }
 
        _serverTask = Task.Run(() => ServeAsync(_cts!.Token));
 
        // Verify the server is reachable
        using var client = new HttpClient();
        using var response = await client.GetAsync($"{BaseUrl}/get-aspire-cli.sh");
        if (!response.IsSuccessStatusCode)
        {
            throw new InvalidOperationException($"Script host failed to start: HTTP {response.StatusCode}");
        }
 
        await Task.CompletedTask;
    }
 
    public async ValueTask DisposeAsync()
    {
        _cts?.Cancel();
 
        try
        {
            _listener?.Stop();
        }
        catch (ObjectDisposedException)
        {
            // Already disposed
        }
        catch (HttpListenerException)
        {
            // The listener may already be torn down while another process has reused the probed port.
        }
 
        if (_serverTask is not null)
        {
            try
            {
                await _serverTask.WaitAsync(TimeSpan.FromSeconds(5));
            }
            catch (TimeoutException)
            {
                // Server didn't stop in time — ignore
            }
            catch (OperationCanceledException)
            {
                // Expected
            }
        }
 
        try
        {
            _listener?.Close();
        }
        catch (ObjectDisposedException)
        {
            // Already disposed
        }
        catch (HttpListenerException)
        {
            // Closing only releases cleanup state; the server loop has already been canceled.
        }
 
        _cts?.Dispose();
    }
 
    private async Task ServeAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested && _listener?.IsListening == true)
        {
            HttpListenerContext ctx;
            try
            {
                ctx = await _listener.GetContextAsync().WaitAsync(cancellationToken);
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (ObjectDisposedException)
            {
                break;
            }
            catch (HttpListenerException)
            {
                break;
            }
 
            try
            {
                await HandleRequestAsync(ctx);
            }
            catch
            {
                // Don't let a single request crash the server
            }
        }
    }
 
    private async Task HandleRequestAsync(HttpListenerContext ctx)
    {
        var requestPath = ctx.Request.Url?.AbsolutePath.TrimStart('/');
 
        if (string.IsNullOrEmpty(requestPath) || _scriptsDirectory is null)
        {
            ctx.Response.StatusCode = 404;
            ctx.Response.Close();
            return;
        }
 
        // Prevent directory traversal
        var safeName = Path.GetFileName(requestPath);
        var filePath = Path.Combine(_scriptsDirectory, safeName);
 
        if (!File.Exists(filePath))
        {
            ctx.Response.StatusCode = 404;
            ctx.Response.Close();
            return;
        }
 
        var content = await File.ReadAllBytesAsync(filePath);
 
        // Serve as text/plain — this matches what raw.githubusercontent.com does
        ctx.Response.ContentType = "text/plain; charset=utf-8";
        ctx.Response.ContentLength64 = content.Length;
        ctx.Response.StatusCode = 200;
        await ctx.Response.OutputStream.WriteAsync(content);
        ctx.Response.Close();
    }
}