File: src\Shared\PackageUpdateHelpers.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.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Semver;
#if CLI
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
#else
using NuGetPackage = Aspire.Shared.NuGetPackage;
#endif
 
namespace Aspire.Shared;
 
#if CLI
internal class NuGetPackageCli
#else
internal class NuGetPackage
#endif
{
    public string Id { get; set; } = string.Empty;
    public string Version { get; set; } = string.Empty;
    public string Source { get; set; } = string.Empty;
}
 
internal static class PackageUpdateHelpers
{
    public static SemVersion? GetCurrentPackageVersion()
    {
        try
        {
            var versionString = GetCurrentAssemblyVersion();
            if (versionString == null)
            {
                return null;
            }
 
            // Remove any build metadata (e.g., +sha.12345) for comparison
            var cleanVersionString = versionString.Split('+')[0];
            return SemVersion.Parse(cleanVersionString, SemVersionStyles.Strict);
        }
        catch
        {
            return null;
        }
    }
 
    public static string? GetCurrentAssemblyVersion()
    {
        // Write some code that gets the informational assembly version of the current assembly and returns it as a string.
        var assembly = typeof(PackageUpdateHelpers).Assembly;
        var informationalVersion = assembly
            .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
            .OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
            .FirstOrDefault()?.InformationalVersion;
 
        return informationalVersion;
    }
 
    public static SemVersion? GetNewerVersion(ILogger logger, SemVersion currentVersion, IEnumerable<NuGetPackage> availablePackages, SemVersion? storedVersion = null)
    {
        SemVersion? newestStable = null;
        SemVersion? newestPrerelease = null;
 
        foreach (var package in availablePackages)
        {
            if (SemVersion.TryParse(package.Version, SemVersionStyles.Strict, out var version))
            {
                ProcessNewVersion(version);
            }
        }
 
        if (storedVersion != null)
        {
            ProcessNewVersion(storedVersion);
        }
 
        logger.LogDebug(
            """
            Current version: {CurrentVersion}
            Newest stable version: {NewestStableVersion}
            Newest prerelease version: {NewestPrereleaseVersion}
            """, currentVersion, newestStable, newestPrerelease);
 
        // Apply notification rules
        if (currentVersion.IsPrerelease)
        {
            // Rule 1: If using a prerelease version where the version is lower than the latest stable version, prompt to upgrade
            if (newestStable is not null && SemVersion.PrecedenceComparer.Compare(currentVersion, newestStable) < 0)
            {
                logger.LogDebug("Current version {CurrentVersion} is prerelease and older than newest stable version {NewestStableVersion}.", currentVersion, newestStable);
                return newestStable;
            }
 
            // Rule 2: If using a prerelease version and there is a newer prerelease version, prompt to upgrade
            if (newestPrerelease is not null && SemVersion.PrecedenceComparer.Compare(currentVersion, newestPrerelease) < 0)
            {
                logger.LogDebug("Current version {CurrentVersion} is prerelease and older than newest prerelease version {NewestPrereleaseVersion}.", currentVersion, newestPrerelease);
                return newestPrerelease;
            }
        }
        else
        {
            // Rule 3: If using a stable version and there is a newer stable version, prompt to upgrade
            if (newestStable is not null && SemVersion.PrecedenceComparer.Compare(currentVersion, newestStable) < 0)
            {
                logger.LogDebug("Current version {CurrentVersion} is stable and older than newest stable version {NewestStableVersion}.", currentVersion, newestStable);
                return newestStable;
            }
        }
 
        logger.LogDebug("No newer version for the current version {CurrentVersion}.", currentVersion);
        return null;
 
        void ProcessNewVersion(SemVersion version)
        {
            if (version.IsPrerelease)
            {
                newestPrerelease = newestPrerelease is null || SemVersion.PrecedenceComparer.Compare(version, newestPrerelease) > 0 ? version : newestPrerelease;
            }
            else
            {
                newestStable = newestStable is null || SemVersion.PrecedenceComparer.Compare(version, newestStable) > 0 ? version : newestStable;
            }
        }
    }
 
    public static List<NuGetPackage> ParsePackageSearchResults(string stdout, string? packageId = null)
    {
        var foundPackages = new List<NuGetPackage>();
 
        using var document = JsonDocument.Parse(ExtractJsonPayload(stdout, IsPackageSearchPayload));
        if (!document.RootElement.TryGetProperty("searchResult", out var searchResultsArray))
        {
            return [];
        }
 
        foreach (var sourceResult in searchResultsArray.EnumerateArray())
        {
            var source = sourceResult.GetProperty("sourceName").GetString()!;
            var sourcePackagesArray = sourceResult.GetProperty("packages");
 
            foreach (var packageResult in sourcePackagesArray.EnumerateArray())
            {
                var id = packageResult.GetProperty("id").GetString()!;
 
                var version = packageResult.TryGetProperty("latestVersion", out var latestVersionProp)
                    ? latestVersionProp.GetString()!
                    : packageResult.GetProperty("version").GetString()!;
 
                if (packageId == null || id == packageId)
                {
                    foundPackages.Add(new NuGetPackage
                    {
                        Id = id,
                        Version = version,
                        Source = source
                    });
                }
            }
        }
 
        return foundPackages;
    }
 
    // `dotnet package search <id> --format json` is expected to write a single JSON object to stdout, but NuGet
    // credential providers use an inherited stdout handle. Their diagnostics can therefore appear before or after
    // the payload while the command still exits 0, and can themselves contain braces or complete JSON objects:
    //
    //     [CredentialProvider]Acquiring token for request {42}
    //     {"error":{"packages":[]}}
    //     {"version":2,"problems":[],"searchResult":[{"sourceName":"azure-default","packages":[ ... ]}]}
    //     [CredentialProvider]VstsCredentialProvider - Acquired bearer token using 'MSAL Silent'
    //
    // Parse each complete object candidate and validate the expected payload shape so a diagnostic object cannot be
    // mistaken for the payload. Return only the consumed object so trailing provider output is excluded.
    // See https://github.com/microsoft/aspire/issues/19339.
    internal static string ExtractJsonPayload(string stdout, Func<JsonElement, bool> isExpectedPayload)
    {
        var utf8 = Encoding.UTF8.GetBytes(stdout);
        var searchOffset = 0;
 
        while (searchOffset < utf8.Length)
        {
            var relativeCandidateOffset = utf8.AsSpan(searchOffset).IndexOf((byte)'{');
            if (relativeCandidateOffset < 0)
            {
                break;
            }
 
            var candidateOffset = searchOffset + relativeCandidateOffset;
            var candidate = utf8.AsSpan(candidateOffset);
            var reader = new Utf8JsonReader(candidate);
 
            try
            {
                if (JsonDocument.TryParseValue(ref reader, out var document) && document is not null)
                {
                    var consumed = checked((int)reader.BytesConsumed);
                    using (document)
                    {
                        if (isExpectedPayload(document.RootElement))
                        {
                            return Encoding.UTF8.GetString(candidate[..consumed]);
                        }
                    }
 
                    // Do not inspect nested objects inside a complete diagnostic object. A nested object could
                    // coincidentally have the expected shape even though its containing diagnostic is not the payload.
                    searchOffset = candidateOffset + consumed;
                    continue;
                }
            }
            catch (JsonException)
            {
                // A diagnostic can contain an unmatched brace or other non-JSON fragment. Advance past this brace
                // and keep looking for the package-search payload.
            }
 
            searchOffset = candidateOffset + 1;
        }
 
        // Preserve the existing behavior when no expected payload is present: callers parse the original output
        // and surface the same JsonException (or handle a valid object with no results) as before.
        return stdout;
    }
 
    private static bool IsPackageSearchPayload(JsonElement root)
    {
        if (root.ValueKind != JsonValueKind.Object ||
            !root.TryGetProperty("version", out var version) ||
            version.ValueKind != JsonValueKind.Number ||
            !root.TryGetProperty("searchResult", out var searchResults) ||
            searchResults.ValueKind != JsonValueKind.Array)
        {
            return false;
        }
 
        foreach (var sourceResult in searchResults.EnumerateArray())
        {
            if (sourceResult.ValueKind != JsonValueKind.Object ||
                !sourceResult.TryGetProperty("sourceName", out var sourceName) ||
                sourceName.ValueKind != JsonValueKind.String ||
                !sourceResult.TryGetProperty("packages", out var packages) ||
                packages.ValueKind != JsonValueKind.Array)
            {
                return false;
            }
        }
 
        return true;
    }
}