|
// 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
{
}
|