File: Utils.cs
Web Access
Project: src\src\SignCheck\Microsoft.SignCheck\Microsoft.DotNet.SignCheckLibrary.csproj (Microsoft.DotNet.SignCheckLibrary)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
#if NET
using System.Formats.Tar;
#endif
 
namespace Microsoft.SignCheck
{
    public static class Utils
    {
#if NET
        private static readonly HttpClient s_client = new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(10) });
#endif
        /// <summary>
        /// Generate a hash for a string value using a given hash algorithm.
        /// </summary>
        /// <param name="value">The value to hash.</param>
        /// <param name="hashName">The name of the <see cref="HashAlgorithm"/> to use.</param>
        /// <returns>A string containing the hash result.</returns>
        public static string GetHash(string value, string hashName)
        {
            byte[] bytes = Encoding.UTF8.GetBytes(value);
            HashAlgorithm ha = CreateHashAlgorithm(hashName);
            byte[] hash = ha.ComputeHash(bytes);
 
            var sb = new StringBuilder();
            foreach (byte b in hash)
            {
                sb.Append(b.ToString("x2"));
            }
 
            return sb.ToString();
        }
 
        public static HashAlgorithm CreateHashAlgorithm(string hashName)
        {
            switch (hashName.ToUpperInvariant())
            {
                case "SHA256":
                    return SHA256.Create();
                case "SHA1":
                    return SHA1.Create();
                case "MD5":
                    return MD5.Create();
                case "SHA384":
                    return SHA384.Create();
                case "SHA512":
                    return SHA512.Create();
                default:
                    throw new ArgumentException("Unsupported hash algorithm name", nameof(hashName));
            }
        }
 
        /// <summary>
        /// Converts a string containing wildcards (*, ?) into a regular expression pattern string.
        /// </summary>
        /// <param name="wildcardPattern">The string pattern.</param>
        /// <returns>A string containing regular expression pattern.</returns>
        public static string ConvertToRegexPattern(string wildcardPattern)
        {
            string escapedPattern = Regex.Escape(wildcardPattern).Replace(@"\*", ".*").Replace(@"\?", ".");
 
            if ((wildcardPattern.EndsWith("*")) || (wildcardPattern.EndsWith("?")))
            {
                return escapedPattern;
            }
            else
            {
                return String.Concat(escapedPattern, "$");
            }            
        }
 
        /// <summary>
        /// Gets the DateTime value from a string
        /// Returns the specified default value if the match is unsuccessful or the timestamp value is 0.
        /// </summary>
        /// <param name="timestamp">The timestamp string to parse.</param>
        /// <param name="defaultValue">The default DateTime value to return if parsing fails.</param>
        /// <returns>The parsed DateTime value or the default value.</returns>
        public static DateTime DateTimeOrDefault(this string timestamp, DateTime defaultValue)
        {
            if (string.IsNullOrEmpty(timestamp))
            {
                return defaultValue;
            }
 
            timestamp = Regex.Replace(timestamp, @"\s{2,}", " ").Trim();
 
            // Try to parse the timestamp as a Unix timestamp (seconds since epoch)
            if (long.TryParse(timestamp, out long unixTime) && unixTime > 0)
            {
                return DateTimeOffset.FromUnixTimeSeconds(unixTime).UtcDateTime;
            }
 
            // Try to parse the timestamp as a DateTime string
            if (DateTime.TryParse(timestamp, out DateTime dateTime))
            {
                return dateTime;
            }
 
            if (TryParseCodeSignTimestamp(timestamp, out dateTime))
            {
                return dateTime;
            }
 
            if (TryParseOpensslTimestamp(timestamp, out dateTime))
            {
                return dateTime;
            }
 
            return defaultValue;
        }
 
        /// <summary>
        /// Gets the value of a named group from a regex match.
        /// Returns null if the match is unsuccessful.
        /// </summary>
        /// <param name="match">The regex match.</param>
        /// <param name="groupName">The name of the group.</param>
        /// <returns>The value of the named group or null if the match is unsuccessful.</returns>
        public static string GroupValueOrDefault(this Match match, string groupName) =>
            match.Success ? match.Groups[groupName].Value : null;
 
        /// <summary>
        /// Captures the console output of an action.
        /// </summary>
        /// <param name="action">The action to execute.</param>
        /// <returns>A tuple containing the result of the action, the standard output, and the error output.</returns>
        public static (bool, string, string) CaptureConsoleOutput(Func<bool> action)
        {
            var consoleOutput = Console.Out;
            StringWriter outputWriter = new StringWriter();
            Console.SetOut(outputWriter);
 
            var errorOutput = Console.Error;
            StringWriter errorOutputWriter = new StringWriter();
            Console.SetError(errorOutputWriter);
 
            try
            {
                bool result = action();
                return (result, outputWriter.ToString(), errorOutputWriter.ToString());
            }
            finally
            {
                Console.SetOut(consoleOutput);
                Console.SetError(errorOutput);
            }
        }
 
        /// <summary>
        /// Runs a bash command and returns the output, error, and exit code.
        /// </summary>
        /// <param name="command">The command to run.</param>
        /// <returns>A tuple containing the exit code, output, and error.</returns>
        public static (int exitCode, string output, string error) RunBashCommand(string command, string workingDirectory = null)
        {
            if (string.IsNullOrEmpty(workingDirectory))
            {
                workingDirectory = Environment.CurrentDirectory;
            }
 
            var psi = new ProcessStartInfo
            {
                FileName = "bash",
                Arguments = $"-c \"{command}\"",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
                CreateNoWindow = true,
                WorkingDirectory = workingDirectory,
            };
 
            using (var process = Process.Start(psi))
            {
                string output = process.StandardOutput.ReadToEnd();
                string error = process.StandardError.ReadToEnd();
 
                process.WaitForExit(10000); // 10 seconds
                
                return (process.ExitCode, output, error);
            }
        }
 
#if NET
        /// <summary>
        /// Download the Microsoft and Azure Linux public keys and import them into the keyring.
        /// </summary>
        public static void DownloadAndConfigurePublicKeys(string tempDir)
        {
            string[] keyUrls = new string[]
            {
                "https://packages.microsoft.com/keys/microsoft.asc", // Microsoft public key
                "https://raw.githubusercontent.com/microsoft/azurelinux/3.0/SPECS/azurelinux-repos/MICROSOFT-RPM-GPG-KEY" // Azure linux public key
            };
            foreach (string keyUrl in keyUrls)
            {
                string keyPath = Path.Combine(tempDir, Path.GetFileName(keyUrl));
                using (Stream stream = s_client.GetStreamAsync(keyUrl).Result)
                {
                    using (FileStream fileStream = File.Create(keyPath))
                    {
                        stream.CopyTo(fileStream);
                    }
                }
 
                (int exitCode, _, string error) = RunBashCommand($"gpg --import {keyPath}");
 
                if (exitCode != 0)
                {
                    throw new Exception($"Failed to import Microsoft public key: {(string.IsNullOrEmpty(error) ? "unknown error" : error)}");
                }
            }
        }
 
        /// <summary>
        /// Gets the next entry in a tar archive.
        /// </summary>
        public static TarEntry TryGetNextTarEntry(this TarReader reader)
        {
            try
            {
                return reader.GetNextEntry();
            }
            catch (EndOfStreamException)
            {
                // The stream is empty
                return null;
            }
        }
#endif
 
        /// <summary>
        /// Parses a code signing timestamp string into a DateTime object.
        /// </summary>
        private static bool TryParseCodeSignTimestamp(string timestamp, out DateTime dateTime)
        {
            // Normalize single-digit day and hour by adding a leading zero where necessary (e.g., "Feb 1," or "at 7:" => "Feb 01," or "at 07:")
            string normalizedTimestamp = Regex.Replace(timestamp, @"(?<=\b[A-Za-z]{3}\s)(\d)(?=,\s)|(?<=at\s)(\d)(?=:)", match =>
            {
                return "0" + match.Value;
            });
            
 
            string codesignFormat = "MMM dd, yyyy 'at' hh:mm:ss tt";
            if (DateTime.TryParseExact(normalizedTimestamp, codesignFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out dateTime))
            {
                return true;
            }
 
            return false;
        }
 
        /// <summary>
        /// Parses an OpenSSL timestamp string into a DateTime object.
        /// </summary>
        private static bool TryParseOpensslTimestamp(string timestamp, out DateTime dateTime)
        {
            // As per https://www.ietf.org/rfc/rfc5280.txt, X.509 certificate time fields must be in GMT.
            string timezone = timestamp.ExtractTimezone();
            if (!string.IsNullOrEmpty(timezone) && timezone.Equals("GMT"))
            {
                // Normalize single-digit day and hour by adding a leading zero where necessary (e.g., "Feb 1" or "7:" => "Feb 01" or "07:").
                string normalizedTimestamp = Regex.Replace(timestamp, @"(?<=\b[A-Za-z]{3}\s)(\d)(?=\s)|(?<=\s)(\d)(?=:)", match =>
                {
                    return "0" + match.Value;
                });
 
                // GMT is equivalent to UTC+0
                normalizedTimestamp = normalizedTimestamp.Replace(timezone, "+00:00").Trim();
 
                string opensslFormat = "MMM dd HH:mm:ss yyyy zzz";
                if (DateTime.TryParseExact(normalizedTimestamp, opensslFormat, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out dateTime))
                {
                    return true;
                }
            }
 
            dateTime = default;
            return false;
        }
 
        /// <summary>
        /// Extracts the timezone from a timestamp string.
        /// </summary>
        private static string ExtractTimezone(this string timestamp)
        {
            var timezoneRegex = new Regex(@"\s(?<timezone>[a-zA-Z]{3,4})");
            return timezoneRegex.Match(timestamp).GroupValueOrDefault("timezone");
        }
    }
}