File: Utils\EnvironmentChecker\OperatingSystemCheck.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text.Json.Nodes;
using Aspire.Cli.Resources;
 
namespace Aspire.Cli.Utils.EnvironmentChecker;
 
/// <summary>
/// Reports operating system information for the current environment.
/// </summary>
internal sealed class OperatingSystemCheck : IEnvironmentCheck
{
    internal const string CheckName = "operating-system";
    private const string UnknownOperatingSystemMetadataValue = "unknown";
 
    private readonly Func<OperatingSystemDetails> _getOperatingSystemDetails;
 
    public OperatingSystemCheck(IEnvironment environment)
        : this(() => GetCurrentOperatingSystemDetails(environment))
    {
    }
 
    internal OperatingSystemCheck(Func<OperatingSystemDetails> getOperatingSystemDetails)
    {
        ArgumentNullException.ThrowIfNull(getOperatingSystemDetails);
 
        _getOperatingSystemDetails = getOperatingSystemDetails;
    }
 
    public int Order => 10; // Fast local check, after Aspire version and before WSL-specific diagnostics.
 
    public Task<IReadOnlyList<EnvironmentCheckResult>> CheckAsync(CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        var details = _getOperatingSystemDetails();
        var messageDisplayName = details.MessageDisplayName ?? details.Name;
        var result = new EnvironmentCheckResult
        {
            Category = EnvironmentCheckCategories.Environment,
            Name = CheckName,
            Status = details.Status,
            Message = string.Format(
                CultureInfo.CurrentCulture,
                DoctorCommandStrings.OperatingSystemMessageFormat,
                string.IsNullOrWhiteSpace(details.Version) ? messageDisplayName : $"{messageDisplayName} {details.Version}"),
            Metadata = BuildMetadata(details)
        };
 
        return Task.FromResult<IReadOnlyList<EnvironmentCheckResult>>([result]);
    }
 
    internal static OperatingSystemDetails GetCurrentOperatingSystemDetails(IEnvironment environment)
    {
        var version = Environment.OSVersion.Version;
        var description = RuntimeInformation.OSDescription;
 
        if (environment.IsWindows())
        {
            return CreateWindowsDetails(version, description);
        }
 
        if (environment.IsMacOS())
        {
            return CreateMacOSDetails(version, description);
        }
 
        if (environment.IsLinux())
        {
            return CreateLinuxDetails(version, description);
        }
 
        return new OperatingSystemDetails(
            UnknownOperatingSystemMetadataValue,
            UnknownOperatingSystemMetadataValue,
            version.ToString(),
            description,
            EnvironmentCheckStatus.Warning,
            DoctorCommandStrings.VersionUnknown);
    }
 
    internal static OperatingSystemDetails CreateWindowsDetails(Version version, string description)
        => new("Windows", "Windows", version.ToString(), description, EnvironmentCheckStatus.Pass);
 
    internal static OperatingSystemDetails CreateMacOSDetails(Version version, string description)
        => new("macOS", "macOS", version.ToString(), description, EnvironmentCheckStatus.Pass);
 
    internal static OperatingSystemDetails CreateLinuxDetails(Version fallbackVersion, string description)
    {
        var osReleaseContents = TryReadLinuxOsRelease();
        var osRelease = ParseLinuxOsRelease(osReleaseContents);
        osRelease.TryGetValue("NAME", out var name);
        osRelease.TryGetValue("ID", out var id);
        osRelease.TryGetValue("VERSION_ID", out var version);
        osRelease.TryGetValue("PRETTY_NAME", out var prettyName);
 
        var distroName = NormalizeLinuxDistributionName(name, id);
        var displayName = string.IsNullOrWhiteSpace(distroName) ? "Linux" : $"Linux {distroName}";
        var linuxDescription = string.IsNullOrWhiteSpace(prettyName) ? description : prettyName;
 
        return new OperatingSystemDetails("Linux", displayName, string.IsNullOrWhiteSpace(version) ? fallbackVersion.ToString() : version, linuxDescription, EnvironmentCheckStatus.Pass);
    }
 
    internal static Dictionary<string, string> ParseLinuxOsRelease(string? contents)
    {
        var values = new Dictionary<string, string>(StringComparer.Ordinal);
        if (string.IsNullOrWhiteSpace(contents))
        {
            return values;
        }
 
        foreach (var rawLine in contents.Split('\n'))
        {
            // /etc/os-release uses KEY=VALUE lines, for example:
            //   NAME="Ubuntu"
            //   VERSION_ID="24.04"
            // Strip surrounding quotes for display. The values doctor surfaces are not expected
            // to contain shell-special characters, so leave any backslashes unchanged.
            var line = rawLine.Trim();
            if (line.Length == 0 || line[0] == '#')
            {
                continue;
            }
 
            var separatorIndex = line.IndexOf('=');
            if (separatorIndex <= 0)
            {
                continue;
            }
 
            var key = line[..separatorIndex];
            var value = line[(separatorIndex + 1)..].Trim();
            values[key] = UnquoteOsReleaseValue(value);
        }
 
        return values;
    }
 
    private static JsonObject BuildMetadata(OperatingSystemDetails details)
    {
        var metadata = new JsonObject
        {
            ["osType"] = details.Type,
            ["displayName"] = details.Name,
            ["version"] = details.Version
        };
 
        if (!string.IsNullOrWhiteSpace(details.Description))
        {
            metadata["description"] = details.Description;
        }
 
        return metadata;
    }
 
    private static string? TryReadLinuxOsRelease()
    {
        try
        {
            return File.ReadAllText("/etc/os-release");
        }
        catch
        {
            return null;
        }
    }
 
    private static string? NormalizeLinuxDistributionName(string? name, string? id)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            return NormalizeLinuxDistributionId(id);
        }
 
        var normalizedName = name.Trim();
        foreach (var suffix in new[] { " GNU/Linux", " Linux" })
        {
            if (normalizedName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
            {
                normalizedName = normalizedName[..^suffix.Length];
                break;
            }
        }
 
        return normalizedName.Equals("Linux", StringComparison.OrdinalIgnoreCase) ? null : normalizedName;
    }
 
    private static string? NormalizeLinuxDistributionId(string? id)
    {
        if (string.IsNullOrWhiteSpace(id))
        {
            return null;
        }
 
        return id.Trim().ToLowerInvariant() switch
        {
            "alpine" => "Alpine",
            "arch" => "Arch Linux",
            "centos" => "CentOS",
            "debian" => "Debian",
            "fedora" => "Fedora",
            "linuxmint" => "Linux Mint",
            "opensuse" or "opensuse-leap" or "opensuse-tumbleweed" => "openSUSE",
            "rhel" => "Red Hat Enterprise Linux",
            "ubuntu" => "Ubuntu",
            var value => CultureInfo.InvariantCulture.TextInfo.ToTitleCase(value.Replace('-', ' '))
        };
    }
 
    private static string UnquoteOsReleaseValue(string value)
    {
        if (value.Length < 2)
        {
            return value;
        }
 
        var quote = value[0];
        if ((quote != '"' && quote != '\'') || value[^1] != quote)
        {
            return value;
        }
 
        var unquoted = value[1..^1];
        return unquoted;
    }
}
 
internal sealed record OperatingSystemDetails(
    string Type,
    string Name,
    string Version,
    string Description,
    EnvironmentCheckStatus Status,
    string? MessageDisplayName = null);