File: ContainerHelpers.cs
Web Access
Project: ..\..\..\src\Containers\Microsoft.NET.Build.Containers\Microsoft.NET.Build.Containers.csproj (Microsoft.NET.Build.Containers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#if NETFRAMEWORK
using System;
using System.Linq;
#endif
#if NET
using System.Diagnostics.CodeAnalysis;
#endif
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.NET.Build.Containers.Resources;
 
namespace Microsoft.NET.Build.Containers;
public static class ContainerHelpers
{
    internal const string HostObjectUser = "DOTNET_CONTAINER_REGISTRY_UNAME";
    internal const string HostObjectUserLegacy = "SDK_CONTAINER_REGISTRY_UNAME";
 
    internal const string HostObjectPass = "DOTNET_CONTAINER_REGISTRY_PWORD";
    internal const string HostObjectPassLegacy = "SDK_CONTAINER_REGISTRY_PWORD";
 
    internal const string PushHostObjectUser = "DOTNET_CONTAINER_PUSH_REGISTRY_UNAME";
    internal const string PushHostObjectPass = "DOTNET_CONTAINER_PUSH_REGISTRY_PWORD";
 
    internal const string PullHostObjectUser = "DOTNET_CONTAINER_PULL_REGISTRY_UNAME";
    internal const string PullHostObjectPass = "DOTNET_CONTAINER_PULL_REGISTRY_PWORD";
 
    internal const string DockerRegistryAlias = "docker.io";
 
    /// <summary>
    /// Matches an environment variable name - must start with a letter or underscore, and can only contain letters, numbers, and underscores.
    /// </summary>
    private static Regex envVarRegex = new(@"^[a-zA-Z_]{1,}[a-zA-Z0-9_]*$");
 
    /// <summary>
    /// The enum contains possible error reasons during port parsing using <see cref="TryParsePort(string, out Port?, out ParsePortError?)"/> or <see cref="TryParsePort(string?, string?, out Port?, out ParsePortError?)"/>.
    /// </summary>
    [Flags]
    public enum ParsePortError
    {
        MissingPortNumber = 1,
        InvalidPortNumber = 2,
        InvalidPortType = 4,
        UnknownPortFormat = 8
    }
 
    /// <summary>
    /// Tries to parse the port from <paramref name="portNumber"/> and <paramref name="portType"/>.
    /// </summary>
    /// <param name="portNumber">The port number to parse.</param>
    /// <param name="portType">The port type to parse (tcp or udp).</param>
    /// <param name="port">Parsed port.</param>
    /// <param name="error">The error occurred during parsing. Only returned when method returns <see langword=""="false"/>.</param>
    /// <returns><see langword=""="true"/> when port was successfully parsed, <see langword=""="false"/> otherwise.</returns>
    public static bool TryParsePort(string? portNumber, string? portType, [NotNullWhen(true)] out Port? port, [NotNullWhen(false)] out ParsePortError? error)
    {
        var portNo = 0;
        error = null;
        if (string.IsNullOrEmpty(portNumber))
        {
            error = ParsePortError.MissingPortNumber;
        }
        else if (!int.TryParse(portNumber, out portNo))
        {
            error = ParsePortError.InvalidPortNumber;
        }
 
        if (!Enum.TryParse(portType, out PortType t))
        {
            if (!string.IsNullOrEmpty(portType))
            {
                error = (error ?? ParsePortError.InvalidPortType) | ParsePortError.InvalidPortType;
            }
            else
            {
                t = PortType.tcp;
            }
        }
 
        if (error is null)
        {
            port = new Port(portNo, t);
            return true;
        }
        else
        {
            port = null;
            return false;
        }
 
    }
 
    /// <summary>
    /// Tries to parse the port from <paramref name="input"/>.
    /// </summary>
    /// <param name="input">The port number to parse. Expected formats are: port number as int value, or value in format 'port number/port type' where
    /// port type can be tcp or udp. If the port type is not present, it is assumed to be tcp.</param>
    /// <param name="port">Parsed port.</param>
    /// <param name="error">The error occurred during parsing. Only returned when method returns <see langword="false"/>.</param>
    /// <returns><see langword="true"/> when port was successfully parsed, <see langword="false"/> otherwise.</returns>
    public static bool TryParsePort(string input, [NotNullWhen(true)] out Port? port, [NotNullWhen(false)] out ParsePortError? error)
    {
        var parts = input.Split('/');
        if (parts.Length == 2)
        {
            string portNumber = parts[0];
            string type = parts[1];
            return TryParsePort(portNumber, type, out port, out error);
        }
        else if (parts.Length == 1)
        {
            string portNum = parts[0];
            return TryParsePort(portNum, null, out port, out error);
        }
        else
        {
            error = ParsePortError.UnknownPortFormat;
            port = null;
            return false;
        }
    }
 
    /// <summary>
    /// Ensures the given registry is valid.
    /// </summary>
    /// <param name="registryName"></param>
    /// <returns></returns>
    internal static bool IsValidRegistry(string registryName) => ReferenceParser.AnchoredDomainRegexp.IsMatch(registryName);
 
    /// <summary>
    /// Ensures the given image name is valid.
    /// Spec: https://github.com/opencontainers/distribution-spec/blob/4ab4752c3b86a926d7e5da84de64cbbdcc18d313/spec.md#pulling-manifests
    /// </summary>
    /// <param name="imageName"></param>
    /// <returns></returns>
    internal static bool IsValidImageName(string imageName)
    {
        return ReferenceParser.anchoredNameRegexp.IsMatch(imageName);
    }
 
    /// <summary>
    /// Ensures the given tag is valid.
    /// Spec: https://github.com/opencontainers/distribution-spec/blob/4ab4752c3b86a926d7e5da84de64cbbdcc18d313/spec.md#pulling-manifests
    /// </summary>
    /// <param name="imageTag"></param>
    /// <returns></returns>
    internal static bool IsValidImageTag(string imageTag)
    {
        return ReferenceParser.anchoredTagRegexp.IsMatch(imageTag);
    }
 
    /// <summary>
    /// Ensures a given environment variable is valid.
    /// </summary>
    /// <param name="envVar"></param>
    /// <returns></returns>
    internal static bool IsValidEnvironmentVariable(string envVar)
    {
        return envVarRegex.IsMatch(envVar);
    }
 
    /// <summary>
    /// Parse a fully qualified container name (e.g. https://mcr.microsoft.com/dotnet/runtime:6.0)
    /// Note: Tag not required.
    /// </summary>
    /// <remarks>
    /// This code is adapted from <see href="https://github.com/distribution/distribution/blob/78b9c98c5c31c30d74f9acb7d96f98552f2cf78f/reference/reference.go#L191-L236">reference.go</see>
    /// and so may not match .NET idioms.
    /// NOTE: We explicitly are not handling digest references at the moment.
    /// </remarks>
    /// <param name="fullyQualifiedContainerName"></param>
    /// <param name="containerRegistry"></param>
    /// <param name="containerName"></param>
    /// <param name="containerTag"></param>
    /// <param name="containerDigest"></param>
    /// <returns>True if the parse was successful. When false is returned, all out vars are set to empty strings.</returns>
    internal static bool TryParseFullyQualifiedContainerName(string fullyQualifiedContainerName,
                                                            [NotNullWhen(true)] out string? containerRegistry,
                                                            [NotNullWhen(true)] out string? containerName,
                                                            out string? containerTag, // tag is always optional - we can't guarantee anything here
                                                            out string? containerDigest, // digest is always optional - we can't guarantee anything here
                                                            out bool isRegistrySpecified
                                                            )
    {
 
        /// if we don't have a reference at all, bail out
        var referenceMatch = ReferenceParser.ReferenceRegexp.Match(fullyQualifiedContainerName);
        if (referenceMatch is not { Success: true })
        {
            containerRegistry = null;
            containerName = null;
            containerTag = null;
            containerDigest = null;
            isRegistrySpecified = false;
            return false;
        }
 
        // if we have a reference, then we have three groups:
        // * reference name
        // * reference tag (optional)
        // * reference digest (optional)
 
        // this will always be successful if the ReferenceRegexp matched, so it's safe to index into.
        var namePortion = referenceMatch.Groups[1].Value;
        // we try to decompose the reference name into registry and image name parts.
        var nameMatch = ReferenceParser.anchoredNameRegexp.Match(namePortion);
        if (nameMatch is { Success: true })
        {
            // the name regex has two groups:
            // * registry (optional)
            // * image name
 
            // safely discover the registry
            var registryPortion = nameMatch.Groups[1];
            isRegistrySpecified = registryPortion.Success;
            containerRegistry = isRegistrySpecified ? registryPortion.Value
                                                    : DockerRegistryAlias;
 
            // direct access to the name portion is safe because the regex matched
            var imageNamePortion = nameMatch.Groups[2];
            containerName = imageNamePortion.Value;
 
            if (containerRegistry == DockerRegistryAlias)
            {
                // Add the 'library/' prefix to expand short names like 'ubuntu' to 'library/ubuntu'.
                if (!containerName.Contains("/"))
                {
                    containerName = $"library/{containerName}";
                }
            }
        }
        else
        {
            containerRegistry = null;
            containerName = null;
            containerTag = null;
            containerDigest = null;
            isRegistrySpecified = false;
            return false;
        }
 
        // tag may not exist in the reference, so we must safely access it
        var tagPortion = referenceMatch.Groups[2];
        containerTag = tagPortion.Success ? tagPortion.Value : null;
 
        // digest may not exist in the reference, so we must safely access it
        var digestPortion = referenceMatch.Groups[3];
        containerDigest = digestPortion.Success ? digestPortion.Value : null;
 
        return true;
    }
 
 
 
    /// <summary>
    /// Checks if a given container image name adheres to the image name spec. If not, and recoverable, then normalizes invalid characters.
    /// </summary>
    internal static (string? normalizedImageName, (string, object[])? normalizationWarning, (string, object[])? normalizationError) NormalizeRepository(string containerRepository)
    {
        if (IsValidImageName(containerRepository))
        {
            return (containerRepository, null, null);
        }
        else
        {
            // check for leading alphanumeric character
            char firstChar = containerRepository[0];
            if (!IsAlpha(firstChar) && !IsNumeric(firstChar))
            {
                // The name did not start with an alphanumeric character, so we can't normalize it.
                var error = (nameof(Strings.InvalidImageName_NonAlphanumericStartCharacter), new[] { containerRepository });
                return (null, null, error);
            }
 
 
            // normalize the name. a little more complex, but this does all of our checks in a single pass and doesn't require coming back
            // after the normalization to check if our invariants hold
            var invalidChars = 0;
            var normalizationOccurred = false;
            var builder = new StringBuilder(containerRepository);
            for (int i = 0; i < containerRepository.Length; i++)
            {
                var current = containerRepository[i];
                if (IsLowerAlpha(current) || IsNumeric(current) || IsAllowedPunctuation(current))
                {
                    // no need to set the builder's char here, since we preloaded
                }
                else if (IsUpperAlpha(current))
                {
                    builder[i] = char.ToLowerInvariant(current);
                    normalizationOccurred = true;
                }
                else
                {
                    builder[i] = '-';
                    normalizationOccurred = true;
                    invalidChars++;
                }
            }
            var normalizedImageName = builder.ToString();
 
            // check for normalization to useless name
            if (invalidChars == builder.Length)
            {
                // The name was normalized to all dashes, so there was nothing recoverable. We should throw.
                var error = (nameof(Strings.InvalidImageName_EntireNameIsInvalidCharacters), new string[] { containerRepository });
                return (null, null, error);
            }
 
            // check for warning/notification that we did indeed perform normalization
            if (normalizationOccurred)
            {
                var warning = (nameof(Strings.NormalizedContainerName), new string[] { containerRepository, normalizedImageName });
                return (normalizedImageName, warning, null);
            }
 
            // user value was already normalized, so we don't need to do anything
            else
            {
                return (containerRepository, null, null);
            }
        }
 
        static bool IsUpperAlpha(char c) => c >= 'A' && c <= 'Z';
        static bool IsLowerAlpha(char c) => c >= 'a' && c <= 'z';
        static bool IsAlpha(char c) => IsLowerAlpha(c) || IsUpperAlpha(c);
        static bool IsNumeric(char c) => c >= '0' && c <= '9';
        static bool IsAllowedPunctuation(char c) => (c == '_') || (c == '-') || (c == '/');
    }
}