File: PowerShellScripts\DownloadNativeArchivesTests.cs
Web Access
Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.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.IO.Compression;
using System.Net;
using System.Text;
using System.Text.Json;
using Aspire.TestUtilities;
using Xunit;
 
namespace Infrastructure.Tests;
 
public sealed class DownloadNativeArchivesTests : IDisposable
{
    private readonly TemporaryWorkspace _workspace;
    private readonly string _scriptPath;
    private readonly ITestOutputHelper _output;
 
    public DownloadNativeArchivesTests(ITestOutputHelper output)
    {
        _output = output;
        _workspace = TemporaryWorkspace.Create(output);
        _scriptPath = Path.Combine(RepoRoot.Path, "eng", "scripts", "download-native-archives.ps1");
    }
 
    public void Dispose() => _workspace.Dispose();
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsWhenAccessTokenIsMissing()
    {
        var (archivesDir, nupkgsDir) = CreateTargetDirs();
 
        var result = await RunScript(
            collectionUri: "http://127.0.0.1:0/",
            project: "test",
            buildId: "1",
            archivesTargetDir: archivesDir,
            nupkgsTargetDir: nupkgsDir,
            accessToken: null,
            clearSystemAccessToken: true);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("AccessToken parameter not set", result.Output);
        Assert.Contains("SYSTEM_ACCESSTOKEN", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsWhenNoArtifactsMatchPattern_ReportsAvailableArtifacts()
    {
        var (archivesDir, nupkgsDir) = CreateTargetDirs();
 
        using var server = new MockAzdoServer();
        server.SetArtifacts(
            new MockArtifact("PackageArtifacts", Type: "Container"),
            new MockArtifact("BlobArtifacts", Type: "Container"));
        server.Start();
 
        var result = await RunScript(
            collectionUri: server.CollectionUri,
            project: "test",
            buildId: "99",
            archivesTargetDir: archivesDir,
            nupkgsTargetDir: nupkgsDir,
            accessToken: "fake-token");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("No build artifacts on build 99 matched 'native_archives_*'", result.Output);
        Assert.Contains("PackageArtifacts", result.Output);
        Assert.Contains("BlobArtifacts", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsWhenArtifactTypeIsNotContainer()
    {
        var (archivesDir, nupkgsDir) = CreateTargetDirs();
 
        using var server = new MockAzdoServer();
        server.SetArtifacts(
            new MockArtifact("native_archives_linux_x64", Type: "PipelineArtifact"));
        server.Start();
 
        var result = await RunScript(
            collectionUri: server.CollectionUri,
            project: "test",
            buildId: "1",
            archivesTargetDir: archivesDir,
            nupkgsTargetDir: nupkgsDir,
            accessToken: "fake-token");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Expected all 'native_archives_*' artifacts to be 'Container' type", result.Output);
        Assert.Contains("native_archives_linux_x64=PipelineArtifact", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task DownloadsAndExtractsMatchingEntries_PreservingArtifactPathComponent()
    {
        var (archivesDir, nupkgsDir) = CreateTargetDirs();
 
        using var server = new MockAzdoServer();
        // Two RIDs; each artifact's zip contains one archive, one nupkg, one
        // npm tgz, and some non-matching files that should be silently skipped.
        server.AddContainerArtifact("native_archives_linux_x64", new[]
        {
            ("Release/Shipping/aspire-cli-linux-x64-13.4.0.tar.gz", "tar-content"),
            ("Release/Shipping/Aspire.Cli.linux-x64.13.4.0.nupkg", "nupkg-content"),
            ("Release/Shipping/microsoft-aspire-cli-linux-x64-13.4.0.tgz", "tgz-content"),
            ("Release/Shipping/aspire-cli-linux-x64-13.4.0.tar.gz.sha512", "ignored"),
            ("Release/Shipping/SomeRandom.txt", "ignored"),
        });
        server.AddContainerArtifact("native_archives_win_x64", new[]
        {
            ("Release/Shipping/aspire-cli-win-x64-13.4.0.zip", "zip-content"),
            ("Release/Shipping/Aspire.Cli.win-x64.13.4.0.nupkg", "nupkg-content"),
            ("Release/Shipping/microsoft-aspire-cli-win-x64-13.4.0.tgz", "tgz-content"),
        });
        server.Start();
 
        var result = await RunScript(
            collectionUri: server.CollectionUri,
            project: "internal",
            buildId: "12345",
            archivesTargetDir: archivesDir,
            nupkgsTargetDir: nupkgsDir,
            accessToken: "fake-token");
 
        result.EnsureSuccessful();
 
        // Per-job summary lines should be present. `nupkgs` counts both
        // Aspire.Cli*.nupkg and microsoft-aspire-cli*.tgz (both land in
        // NupkgsTargetDir for stage-native-cli-tool-packages.ps1 to consume).
        Assert.Contains("native_archives_linux_x64", result.Output);
        Assert.Contains("native_archives_win_x64", result.Output);
        Assert.Contains("archives=1 nupkgs=2", result.Output);
 
        var extractedArchives = Directory.GetFiles(archivesDir, "*", SearchOption.AllDirectories)
            .Select(p => Path.GetRelativePath(archivesDir, p).Replace(Path.DirectorySeparatorChar, '/'))
            .Order(StringComparer.Ordinal)
            .ToArray();
        Assert.Equal(
            new[]
            {
                "native_archives_linux_x64/Release/Shipping/aspire-cli-linux-x64-13.4.0.tar.gz",
                "native_archives_win_x64/Release/Shipping/aspire-cli-win-x64-13.4.0.zip",
            },
            extractedArchives);
 
        var extractedNupkgs = Directory.GetFiles(nupkgsDir, "*", SearchOption.AllDirectories)
            .Select(p => Path.GetRelativePath(nupkgsDir, p).Replace(Path.DirectorySeparatorChar, '/'))
            .Order(StringComparer.Ordinal)
            .ToArray();
        Assert.Equal(
            new[]
            {
                "native_archives_linux_x64/Release/Shipping/Aspire.Cli.linux-x64.13.4.0.nupkg",
                "native_archives_linux_x64/Release/Shipping/microsoft-aspire-cli-linux-x64-13.4.0.tgz",
                "native_archives_win_x64/Release/Shipping/Aspire.Cli.win-x64.13.4.0.nupkg",
                "native_archives_win_x64/Release/Shipping/microsoft-aspire-cli-win-x64-13.4.0.tgz",
            },
            extractedNupkgs);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task RefusesToExtractZipEntryThatEscapesStagingRoot()
    {
        var (archivesDir, nupkgsDir) = CreateTargetDirs();
 
        using var server = new MockAzdoServer();
        // Zip entry whose FullName uses `..` segments to escape the
        // <archivesDir>/<artifactName>/ staging root. The filename portion
        // (Name) still matches `aspire-cli-*.tar.gz` so the entry passes the
        // shape filter; without a zip-slip guard, Join-Path + ExtractToFile
        // would write outside the staging directory. See Zip-Slip /
        // CVE-2018-1002200 family.
        server.AddContainerArtifact("native_archives_linux_x64", new[]
        {
            ("../../../escaped/aspire-cli-linux-x64-13.4.0.tar.gz", "evil-content"),
        });
        server.Start();
 
        var result = await RunScript(
            collectionUri: server.CollectionUri,
            project: "internal",
            buildId: "12345",
            archivesTargetDir: archivesDir,
            nupkgsTargetDir: nupkgsDir,
            accessToken: "fake-token");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("zip-slip", result.Output);
        Assert.False(
            File.Exists(Path.Combine(_workspace.Path, "escaped", "aspire-cli-linux-x64-13.4.0.tar.gz")),
            "Escaped file should not have been written.");
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsWhenOneArtifactDownloadFails_ButReportsAllResults()
    {
        var (archivesDir, nupkgsDir) = CreateTargetDirs();
 
        using var server = new MockAzdoServer();
        server.AddContainerArtifact("native_archives_linux_x64", new[]
        {
            ("Release/Shipping/aspire-cli-linux-x64-13.4.0.tar.gz", "tar-content"),
            ("Release/Shipping/Aspire.Cli.linux-x64.13.4.0.nupkg", "nupkg-content"),
        });
        // Returned in the artifact list but the download URL is set to a path
        // the mock server responds to with 500 — simulates a flaky artifact
        // store while the other download still succeeds.
        server.AddFailingArtifact("native_archives_win_x64");
        server.Start();
 
        var result = await RunScript(
            collectionUri: server.CollectionUri,
            project: "internal",
            buildId: "12345",
            archivesTargetDir: archivesDir,
            nupkgsTargetDir: nupkgsDir,
            accessToken: "fake-token",
            maxDownloadAttempts: 2);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Failed downloads: native_archives_win_x64", result.Output);
        // The download was retried up to the attempt cap before giving up.
        Assert.Contains("after 2 attempt(s)", result.Output);
        // The succeeded artifact's results should still be reported.
        Assert.Contains("native_archives_linux_x64", result.Output);
        Assert.Contains("archives=1 nupkgs=1", result.Output);
        // And the matching files should actually be on disk.
        Assert.True(File.Exists(Path.Combine(
            archivesDir,
            "native_archives_linux_x64", "Release", "Shipping", "aspire-cli-linux-x64-13.4.0.tar.gz")));
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task RetriesTransientDownloadFailure_ThenSucceeds()
    {
        var (archivesDir, nupkgsDir) = CreateTargetDirs();
 
        using var server = new MockAzdoServer();
        // The download responds 500 for its first two attempts, then serves the
        // zip — exercises retrying HTTP failures. Without retry, the first
        // failure would fail the whole assemble stage.
        server.AddFlakyArtifact("native_archives_osx_x64", failTimes: 2, new[]
        {
            ("Release/Shipping/aspire-cli-osx-x64-13.4.0.tar.gz", "tar-content"),
            ("Release/Shipping/Aspire.Cli.osx-x64.13.4.0.nupkg", "nupkg-content"),
        });
        server.Start();
 
        var result = await RunScript(
            collectionUri: server.CollectionUri,
            project: "internal",
            buildId: "12345",
            archivesTargetDir: archivesDir,
            nupkgsTargetDir: nupkgsDir,
            accessToken: "fake-token",
            maxDownloadAttempts: 4);
 
        result.EnsureSuccessful();
        // Both early attempts were retried, then the third succeeded.
        Assert.Contains("Download attempt 1/4 failed for 'native_archives_osx_x64'", result.Output);
        Assert.Contains("Download attempt 2/4 failed for 'native_archives_osx_x64'", result.Output);
        Assert.Contains("archives=1 nupkgs=1", result.Output);
        Assert.True(File.Exists(Path.Combine(
            archivesDir,
            "native_archives_osx_x64", "Release", "Shipping", "aspire-cli-osx-x64-13.4.0.tar.gz")));
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsFastForNonTransientHttpDownloadFailure()
    {
        var (archivesDir, nupkgsDir) = CreateTargetDirs();
 
        using var server = new MockAzdoServer();
        // The artifact list is valid, but the artifact download URL returns 404.
        // That is a configuration/auth-style failure, not a transient artifact
        // store failure, so retrying would only delay the actionable error.
        server.SetArtifacts(new MockArtifact("native_archives_missing"));
        server.Start();
 
        var result = await RunScript(
            collectionUri: server.CollectionUri,
            project: "internal",
            buildId: "12345",
            archivesTargetDir: archivesDir,
            nupkgsTargetDir: nupkgsDir,
            accessToken: "fake-token",
            maxDownloadAttempts: 4);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("non-retryable HTTP 404", result.Output);
        Assert.DoesNotContain("Download attempt 1/4 failed for 'native_archives_missing'", result.Output);
        Assert.DoesNotContain("after 4 attempt(s)", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task RetriesConnectionClosedDuringDownload_ThenSucceeds()
    {
        var (archivesDir, nupkgsDir) = CreateTargetDirs();
 
        using var server = new MockAzdoServer();
        // First attempt advertises a full zip response but closes the connection
        // after writing only a prefix. That deterministically exercises the
        // no-successful-response transport failure path, without sleeps or timing
        // races. Second attempt serves a valid zip.
        server.AddTruncatedThenValidArtifact("native_archives_osx_arm64", truncateTimes: 1, new[]
        {
            ("Release/Shipping/aspire-cli-osx-arm64-13.4.0.tar.gz", "tar-content"),
            ("Release/Shipping/Aspire.Cli.osx-arm64.13.4.0.nupkg", "nupkg-content"),
        });
        server.Start();
 
        var result = await RunScript(
            collectionUri: server.CollectionUri,
            project: "internal",
            buildId: "12345",
            archivesTargetDir: archivesDir,
            nupkgsTargetDir: nupkgsDir,
            accessToken: "fake-token",
            maxDownloadAttempts: 4);
 
        result.EnsureSuccessful();
        Assert.Contains("Download attempt 1/4 failed for 'native_archives_osx_arm64'", result.Output);
        Assert.Contains("archives=1 nupkgs=1", result.Output);
        Assert.True(File.Exists(Path.Combine(
            archivesDir,
            "native_archives_osx_arm64", "Release", "Shipping", "aspire-cli-osx-arm64-13.4.0.tar.gz")));
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task RetriesCorruptDownload_ThenSucceeds()
    {
        var (archivesDir, nupkgsDir) = CreateTargetDirs();
 
        using var server = new MockAzdoServer();
        // First attempt downloads successfully (HTTP 200) but the body is not a
        // valid zip — mirrors a truncated-yet-complete-looking file. The script
        // opens the zip inside the retry loop, so the open failure must trigger a
        // re-download rather than failing the stage. Second attempt serves a
        // valid zip.
        server.AddCorruptThenValidArtifact("native_archives_linux_arm64", corruptTimes: 1, new[]
        {
            ("Release/Shipping/aspire-cli-linux-arm64-13.4.0.tar.gz", "tar-content"),
            ("Release/Shipping/Aspire.Cli.linux-arm64.13.4.0.nupkg", "nupkg-content"),
        });
        server.Start();
 
        var result = await RunScript(
            collectionUri: server.CollectionUri,
            project: "internal",
            buildId: "12345",
            archivesTargetDir: archivesDir,
            nupkgsTargetDir: nupkgsDir,
            accessToken: "fake-token",
            maxDownloadAttempts: 4);
 
        result.EnsureSuccessful();
        // The corrupt download was retried (the warning carries the zip-open
        // failure), then the valid zip extracted.
        Assert.Contains("Download attempt 1/4 failed for 'native_archives_linux_arm64'", result.Output);
        Assert.Contains("archives=1 nupkgs=1", result.Output);
        Assert.True(File.Exists(Path.Combine(
            archivesDir,
            "native_archives_linux_arm64", "Release", "Shipping", "aspire-cli-linux-arm64-13.4.0.tar.gz")));
    }
 
    private async Task<CommandResult> RunScript(
        string collectionUri,
        string project,
        string buildId,
        string archivesTargetDir,
        string nupkgsTargetDir,
        string? accessToken,
        bool clearSystemAccessToken = false,
        int? maxDownloadAttempts = null,
        int retryBaseDelaySeconds = 0)
    {
        using var cmd = new PowerShellCommand(_scriptPath, _output)
            .WithTimeout(TimeSpan.FromMinutes(2));
 
        if (clearSystemAccessToken)
        {
            cmd.WithEnvironmentVariable("SYSTEM_ACCESSTOKEN", "");
        }
 
        var args = new List<string>
        {
            "-CollectionUri", $"\"{collectionUri}\"",
            "-Project", $"\"{project}\"",
            "-BuildId", $"\"{buildId}\"",
            "-ArchivesTargetDir", $"\"{archivesTargetDir}\"",
            "-NupkgsTargetDir", $"\"{nupkgsTargetDir}\"",
            // Keep tests fast: never sleep between retries. The mock server
            // returns its failures instantly, so backoff time is pure overhead.
            "-RetryBaseDelaySeconds", retryBaseDelaySeconds.ToString(),
        };
        if (maxDownloadAttempts is not null)
        {
            args.Add("-MaxDownloadAttempts");
            args.Add(maxDownloadAttempts.Value.ToString());
        }
        if (accessToken is not null)
        {
            args.Add("-AccessToken");
            args.Add($"\"{accessToken}\"");
        }
 
        return await cmd.ExecuteAsync([.. args]);
    }
 
    private (string archivesDir, string nupkgsDir) CreateTargetDirs()
    {
        var unique = Path.GetRandomFileName();
        var archives = Path.Combine(_workspace.Path, unique, "archives");
        var nupkgs = Path.Combine(_workspace.Path, unique, "nupkgs");
        return (archives, nupkgs);
    }
 
    /// <summary>
    /// A tiny in-process HTTP server that mimics the two AzDO endpoints the
    /// script hits: <c>GET .../_apis/build/builds/{id}/artifacts</c> (returns
    /// JSON with a <c>value</c> array of artifact descriptors) and the
    /// per-artifact <c>downloadUrl</c> which serves a zip back.
    /// </summary>
    private sealed record MockArtifact(string Name, string Type = "Container");
 
    private sealed class MockAzdoServer : IDisposable
    {
        private readonly HttpListener _listener;
        private readonly int _port;
        private readonly Dictionary<string, byte[]> _artifactZips = new();
        private readonly HashSet<string> _failingArtifacts = new();
        private readonly Dictionary<string, int> _flakyRemaining = new(StringComparer.Ordinal);
        private readonly Dictionary<string, int> _truncatedRemaining = new(StringComparer.Ordinal);
        private readonly Dictionary<string, int> _corruptRemaining = new(StringComparer.Ordinal);
        private List<MockArtifact> _artifacts = new();
        private CancellationTokenSource? _cts;
        private Task? _serverTask;
 
        public MockAzdoServer()
        {
            _port = GetFreeTcpPort();
            _listener = new HttpListener();
            _listener.Prefixes.Add($"http://127.0.0.1:{_port}/");
        }
 
        public string CollectionUri => $"http://127.0.0.1:{_port}/";
 
        public void SetArtifacts(params MockArtifact[] artifacts)
        {
            _artifacts = artifacts.ToList();
        }
 
        public void AddContainerArtifact(string name, IEnumerable<(string path, string content)> zipEntries)
        {
            _artifacts.Add(new MockArtifact(name));
            _artifactZips[name] = BuildZip(zipEntries);
        }
 
        private static byte[] BuildZip(IEnumerable<(string path, string content)> zipEntries)
        {
            using var ms = new MemoryStream();
            using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
            {
                foreach (var (path, content) in zipEntries)
                {
                    var entry = archive.CreateEntry(path);
                    using var stream = entry.Open();
                    var bytes = Encoding.UTF8.GetBytes(content);
                    stream.Write(bytes, 0, bytes.Length);
                }
            }
            return ms.ToArray();
        }
 
        public void AddFailingArtifact(string name)
        {
            _artifacts.Add(new MockArtifact(name));
            _failingArtifacts.Add(name);
        }
 
        /// <summary>
        /// Registers an artifact whose download responds with HTTP 500 for its
        /// first <paramref name="failTimes"/> requests and then serves the zip,
        /// simulating a transient artifact-store failure that the script should
        /// retry through.
        /// </summary>
        public void AddFlakyArtifact(string name, int failTimes, IEnumerable<(string path, string content)> zipEntries)
        {
            _artifacts.Add(new MockArtifact(name));
            _artifactZips[name] = BuildZip(zipEntries);
            _flakyRemaining[name] = failTimes;
        }
 
        /// <summary>
        /// Registers an artifact whose first <paramref name="truncateTimes"/>
        /// requests return HTTP 200 with a Content-Length for the full zip but
        /// close the connection after writing only a prefix. This exercises a
        /// deterministic mid-transfer failure without relying on timing.
        /// </summary>
        public void AddTruncatedThenValidArtifact(string name, int truncateTimes, IEnumerable<(string path, string content)> zipEntries)
        {
            _artifacts.Add(new MockArtifact(name));
            _artifactZips[name] = BuildZip(zipEntries);
            _truncatedRemaining[name] = truncateTimes;
        }
 
        /// <summary>
        /// Registers an artifact whose download responds 200 with corrupt
        /// (non-zip) bytes for its first <paramref name="corruptTimes"/> requests
        /// and then serves the real zip. Simulates a connection dropped
        /// mid-transfer that yields a truncated-yet-complete-looking file the
        /// script only detects when opening the zip — which should still retry.
        /// </summary>
        public void AddCorruptThenValidArtifact(string name, int corruptTimes, IEnumerable<(string path, string content)> zipEntries)
        {
            _artifacts.Add(new MockArtifact(name));
            _artifactZips[name] = BuildZip(zipEntries);
            _corruptRemaining[name] = corruptTimes;
        }
 
        public void Start()
        {
            _listener.Start();
            _cts = new CancellationTokenSource();
            _serverTask = Task.Run(() => RunAsync(_cts.Token));
        }
 
        private async Task RunAsync(CancellationToken token)
        {
            while (!token.IsCancellationRequested)
            {
                HttpListenerContext ctx;
                try
                {
                    ctx = await _listener.GetContextAsync().WaitAsync(token).ConfigureAwait(false);
                }
                catch (OperationCanceledException) { return; }
                catch (HttpListenerException) { return; }
                catch (ObjectDisposedException) { return; }
 
                try
                {
                    HandleRequest(ctx);
                }
                catch
                {
                    // Best-effort: don't crash the server loop on per-request errors.
                    try { ctx.Response.StatusCode = 500; ctx.Response.Close(); } catch { }
                }
            }
        }
 
        private void HandleRequest(HttpListenerContext ctx)
        {
            var path = ctx.Request.Url?.AbsolutePath ?? string.Empty;
 
            // Artifact list endpoint.
            if (path.EndsWith("/_apis/build/builds/" + GetBuildIdFromPath(path) + "/artifacts", StringComparison.Ordinal)
                || path.Contains("/_apis/build/builds/", StringComparison.Ordinal) && path.EndsWith("/artifacts", StringComparison.Ordinal))
            {
                var artifactDescriptors = _artifacts.Select(a => new
                {
                    name = a.Name,
                    resource = new
                    {
                        type = a.Type,
                        downloadUrl = $"{CollectionUri}_artifact/{a.Name}.zip",
                    },
                }).ToArray();
 
                var payload = JsonSerializer.Serialize(new { value = artifactDescriptors });
                var bytes = Encoding.UTF8.GetBytes(payload);
                ctx.Response.ContentType = "application/json";
                ctx.Response.ContentLength64 = bytes.Length;
                ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
                ctx.Response.Close();
                return;
            }
 
            // Per-artifact download endpoint.
            if (path.StartsWith("/_artifact/", StringComparison.Ordinal))
            {
                var fileName = Path.GetFileNameWithoutExtension(path); // strip .zip
                if (_failingArtifacts.Contains(fileName))
                {
                    ctx.Response.StatusCode = 500;
                    ctx.Response.Close();
                    return;
                }
                // Flaky artifacts fail their first N requests, then fall through
                // to serving the zip. The HttpListener loop handles requests one
                // at a time, so this counter needs no extra synchronization.
                if (_flakyRemaining.TryGetValue(fileName, out var remaining) && remaining > 0)
                {
                    _flakyRemaining[fileName] = remaining - 1;
                    ctx.Response.StatusCode = 500;
                    ctx.Response.Close();
                    return;
                }
                // Truncated artifacts start like successful downloads, then close
                // early. Content-Length stays at the full zip size so the client
                // sees a deterministic mid-response transport failure instead of
                // an HTTP status retry.
                if (_truncatedRemaining.TryGetValue(fileName, out var truncateLeft) && truncateLeft > 0
                    && _artifactZips.TryGetValue(fileName, out var zipBytes))
                {
                    _truncatedRemaining[fileName] = truncateLeft - 1;
                    var prefixLength = Math.Min(16, zipBytes.Length - 1);
                    ctx.Response.ContentType = "application/zip";
                    ctx.Response.ContentLength64 = zipBytes.Length;
                    ctx.Response.OutputStream.Write(zipBytes, 0, prefixLength);
                    ctx.Response.OutputStream.Flush();
                    ctx.Response.Abort();
                    return;
                }
                // Corrupt artifacts respond 200 with non-zip bytes for their first
                // N requests, then serve the real zip — exercising the script's
                // open-the-zip integrity check inside the retry loop.
                if (_corruptRemaining.TryGetValue(fileName, out var corruptLeft) && corruptLeft > 0)
                {
                    _corruptRemaining[fileName] = corruptLeft - 1;
                    var garbage = Encoding.UTF8.GetBytes("this is not a zip file");
                    ctx.Response.ContentType = "application/zip";
                    ctx.Response.ContentLength64 = garbage.Length;
                    ctx.Response.OutputStream.Write(garbage, 0, garbage.Length);
                    ctx.Response.Close();
                    return;
                }
                if (_artifactZips.TryGetValue(fileName, out var bytes))
                {
                    ctx.Response.ContentType = "application/zip";
                    ctx.Response.ContentLength64 = bytes.Length;
                    ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
                    ctx.Response.Close();
                    return;
                }
                ctx.Response.StatusCode = 404;
                ctx.Response.Close();
                return;
            }
 
            ctx.Response.StatusCode = 404;
            ctx.Response.Close();
        }
 
        private static string GetBuildIdFromPath(string path)
        {
            // ".../_apis/build/builds/{id}/artifacts" — pull {id}
            var marker = "/builds/";
            var i = path.IndexOf(marker, StringComparison.Ordinal);
            if (i < 0) { return ""; }
            var rest = path.Substring(i + marker.Length);
            var slash = rest.IndexOf('/');
            return slash < 0 ? rest : rest[..slash];
        }
 
        private static int GetFreeTcpPort()
        {
            var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
            l.Start();
            var port = ((IPEndPoint)l.LocalEndpoint).Port;
            l.Stop();
            return port;
        }
 
        public void Dispose()
        {
            _cts?.Cancel();
            try { _listener.Stop(); } catch { }
            try { _listener.Close(); } catch { }
            try { _serverTask?.Wait(TimeSpan.FromSeconds(2)); } catch { }
            _cts?.Dispose();
        }
    }
}