| File: src\Aspire.Tray\Common\FileTraySavedStateStore.cs | Web Access |
| Project: src\src\Aspire.Tray\Windows\Aspire.Tray.Windows.csproj (aspire-tray) |
// 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 Aspire.Shared; namespace Aspire.Tray; /// <summary> /// Atomically persists history in a private, explicitly selected per-user state directory. /// </summary> internal sealed class FileTraySavedStateStore : ITraySavedStateStore { private const int MaximumFileBytes = 1024 * 1024; private readonly string _path; private readonly string _directory; private readonly string? _legacyPath; private bool _loaded; private bool _writable; private byte[]? _lastContents; public FileTraySavedStateStore(string path) : this(path, null) { } private FileTraySavedStateStore(string path, string? legacyPath) { _path = TrayAppHostPath.Normalize(path); _directory = Path.GetDirectoryName(_path) ?? throw new ArgumentException("A saved-state file path is required.", nameof(path)); _legacyPath = legacyPath; } public static FileTraySavedStateStore CreateWithLegacyMigration(string path, string legacyDirectory) => new(path, Path.Combine(TrayAppHostPath.Normalize(legacyDirectory), "apphosts.json")); public TraySavedState Load() { _loaded = true; _writable = false; var contents = ReadContents(); // Prefer the per-user file even when empty. Import old preferences only when it // is absent, leaving the original intact for older tray builds. var migrate = contents is null && _legacyPath is not null; // Only paths and flags are accepted: // {"appHosts":[{"appHostPath":"/src/shop/apphost.cs","isPinned":true,"isRecent":true}],"confirmStop":false} // Older files omit confirmStop; the JSON constructor retains confirmation. // Array order is recency; no timestamp, PID, or dashboard URL is persisted. var state = contents is null ? TraySavedState.Empty : JsonSerializer.Deserialize(contents, TraySavedStateJsonContext.Default.TraySavedState) ?? throw new InvalidDataException("The saved AppHost state is invalid."); Validate(state); if (migrate) { var legacyStore = new FileTraySavedStateStore(_legacyPath!); if (legacyStore.ReadContents() is not null) { state = legacyStore.Load(); } else { migrate = false; } } _lastContents = contents; _writable = true; if (migrate) { Save(state); } return state; } public void Save(TraySavedState state) { if (!_loaded) { Load(); } if (!_writable) { throw new InvalidDataException("Saved AppHost state could not be loaded; the file was left unchanged."); } Validate(state); var current = ReadContents(); if (!SameContents(current, _lastContents)) { _writable = false; throw new IOException("Saved AppHost state changed outside the tray; the file was left unchanged."); } var contents = JsonSerializer.SerializeToUtf8Bytes(state, TraySavedStateJsonContext.Default.TraySavedState); if (contents.Length > MaximumFileBytes) { throw new InvalidDataException("Saved AppHost state exceeds the size limit."); } DirectoryHelper.CreateWithOwnerOnlyPermissions(_directory); // A private same-directory temporary file keeps replacement on the same filesystem. // CreateNew avoids following a pre-existing link; rename never truncates the original. var temporaryPath = Path.Combine(_directory, $".history-{Guid.NewGuid():N}.tmp"); try { var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }; if (!OperatingSystem.IsWindows()) { options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; } using (var stream = new FileStream(temporaryPath, options)) { stream.Write(contents); stream.Flush(flushToDisk: true); } File.Move(temporaryPath, _path, overwrite: true); _lastContents = contents; } finally { File.Delete(temporaryPath); } } private byte[]? ReadContents() { RejectLink(new DirectoryInfo(_directory)); RejectLink(new FileInfo(_path)); try { using var stream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.Read); if (stream.Length > MaximumFileBytes) { throw new InvalidDataException("Saved AppHost state exceeds the size limit."); } var contents = new byte[(int)stream.Length]; stream.ReadExactly(contents); return contents; } catch (FileNotFoundException) { return null; } catch (DirectoryNotFoundException) { return null; } } private static void RejectLink(FileSystemInfo entry) { if (entry.LinkTarget is not null || (entry.Exists && (entry.Attributes & FileAttributes.ReparsePoint) != 0)) { throw new IOException("Saved AppHost state must not use symbolic links."); } } private static bool SameContents(byte[]? left, byte[]? right) => left is null ? right is null : right is not null && left.AsSpan().SequenceEqual(right); private static void Validate(TraySavedState state) { if (state.AppHosts is null) { throw new InvalidDataException("The saved AppHost state is invalid."); } var paths = new HashSet<string>(TrayAppHostPath.Comparer); var recent = 0; foreach (var host in state.AppHosts) { if (host is null || (!host.IsPinned && !host.IsRecent) || string.IsNullOrWhiteSpace(host.AppHostPath) || !Path.IsPathFullyQualified(host.AppHostPath) || host.AppHostPath.Contains('\0')) { throw new InvalidDataException("The saved AppHost state is invalid."); } var normalized = TrayAppHostPath.Normalize(host.AppHostPath); if (!string.Equals(normalized, host.AppHostPath, StringComparison.Ordinal) || !paths.Add(normalized) || (host.IsRecent && ++recent > TraySavedState.MaximumRecentAppHosts)) { throw new InvalidDataException("The saved AppHost state is invalid."); } } } } [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] [JsonSerializable(typeof(TraySavedState))] internal sealed partial class TraySavedStateJsonContext : JsonSerializerContext { }