| File: SdkVulnerability\SdkReleaseMetadataCache.cs | Web Access |
| Project: 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 { }