File: Scripts\Common\FakeArchiveHelper.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.IO.Compression;
using System.Security.Cryptography;
 
namespace Aspire.Acquisition.Tests.Scripts;
 
/// <summary>
/// Creates minimal tar.gz or zip archives containing a fake aspire binary, plus matching .sha512 checksum files.
/// </summary>
public static class FakeArchiveHelper
{
    public static async Task<FakeArchive> CreateFakeArchiveAsync(
        string outputDir,
        string platform = "linux-x64")
    {
        Directory.CreateDirectory(outputDir);
 
        var isWindows = platform.StartsWith("win", StringComparison.OrdinalIgnoreCase);
        var extension = isWindows ? "zip" : "tar.gz";
        var archivePath = Path.Combine(outputDir, $"aspire-cli-{platform}.{extension}");
        var checksumPath = archivePath + ".sha512";
 
        var contentDir = Path.Combine(outputDir, $"archive-content-{Guid.NewGuid():N}");
        Directory.CreateDirectory(contentDir);
 
        try
        {
            var binaryName = isWindows ? "aspire.exe" : "aspire";
            var binaryPath = Path.Combine(contentDir, binaryName);
            await File.WriteAllTextAsync(binaryPath, "#!/bin/bash\necho \"aspire mock v1.0\"\n");
 
            if (!OperatingSystem.IsWindows())
            {
                FileHelper.MakeExecutable(binaryPath);
            }
 
            if (isWindows)
            {
                ZipFile.CreateFromDirectory(contentDir, archivePath);
            }
            else
            {
                await CreateTarGzAsync(contentDir, archivePath);
            }
 
            var hashHex = await ComputeSha512Async(archivePath);
            await File.WriteAllTextAsync(checksumPath, hashHex);
 
            return new FakeArchive(archivePath, checksumPath, hashHex);
        }
        finally
        {
            if (Directory.Exists(contentDir))
            {
                Directory.Delete(contentDir, recursive: true);
            }
        }
    }
 
    public static async Task<string> CreateFakeNupkgAsync(string outputDir, string packageName, string version)
    {
        Directory.CreateDirectory(outputDir);
        var filename = $"{packageName}.{version}.nupkg";
        var path = Path.Combine(outputDir, filename);
        await File.WriteAllTextAsync(path, "fake-nupkg-content");
        return path;
    }
 
    public static async Task<FakeArchive> CreateFakeArchiveWithBadChecksumAsync(string outputDir, string platform = "linux-x64")
    {
        var archive = await CreateFakeArchiveAsync(outputDir, platform);
        await File.WriteAllTextAsync(archive.ChecksumPath, "0000000000000000000000000000000000000000000000000000000000000000");
        return archive with { ChecksumHex = "0000000000000000000000000000000000000000000000000000000000000000" };
    }
 
    /// <summary>
    /// Creates a per-RID CLI archive whose fake aspire binary handles the commands
    /// verify-cli-archive.ps1 invokes (<c>aspire --version</c> and
    /// <c>aspire new aspire-starter --name NAME --output OUTPUT ...</c>). The fake
    /// binary is a bash script, so this is only usable on platforms where the
    /// verifier executes the binary directly (Linux/macOS).
    /// </summary>
    /// <param name="includeStraySidecar">When true, adds a <c>.aspire-install.json</c>
    /// at the archive root so the verifier's sidecar-rejection contract can be
    /// asserted; see <c>docs/specs/install-routes.md</c>.</param>
    /// <param name="nestAspireUnderSubdir">When true, places the <c>aspire</c> binary
    /// under a single subdirectory inside the archive rather than at the root. This
    /// exercises <c>Get-ArchiveRoot</c>'s single-subdirectory branch and confirms the
    /// sidecar scan still inspects the true archive root.</param>
    public static async Task<FakeArchive> CreateFakeVerifyArchiveAsync(
        string outputDir,
        string platform = "linux-x64",
        bool includeStraySidecar = false,
        bool nestAspireUnderSubdir = false)
    {
        Directory.CreateDirectory(outputDir);
 
        var extension = "tar.gz";
        var archivePath = Path.Combine(outputDir, $"aspire-cli-{platform}.{extension}");
        var checksumPath = archivePath + ".sha512";
 
        var contentDir = Path.Combine(outputDir, $"verify-content-{Guid.NewGuid():N}");
        Directory.CreateDirectory(contentDir);
 
        try
        {
            // When nesting, the binary lives at <contentDir>/payload/aspire, mirroring
            // archives whose producer wraps everything in a top-level directory.
            var binaryDir = nestAspireUnderSubdir
                ? Path.Combine(contentDir, "payload")
                : contentDir;
            Directory.CreateDirectory(binaryDir);
 
            var binaryPath = Path.Combine(binaryDir, "aspire");
            await File.WriteAllTextAsync(binaryPath, FakeVerifyAspireScript);
            FileHelper.MakeExecutable(binaryPath);
 
            if (includeStraySidecar)
            {
                // Per docs/specs/install-routes.md, per-RID archives must ship
                // sidecar-free. The verifier rejects archives that contain a
                // .aspire-install.json at the archive root.
                var sidecarPath = Path.Combine(contentDir, ".aspire-install.json");
                await File.WriteAllTextAsync(sidecarPath, "{\"source\":\"stray\"}");
            }
 
            await CreateTarGzAsync(contentDir, archivePath);
 
            var hashHex = await ComputeSha512Async(archivePath);
            await File.WriteAllTextAsync(checksumPath, hashHex);
 
            return new FakeArchive(archivePath, checksumPath, hashHex);
        }
        finally
        {
            if (Directory.Exists(contentDir))
            {
                Directory.Delete(contentDir, recursive: true);
            }
        }
    }
 
    // Minimal aspire mock for verify-cli-archive.ps1. Handles:
    //   aspire --version              -> print version, exit 0
    //   aspire new aspire-starter --name <n> --output <dir> [--non-interactive] [--nologo]
    //                                  -> mkdir "<dir>/<n>.AppHost", exit 0
    // Anything else exits non-zero so unexpected verifier invocations surface as test failures.
    private const string FakeVerifyAspireScript = """
        #!/usr/bin/env bash
        set -euo pipefail
 
        if [[ "${1:-}" == "--version" ]]; then
            echo "aspire mock v1.0"
            exit 0
        fi
 
        if [[ "${1:-}" == "new" ]]; then
            template="${2:-}"
            shift 2 || true
 
            name=""
            output=""
            while [[ $# -gt 0 ]]; do
                case "$1" in
                    --name)   name="$2";   shift 2 ;;
                    --output) output="$2"; shift 2 ;;
                    *)        shift ;;
                esac
            done
 
            if [[ "$template" != "aspire-starter" ]]; then
                echo "Unsupported template: $template" >&2
                exit 1
            fi
 
            mkdir -p "$output/$name.AppHost"
            exit 0
        fi
 
        echo "Unsupported command: $*" >&2
        exit 1
 
        """;
 
    private static async Task CreateTarGzAsync(string contentDir, string archivePath)
    {
        // Use TarWriter manually with AttributesToSkip = 0 instead of
        // TarFile.CreateFromDirectoryAsync, because the convenience API uses
        // default EnumerationOptions which skip files .NET considers hidden.
        // On Unix that includes dotfiles, so any dotfile entry under contentDir
        // would silently drop out of the archive.
        await using var fileStream = File.Create(archivePath);
        await using var gzipStream = new System.IO.Compression.GZipStream(fileStream, System.IO.Compression.CompressionLevel.Fastest);
        await using var tarWriter = new System.Formats.Tar.TarWriter(gzipStream, System.Formats.Tar.TarEntryFormat.Pax, leaveOpen: true);
 
        var options = new EnumerationOptions
        {
            AttributesToSkip = 0,
            RecurseSubdirectories = true,
        };
 
        foreach (var fullPath in Directory.EnumerateFiles(contentDir, "*", options))
        {
            var relativePath = Path.GetRelativePath(contentDir, fullPath).Replace(Path.DirectorySeparatorChar, '/');
            await tarWriter.WriteEntryAsync(fullPath, entryName: relativePath);
        }
    }
 
    private static async Task<string> ComputeSha512Async(string filePath)
    {
        using var stream = File.OpenRead(filePath);
        var hash = await SHA512.HashDataAsync(stream);
        return Convert.ToHexString(hash).ToLowerInvariant();
    }
}
 
public record FakeArchive(string ArchivePath, string ChecksumPath, string ChecksumHex);