File: SdkVulnerability\SdkReleaseMetadataCache.cs
Web Access
Project: src\src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// 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.Json;
using System.Text.Json.Serialization;
using Microsoft.Deployment.DotNet.Releases;
using Microsoft.DotNet.Configurer;

namespace Microsoft.DotNet.Cli.SdkVulnerability;

/// <summary>
/// Manages a local cache of .NET release metadata used for SDK vulnerability and EOL checks.
/// The cache lives at ~/.dotnet/sdk-vulnerability-cache/ and is refreshed in the background
/// on a sentinel-based interval (default 24 hours).
/// </summary>
internal sealed class SdkReleaseMetadataCache
{
    private const string CacheDirectoryName = "sdk-vulnerability-cache";
    private const string SentinelFileName = ".sentinel";
    private const string SummaryFilePrefix = "sdk-status-";
    private const int DefaultUpdateIntervalHours = 24;

    private readonly string _cacheDirectory;
    private readonly Func<string, string?> _getEnvironmentVariable;

    public SdkReleaseMetadataCache()
        : this(
            Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, CacheDirectoryName),
            Environment.GetEnvironmentVariable)
    {
    }

    internal SdkReleaseMetadataCache(string cacheDirectory, Func<string, string?> getEnvironmentVariable)
    {
        _cacheDirectory = cacheDirectory;
        _getEnvironmentVariable = getEnvironmentVariable;
    }

    /// <summary>
    /// Returns true if the vulnerability check is disabled via environment variable.
    /// </summary>
    public bool IsDisabled()
    {
        return bool.TryParse(_getEnvironmentVariable(EnvironmentVariableNames.SDK_VULNERABILITY_CHECK_DISABLE), out bool disabled) && disabled;
    }

    /// <summary>
    /// Returns true if the sentinel file indicates the cache is due for a refresh.
    /// </summary>
    public bool IsDueForUpdate()
    {
        var sentinelPath = Path.Combine(_cacheDirectory, SentinelFileName);

        if (!int.TryParse(_getEnvironmentVariable(EnvironmentVariableNames.SDK_VULNERABILITY_CHECK_INTERVAL_HOURS), out int updateIntervalHours)
            || updateIntervalHours < 1)
        {
            updateIntervalHours = DefaultUpdateIntervalHours;
        }

        if (File.Exists(sentinelPath))
        {
            var lastWriteTime = File.GetLastWriteTimeUtc(sentinelPath);
            if (lastWriteTime.AddHours(updateIntervalHours) > DateTime.UtcNow)
            {
                return false;
            }
        }

        return true;
    }

    /// <summary>
    /// Reads a previously cached vulnerability summary for the given SDK version.
    /// Returns null if no cached data is available.
    /// </summary>
    public SdkVulnerabilityInfo? ReadCachedSummary(string sdkVersion)
    {
        if (sdkVersion.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
        {
            return null;
        }

        var summaryPath = GetSummaryPath(sdkVersion);
        if (!File.Exists(summaryPath))
        {
            return null;
        }

        try
        {
            var json = File.ReadAllText(summaryPath);
            return JsonSerializer.Deserialize(json, SdkVulnerabilityJsonContext.Default.SdkVulnerabilityInfo);
        }
        catch
        {
            return null;
        }
    }

    /// <summary>
    /// Fetches release metadata, computes vulnerability status for the given SDK version,
    /// writes the result to cache, and updates the sentinel. This is intended to be called
    /// from a background task.
    /// </summary>
    public async Task UpdateCacheAsync(string sdkVersion, CancellationToken cancellationToken = default)
    {
        ProductCollection productCollection = await ProductCollection.GetAsync().ConfigureAwait(false);
        cancellationToken.ThrowIfCancellationRequested();

        if (!ReleaseVersion.TryParse(sdkVersion, out ReleaseVersion? parsedVersion))
        {
            TouchSentinel();
            return;
        }

        string channelVersion = $"{parsedVersion.Major}.{parsedVersion.Minor}";
        Product? product = productCollection
            .FirstOrDefault(p => p.ProductVersion.Equals(channelVersion));

        if (product is null)
        {
            TouchSentinel();
            return;
        }

        IEnumerable<ProductRelease> releases;
        try
        {
            releases = await product.GetReleasesAsync().ConfigureAwait(false);
        }
        catch
        {
            releases = [];
        }
        cancellationToken.ThrowIfCancellationRequested();

        SdkVulnerabilityInfo? info = SdkVulnerabilityChecker.Check(sdkVersion, productCollection, _ => releases);
        if (info is null)
        {
            TouchSentinel();
            return;
        }

        WriteSummary(sdkVersion, info);
    }

    private void WriteSummary(string sdkVersion, SdkVulnerabilityInfo info)
    {
        try
        {
            Directory.CreateDirectory(_cacheDirectory);
            var summaryPath = GetSummaryPath(sdkVersion);
            var json = JsonSerializer.Serialize(info, SdkVulnerabilityJsonContext.Default.SdkVulnerabilityInfo);

            // Atomic write: write to temp file then move, so concurrent readers
            // (e.g., the MSBuild task) never see partially-written JSON.
            var tempPath = Path.Combine(_cacheDirectory, Path.GetRandomFileName());
            File.WriteAllText(tempPath, json);
            File.Move(tempPath, summaryPath, overwrite: true);

            // Only mark the cache as fresh after a successful write.
            TouchSentinel();
        }
        catch
        {
            // Silently fail — cache write failure should not break the CLI
        }
    }

    private void TouchSentinel()
    {
        try
        {
            var sentinelPath = Path.Combine(_cacheDirectory, SentinelFileName);
            Directory.CreateDirectory(_cacheDirectory);
            if (File.Exists(sentinelPath))
            {
                File.SetLastWriteTimeUtc(sentinelPath, DateTime.UtcNow);
            }
            else
            {
                File.Create(sentinelPath).Close();
            }
        }
        catch
        {
            // Silently fail
        }
    }

    private string GetSummaryPath(string sdkVersion) =>
        Path.Combine(_cacheDirectory, $"{SummaryFilePrefix}{sdkVersion}.json");
}

[JsonSerializable(typeof(SdkVulnerabilityInfo))]
[JsonSerializable(typeof(SdkCveInfo))]
internal partial class SdkVulnerabilityJsonContext : JsonSerializerContext
{
}