File: Publishing\TestContainerImageArchive.cs
Web Access
Project: src\tests\Aspire.Hosting.TestUtilities\Aspire.Hosting.TestUtilities.csproj (Aspire.Hosting.TestUtilities)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Formats.Tar;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
 
namespace Aspire.Hosting.Tests.Publishing;
 
public static class TestContainerImageArchive
{
    private const string FixtureConfig = """
        {
          "architecture": "amd64",
          "config": {
            "Env": [ "PATH=/usr/local/bin" ],
            "Labels": {
              "aspire.test.fixture": "true"
            }
          },
          "created": "1970-01-01T00:00:00Z",
          "os": "linux",
          "rootfs": {
            "type": "layers",
            "diff_ids": [
              "sha256:0000000000000000000000000000000000000000000000000000000000000000"
            ]
          }
        }
        """;
 
    public static void WriteDockerArchive(string archivePath, string imageReference, string layerContents)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(archivePath);
        ArgumentException.ThrowIfNullOrWhiteSpace(imageReference);
        ArgumentNullException.ThrowIfNull(layerContents);
 
        var (repository, tag) = SplitTaggedReference(imageReference);
        var manifest = JsonSerializer.Serialize(
        new[]
        {
            new
            {
                Config = "config.json",
                RepoTags = new[] { imageReference },
                Layers = new[] { "layer.tar" }
            }
        });
        var repositories = JsonSerializer.Serialize(new Dictionary<string, Dictionary<string, string>>
        {
            [repository] = new()
            {
                [tag] = "layer"
            }
        });
 
        WriteArchive(
            archivePath,
            layerContents,
            ("manifest.json", manifest),
            ("repositories", repositories),
            ("config.json", FixtureConfig));
    }
 
    public static string[] ReadDockerImageReferences(string archivePath)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(archivePath);
 
        using var archiveStream = OpenArchiveStream(archivePath);
        using var reader = new TarReader(archiveStream);
        while (reader.GetNextEntry(copyData: false) is { } entry)
        {
            if (GetRootEntryName(entry.Name) != "manifest.json")
            {
                continue;
            }
 
            using var document = JsonDocument.Parse(
                entry.DataStream ?? throw new InvalidDataException("The Docker manifest entry has no data."));
            if (document.RootElement.ValueKind != JsonValueKind.Array)
            {
                throw new InvalidDataException("The Docker manifest root must be an array.");
            }
 
            var references = new List<string>();
            foreach (var manifest in document.RootElement.EnumerateArray())
            {
                if (!manifest.TryGetProperty("RepoTags", out var repoTags) ||
                    repoTags.ValueKind == JsonValueKind.Null)
                {
                    continue;
                }
 
                if (repoTags.ValueKind != JsonValueKind.Array)
                {
                    throw new InvalidDataException("Docker manifest RepoTags must be an array.");
                }
 
                foreach (var reference in repoTags.EnumerateArray())
                {
                    references.Add(reference.GetString()
                        ?? throw new InvalidDataException("Docker manifest RepoTags must contain strings."));
                }
            }
 
            return references.ToArray();
        }
 
        throw new InvalidDataException("The archive does not contain manifest.json.");
    }
 
    public static string ReadLayerContents(string archivePath)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(archivePath);
 
        using var archiveStream = OpenArchiveStream(archivePath);
        using var reader = new TarReader(archiveStream);
        while (reader.GetNextEntry(copyData: false) is { } entry)
        {
            if (!string.Equals(GetRootEntryName(entry.Name), "layer.tar", StringComparison.Ordinal))
            {
                continue;
            }
 
            using var layerArchive = new MemoryStream();
            (entry.DataStream ?? throw new InvalidDataException("The layer.tar entry has no data.")).CopyTo(layerArchive);
            layerArchive.Position = 0;
 
            using var layerReader = new TarReader(layerArchive);
            while (layerReader.GetNextEntry(copyData: false) is { } layerEntry)
            {
                if (layerEntry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile))
                {
                    continue;
                }
 
                using var textReader = new StreamReader(
                    layerEntry.DataStream ?? throw new InvalidDataException("The layer payload entry has no data."),
                    Encoding.UTF8,
                    detectEncodingFromByteOrderMarks: true,
                    bufferSize: 1024,
                    leaveOpen: true);
                return textReader.ReadToEnd();
            }
 
            throw new InvalidDataException("The fixture layer.tar does not contain a regular file.");
        }
 
        throw new InvalidDataException("The archive does not contain layer.tar.");
    }
 
    internal static void WriteArchive(
        string archivePath,
        string layerContents,
        params (string Name, string Contents)[] textEntries)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(archivePath);
        ArgumentNullException.ThrowIfNull(layerContents);
        ArgumentNullException.ThrowIfNull(textEntries);
 
        using var archiveStream = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None);
        using var writer = new TarWriter(archiveStream, leaveOpen: true);
 
        foreach (var (name, contents) in textEntries)
        {
            WriteTextEntry(writer, name, contents);
        }
 
        using var layerArchive = new MemoryStream();
        using (var layerWriter = new TarWriter(layerArchive, leaveOpen: true))
        {
            WriteTextEntry(layerWriter, "payload.txt", layerContents);
        }
 
        layerArchive.Position = 0;
        var layerEntry = new PaxTarEntry(TarEntryType.RegularFile, "layer.tar")
        {
            DataStream = layerArchive
        };
        writer.WriteEntry(layerEntry);
    }
 
    internal static string ReadEntryText(string archivePath, string entryName)
    {
        using var archiveStream = OpenArchiveStream(archivePath);
        using var reader = new TarReader(archiveStream);
        while (reader.GetNextEntry(copyData: false) is { } entry)
        {
            if (!string.Equals(entry.Name, entryName, StringComparison.Ordinal))
            {
                continue;
            }
 
            using var textReader = new StreamReader(
                entry.DataStream ?? throw new InvalidDataException($"The '{entryName}' entry has no data."),
                Encoding.UTF8,
                detectEncodingFromByteOrderMarks: true,
                bufferSize: 1024,
                leaveOpen: true);
            return textReader.ReadToEnd();
        }
 
        throw new InvalidDataException($"The archive does not contain '{entryName}'.");
    }
 
    internal static string[] ReadEntryNames(string archivePath)
    {
        using var archiveStream = OpenArchiveStream(archivePath);
        using var reader = new TarReader(archiveStream);
        var names = new List<string>();
        while (reader.GetNextEntry(copyData: false) is { } entry)
        {
            names.Add(entry.Name);
        }
 
        return names.ToArray();
    }
 
    private static void WriteTextEntry(TarWriter writer, string name, string contents)
    {
        var bytes = Encoding.UTF8.GetBytes(contents);
        using var data = new MemoryStream(bytes, writable: false);
        var entry = new PaxTarEntry(TarEntryType.RegularFile, name)
        {
            DataStream = data
        };
        writer.WriteEntry(entry);
    }
 
    private static Stream OpenArchiveStream(string archivePath)
    {
        var file = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read);
        Span<byte> magic = stackalloc byte[2];
        var bytesRead = file.Read(magic);
        file.Position = 0;
 
        // Detect compression from the gzip magic bytes rather than the extension because publishing
        // also supports custom output extensions and .tgz aliases.
        return bytesRead == magic.Length && magic[0] == 0x1f && magic[1] == 0x8b
            ? new GZipStream(file, CompressionMode.Decompress, leaveOpen: false)
            : file;
    }
 
    private static string? GetRootEntryName(string entryName)
    {
        while (entryName.StartsWith("./", StringComparison.Ordinal))
        {
            entryName = entryName[2..];
        }
 
        return entryName.Contains('/') ? null : entryName;
    }
 
    private static (string Repository, string Tag) SplitTaggedReference(string reference)
    {
        var lastSlash = reference.LastIndexOf('/');
        var lastColon = reference.LastIndexOf(':');
        if (lastColon <= lastSlash || lastColon <= 0 || lastColon == reference.Length - 1)
        {
            throw new ArgumentException($"'{reference}' is not a tagged image reference.", nameof(reference));
        }
 
        return (reference[..lastColon], reference[(lastColon + 1)..]);
    }
}