diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f2f252b..78a6ccb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,11 +60,15 @@ jobs: - name: Verify Sandbox report contract (Windows PowerShell 5.1) shell: powershell - run: ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 + run: | + ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 + ./ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 - name: Verify Sandbox report contract (PowerShell 7) shell: pwsh - run: ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 + run: | + ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1 + ./ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1 - name: Build Release run: dotnet build ClashSharp/ClashSharp.slnx -c Release -p:Platform=x64 --no-restore diff --git a/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.Services.cs b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.Services.cs new file mode 100644 index 00000000..b670d1f6 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.Services.cs @@ -0,0 +1,31 @@ +namespace ClashSharp.ApplicationModel.Data; + +public sealed partial class DataGenerationManager +{ + /// Resolves a generation-owned service and pins its complete asynchronous operation before transition can retire it. + /// Service provided by the scope's owned lifetime through . + /// Immutable operation result that can outlive the scope. + /// Owned operation; it must await all work and must not return a service or live repository handle. + /// Cancels acquisition and is passed to the owned operation without abandoning its task. + public async Task ExecuteAsync( + Func> operation, + CancellationToken cancellationToken) where TService : class + { + ArgumentNullException.ThrowIfNull(operation); + await using DataGenerationLease lease = await AcquireAsync(cancellationToken).ConfigureAwait(false); + TService service = lease.Scope.GetOwnedService(); + return await operation(service, lease.Descriptor, cancellationToken).ConfigureAwait(false); + } + + /// Captures an immutable in-memory projection under a short synchronous generation pin without blocking on asynchronous work. + /// Service provided by this generation's owned lifetime. + /// Immutable projection that can outlive the scope. + /// Pure synchronous reader; it must not perform I/O, start tasks, or return a live service or repository. + public TResult ReadSnapshot(Func capture) + where TService : class + { + ArgumentNullException.ThrowIfNull(capture); + using DataGenerationLease lease = AcquireCore(CancellationToken.None); + return capture(lease.Scope.GetOwnedService(), lease.Descriptor); + } +} diff --git a/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs index 35117e90..3377ec11 100644 --- a/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs +++ b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs @@ -72,6 +72,9 @@ public void Initialize( /// Cancels acquisition before a lease is granted. /// A lease that must cover the complete repository operation. public ValueTask AcquireAsync(CancellationToken cancellationToken) + => ValueTask.FromResult(AcquireCore(cancellationToken)); + + private DataGenerationLease AcquireCore(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); lock (_syncLock) @@ -93,7 +96,7 @@ public ValueTask AcquireAsync(CancellationToken cancellatio _leaseCount++; } - return ValueTask.FromResult(new DataGenerationLease(this, _currentScope)); + return new DataGenerationLease(this, _currentScope); } } diff --git a/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs b/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs index bf6165b3..e468f682 100644 --- a/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs +++ b/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs @@ -12,7 +12,7 @@ public sealed class DataGenerationScope : IAsyncDisposable /// Initializes a paused scope without starting work or touching the filesystem. /// Immutable generation descriptor. - /// Optional composite repository lifetime transferred to this scope. + /// Optional composite repository lifetime transferred to this scope; scoped service access also requires it to implement . public DataGenerationScope( DataGenerationDescriptor descriptor, IAsyncDisposable? ownedLifetime = null) @@ -37,6 +37,17 @@ public DataGenerationScopeState State } } + internal TService GetOwnedService() where TService : class + { + if (_ownedLifetime is not IServiceProvider provider + || provider.GetService(typeof(TService)) is not TService service) + { + throw new InvalidOperationException("The requested service is not registered in this generation's owned lifetime."); + } + + return service; + } + /// Disposes an unclaimed staged scope; claimed scopes remain owner-controlled. public ValueTask DisposeAsync() { diff --git a/ClashSharp/ClashSharp.Application/Data/RepositoryOperationLifetime.cs b/ClashSharp/ClashSharp.Application/Data/RepositoryOperationLifetime.cs new file mode 100644 index 00000000..df6e6fde --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Data/RepositoryOperationLifetime.cs @@ -0,0 +1,65 @@ +namespace ClashSharp.ApplicationModel.Data; + +/// Retires a repository only after every accepted operation has completed its full storage work. +public sealed class RepositoryOperationLifetime : IAsyncDisposable +{ + private readonly object _gate = new(); + private readonly object _repository; + private readonly TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _operations; + private bool _retired; + + /// Creates a lifetime without accessing storage or accepting any operations. + /// Repository identified by a rejection after retirement. + public RepositoryOperationLifetime(object repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + /// Accepts one operation whose lease must span all asynchronous work and compensation. + /// An idempotent lease released when the complete operation leaves the repository. + public IDisposable Enter() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_retired, _repository); + _operations++; + return new Operation(this); + } + } + + /// Rejects new operations immediately and asynchronously waits for accepted operations to drain. + /// The shared completion of repository retirement. + public ValueTask DisposeAsync() + { + lock (_gate) + { + _retired = true; + if (_operations == 0) + { + _drained.TrySetResult(); + } + + return new ValueTask(_drained.Task); + } + } + + private void Leave() + { + lock (_gate) + { + _operations--; + if (_retired && _operations == 0) + { + _drained.TrySetResult(); + } + } + } + + private sealed class Operation(RepositoryOperationLifetime owner) : IDisposable + { + private RepositoryOperationLifetime? _owner = owner; + + public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Leave(); + } +} diff --git a/ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs b/ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs new file mode 100644 index 00000000..2010c125 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs @@ -0,0 +1,125 @@ +using ClashSharp.ApplicationModel.Diagnostics; + +namespace ClashSharp.ApplicationModel.Presentation; + +/// Owns queued synchronous UI operations until they finish or are revoked before execution. +/// The containing scope retires this owner before releasing its window and generation. +public sealed class OwnedUiDispatcher : IAsyncDisposable +{ + private readonly object _gate = new(); + private readonly Func _hasThreadAccess; + private readonly Func _tryEnqueue; + private readonly HashSet _operations = []; + private readonly CancellationTokenRegistration _windowLifetime; + private bool _closed; + private Task? _retirement; + + /// Creates a dispatcher boundary without accessing the platform or scheduling work. + /// Reports access to the owning UI thread. + /// Queues one callback; rejection never permits a fallback on another thread. + /// Revoked when the window can no longer execute queued work. + public OwnedUiDispatcher(Func hasThreadAccess, Func tryEnqueue, CancellationToken windowLifetime) + { + _hasThreadAccess = hasThreadAccess ?? throw new ArgumentNullException(nameof(hasThreadAccess)); + _tryEnqueue = tryEnqueue ?? throw new ArgumentNullException(nameof(tryEnqueue)); + _windowLifetime = windowLifetime.Register(static state => ((OwnedUiDispatcher)state!).Close(), this); + } + + /// Waits for the actual callback, including after cancellation or a lost enqueue reply. + /// The immutable result captured on the UI thread. + /// Synchronous work that must not launch unowned asynchronous operations. + /// Cancels before the callback starts effects. + /// The callback's result or original failure. + public Task InvokeAsync(Func action, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(action); + cancellationToken.ThrowIfCancellationRequested(); + QueuedOperation operation = new(this, action, cancellationToken); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_closed, this); + _operations.Add(operation); + } + try + { + if (_hasThreadAccess()) { operation.Run(); } + else if (!_tryEnqueue(operation.Run)) { operation.Reject(new InvalidOperationException("The UI dispatcher rejected the operation.")); } + } + catch (Exception exception) + { + // A throwing enqueue may already have handed the callback to the queue. Revoke + // only unstarted work; a callback already executing retains its completion owner. + operation.Reject(exception); + if (ExceptionGraphClassifier.IsProcessFatal(exception)) { throw; } + } + return operation.Result; + } + + /// Rejects new and unstarted work, then drains every callback that has begun execution. + public ValueTask DisposeAsync() + { + lock (_gate) { return new(_retirement ??= RetireAsync()); } + } + + private async Task RetireAsync() + { + Task[] pending = Close(); + await _windowLifetime.DisposeAsync().ConfigureAwait(false); + foreach (Task completion in pending) + { + try { await completion.ConfigureAwait(false); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { } + } + } + + private Task[] Close() + { + Operation[] operations; + lock (_gate) { _closed = true; operations = _operations.ToArray(); } + foreach (Operation operation in operations) { operation.Reject(new ObjectDisposedException(nameof(OwnedUiDispatcher))); } + return operations.Select(operation => operation.Completion).ToArray(); + } + + private void Complete(Operation operation) { lock (_gate) { _operations.Remove(operation); } } + + private abstract class Operation + { + public abstract Task Completion { get; } + public abstract void Reject(Exception exception); + } + + private sealed class QueuedOperation(OwnedUiDispatcher owner, Func action, CancellationToken cancellationToken) : Operation + { + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _state; + public Task Result => _completion.Task; + public override Task Completion => _completion.Task; + + public void Run() + { + if (Interlocked.CompareExchange(ref _state, 1, 0) != 0) { return; } + try + { + cancellationToken.ThrowIfCancellationRequested(); + _completion.TrySetResult(action()); + } + catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested && exception.CancellationToken == cancellationToken) + { + _completion.TrySetCanceled(cancellationToken); + } + catch (Exception exception) + { + _completion.TrySetException(exception); + if (ExceptionGraphClassifier.IsProcessFatal(exception)) { throw; } + } + finally { Volatile.Write(ref _state, 2); owner.Complete(this); } + } + + public override void Reject(Exception exception) + { + if (Interlocked.CompareExchange(ref _state, 2, 0) != 0) { return; } + _completion.TrySetException(exception); + owner.Complete(this); + } + } +} diff --git a/ClashSharp/ClashSharp.Application/Security/ControllerCredentialException.cs b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialException.cs new file mode 100644 index 00000000..52e76dc0 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialException.cs @@ -0,0 +1,16 @@ +namespace ClashSharp.ApplicationModel.Security; + +/// Reports an unavailable credential using a stable code without carrying private storage values. +public sealed class ControllerCredentialException : InvalidOperationException +{ + /// Creates a value-free credential failure. + /// Stable diagnostic code. + public ControllerCredentialException(string code) : base(code) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + Code = code; + } + + /// Gets the stable failure code. + public string Code { get; } +} diff --git a/ClashSharp/ClashSharp.Application/Security/ControllerCredentialService.cs b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialService.cs new file mode 100644 index 00000000..7e8b70e0 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialService.cs @@ -0,0 +1,107 @@ +using System.Security.Cryptography; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Security; + +namespace ClashSharp.ApplicationModel.Security; + +/// Owns verified initialization and explicit deletion of the process controller credential. +/// Constructors and runtime reads have no storage effects. Only admitted startup and data deletion may write. +public sealed class ControllerCredentialService : IControllerCredentialProvider, IDisposable +{ + private readonly object _gate = new(); + private readonly IControllerCredentialStore _store; + private readonly MutationAdmissionBarrier _admission; + private string? _secret; + private bool _disposed; + + /// Creates an uninitialized credential owner without opening storage. + /// Independent private credential slot. + /// Process-wide mutation admission. + public ControllerCredentialService(IControllerCredentialStore store, MutationAdmissionBarrier admission) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + } + + /// + public string GetSecret() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _secret ?? throw new ControllerCredentialException("controller.credential.not_initialized"); + } + } + + /// Loads an existing valid credential or creates and independently verifies its durable replacement. + /// Active startup authority retained through verification, including a lost write reply. + /// Cancels before the first storage mutation; verification after a write is mandatory. + public void InitializeAdmitted(MutationAdmissionLease lease, CancellationToken cancellationToken) + { + _admission.EnsureActiveLease(lease); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + if (_secret is not null) { return; } + if (TryRead(out string? stored) && ControllerCredentialPolicy.IsValid(stored)) + { + _secret = stored; + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + _admission.EnsureActiveLease(lease); + string generated = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)); + bool failed = false; + try { _store.Write(generated); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { failed = true; } + if (!TryRead(out string? verified) || !StringComparer.Ordinal.Equals(generated, verified)) + { + throw new ControllerCredentialException(failed + ? "controller.credential.write_failed" : "controller.credential.verification_failed"); + } + + _secret = verified; + } + } + + /// Invalidates the cached credential and verifies removal after the runtime has been stopped. + /// Active data-deletion authority; ordinary settings resets must never call this method. + /// Cancels before deletion begins; absence must be verified after the attempt. + public void ClearAdmitted(MutationAdmissionLease lease, CancellationToken cancellationToken) + { + _admission.EnsureActiveLease(lease); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + _admission.EnsureActiveLease(lease); + _secret = null; + bool failed = false; + try { _store.Delete(); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { failed = true; } + if (TryRead(out _)) + { + throw new ControllerCredentialException(failed + ? "controller.credential.delete_failed" : "controller.credential.delete_not_verified"); + } + } + } + + /// Retires the process projection without changing durable storage. + public void Dispose() + { + lock (_gate) { _disposed = true; _secret = null; } + } + + private bool TryRead(out string? secret) + { + try { return _store.TryRead(out secret); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) + { + throw new ControllerCredentialException("controller.credential.read_failed"); + } + } +} diff --git a/ClashSharp/ClashSharp.Application/Security/IControllerCredentialProvider.cs b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialProvider.cs new file mode 100644 index 00000000..90fa10aa --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialProvider.cs @@ -0,0 +1,9 @@ +namespace ClashSharp.ApplicationModel.Security; + +/// Supplies the independently initialized App-owned controller credential to runtime consumers. +public interface IControllerCredentialProvider +{ + /// Reads the verified process credential without opening storage or acquiring mutation admission. + /// The private credential; callers must not log, export, or include it in preference snapshots. + string GetSecret(); +} diff --git a/ClashSharp/ClashSharp.Application/Security/IControllerCredentialStore.cs b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialStore.cs new file mode 100644 index 00000000..656e6ae8 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialStore.cs @@ -0,0 +1,17 @@ +namespace ClashSharp.ApplicationModel.Security; + +/// Persists exactly one controller credential outside the preferences authority. +public interface IControllerCredentialStore +{ + /// Reads the credential slot without enumerating user preferences. + /// Stored string, or null when absent or present with an invalid storage type. + /// Whether the slot exists; a present invalid value remains distinct from absence. + bool TryRead(out string? secret); + + /// Writes one canonical private credential; return alone does not prove persistence. + /// Canonical lowercase hexadecimal credential. + void Write(string secret); + + /// Removes only the credential slot; callers independently verify absence. + void Delete(); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/AccentColorConfiguration.cs b/ClashSharp/ClashSharp.Application/Settings/AccentColorConfiguration.cs new file mode 100644 index 00000000..66f2947c --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/AccentColorConfiguration.cs @@ -0,0 +1,31 @@ +using ClashSharp.Model; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Contains the configured accent policy and canonical custom color, including the retained color while following Windows. +public sealed record AccentColorConfiguration +{ + /// Normalizes one complete accent selection without touching platform resources. + /// A declared accent policy. + /// A valid custom ARGB color. + public AccentColorConfiguration(AppAccentColorMode mode, string colorValue) + { + if (!Enum.IsDefined(mode)) { throw new ArgumentOutOfRangeException(nameof(mode)); } + SettingNormalizationResult normalized = SettingsRegistry.Default.Get(SettingsRegistry.Keys.AppAccentColorValue.Value).Normalize(colorValue); + if (!normalized.IsSuccess) { throw new ArgumentException("The accent color is invalid.", nameof(colorValue)); } + Mode = mode; + ColorValue = normalized.Value!.Get(); + } + + /// Gets the installed accent policy. + public AppAccentColorMode Mode { get; } + + /// Gets the normalized configured custom color. + public string ColorValue { get; } +} + +/// Describes one color or fully opaque solid-brush resource independently of WinUI objects. +/// The exact ARGB channels; color alpha is independent of brush opacity. +/// Whether the resource must be a solid brush instead of a color value. +public readonly record struct AccentResourceValue(uint Argb, bool IsBrush); diff --git a/ClashSharp/ClashSharp.Application/Settings/AccentColorRuntime.cs b/ClashSharp/ClashSharp.Application/Settings/AccentColorRuntime.cs new file mode 100644 index 00000000..a41aaf26 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/AccentColorRuntime.cs @@ -0,0 +1,91 @@ +using System.Collections.ObjectModel; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.Model; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Records accent application only after independently observing the complete resource palette. +/// All calls are made on the resource dictionary's owning UI thread. Construction performs no platform access. +public sealed class AccentColorRuntime +{ + private readonly IAccentResourceStore _resources; + private readonly IReadOnlyCollection _ownedKeys; + private readonly Func> _buildPalette; + private Palette _verified; + private Palette? _attempted; + + /// Creates an owner with a declared default policy that still requires a real resource observation. + /// The actual application dictionary boundary. + /// Every primary resource key this owner can change. + /// Pure complete-palette construction; follow-system selections produce no overrides. + public AccentColorRuntime(IAccentResourceStore resources, IEnumerable ownedKeys, + Func> buildPalette) + { + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + ArgumentNullException.ThrowIfNull(ownedKeys); + string[] keys = ownedKeys.ToArray(); + if (keys.Length == 0 || keys.Any(string.IsNullOrWhiteSpace) || keys.Distinct(StringComparer.Ordinal).Count() != keys.Length) + { + throw new ArgumentException("Accent resource keys must be nonempty and unique.", nameof(ownedKeys)); + } + _ownedKeys = Array.AsReadOnly(keys); + _buildPalette = buildPalette ?? throw new ArgumentNullException(nameof(buildPalette)); + _verified = Build(new(AppAccentColorMode.FollowSystem, "#FF0078D4")); + } + + /// Reads configured state only when the actual complete resource set supports that state. + /// The independently verified current selection. + public AccentColorConfiguration CaptureConfiguration() + { + IReadOnlyDictionary actual = _resources.CaptureLocalOverrides(_ownedKeys); + if (_attempted is not null && Matches(_attempted, actual)) { _verified = _attempted; _attempted = null; } + if (!Matches(_verified, actual)) { throw new InvalidOperationException("The application accent resources are not verified."); } + return _verified.Configuration; + } + + /// Writes a complete palette, then reads every owned override before acknowledging installation. + /// The complete canonical selection. + public void Apply(AccentColorConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + Palette target = Build(configuration); + // Confirm availability before the first effect. An attempted palette is only a candidate + // for later independent observation, never evidence that a write succeeded. + _ = _resources.CaptureLocalOverrides(_ownedKeys); + _attempted = target; + Exception? writeFailure = null; + try + { + foreach (string key in _ownedKeys) + { + if (target.Values.TryGetValue(key, out AccentResourceValue value)) { _resources.WriteOverride(key, value); } + else { _resources.RemoveOverride(key); } + } + } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { writeFailure = exception; } + + if (!Matches(target, _resources.CaptureLocalOverrides(_ownedKeys))) + { + if (writeFailure is not null) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(writeFailure).Throw(); } + throw new InvalidOperationException("The complete application accent palette could not be verified."); + } + _verified = target; + _attempted = null; + } + + private Palette Build(AccentColorConfiguration configuration) + { + Dictionary values = _buildPalette(configuration).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + if (values.Keys.Any(key => !_ownedKeys.Contains(key, StringComparer.Ordinal)) + || values.Count != (configuration.Mode == AppAccentColorMode.FollowSystem ? 0 : _ownedKeys.Count)) + { + throw new InvalidOperationException("The accent palette does not cover the declared resource contract."); + } + return new(configuration, new ReadOnlyDictionary(values)); + } + + private static bool Matches(Palette target, IReadOnlyDictionary actual) => + actual.Count == target.Values.Count && target.Values.All(pair => actual.TryGetValue(pair.Key, out AccentResourceValue? observed) && observed == pair.Value); + + private sealed record Palette(AccentColorConfiguration Configuration, IReadOnlyDictionary Values); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsParticipant.cs b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsParticipant.cs new file mode 100644 index 00000000..1539c074 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsParticipant.cs @@ -0,0 +1,175 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.ApplicationModel.Presentation; +using ClashSharp.Model; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Applies complete appearance batches on the owning UI thread and independently observes their results. +public sealed class AppearanceSettingsParticipant : ISettingsApplicationParticipant, IAppearanceSettingsReader, IAsyncDisposable +{ + private static readonly HashSet NativeKeys = + [SettingsRegistry.Keys.DisplayLanguage, SettingsRegistry.Keys.AppThemeMode, + SettingsRegistry.Keys.AppAccentColorMode, SettingsRegistry.Keys.AppAccentColorValue]; + private readonly object _lifetimeGate = new(); + private readonly DataGenerationDescriptor _generation; + private readonly MutationAdmissionBarrier _admission; + private readonly OwnedUiDispatcher _dispatcher; + private readonly IAppearanceNativeSettings _native; + private readonly IReadOnlyDictionary _definitions; + private AppearanceSettingsSnapshot? _installed; + private int _retiring; + private Task? _retirement; + + /// Creates a pure configuration owner; the caller transfers this generation's dispatcher lifetime. + /// The exact generation that owns the installed policies. + /// Process-wide admission retained by the settings authority. + /// The same canonical registry used by the settings session. + /// A dedicated operation owner bound to the live window. + /// The actual UI configuration boundary. + public AppearanceSettingsParticipant(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, + SettingsRegistry registry, OwnedUiDispatcher dispatcher, IAppearanceNativeSettings native) + { + _generation = generation ?? throw new ArgumentNullException(nameof(generation)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _native = native ?? throw new ArgumentNullException(nameof(native)); + ArgumentNullException.ThrowIfNull(registry); + _definitions = registry.Definitions.Where(definition => definition.ApplicationKind == SettingApplicationKind.Appearance) + .ToDictionary(definition => definition.Key); + if (_definitions.Count <= NativeKeys.Count || NativeKeys.Any(key => !_definitions.TryGetValue(key, out SettingDefinition? definition) + || definition.ValueType != SettingsRegistry.Default.Get(key.Value).ValueType) + || _definitions.Values.Any(definition => definition.Authority != SettingAuthority.Internal)) + { + throw new ArgumentException("Appearance requires canonical UI definitions and application-owned consumer policies.", nameof(registry)); + } + _installed = new(_generation, _definitions.Where(pair => !NativeKeys.Contains(pair.Key)) + .Select(pair => KeyValuePair.Create(pair.Key, pair.Value.DefaultValue))); + } + + /// + public SettingApplicationKind ApplicationKind => SettingApplicationKind.Appearance; + /// + public AppearanceSettingsSnapshot CaptureSnapshot() => Volatile.Read(ref _installed) + ?? throw new ObjectDisposedException(nameof(AppearanceSettingsParticipant)); + + /// + public Task ProbeAsync(SettingsApplicationRequest request, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + Validate(request, admissionLease); + return _dispatcher.InvokeAsync(() => + { + Validate(request, admissionLease); + AppearanceNativeConfiguration native = _native.CaptureConfiguration(); + AppearanceSettingsSnapshot installed = CaptureSnapshot(); + return new SettingsApplicationObservation(_generation, request.Batch.BatchId, request.Batch.AttemptId, + request.Values.Keys.Select(key => new SettingValueChange(key, Read(key, native, installed)))); + }, cancellationToken); + } + + /// + public Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + Validate(request, admissionLease); + return _dispatcher.InvokeAsync(() => + { + Validate(request, admissionLease); + AppearanceNativeConfiguration before = _native.CaptureConfiguration(); + AppearanceSettingsSnapshot installed = CaptureSnapshot(); + T Target(SettingKey key) where T : notnull => request.Values.TryGetValue(key, out SettingValue? value) + ? value.Get() : Read(key, before, installed).Get(); + AppearanceNativeConfiguration target = new(Target(SettingsRegistry.Keys.DisplayLanguage), + Target(SettingsRegistry.Keys.AppThemeMode), + new(Target(SettingsRegistry.Keys.AppAccentColorMode), Target(SettingsRegistry.Keys.AppAccentColorValue))); + Dictionary policies = installed.Values.ToDictionary(); + foreach ((SettingKey key, SettingValue value) in request.Values.Where(pair => !NativeKeys.Contains(pair.Key))) { policies[key] = value; } + AppearanceSettingsSnapshot next = new(_generation, policies); + ApplyNative(before, target); + Volatile.Write(ref _installed, next); + return true; + }, cancellationToken); + } + + /// Retires queued work and drains started UI callbacks before withdrawing the consumer snapshot. + public ValueTask DisposeAsync() + { + lock (_lifetimeGate) { return new(_retirement ??= RetireAsync()); } + } + + private async Task RetireAsync() + { + Volatile.Write(ref _retiring, 1); + try { await _dispatcher.DisposeAsync().ConfigureAwait(false); } + finally { Volatile.Write(ref _installed, null); } + } + + private void ApplyNative(AppearanceNativeConfiguration before, AppearanceNativeConfiguration target) + { + try + { + ApplyNativeDifference(before, target); + if (_native.CaptureConfiguration() != target) { throw new InvalidOperationException("The requested appearance could not be verified."); } + } + catch (Exception failure) when (!ExceptionGraphClassifier.IsProcessFatal(failure)) + { + if (TryObserve(target)) { return; } + if (TryObserve(before)) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw(); } + List failures = [failure]; + // These effects only replace this application's UI configuration. Recover the + // observed baseline so an explicit retry can start from independently known state. + Restore(() => { if (before.Language != target.Language) { _native.ApplyLanguage(before.Language); } }, failures); + Restore(() => { if (before.Theme != target.Theme) { _native.ApplyTheme(before.Theme); } }, failures); + Restore(() => { if (before.Accent != target.Accent) { _native.ApplyAccent(before.Accent); } }, failures); + if (TryObserve(before)) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw(); } + failures.Add(new InvalidOperationException("The previous appearance could not be independently restored.")); + throw new AggregateException("Appearance application and recovery did not reach a verified configuration.", failures); + } + } + + private void ApplyNativeDifference(AppearanceNativeConfiguration before, AppearanceNativeConfiguration target) + { + if (before.Accent != target.Accent) { _native.ApplyAccent(target.Accent); } + if (before.Theme != target.Theme) { _native.ApplyTheme(target.Theme); } + if (before.Language != target.Language) { _native.ApplyLanguage(target.Language); } + } + + private bool TryObserve(AppearanceNativeConfiguration expected) + { + try { return _native.CaptureConfiguration() == expected; } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { return false; } + } + + private static void Restore(Action action, ICollection failures) + { + try { action(); } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { failures.Add(exception); } + } + + private SettingValue Read(SettingKey key, AppearanceNativeConfiguration native, AppearanceSettingsSnapshot installed) + { + if (!NativeKeys.Contains(key)) { return installed.Values[key]; } + object value = key == SettingsRegistry.Keys.DisplayLanguage ? native.Language + : key == SettingsRegistry.Keys.AppThemeMode ? native.Theme + : key == SettingsRegistry.Keys.AppAccentColorMode ? native.Accent.Mode : native.Accent.ColorValue; + SettingNormalizationResult normalized = _definitions[key].NormalizeValue(value); + return normalized.IsSuccess ? normalized.Value! + : throw new InvalidOperationException("The UI returned a noncanonical appearance value."); + } + + private void Validate(SettingsApplicationRequest request, MutationAdmissionLease lease) + { + ArgumentNullException.ThrowIfNull(request); + _admission.EnsureActiveLease(lease); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _retiring) != 0, this); + if (request.Phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(lease); } + if (!_generation.IsSameGeneration(request.Generation) || request.Batch.ApplicationKind != ApplicationKind || request.Values.Count == 0 + || request.Values.Any(pair => !_definitions.TryGetValue(pair.Key, out SettingDefinition? definition) + || !pair.Value.Equals(definition.Normalize(pair.Value.CanonicalText).Value))) + { + throw new InvalidOperationException("The appearance attempt does not match its generation, participant or canonical values."); + } + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsSnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsSnapshot.cs new file mode 100644 index 00000000..933e84dd --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsSnapshot.cs @@ -0,0 +1,34 @@ +using System.Collections.ObjectModel; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Reads the installed tray, regional and tile configuration without exposing preference mutation or platform access. +public interface IAppearanceSettingsReader +{ + /// Captures one immutable configuration from the owning generation. + AppearanceSettingsSnapshot CaptureSnapshot(); +} + +/// Contains the installed policies consumed by tray, regional display and tile layout services. +/// Native language, theme and accent observations belong to the UI adapter and are not cached here. +public sealed class AppearanceSettingsSnapshot +{ + internal AppearanceSettingsSnapshot(DataGenerationDescriptor generation, IEnumerable> values) + { + Generation = generation; + Values = new ReadOnlyDictionary(values.ToDictionary()); + } + + /// Gets the lifetime that owns these installed policies. + public DataGenerationDescriptor Generation { get; } + /// Gets the complete installed policy set, excluding desired intent and native UI values. + public IReadOnlyDictionary Values { get; } + + /// Reads a registry-typed immutable policy value. + /// The exact registry type. + /// A tray, regional or tile policy key. + /// The value installed when the snapshot was captured. + public T Get(SettingKey key) where T : notnull => Values[key].Get(); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs b/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs new file mode 100644 index 00000000..a66a6f01 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs @@ -0,0 +1,175 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Owns admission and one generation pin across complete consumer commands, including every affected runtime batch. +public sealed class GenerationSettingsAuthority : ISettingsAuthority +{ + private readonly DataGenerationManager _generations; + private readonly MutationAdmissionBarrier _admission; + private readonly SemaphoreSlim _commandGate = new(1, 1); + + /// Creates the facade without resolving services, opening data, or publishing defaults. + /// Owner of current repositories and transition drain. + /// Shared mutation authority for ordinary and exclusive work. + public GenerationSettingsAuthority(DataGenerationManager generations, MutationAdmissionBarrier admission) + { + _generations = generations ?? throw new ArgumentNullException(nameof(generations)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + } + + /// + public SettingsAuthoritySnapshot CaptureSnapshot() => _generations.ReadSnapshot( + (context, generation) => new(generation, RequireSession(context, generation).Snapshot)); + + /// + public Task OpenAsync(CancellationToken cancellationToken) => + ExecuteOrdinaryAsync((context, lease, token) => context.Session.OpenAdmittedAsync(lease, token), cancellationToken); + + /// + public Task ApplyChangesAsync( + IEnumerable changes, Guid transactionId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(changes); + SettingValueChange[] snapshot = changes.ToArray(); + return ExecuteConsumerAsync((context, lease, token) => ChangeAndApplyAsync(context, snapshot, transactionId, lease, token), + RequiresProducerDrain(snapshot.Select(change => change.Key)), cancellationToken); + } + + /// + public Task ApplyChangesAdmittedAsync( + IEnumerable changes, Guid transactionId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(changes); + SettingValueChange[] snapshot = changes.ToArray(); + if (RequiresProducerDrain(snapshot.Select(change => change.Key))) { _admission.EnsureActiveExclusiveLease(admissionLease); } + return ExecuteAdmittedAsync((context, lease, token) => ChangeAndApplyAsync(context, snapshot, transactionId, lease, token), + admissionLease, cancellationToken); + } + + /// + public Task RevertAsync(IEnumerable keys, Guid transactionId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(keys); + SettingKey[] snapshot = keys.ToArray(); + return ExecuteConsumerAsync(async (context, lease, token) => + { + SettingsAuthorityResult reverted = await context.Session.RevertAdmittedAsync(snapshot, transactionId, lease, token).ConfigureAwait(false); + return await ApplyAffectedAsync(context, reverted, snapshot.ToHashSet(), SettingsApplicationPhase.Live, lease).ConfigureAwait(false); + }, RequiresProducerDrain(snapshot), cancellationToken); + } + + /// + public Task RetryAsync(Guid batchId, Guid expectedAttemptId, Guid newAttemptId, CancellationToken cancellationToken) => + ExecuteConsumerAsync(async (context, lease, token) => + { + SettingsAuthorityResult retry = await context.Session.RetryAdmittedAsync(batchId, expectedAttemptId, newAttemptId, lease, token).ConfigureAwait(false); + if (!retry.IsSucceeded) { return retry; } + SettingsApplicationBatch batch = retry.Envelope!.PendingApplications.Single(item => item.BatchId == batchId); + return await ApplyOneAsync(context, retry.Envelope, batch, SettingsApplicationPhase.Live, lease).ConfigureAwait(false); + }, drainProducers: true, cancellationToken); + + /// + public Task ReconcileStartupAdmittedAsync( + Guid startupId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + _admission.EnsureActiveExclusiveLease(admissionLease); + return ExecuteAdmittedAsync(async (context, lease, token) => + { + SettingsAuthorityResult prepared = await context.Session.PrepareStartupAdmittedAsync(startupId, lease, token).ConfigureAwait(false); + return !prepared.IsSucceeded ? prepared : await ApplyAffectedAsync(context, prepared, + prepared.Envelope!.Desired.Keys.ToHashSet(), SettingsApplicationPhase.Startup, lease).ConfigureAwait(false); + }, admissionLease, cancellationToken); + } + + private static async Task ChangeAndApplyAsync( + SettingsGenerationContext context, SettingValueChange[] changes, Guid transactionId, + MutationAdmissionLease lease, CancellationToken cancellationToken) + { + SettingsAuthorityResult changed = await context.Session.ChangeAdmittedAsync(changes, transactionId, lease, cancellationToken).ConfigureAwait(false); + return !changed.IsSucceeded ? changed : await ApplyAffectedAsync(context, changed, + changes.Select(change => change.Key).ToHashSet(), SettingsApplicationPhase.Live, lease).ConfigureAwait(false); + } + + private static async Task ApplyAffectedAsync( + SettingsGenerationContext context, SettingsAuthorityResult committed, IReadOnlySet keys, + SettingsApplicationPhase phase, MutationAdmissionLease lease) + { + if (!committed.IsSucceeded) { return committed; } + SettingsApplicationBatch[] batches = committed.Envelope!.PendingApplications + .Where(batch => batch.Entries.Any(entry => keys.Contains(entry.Key))).ToArray(); + SettingsAuthorityResult result = committed; + bool deferred = false; + foreach (SettingsApplicationBatch batch in batches) + { + SettingsAuthorityResult applied = await ApplyOneAsync(context, result.Envelope!, batch, phase, lease).ConfigureAwait(false); + if (applied.Status == SettingsAuthorityStatus.DeferredToRestart) { deferred = true; result = applied; continue; } + if (!applied.IsSucceeded) { return applied; } + result = applied; + } + + return deferred ? new(SettingsAuthorityStatus.DeferredToRestart, result.Envelope, "settings.application.restart_required") : result; + } + + private static Task ApplyOneAsync( + SettingsGenerationContext context, SettingsEnvelope envelope, SettingsApplicationBatch batch, + SettingsApplicationPhase phase, MutationAdmissionLease lease) + { + if (!context.Participants.TryGetValue(batch.ApplicationKind, out ISettingsApplicationParticipant? participant)) + { + return Task.FromResult(new SettingsAuthorityResult(SettingsAuthorityStatus.Rejected, envelope, "settings.application.participant_missing")); + } + + return context.Session.ContinueCommittedBatchAdmittedAsync(batch.BatchId, batch.AttemptId, participant, phase, lease); + } + + private static bool RequiresProducerDrain(IEnumerable keys) => keys.Any(key => + key == SettingsRegistry.Keys.TriggersEnabled || key == SettingsRegistry.Keys.TriggerNotificationsEnabled); + + private Task ExecuteOrdinaryAsync( + Func> command, + CancellationToken cancellationToken) => ExecuteConsumerAsync(command, drainProducers: false, cancellationToken); + + private async Task ExecuteConsumerAsync( + Func> command, + bool drainProducers, CancellationToken cancellationToken) + { + // A trigger evaluation can itself submit a settings command. Drain/revoke its ordinary + // authority before taking the command gate, so quiescence never waits on that gate's owner. + // Retry resolves its batch inside the pinned command; exclusive admission avoids a racy + // preflight lookup and covers a retry that must quiesce the trigger scheduler. + await using MutationAdmissionLease lease = drainProducers + ? await _admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, cancellationToken).ConfigureAwait(false) + : await _admission.AcquireOrdinaryAsync(cancellationToken).ConfigureAwait(false); + return await ExecuteAdmittedAsync(command, lease, cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteAdmittedAsync( + Func> command, + MutationAdmissionLease lease, CancellationToken cancellationToken) + { + _admission.EnsureActiveLease(lease); + using CancellationTokenSource waiting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lease.RevocationToken); + await _commandGate.WaitAsync(waiting.Token).ConfigureAwait(false); + try + { + _admission.EnsureActiveLease(lease); + waiting.Token.ThrowIfCancellationRequested(); + return await _generations.ExecuteAsync((context, generation, token) => + { + _ = RequireSession(context, generation); + return command(context, lease, token); + }, waiting.Token).ConfigureAwait(false); + } + finally + { + _commandGate.Release(); + } + } + + private static SettingsAuthoritySession RequireSession(SettingsGenerationContext context, DataGenerationDescriptor generation) => + context.Session.Generation.IsSameGeneration(generation) ? context.Session + : throw new InvalidOperationException("The resolved settings session does not belong to the pinned generation."); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/IAccentResourceStore.cs b/ClashSharp/ClashSharp.Application/Settings/IAccentResourceStore.cs new file mode 100644 index 00000000..42727ff9 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/IAccentResourceStore.cs @@ -0,0 +1,19 @@ +namespace ClashSharp.ApplicationModel.Settings; + +/// Accesses the primary application dictionary's accent overrides on its owning UI thread. +public interface IAccentResourceStore +{ + /// Captures locally defined owned keys; absent keys are omitted and incompatible resource values are represented by null. + /// The complete set of keys owned by accent application. + /// A stable snapshot that excludes inherited and merged resources. + IReadOnlyDictionary CaptureLocalOverrides(IReadOnlyCollection ownedKeys); + + /// Installs one exact owned color or solid-brush resource. + /// The exact owned resource key. + /// The requested color and resource kind. + void WriteOverride(string key, AccentResourceValue value); + + /// Removes one primary-dictionary override while preserving merged system resources. + /// The exact owned resource key. + void RemoveOverride(string key); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/IAppearanceNativeSettings.cs b/ClashSharp/ClashSharp.Application/Settings/IAppearanceNativeSettings.cs new file mode 100644 index 00000000..92e70410 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/IAppearanceNativeSettings.cs @@ -0,0 +1,46 @@ +using ClashSharp.Model; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Reads and changes actual UI configuration on the owning window thread, without preference storage. +public interface IAppearanceNativeSettings +{ + /// Reads the actual selected language, window theme and independently verified accent resources. + AppearanceNativeConfiguration CaptureConfiguration(); + + /// Installs the requested language in the resource resolver. + /// Canonical selected language. + void ApplyLanguage(AppLanguage language); + + /// Installs the requested theme on the actual window root. + /// Canonical selected theme. + void ApplyTheme(AppThemeMode theme); + + /// Installs and verifies the complete application accent resource set. + /// The configured mode and retained custom color. + void ApplyAccent(AccentColorConfiguration accent); +} + +/// Contains UI configuration observed in one synchronous operation on the window thread. +public sealed record AppearanceNativeConfiguration +{ + /// Creates an immutable and canonical UI configuration. + /// Selected resource language. + /// Requested window theme. + /// Independently verified accent configuration. + public AppearanceNativeConfiguration(AppLanguage language, AppThemeMode theme, AccentColorConfiguration accent) + { + if (!Enum.IsDefined(language)) { throw new ArgumentOutOfRangeException(nameof(language)); } + if (!Enum.IsDefined(theme)) { throw new ArgumentOutOfRangeException(nameof(theme)); } + Language = language; + Theme = theme; + Accent = accent ?? throw new ArgumentNullException(nameof(accent)); + } + + /// Gets the selected resource language. + public AppLanguage Language { get; } + /// Gets the requested window theme. + public AppThemeMode Theme { get; } + /// Gets the configured accent whose resources were verified. + public AccentColorConfiguration Accent { get; } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/IInternalSettingsReader.cs b/ClashSharp/ClashSharp.Application/Settings/IInternalSettingsReader.cs new file mode 100644 index 00000000..a8549f28 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/IInternalSettingsReader.cs @@ -0,0 +1,9 @@ +namespace ClashSharp.ApplicationModel.Settings; + +/// Provides internal consumers with installed configuration without granting settings mutation or storage access. +public interface IInternalSettingsReader +{ + /// Captures one immutable, generation-bound configuration without I/O; a retired owner rejects new captures. + /// The complete internal configuration installed at this observation. + InternalSettingsSnapshot CaptureSnapshot(); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/ILegacySettingsSource.cs b/ClashSharp/ClashSharp.Application/Settings/ILegacySettingsSource.cs new file mode 100644 index 00000000..da5b3a70 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/ILegacySettingsSource.cs @@ -0,0 +1,9 @@ +namespace ClashSharp.ApplicationModel.Settings; + +/// Reads legacy preferences once during migration without exposing any writer. +public interface ILegacySettingsSource +{ + /// Reads one atomic allowlisted legacy preference snapshot. + /// Cancels observation before a migration write starts. + Task ReadSnapshotAsync(CancellationToken cancellationToken); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/ISettingsApplicationParticipant.cs b/ClashSharp/ClashSharp.Application/Settings/ISettingsApplicationParticipant.cs new file mode 100644 index 00000000..d1b32b08 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/ISettingsApplicationParticipant.cs @@ -0,0 +1,25 @@ +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Applies one settings batch through an admitted runtime boundary and independently probes its effect. +/// Implementations use explicit request values, never write settings authority, and own all tasks until completion. +public interface ISettingsApplicationParticipant +{ + /// Gets the application kind this participant can observe and apply. + SettingApplicationKind ApplicationKind { get; } + + /// Observes actual effective values without changing runtime or preference state. + /// Immutable generation and attempt to observe. + /// Caller-owned lease retained for the complete command. + /// Observation cancellation; failures must not fabricate desired values. + Task ProbeAsync( + SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken); + + /// Converges idempotently toward explicit desired values; successful return still requires an independent probe. + /// Immutable target and companion settings. + /// Existing authority; implementations must not reacquire ordinary admission. + /// Owner-controlled token; a page cancellation cannot abandon a started effect. + Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs b/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs new file mode 100644 index 00000000..177f1f72 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs @@ -0,0 +1,55 @@ +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Provides generation-pinned snapshots and complete asynchronous desired/application commands to production consumers. +public interface ISettingsAuthority +{ + /// Captures current immutable state without storage I/O or retaining a repository reference. + SettingsAuthoritySnapshot CaptureSnapshot(); + + /// Opens the current initialized authority under ordinary admission. + /// Cancels acquisition and storage observation. + Task OpenAsync(CancellationToken cancellationToken); + + /// Commits a complete desired change set and verifies affected application batches under one generation pin. + /// Changes that quiesce settings-producing triggers acquire exclusive admission before the command gate. + /// Canonical typed desired changes copied before waiting. + /// Stable identity of the requested change set. + /// Cancels waiting and work before the desired publication boundary. + Task ApplyChangesAsync( + IEnumerable changes, Guid transactionId, CancellationToken cancellationToken); + + /// Performs the complete change and application using an existing caller-owned admission lease. + /// Trigger settings require an exclusive lease; an ordinary lease is rejected before desired publication. + /// Canonical typed desired changes copied before waiting. + /// Stable identity of the change set. + /// Active lease retained by the caller until the complete command finishes. + /// Cancels work before durable desired publication. + Task ApplyChangesAdmittedAsync( + IEnumerable changes, Guid transactionId, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken); + + /// Reverts selected desired values to verified evidence or explicit safe fallbacks and applies affected work. + /// Canonical keys to revert as one change set. + /// Stable identity of the revert command. + /// Cancels work before publication. + Task RevertAsync(IEnumerable keys, Guid transactionId, CancellationToken cancellationToken); + + /// Explicitly retries a failed attempt under a fresh identity and verifies its effect. + /// Acquires exclusive admission before resolving the failed batch so runtime producers cannot form a wait cycle. + /// Exact failed batch. + /// Identity of the failed attempt being replaced. + /// Fresh nonempty retry identity. + /// Cancels work before durable retry publication. + Task RetryAsync( + Guid batchId, Guid expectedAttemptId, Guid newAttemptId, CancellationToken cancellationToken); + + /// Reobserves previous-process evidence and reconciles startup work under exclusive admission. + /// Fresh nonempty startup identity. + /// Exclusive lease retained through startup application. + /// Cancels work before startup reconciliation is durable. + Task ReconcileStartupAdmittedAsync( + Guid startupId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/InternalSettingsParticipant.cs b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsParticipant.cs new file mode 100644 index 00000000..c4001376 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsParticipant.cs @@ -0,0 +1,99 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Owns atomic installation and independent observation of the application-internal consumer configuration. +/// Defaults initialize the actual consumer contract without claiming durable application. The containing generation owns retirement. +public sealed class InternalSettingsParticipant : ISettingsApplicationParticipant, IInternalSettingsReader, IDisposable +{ + private readonly object _gate = new(); + private readonly DataGenerationDescriptor _generation; + private readonly MutationAdmissionBarrier _admission; + private readonly IReadOnlyDictionary _definitions; + private InternalSettingsSnapshot? _installed; + + /// Creates a pure memory owner without opening storage, reading legacy preferences or starting tasks. + /// Immutable consumer lifetime. + /// The process-wide admission owner used by the settings authority. + /// Canonical definitions used by the same generation's settings session. + public InternalSettingsParticipant(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, SettingsRegistry registry) + { + _generation = generation ?? throw new ArgumentNullException(nameof(generation)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + ArgumentNullException.ThrowIfNull(registry); + _definitions = registry.Definitions.Where(definition => definition.ApplicationKind == SettingApplicationKind.Internal) + .ToDictionary(definition => definition.Key); + if (_definitions.Count == 0 || _definitions.Values.Any(definition => definition.Authority != SettingAuthority.Internal)) + { + throw new ArgumentException("Internal settings require application-owned definitions.", nameof(registry)); + } + _installed = new(_generation, _definitions.Select(pair => KeyValuePair.Create(pair.Key, pair.Value.DefaultValue))); + } + + /// + public SettingApplicationKind ApplicationKind => SettingApplicationKind.Internal; + + /// Reads one immutable installed snapshot; subsequent changes cannot modify an already captured value. + /// The complete current consumer configuration from this generation. + public InternalSettingsSnapshot CaptureSnapshot() => Volatile.Read(ref _installed) + ?? throw new ObjectDisposedException(nameof(InternalSettingsParticipant)); + + /// + public Task ProbeAsync(SettingsApplicationRequest request, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + lock (_gate) + { + Validate(request, admissionLease); + cancellationToken.ThrowIfCancellationRequested(); + InternalSettingsSnapshot snapshot = CaptureSnapshot(); + return Task.FromResult(new SettingsApplicationObservation(_generation, request.Batch.BatchId, request.Batch.AttemptId, + request.Values.Keys.Select(key => new SettingValueChange(key, snapshot.Values[key])))); + } + } + + /// + public Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + lock (_gate) + { + Validate(request, admissionLease); + cancellationToken.ThrowIfCancellationRequested(); + Dictionary installed = CaptureSnapshot().Values.ToDictionary(); + foreach ((SettingKey key, SettingValue value) in request.Values) { installed[key] = value; } + InternalSettingsSnapshot next = new(_generation, installed); + cancellationToken.ThrowIfCancellationRequested(); + Volatile.Write(ref _installed, next); + return Task.CompletedTask; + } + } + + /// Rejects future calls without changing historical snapshots or durable preferences. + public void Dispose() + { + lock (_gate) { Volatile.Write(ref _installed, null); } + } + + private void Validate(SettingsApplicationRequest request, MutationAdmissionLease lease) + { + ArgumentNullException.ThrowIfNull(request); + _admission.EnsureActiveLease(lease); + _ = CaptureSnapshot(); + if (request.Phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(lease); } + if (!_generation.IsSameGeneration(request.Generation) || request.Batch.ApplicationKind != ApplicationKind || request.Values.Count == 0) + { + throw new InvalidOperationException("The internal settings attempt belongs to another participant or generation."); + } + foreach ((SettingKey key, SettingValue value) in request.Values) + { + if (!_definitions.TryGetValue(key, out SettingDefinition? definition) + || definition.ApplicationTiming == SettingApplicationTiming.Restart && request.Phase != SettingsApplicationPhase.Startup + || !value.Equals(definition.Normalize(value.CanonicalText).Value)) + { + throw new InvalidOperationException("The internal settings attempt contains an unsupported key or value."); + } + } + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/InternalSettingsSnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsSnapshot.cs new file mode 100644 index 00000000..1d9c4b8d --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsSnapshot.cs @@ -0,0 +1,27 @@ +using System.Collections.ObjectModel; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Captures the complete immutable configuration actually installed for internal consumers in one generation. +public sealed class InternalSettingsSnapshot +{ + internal InternalSettingsSnapshot(DataGenerationDescriptor generation, IEnumerable> values) + { + Generation = generation; + Values = new ReadOnlyDictionary(values.ToDictionary()); + } + + /// Gets the exact lifetime that published this historical snapshot. + public DataGenerationDescriptor Generation { get; } + + /// Gets installed values only, excluding pending desired intent and other participants' settings. + public IReadOnlyDictionary Values { get; } + + /// Reads an exact typed internal value without storage I/O. + /// The registry-declared immutable value type. + /// An internal consumer key. + /// The value installed when this snapshot was captured. + public T Get(SettingKey key) where T : notnull => Values[key].Get(); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/LegacySettingsSnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/LegacySettingsSnapshot.cs new file mode 100644 index 00000000..f71d75b3 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/LegacySettingsSnapshot.cs @@ -0,0 +1,78 @@ +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Text.Json; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Owns one immutable, allowlisted legacy preference snapshot without internal credentials. +public sealed class LegacySettingsSnapshot +{ + private const int MaximumStringLength = 1024 * 1024; + private readonly IReadOnlyDictionary _values; + + /// Copies only registered preference keys and read-only aliases before computing their identity. + /// Canonical preference schema; unregistered keys are never copied or hashed. + /// Atomic legacy read whose primitive values are snapshotted by this constructor. + public LegacySettingsSnapshot(SettingsRegistry registry, IReadOnlyDictionary values) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(values); + Dictionary snapshot = new(StringComparer.Ordinal); + foreach (string key in registry.Definitions.SelectMany( + definition => new[] { definition.Key.Value }.Concat(definition.Aliases.Select(alias => alias.Value)))) + { + if (!values.Keys.Contains(key, StringComparer.Ordinal) || !values.TryGetValue(key, out object? value)) + { + continue; + } + + if (value is string text && text.Length > MaximumStringLength) + { + throw new ArgumentException("A legacy preference exceeds the snapshot size limit.", nameof(values)); + } + + // LocalSettings preferences use only these immutable primitives. Unknown + // types become an invalid-value marker without invoking arbitrary ToString. + snapshot.Add(key, value is bool or int or string ? value : null); + } + + _values = new ReadOnlyDictionary(snapshot); + SourceHash = ComputeHash(snapshot); + } + + /// Gets the SHA-256 identity of the allowlisted primitive snapshot. + public string SourceHash { get; } + + /// Gets a copied raw preference; null denotes a present but unsupported legacy value. + /// Canonical or registered legacy key. + /// Copied immutable primitive or the invalid-value marker. + /// Whether the allowlisted source contained this exact key. + public bool TryGetValue(string key, out object? value) => _values.TryGetValue(key, out value); + + private static string ComputeHash(IReadOnlyDictionary values) + { + using MemoryStream bytes = new(); + using (Utf8JsonWriter writer = new(bytes)) + { + writer.WriteStartObject(); + writer.WriteString("schema", "clashsharp-legacy-preferences-v1"); + writer.WriteStartObject("values"); + foreach ((string key, object? value) in values.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + switch (value) + { + case bool boolean: writer.WriteBoolean(key, boolean); break; + case int integer: writer.WriteNumber(key, integer); break; + case string text: writer.WriteString(key, text); break; + default: writer.WriteNull(key); break; + } + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + return Convert.ToHexStringLower(SHA256.HashData(bytes.GetBuffer().AsSpan(0, checked((int)bytes.Length)))); + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsApplicationRequest.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsApplicationRequest.cs new file mode 100644 index 00000000..f6e66ae3 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsApplicationRequest.cs @@ -0,0 +1,76 @@ +using System.Collections.ObjectModel; +using ClashSharp.ApplicationModel.Data; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Specifies whether application occurs during an exclusively owned startup or a live command. +public enum SettingsApplicationPhase +{ + /// Live application cannot consume restart-bound batches. + Live, + /// Startup application requires an active exclusive mutation lease. + Startup, +} + +/// Captures the exact generation, attempt, and immutable desired values assigned to a participant. +public sealed class SettingsApplicationRequest +{ + internal SettingsApplicationRequest( + DataGenerationDescriptor generation, SettingsEnvelope envelope, SettingsApplicationBatch batch, + SettingsApplicationPhase phase) + { + Generation = generation; + Envelope = envelope; + Batch = batch; + Phase = phase; + Values = new ReadOnlyDictionary( + batch.Entries.ToDictionary(entry => entry.Key, entry => envelope.Desired[entry.Key].Value)); + } + + /// Gets the pinned storage generation. + public DataGenerationDescriptor Generation { get; } + + /// Gets the durable running envelope, including companion desired settings. + public SettingsEnvelope Envelope { get; } + + /// Gets the exact current attempt. + public SettingsApplicationBatch Batch { get; } + + /// Gets the owned application phase. + public SettingsApplicationPhase Phase { get; } + + /// Gets only this batch's canonical desired values. + public IReadOnlyDictionary Values { get; } +} + +/// Contains independently probed values bound to one exact generation and application attempt. +public sealed class SettingsApplicationObservation +{ + /// Copies a completed participant probe without deriving observed values from the request. + /// Generation under which the participant was observed. + /// Observed batch identity. + /// Observed attempt identity. + /// Independent effective values; the authority validates complete canonical coverage. + public SettingsApplicationObservation( + DataGenerationDescriptor generation, Guid batchId, Guid attemptId, IEnumerable values) + { + Generation = generation ?? throw new ArgumentNullException(nameof(generation)); + ArgumentNullException.ThrowIfNull(values); + BatchId = batchId; + AttemptId = attemptId; + Values = Array.AsReadOnly(values.ToArray()); + } + + /// Gets the generation actually observed. + public DataGenerationDescriptor Generation { get; } + + /// Gets the batch identity of the observation. + public Guid BatchId { get; } + + /// Gets the current attempt identity of the observation. + public Guid AttemptId { get; } + + /// Gets the defensively copied effective values. + public IReadOnlyList Values { get; } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityBootstrapper.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityBootstrapper.cs new file mode 100644 index 00000000..3e952815 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityBootstrapper.cs @@ -0,0 +1,73 @@ +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Opens a pinned settings authority or initializes a demonstrably unused repository from legacy preferences. +/// The caller retains exclusive startup or generation-transition ownership for the complete operation. +public sealed class SettingsAuthorityBootstrapper +{ + private readonly ISettingsRepository _repository; + private readonly ILegacySettingsSource _legacy; + private readonly SettingsMigrationPlanner _migration; + private readonly SemaphoreSlim _initializationGate = new(1, 1); + + /// Creates the initializer without reading either storage system. + /// Repository pinned to the generation being opened. + /// Read-only source consulted only for an unused repository. + /// Canonical legacy normalization and initial pending-state planner. + public SettingsAuthorityBootstrapper( + ISettingsRepository repository, + ILegacySettingsSource legacy, + SettingsMigrationPlanner migration) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _legacy = legacy ?? throw new ArgumentNullException(nameof(legacy)); + _migration = migration ?? throw new ArgumentNullException(nameof(migration)); + } + + /// Returns a verified nonempty authority or a persistence failure without silently replacing existing data. + /// Stable identity for a possible first migration. + /// Cancels observation and repository work before its durable commit point. + public async Task OpenAsync(Guid migrationId, CancellationToken cancellationToken) + { + if (migrationId == Guid.Empty) + { + throw new ArgumentException("Migration identity cannot be empty.", nameof(migrationId)); + } + + await _initializationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + SettingsPersistenceResult existing = await _repository.OpenAsync(cancellationToken).ConfigureAwait(false); + if (!existing.IsSucceeded || existing.Envelope is not null) + { + return existing; + } + + cancellationToken.ThrowIfCancellationRequested(); + LegacySettingsSnapshot snapshot = await _legacy.ReadSnapshotAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + SettingsMigrationPlan plan = _migration.CreatePlan(snapshot, migrationId); + SettingsPersistenceResult saved = await _repository.SaveAsync( + plan.Envelope, expectedRevision: 0, cancellationToken).ConfigureAwait(false); + if (saved.Status == SettingsPersistenceStatus.Conflict && saved.Envelope is not null) + { + // Another initializer won the atomic first publication. Its verified + // authority wins; never repeat migration over the observed revision. + return SettingsPersistenceResult.Succeeded(saved.Envelope); + } + + if (saved.IsSucceeded && saved.Envelope is null) + { + return SettingsPersistenceResult.Invalid(new SettingsPersistenceDiagnostic( + "settings.bootstrap.empty_commit", "envelope")); + } + + return saved; + } + finally + { + _initializationGate.Release(); + } + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityResult.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityResult.cs new file mode 100644 index 00000000..fcc0aef9 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityResult.cs @@ -0,0 +1,49 @@ +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Classifies a settings command independently of whether desired values are durable. +public enum SettingsAuthorityStatus +{ + /// The requested command completed and its resulting envelope was verified. + Succeeded, + /// The requested edit already matched the verified envelope. + NoChange, + /// The command was stale, invalid, or incompatible with the current application state. + Rejected, + /// Storage did not verify the requested commit. + PersistenceFailed, + /// The participant did not verify the target; failed work remains durable. + ApplicationFailed, + /// The batch requires startup ownership and was left unchanged. + DeferredToRestart, +} + +/// Contains the verified outcome of a command without equating persistence with runtime application. +public sealed class SettingsAuthorityResult +{ + internal SettingsAuthorityResult( + SettingsAuthorityStatus status, SettingsEnvelope? envelope, string? code = null, + SettingsPersistenceStatus? persistenceStatus = null) + { + Status = status; + Envelope = envelope; + Code = code; + PersistenceStatus = persistenceStatus; + } + + /// Gets the command outcome. + public SettingsAuthorityStatus Status { get; } + + /// Gets the last verified envelope, when one was observed by this command. + public SettingsEnvelope? Envelope { get; } + + /// Gets a stable value-free diagnostic. + public string? Code { get; } + + /// Gets the storage classification when persistence failed. + public SettingsPersistenceStatus? PersistenceStatus { get; } + + /// Gets whether the requested command completed successfully. + public bool IsSucceeded => Status is SettingsAuthorityStatus.Succeeded or SettingsAuthorityStatus.NoChange; +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs new file mode 100644 index 00000000..db7ace3b --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs @@ -0,0 +1,151 @@ +using ClashSharp.ApplicationModel.Diagnostics; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +public sealed partial class SettingsAuthoritySession +{ + /// Persists running intent, probes before applying, and clears work only after complete independent verification. + /// Exact batch selected from the verified envelope. + /// Expected current attempt identity. + /// Admitted runtime implementation for this batch's application kind. + /// Live application or startup under exclusive ownership. + /// Active lease retained through observation, effects, and final durable classification. + /// Cancels waiting and work before running intent is durably acknowledged. + public Task ApplyBatchAdmittedAsync( + Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) => + ApplyBatchEntryAsync(batchId, attemptId, participant, phase, admissionLease, honorRevocation: true, cancellationToken); + + /// Continues a facade-owned durable command while retaining its active lease across admission drain. + internal Task ContinueCommittedBatchAdmittedAsync( + Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase, + MutationAdmissionLease admissionLease) => + ApplyBatchEntryAsync(batchId, attemptId, participant, phase, admissionLease, honorRevocation: false, CancellationToken.None); + + private Task ApplyBatchEntryAsync( + Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase, + MutationAdmissionLease admissionLease, bool honorRevocation, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(participant); + if (!Enum.IsDefined(phase)) { throw new ArgumentOutOfRangeException(nameof(phase)); } + if (phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(admissionLease); } + return ExecuteAdmittedAsync(admissionLease, + waiting => ApplyCoreAsync(batchId, attemptId, participant, phase, admissionLease, waiting), honorRevocation, cancellationToken); + } + + private async Task ApplyCoreAsync( + Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase, + MutationAdmissionLease lease, CancellationToken waiting) + { + SettingsAuthorityResult read = await ReadCoreAsync(waiting).ConfigureAwait(false); + if (!read.IsSucceeded) { return read; } + SettingsEnvelope envelope = read.Envelope!; + SettingsApplicationBatch? batch = envelope.PendingApplications.SingleOrDefault(item => item.BatchId == batchId); + if (batch is null || batch.AttemptId != attemptId) + { + return new(SettingsAuthorityStatus.Rejected, envelope, "settings.application.stale_attempt"); + } + + if (batch.ApplicationKind != participant.ApplicationKind) + { + return new(SettingsAuthorityStatus.Rejected, envelope, "settings.application.participant_mismatch"); + } + + if (batch.Kind == SettingsApplicationBatchKind.Restart && phase != SettingsApplicationPhase.Startup) + { + return new(SettingsAuthorityStatus.DeferredToRestart, envelope, "settings.application.restart_required"); + } + + waiting.ThrowIfCancellationRequested(); + SettingsAuthorityResult started = await PersistEditAsync(envelope, + _batches.BeginAttempt(envelope, batchId, attemptId), waiting).ConfigureAwait(false); + if (!started.IsSucceeded) { return started; } + envelope = started.Envelope!; + batch = envelope.PendingApplications.Single(item => item.BatchId == batchId); + SettingsApplicationRequest request = new(Generation, envelope, batch, phase); + + // Running intent is durable. Ignore page cancellation until the participant and + // the final save finish; exclusive generation changes still wait for this lease. + SettingsApplicationObservation? observation = await TryProbeAsync(participant, request, lease).ConfigureAwait(false); + if (!ValidateObservation(request, observation, out bool matches)) + { + return await FailCoreAsync(envelope, batch, "settings.application.probe_failed").ConfigureAwait(false); + } + + bool applied = false; + bool replyFailed = false; + if (!matches) + { + try + { + await participant.ApplyAsync(request, lease, CancellationToken.None).ConfigureAwait(false); + applied = true; + } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) + { + replyFailed = true; + // A participant may have completed its effect before losing the reply. + // Resolve that ambiguity by probing; never infer rollback from an exception. + } + + observation = await TryProbeAsync(participant, request, lease).ConfigureAwait(false); + if (!ValidateObservation(request, observation, out matches) || !matches) + { + return await FailCoreAsync(envelope, batch, replyFailed + ? "settings.application.participant_failed" : "settings.application.verification_failed").ConfigureAwait(false); + } + } + + SettingAppliedValueSource source = phase == SettingsApplicationPhase.Startup + ? SettingAppliedValueSource.StartupReconciliation + : applied ? SettingAppliedValueSource.MutationVerification : SettingAppliedValueSource.RuntimeProbe; + SettingsEnvelopeEditResult complete = _batches.CompleteAttempt(envelope, batchId, attemptId, + observation!.Values, source, _time.GetUtcNow()); + SettingsAuthorityResult completed = await PersistEditAsync(envelope, complete, CancellationToken.None).ConfigureAwait(false); + return completed.IsSucceeded && replyFailed + ? new(completed.Status, completed.Envelope, "settings.application.reply_lost_resolved") : completed; + } + + private async Task FailCoreAsync(SettingsEnvelope source, SettingsApplicationBatch batch, string code) + { + SettingsAuthorityResult failed = await PersistEditAsync(source, + _batches.FailAttempt(source, batch.BatchId, batch.AttemptId, new(code)), CancellationToken.None).ConfigureAwait(false); + return failed.IsSucceeded ? new(SettingsAuthorityStatus.ApplicationFailed, failed.Envelope, code) : failed; + } + + private static async Task TryProbeAsync( + ISettingsApplicationParticipant participant, SettingsApplicationRequest request, MutationAdmissionLease lease) + { + try + { + return await participant.ProbeAsync(request, lease, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) + { + return null; + } + } + + private bool ValidateObservation(SettingsApplicationRequest request, SettingsApplicationObservation? observation, out bool matches) + { + matches = false; + if (observation is null || !Generation.IsSameGeneration(observation.Generation) + || observation.BatchId != request.Batch.BatchId || observation.AttemptId != request.Batch.AttemptId + || observation.Values.Count != request.Values.Count) { return false; } + HashSet seen = []; + bool allMatch = true; + foreach (SettingValueChange value in observation.Values) + { + if (value is null || !seen.Add(value.Key) || !request.Values.TryGetValue(value.Key, out SettingValue? desired)) { return false; } + SettingNormalizationResult normalized = _registry.Get(value.Key.Value).Normalize(value.Value.CanonicalText); + if (!normalized.IsSuccess || !value.Value.Equals(normalized.Value)) { return false; } + allMatch &= value.Value.Equals(desired); + } + + matches = allMatch; + return true; + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Lifetime.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Lifetime.cs new file mode 100644 index 00000000..607aea5b --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Lifetime.cs @@ -0,0 +1,36 @@ +namespace ClashSharp.ApplicationModel.Settings; + +public sealed partial class SettingsAuthoritySession +{ + private readonly object _lifetimeLock = new(); + private Task? _disposal; + private bool _closing; + + /// Rejects later commands and drains any started effect and final save before releasing this generation's projection. + public ValueTask DisposeAsync() + { + lock (_lifetimeLock) + { + Volatile.Write(ref _closing, true); + _disposal ??= DrainAsync(); + return new(_disposal); + } + } + + private async Task DrainAsync() + { + await _operationGate.WaitAsync().ConfigureAwait(false); + try + { + Volatile.Write(ref _snapshot, null); + } + finally + { + // Queued callers still need to acquire and reject themselves. The managed + // semaphore stays valid until their continuations have observed closure. + _operationGate.Release(); + } + } + + private void ThrowIfClosing() => ObjectDisposedException.ThrowIf(Volatile.Read(ref _closing), this); +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs new file mode 100644 index 00000000..975b667e --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs @@ -0,0 +1,197 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.ApplicationModel.Mutations; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Serializes asynchronous edits and verified application within one pinned repository lifetime. +/// The generation owner keeps this session and its repository pinned for each complete call, including participant recovery. +public sealed partial class SettingsAuthoritySession : IAsyncDisposable +{ + private readonly ISettingsRepository _repository; + private readonly SettingsRegistry _registry; + private readonly MutationAdmissionBarrier _admission; + private readonly SettingsEnvelopeEditor _editor; + private readonly SettingsApplicationBatchEditor _batches; + private readonly SettingsEnvelopeValidator _validator; + private readonly TimeProvider _time; + private readonly SemaphoreSlim _operationGate = new(1, 1); + private SettingsEnvelope? _snapshot; + + /// Creates one session without opening storage, starting tasks, or publishing fallback preferences. + /// Repository owned by the immutable generation lifetime. + /// Canonical settings definitions. + /// Process-wide mutation admission shared with import and shutdown. + /// Optional clock for completed observation timestamps. + public SettingsAuthoritySession( + ISettingsRepository repository, SettingsRegistry registry, MutationAdmissionBarrier admission, + TimeProvider? timeProvider = null) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _admission = admission ?? throw new ArgumentNullException(nameof(admission)); + _editor = new(registry); + _batches = new(registry); + _validator = new(registry); + _time = timeProvider ?? TimeProvider.System; + } + + /// Gets the immutable storage owner of this session. + public DataGenerationDescriptor Generation => _repository.Generation; + + /// Gets the last verified immutable envelope; uncertain commits invalidate this projection until storage is reread. + public SettingsEnvelope Snapshot + { + get + { + ThrowIfClosing(); + return Volatile.Read(ref _snapshot) + ?? throw new InvalidOperationException("Settings authority has not been verified for this generation."); + } + } + + /// Opens an already initialized authority; migration remains owned by the exclusive startup bootstrapper. + /// Active caller-owned lease. + /// Cancels queued or read-only work. + public Task OpenAdmittedAsync(MutationAdmissionLease admissionLease, CancellationToken cancellationToken) => + ExecuteAdmittedAsync(admissionLease, ReadCoreAsync, cancellationToken); + + /// Replaces previous-process evidence with pending observation while preserving blocked probes and existing attempt identities. + /// Fresh identity of the exclusively owned startup. + /// Exclusive startup lease held until reconciliation finishes. + /// Cancels before the repository's atomic publication point. + public Task PrepareStartupAdmittedAsync( + Guid startupId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + _admission.EnsureActiveExclusiveLease(admissionLease); + return EditAdmittedAsync(envelope => _batches.ScheduleStartupReconciliation(envelope, startupId), admissionLease, cancellationToken); + } + + /// Commits a complete canonical change set using the current durable revision. + /// Desired changes, copied before asynchronous admission waiting. + /// Stable identity for newly planned application batches. + /// Active caller-owned lease. + /// Cancels only before the repository's atomic publication point. + public Task ChangeAdmittedAsync( + IEnumerable changes, Guid transactionId, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(changes); + SettingValueChange[] snapshot = changes.ToArray(); + return EditAdmittedAsync(envelope => _editor.ApplyChanges(envelope, snapshot, transactionId), admissionLease, cancellationToken); + } + + /// Reverts selected desired preferences to verified evidence or the registry's explicit fallback while retaining necessary application work. + /// Canonical keys copied before asynchronous waiting. + /// Stable edit identity. + /// Active caller-owned lease. + /// Cancels before atomic publication. + public Task RevertAdmittedAsync( + IEnumerable keys, Guid transactionId, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(keys); + SettingKey[] snapshot = keys.ToArray(); + return EditAdmittedAsync(envelope => _editor.Revert(envelope, snapshot, transactionId), admissionLease, cancellationToken); + } + + /// Explicitly retries failed work under a new attempt identity. + /// Exact batch. + /// Failed attempt being replaced. + /// New nonempty attempt identity. + /// Active caller-owned lease. + /// Cancels before atomic publication. + public Task RetryAdmittedAsync( + Guid batchId, Guid expectedAttemptId, Guid newAttemptId, + MutationAdmissionLease admissionLease, CancellationToken cancellationToken) => + EditAdmittedAsync(envelope => _batches.RetryFailed(envelope, batchId, expectedAttemptId, newAttemptId), admissionLease, cancellationToken); + + private Task EditAdmittedAsync( + Func edit, MutationAdmissionLease lease, CancellationToken token) => + ExecuteAdmittedAsync(lease, async waiting => + { + SettingsAuthorityResult read = await ReadCoreAsync(waiting).ConfigureAwait(false); + return !read.IsSucceeded ? read + : await PersistEditAsync(read.Envelope!, edit(read.Envelope!), waiting).ConfigureAwait(false); + }, token); + + private Task ExecuteAdmittedAsync( + MutationAdmissionLease lease, Func> operation, + CancellationToken cancellationToken) => ExecuteAdmittedAsync(lease, operation, honorRevocation: true, cancellationToken); + + private async Task ExecuteAdmittedAsync( + MutationAdmissionLease lease, Func> operation, bool honorRevocation, + CancellationToken cancellationToken) + { + ThrowIfClosing(); + _admission.EnsureActiveLease(lease); + using CancellationTokenSource waiting = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, honorRevocation ? lease.RevocationToken : CancellationToken.None); + await _operationGate.WaitAsync(waiting.Token).ConfigureAwait(false); + try + { + ThrowIfClosing(); + _admission.EnsureActiveLease(lease); + waiting.Token.ThrowIfCancellationRequested(); + return await operation(waiting.Token).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + private async Task ReadCoreAsync(CancellationToken cancellationToken) + { + try + { + SettingsPersistenceResult result = await _repository.OpenAsync(cancellationToken).ConfigureAwait(false); + return ObservePersistence(result); + } + catch + { + Volatile.Write(ref _snapshot, null); + throw; + } + } + + private async Task PersistEditAsync( + SettingsEnvelope source, SettingsEnvelopeEditResult edit, CancellationToken cancellationToken) + { + if (!edit.IsSuccess) { return new(SettingsAuthorityStatus.Rejected, source, edit.ErrorCode); } + if (edit.Outcome == SettingsEnvelopeEditOutcome.NoChange) { return new(SettingsAuthorityStatus.NoChange, source); } + try + { + SettingsPersistenceResult persisted = await _repository.SaveAsync(edit.Envelope, source.EnvelopeRevision, cancellationToken) + .ConfigureAwait(false); + return ObservePersistence(persisted); + } + catch + { + Volatile.Write(ref _snapshot, null); + throw; + } + } + + private SettingsAuthorityResult ObservePersistence(SettingsPersistenceResult result) + { + if (result.IsSucceeded && result.Envelope is null) + { + result = SettingsPersistenceResult.Invalid(new("settings.authority.uninitialized", "envelope")); + } + + SettingsEnvelope? observed = result.Status is SettingsPersistenceStatus.Succeeded or SettingsPersistenceStatus.Conflict + ? result.Envelope : null; + if (observed is not null && !_validator.Validate(observed).IsValid) + { + observed = null; + result = SettingsPersistenceResult.Invalid(new("settings.authority.invalid_observation", "envelope")); + } + + Volatile.Write(ref _snapshot, observed); + if (result.IsSucceeded && observed is not null) { return new(SettingsAuthorityStatus.Succeeded, observed); } + return new(SettingsAuthorityStatus.PersistenceFailed, observed, + result.Diagnostic?.Code ?? (result.Status == SettingsPersistenceStatus.Conflict + ? "settings.authority.conflict" : "settings.authority.uninitialized"), result.Status); + } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySnapshot.cs new file mode 100644 index 00000000..0b166a33 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySnapshot.cs @@ -0,0 +1,23 @@ +using ClashSharp.ApplicationModel.Data; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Captures one immutable authority projection with its exact generation identity. +public sealed class SettingsAuthoritySnapshot +{ + /// Creates a projection from one verified generation and envelope. + /// Immutable storage owner of the captured envelope. + /// Complete immutable desired, applied, and pending state. + public SettingsAuthoritySnapshot(DataGenerationDescriptor generation, SettingsEnvelope envelope) + { + Generation = generation ?? throw new ArgumentNullException(nameof(generation)); + Envelope = envelope ?? throw new ArgumentNullException(nameof(envelope)); + } + + /// Gets the captured generation identity. + public DataGenerationDescriptor Generation { get; } + + /// Gets the complete immutable settings state. + public SettingsEnvelope Envelope { get; } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsGenerationContext.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsGenerationContext.cs new file mode 100644 index 00000000..87c26df4 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsGenerationContext.cs @@ -0,0 +1,35 @@ +using System.Collections.ObjectModel; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Groups the settings session and runtime adapters resolved from one generation-owned service container. +/// The containing service lifetime owns disposal; participant constructors must not read storage or start effects. +public sealed class SettingsGenerationContext +{ + /// Creates an immutable generation-local participant catalog. + /// Settings authority session owned by the same service container. + /// At most one runtime adapter for each declared application kind. + public SettingsGenerationContext(SettingsAuthoritySession session, IEnumerable participants) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + ArgumentNullException.ThrowIfNull(participants); + Dictionary catalog = []; + foreach (ISettingsApplicationParticipant participant in participants) + { + if (participant is null || !Enum.IsDefined(participant.ApplicationKind) + || !catalog.TryAdd(participant.ApplicationKind, participant)) + { + throw new ArgumentException("Settings participants must be non-null and unique by declared application kind.", nameof(participants)); + } + } + + Participants = new ReadOnlyDictionary(catalog); + } + + /// Gets the generation's sole settings session. + public SettingsAuthoritySession Session { get; } + + /// Gets the immutable catalog used for generation-local runtime application. + public IReadOnlyDictionary Participants { get; } +} diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsMigrationPlanner.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsMigrationPlanner.cs new file mode 100644 index 00000000..ed1cded9 --- /dev/null +++ b/ClashSharp/ClashSharp.Application/Settings/SettingsMigrationPlanner.cs @@ -0,0 +1,144 @@ +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Text; +using ClashSharp.Model; +using ClashSharp.Settings; + +namespace ClashSharp.ApplicationModel.Settings; + +/// Explains a migration decision using only an allowlisted key and stable code. +/// Canonical preference key. +/// Stable decision code; source values are never included. +public sealed record SettingsMigrationDiagnostic(SettingKey Key, string Code); + +/// Contains the complete initial envelope and value-free migration decisions. +/// Validated canonical envelope with pending application coverage. +/// Immutable key-addressed migration decisions. +public sealed record SettingsMigrationPlan(SettingsEnvelope Envelope, IReadOnlyList Diagnostics); + +/// Converts legacy preferences into a complete canonical authority without asserting runtime effects. +public sealed class SettingsMigrationPlanner +{ + private readonly SettingsRegistry _registry; + + /// Creates a pure planner for the canonical schema. + /// Immutable preference definitions and validation rules. + public SettingsMigrationPlanner(SettingsRegistry registry) => + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + + /// Creates one revision-one migration with deterministic application identities. + /// Immutable allowlisted source. + /// Stable caller-owned identity for this migration attempt. + public SettingsMigrationPlan CreatePlan(LegacySettingsSnapshot snapshot, Guid migrationId) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (migrationId == Guid.Empty) + { + throw new ArgumentException("Migration identity cannot be empty.", nameof(migrationId)); + } + + Dictionary desired = []; + Dictionary applied = []; + List diagnostics = []; + foreach (SettingDefinition definition in _registry.Definitions) + { + (SettingValue value, string code) = ReadValue(snapshot, definition); + desired.Add(definition.Key, new SettingDesiredEntry(value, keyDesiredRevision: 1)); + applied.Add(definition.Key, SettingAppliedState.Unknown( + SettingAppliedUnknownReason.NotObserved, SettingAppliedUnknownHandling.QueueApplication)); + diagnostics.Add(new(definition.Key, code)); + } + + List pending = []; + foreach (var group in _registry.Definitions + .GroupBy(definition => (definition.ApplicationKind, definition.ApplicationTiming)) + .OrderBy(group => group.Key.ApplicationTiming) + .ThenBy(group => group.Key.ApplicationKind)) + { + long sequence = pending.Count + 1; + pending.Add(new SettingsApplicationBatch( + CreateIdentity(migrationId, sequence, "batch"), + group.Key.ApplicationTiming == SettingApplicationTiming.Restart + ? SettingsApplicationBatchKind.Restart + : SettingsApplicationBatchKind.LiveReconcile, + sequence, + CreateIdentity(migrationId, sequence, "attempt"), + SettingsApplicationBatchState.Pending, + group.Key.ApplicationKind, + group.Select(definition => SettingsApplicationBatchEntry.Create(definition.Key, desired[definition.Key])))); + } + + SettingsEnvelope envelope = new(SettingsEnvelope.CurrentSchemaVersion, 1, desired, applied, pending, + [new SettingsMigrationRecord(migrationId, 0, SettingsEnvelope.CurrentSchemaVersion, snapshot.SourceHash)]); + SettingsEnvelopeValidationResult validation = new SettingsEnvelopeValidator(_registry).Validate(envelope); + if (!validation.IsValid) + { + throw new InvalidOperationException("The generated migration envelope violates the canonical schema."); + } + + return new(envelope, new ReadOnlyCollection(diagnostics)); + } + + private static (SettingValue Value, string Code) ReadValue(LegacySettingsSnapshot source, SettingDefinition definition) + { + string key = definition.Key.Value; + bool present = source.TryGetValue(key, out object? raw); + string code = "settings.migration.canonical"; + if (!present) + { + foreach (SettingKey aliasKey in definition.Aliases) + { + if (source.TryGetValue(aliasKey.Value, out raw)) + { + present = true; + code = "settings.migration.alias"; + break; + } + } + } + + if (key == SettingsRegistry.Keys.MainlandChinaFeatureMode.Value) + { + if (raw is int legacyMode && legacyMode == (int)MainlandChinaFeatureMode.AllIncludingUrlBlacklist) + { + raw = (int)MainlandChinaFeatureMode.FlagTextCompletionAndKeywordFilter; + code = "settings.migration.legacy_mode_split"; + } + else if ((!present || raw is not int || !Enum.IsDefined(typeof(MainlandChinaFeatureMode), raw)) + && source.TryGetValue(SettingsRegistry.Keys.MainlandChinaDisplayEnabled.Value, out object? alias) + && alias is bool display) + { + raw = (int)(display ? MainlandChinaFeatureMode.FlagReplacementAndTextCompletion : MainlandChinaFeatureMode.Disabled); + present = true; + code = "settings.migration.alias"; + } + } + else if (key == SettingsRegistry.Keys.MainlandChinaUrlBlockingEnabled.Value + && source.TryGetValue(SettingsRegistry.Keys.MainlandChinaFeatureMode.Value, out object? mode) + && mode is int oldMode && oldMode == (int)MainlandChinaFeatureMode.AllIncludingUrlBlacklist) + { + raw = true; + present = true; + code = "settings.migration.legacy_mode_split"; + } + + if (!present) + { + return (definition.DefaultValue, "settings.migration.default"); + } + + SettingNormalizationResult normalized = definition.ValueType.IsEnum && raw is int enumValue + ? definition.NormalizeValue(Enum.ToObject(definition.ValueType, enumValue)) + : definition.NormalizeValue(raw); + return normalized.IsSuccess + ? (normalized.Value!, code) + : (definition.SafeFallback, "settings.migration.invalid_fallback"); + } + + private static Guid CreateIdentity(Guid migrationId, long sequence, string purpose) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes( + FormattableString.Invariant($"clashsharp-settings-migration-v1/{migrationId:N}/{sequence}/{purpose}"))); + return new Guid(hash.AsSpan(0, 16)); + } +} diff --git a/ClashSharp/ClashSharp.Core/Security/ControllerCredentialPolicy.cs b/ClashSharp/ClashSharp.Core/Security/ControllerCredentialPolicy.cs new file mode 100644 index 00000000..99d33296 --- /dev/null +++ b/ClashSharp/ClashSharp.Core/Security/ControllerCredentialPolicy.cs @@ -0,0 +1,18 @@ +namespace ClashSharp.Security; + +/// Defines the private App-owned controller credential independently of user preferences. +public static class ControllerCredentialPolicy +{ + /// Checks the canonical 256-bit lowercase hexadecimal representation. + /// Untrusted persisted credential; never included in diagnostics. + public static bool IsValid(string? secret) + { + if (secret is not { Length: 64 }) { return false; } + foreach (char character in secret) + { + if (character is not (>= '0' and <= '9') and not (>= 'a' and <= 'f')) { return false; } + } + + return true; + } +} diff --git a/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.Reconciliation.cs b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.Reconciliation.cs new file mode 100644 index 00000000..d39cda62 --- /dev/null +++ b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.Reconciliation.cs @@ -0,0 +1,56 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ClashSharp.Settings; + +public sealed partial class SettingsApplicationBatchEditor +{ + /// Invalidates evidence from a previous process and queues observation without removing blocked probes or changing desired values. + /// Verified persisted envelope being opened by a new process. + /// Fresh nonempty startup identity used for new application batches. + /// Existing running and failed attempts retain their identities and state; failed work still requires explicit retry. + public SettingsEnvelopeEditResult ScheduleStartupReconciliation(SettingsEnvelope envelope, Guid startupId) + { + ArgumentNullException.ThrowIfNull(envelope); + if (!_validator.Validate(envelope).IsValid) { return Invalid(envelope, "source_invalid"); } + if (startupId == Guid.Empty) { return Invalid(envelope, "startup_identity"); } + SettingKey[] staleEvidence = envelope.Applied.Where(pair => pair.Value.Kind == SettingAppliedStateKind.Verified) + .Select(pair => pair.Key).ToArray(); + if (staleEvidence.Length == 0) { return SettingsEnvelopeEditResult.NoChange(envelope); } + if (envelope.EnvelopeRevision == long.MaxValue) { return Invalid(envelope, "revision_exhausted"); } + + Dictionary applied = new(envelope.Applied); + HashSet alreadyCovered = [.. envelope.PendingApplications.SelectMany(batch => batch.Entries).Select(entry => entry.Key)]; + foreach (SettingKey key in staleEvidence) + { + applied[key] = SettingAppliedState.Unknown(SettingAppliedUnknownReason.NotObserved, SettingAppliedUnknownHandling.QueueApplication); + } + + List batches = [.. envelope.PendingApplications]; + long sequence = batches.Count == 0 ? 0 : batches.Max(batch => batch.CreationSequence); + foreach (var group in staleEvidence.Where(key => !alreadyCovered.Contains(key)) + .Select(key => _registry.Get(key.Value)) + .GroupBy(definition => (definition.ApplicationTiming, definition.ApplicationKind)) + .OrderBy(group => group.Key.ApplicationTiming).ThenBy(group => group.Key.ApplicationKind)) + { + if (sequence == long.MaxValue) { return Invalid(envelope, "sequence_exhausted"); } + ++sequence; + batches.Add(new( + CreateStartupIdentity(startupId, sequence, "batch"), + group.Key.ApplicationTiming == SettingApplicationTiming.Restart ? SettingsApplicationBatchKind.Restart : SettingsApplicationBatchKind.LiveReconcile, + sequence, CreateStartupIdentity(startupId, sequence, "attempt"), SettingsApplicationBatchState.Pending, + group.Key.ApplicationKind, group.Select(definition => SettingsApplicationBatchEntry.Create(definition.Key, envelope.Desired[definition.Key])))); + } + + SettingsEnvelope next = new(envelope.SchemaVersion, envelope.EnvelopeRevision + 1, envelope.Desired, applied, + batches.OrderBy(batch => batch, SettingsApplicationBatchComparer.Instance), envelope.MigrationHistory); + return _validator.Validate(next).IsValid ? SettingsEnvelopeEditResult.Updated(next) : Invalid(envelope, "result_invalid"); + } + + private static Guid CreateStartupIdentity(Guid startupId, long sequence, string purpose) + { + byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes( + FormattableString.Invariant($"clashsharp-settings-startup-v1/{startupId:N}/{sequence}/{purpose}"))); + return new(digest.AsSpan(0, 16)); + } +} diff --git a/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.cs b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.cs new file mode 100644 index 00000000..bf027cca --- /dev/null +++ b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.cs @@ -0,0 +1,156 @@ +namespace ClashSharp.Settings; + +/// Advances exact application attempts without changing desired values or unrelated batch identities. +public sealed partial class SettingsApplicationBatchEditor +{ + private readonly SettingsRegistry _registry; + private readonly SettingsEnvelopeValidator _validator; + + /// Creates a pure lifecycle editor using the canonical schema. + /// Immutable setting definitions. + public SettingsApplicationBatchEditor(SettingsRegistry registry) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _validator = new(registry); + } + + /// Marks a pending attempt as possibly side-effecting before the participant is invoked. + /// Validated source envelope. + /// Exact batch to begin. + /// Expected current attempt; stale callbacks cannot begin a replacement attempt. + public SettingsEnvelopeEditResult BeginAttempt(SettingsEnvelope envelope, Guid batchId, Guid attemptId) + { + SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, attemptId, out SettingsApplicationBatch? batch); + if (invalid is not null) { return invalid; } + if (batch!.State == SettingsApplicationBatchState.Running) { return SettingsEnvelopeEditResult.NoChange(envelope); } + if (batch.State != SettingsApplicationBatchState.Pending) { return Invalid(envelope, "state"); } + + return Rewrite(envelope, ReplaceState(batch, SettingsApplicationBatchState.Running), + InvalidateEvidence(envelope, batch, SettingAppliedUnknownReason.NotObserved)); + } + + /// Retains failed work with unknown effective state; an attempted effect is never reported as rolled back without evidence. + /// Validated source envelope. + /// Exact running batch. + /// Expected current attempt. + /// Stable diagnostic chosen by the participant boundary. + public SettingsEnvelopeEditResult FailAttempt(SettingsEnvelope envelope, Guid batchId, Guid attemptId, SettingsApplicationError error) + { + ArgumentNullException.ThrowIfNull(error); + SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, attemptId, out SettingsApplicationBatch? batch); + if (invalid is not null) { return invalid; } + if (batch!.State != SettingsApplicationBatchState.Running) { return Invalid(envelope, "state"); } + + return Rewrite(envelope, ReplaceState(batch, SettingsApplicationBatchState.Failed, error), + InvalidateEvidence(envelope, batch, SettingAppliedUnknownReason.ProbeFailed)); + } + + /// Removes one running batch only when every independently observed value matches its exact desired revision and hash. + /// Validated source envelope. + /// Exact running batch. + /// Expected current attempt. + /// Complete independently obtained canonical values; duplicate or unrelated keys are rejected. + /// Runtime probe, mutation verification, or startup reconciliation evidence. + /// Nondefault UTC time of observation. + public SettingsEnvelopeEditResult CompleteAttempt( + SettingsEnvelope envelope, + Guid batchId, + Guid attemptId, + IEnumerable observedValues, + SettingAppliedValueSource source, + DateTimeOffset observedAt) + { + ArgumentNullException.ThrowIfNull(observedValues); + SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, attemptId, out SettingsApplicationBatch? batch); + if (invalid is not null) { return invalid; } + if (batch!.State != SettingsApplicationBatchState.Running) { return Invalid(envelope, "state"); } + if (source is not (SettingAppliedValueSource.RuntimeProbe or SettingAppliedValueSource.MutationVerification + or SettingAppliedValueSource.StartupReconciliation)) { return Invalid(envelope, "observation_source"); } + if (observedAt == default || observedAt.Offset != TimeSpan.Zero) { return Invalid(envelope, "observation_time"); } + + Dictionary observed = []; + foreach (SettingValueChange change in observedValues) + { + if (change is null || !observed.TryAdd(change.Key, change.Value)) { return Invalid(envelope, "observation_keys"); } + } + + if (observed.Count != batch.Entries.Count) { return Invalid(envelope, "observation_keys"); } + Dictionary applied = new(envelope.Applied); + foreach (SettingsApplicationBatchEntry entry in batch.Entries) + { + if (!observed.TryGetValue(entry.Key, out SettingValue? value)) { return Invalid(envelope, "observation_keys"); } + SettingDefinition definition = _registry.Get(entry.Key.Value); + SettingNormalizationResult normalized = definition.Normalize(value.CanonicalText); + if (!normalized.IsSuccess || !value.Equals(normalized.Value) + || !value.Equals(envelope.Desired[entry.Key].Value) + || SettingsApplicationBatchEntry.ComputeValueHash(value) != entry.ValueHash) + { + return Invalid(envelope, "observation_mismatch"); + } + + applied[entry.Key] = SettingAppliedState.Verified(value, source, entry.ValueHash, observedAt); + } + + return Rewrite(envelope, replacement: null, applied, batchId); + } + + /// Authorizes a failed batch to retry under a new attempt identity while retaining key revisions. + /// Validated source envelope. + /// Failed batch to retry. + /// Identity of the failed attempt being retried. + /// Fresh nonempty identity that invalidates previous attempt callbacks. + public SettingsEnvelopeEditResult RetryFailed(SettingsEnvelope envelope, Guid batchId, Guid expectedAttemptId, Guid newAttemptId) + { + SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, expectedAttemptId, out SettingsApplicationBatch? batch); + if (invalid is not null) { return invalid; } + if (batch!.State != SettingsApplicationBatchState.Failed) { return Invalid(envelope, "state"); } + if (newAttemptId == Guid.Empty || envelope.PendingApplications.Any(item => item.AttemptId == newAttemptId)) + { + return Invalid(envelope, "retry_identity"); + } + + SettingsApplicationBatch next = new(batch.BatchId, batch.Kind, batch.CreationSequence, newAttemptId, + SettingsApplicationBatchState.Pending, batch.ApplicationKind, batch.Entries); + return Rewrite(envelope, next, envelope.Applied); + } + + private SettingsEnvelopeEditResult? FindAttempt( + SettingsEnvelope envelope, Guid batchId, Guid attemptId, out SettingsApplicationBatch? batch) + { + ArgumentNullException.ThrowIfNull(envelope); + batch = null; + if (!_validator.Validate(envelope).IsValid) { return Invalid(envelope, "source_invalid"); } + batch = envelope.PendingApplications.SingleOrDefault(item => item.BatchId == batchId); + return batch is null || batch.AttemptId != attemptId ? Invalid(envelope, "stale_attempt") : null; + } + + private static SettingsApplicationBatch ReplaceState( + SettingsApplicationBatch batch, SettingsApplicationBatchState state, SettingsApplicationError? error = null) => + new(batch.BatchId, batch.Kind, batch.CreationSequence, batch.AttemptId, state, batch.ApplicationKind, batch.Entries, error); + + private static Dictionary InvalidateEvidence( + SettingsEnvelope envelope, SettingsApplicationBatch batch, SettingAppliedUnknownReason reason) + { + Dictionary result = new(envelope.Applied); + foreach (SettingsApplicationBatchEntry entry in batch.Entries) + { + result[entry.Key] = SettingAppliedState.Unknown(reason, SettingAppliedUnknownHandling.QueueApplication); + } + + return result; + } + + private SettingsEnvelopeEditResult Rewrite( + SettingsEnvelope source, SettingsApplicationBatch? replacement, + IEnumerable> applied, Guid? removedBatchId = null) + { + if (source.EnvelopeRevision == long.MaxValue) { return Invalid(source, "revision_exhausted"); } + SettingsEnvelope next = new(source.SchemaVersion, source.EnvelopeRevision + 1, source.Desired, applied, + source.PendingApplications.Where(batch => batch.BatchId != removedBatchId) + .Select(batch => batch.BatchId == replacement?.BatchId ? replacement! : batch), source.MigrationHistory); + return _validator.Validate(next).IsValid ? SettingsEnvelopeEditResult.Updated(next) : Invalid(source, "result_invalid"); + } + + private static SettingsEnvelopeEditResult Invalid(SettingsEnvelope source, string code) => + SettingsEnvelopeEditResult.Invalid(source, "settings.application." + code); +} diff --git a/ClashSharp/ClashSharp.Infrastructure/Security/WindowsControllerCredentialStore.cs b/ClashSharp/ClashSharp.Infrastructure/Security/WindowsControllerCredentialStore.cs new file mode 100644 index 00000000..2c8a3c4d --- /dev/null +++ b/ClashSharp/ClashSharp.Infrastructure/Security/WindowsControllerCredentialStore.cs @@ -0,0 +1,36 @@ +using ClashSharp.ApplicationModel.Security; +using ClashSharp.Security; +using Windows.Storage; + +namespace ClashSharp.Infrastructure.Security; + +/// Owns only the existing packaged-app controller credential slot, independently of preferences migration. +public sealed class WindowsControllerCredentialStore : IControllerCredentialStore +{ + internal const string CredentialKey = "MihomoControllerSecret"; + private readonly Func> _getValues; + + /// Creates the packaged Windows boundary without opening LocalSettings. + public WindowsControllerCredentialStore() : this(static () => ApplicationData.Current.LocalSettings.Values) { } + + internal WindowsControllerCredentialStore(Func> getValues) => + _getValues = getValues ?? throw new ArgumentNullException(nameof(getValues)); + + /// + public bool TryRead(out string? secret) + { + bool present = _getValues().TryGetValue(CredentialKey, out object? value); + secret = value as string; + return present; + } + + /// + public void Write(string secret) + { + if (!ControllerCredentialPolicy.IsValid(secret)) { throw new ArgumentException("Invalid controller credential shape.", nameof(secret)); } + _getValues()[CredentialKey] = secret; + } + + /// + public void Delete() => _getValues().Remove(CredentialKey); +} diff --git a/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs b/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs index 5a97d9fd..e31c09c1 100644 --- a/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs +++ b/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs @@ -43,6 +43,14 @@ await RestorePrimaryAsync(backup.Bytes!, cancellationToken) if (primary.Kind == SettingsFileReadKind.Missing && backup.Kind == SettingsFileReadKind.Missing) { + if (HasQuarantinedEnvelope()) + { + return SettingsPersistenceResult.Corrupt( + new SettingsPersistenceDiagnostic( + "settings.persistence.quarantined_without_valid_envelope", + SettingsDirectoryPath)); + } + return SettingsPersistenceResult.Succeeded(); } @@ -173,6 +181,26 @@ private void Quarantine(string path) } } + private bool HasQuarantinedEnvelope() + { + // Quarantine survives process restart. Its absence from the primary names + // must not authorize a new migration over a previously corrupt authority. + foreach (string path in Directory.EnumerateFileSystemEntries(SettingsDirectoryPath)) + { + string name = Path.GetFileName(path); + foreach (string prefix in new[] { PrimaryFileName + ".corrupt.", BackupFileName + ".corrupt." }) + { + if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && Guid.TryParseExact(name.AsSpan(prefix.Length), "N", out _)) + { + return true; + } + } + } + + return false; + } + private enum SettingsFileReadKind { Missing, diff --git a/ClashSharp/ClashSharp.Infrastructure/Settings/WindowsLegacySettingsSource.cs b/ClashSharp/ClashSharp.Infrastructure/Settings/WindowsLegacySettingsSource.cs new file mode 100644 index 00000000..61cfc00e --- /dev/null +++ b/ClashSharp/ClashSharp.Infrastructure/Settings/WindowsLegacySettingsSource.cs @@ -0,0 +1,34 @@ +using ClashSharp.ApplicationModel.Settings; +using ClashSharp.Settings; +using Windows.Storage; + +namespace ClashSharp.Infrastructure.Settings; + +/// Reads only registered legacy preferences during exclusively owned packaged-app startup. +/// This adapter never writes LocalSettings and never requests the internal controller credential. +public sealed class WindowsLegacySettingsSource : ILegacySettingsSource +{ + private readonly SettingsRegistry _registry; + + /// Creates a read-only adapter without accessing Windows storage. + /// Canonical allowlist and legacy aliases. + public WindowsLegacySettingsSource(SettingsRegistry registry) => + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + + /// + public Task ReadSnapshotAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var values = ApplicationData.Current.LocalSettings.Values; + Dictionary snapshot = new(StringComparer.Ordinal); + foreach (string key in _registry.Definitions.SelectMany( + definition => new[] { definition.Key.Value }.Concat(definition.Aliases.Select(alias => alias.Value)))) + { + cancellationToken.ThrowIfCancellationRequested(); + if (values.TryGetValue(key, out object? value)) { snapshot.Add(key, value); } + } + + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new LegacySettingsSnapshot(_registry, snapshot)); + } +} diff --git a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs index a6217bd1..81e454ba 100644 --- a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs +++ b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs @@ -221,7 +221,7 @@ public void WindowCloseWaitsForTheActiveRuntimeGeneration() } [Fact] - public void ShellUsesCSharpPurpleAsItsAccessibleInstallerAccent() + public void ShellUsesBrandGreenAsItsAccessibleInstallerAccent() { XNamespace presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; @@ -234,7 +234,7 @@ public void ShellUsesCSharpPurpleAsItsAccessibleInstallerAccent() XElement accent = Assert.Single( theme.Descendants(presentation + "Color"), color => (string?)color.Attribute(x + "Key") == "InstallerAccentColor"); - Assert.Equal("#7355DD", accent.Value.Trim()); + Assert.Equal("#0C7428", accent.Value.Trim()); Assert.Contains( theme.Descendants(presentation + "DataTrigger"), trigger => (string?)trigger.Attribute("Value") == "True" @@ -249,9 +249,9 @@ public void ShellUsesCSharpPurpleAsItsAccessibleInstallerAccent() "ClashSharp.Installer", "Themes", "InstallerTheme.xaml")); - Assert.DoesNotContain("#0C7428", themeText, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("#0B7026", themeText, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("#0A6822", themeText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("#7355DD", themeText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("#8066E8", themeText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("#6042C4", themeText, StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerCleanupTransactionStoreTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerCleanupTransactionStoreTests.cs new file mode 100644 index 00000000..15e8d80b --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerCleanupTransactionStoreTests.cs @@ -0,0 +1,408 @@ +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; +using ClashSharp.Installer.Windows.Files; +using ClashSharp.Installer.Windows.Transactions; + +namespace ClashSharp.Installer.Windows.Tests; + +public sealed class WindowsInstallerCleanupTransactionStoreTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LoadRecoversOnlyTheExactVerifiedUninstall(bool activePresent) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, activePresent ? verified : null); + var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified)); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + Assert.Equal(verified, await store.LoadAsync(CancellationToken.None)); + Assert.Equal(0, inner.SaveCalls); + Assert.Equal(0, ledger.SaveCalls); + } + + [Theory] + [InlineData("transaction")] + [InlineData("target")] + [InlineData("version")] + [InlineData("payload")] + public async Task LoadRejectsConflictingActiveAndTerminalIdentity(string field) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, ChangeIdentity(verified, field)); + var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified)); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => store.LoadAsync(CancellationToken.None)); + + Assert.Equal("installer.directory_ledger.terminal_identity_mismatch", failure.DiagnosticCode); + Assert.Equal(0, inner.ClearCalls); + Assert.Equal(0, ledger.SaveCalls); + } + + [Fact] + public async Task ClearPersistsAndReobservesTerminalBeforeDeletingJournalAndOnlySuppressesThisSession() + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + WindowsInstallerDirectoryLedger owned = OwnedLedger(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, owned); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None); + + Assert.Equal(["inner.load", "ledger.load", "ledger.save", "ledger.load", "inner.clear", "inner.load", "ledger.load"], events); + Assert.Null(inner.Current); + Assert.Equal(verified, ledger.Current!.Terminal); + Assert.Equal(owned.Directories, ledger.Current.Directories); + Assert.Equal(owned.Generation + 1, ledger.Current.Generation); + Assert.Null(await store.LoadAsync(CancellationToken.None)); + var reopened = new WindowsInstallerCleanupTransactionStore(inner, ledger); + Assert.Equal(verified, await reopened.LoadAsync(CancellationToken.None)); + Assert.Equal(0, inner.SaveCalls); + Assert.Equal(0, ledger.DeleteCalls); + } + + [Fact] + public async Task MissingLedgerBecomesTerminalWithoutInventingDirectoryOwnership() + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, null); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None); + + Assert.NotNull(ledger.Current); + Assert.Empty(ledger.Current.Directories); + Assert.Equal(verified, ledger.Current.Terminal); + Assert.Equal(1, ledger.Current.Generation); + } + + [Fact] + public async Task ReopenedTerminalCanReverifyAndClearWithoutRecreatingAnOrdinaryJournal() + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, null); + var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified)); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + Assert.Equal(verified, await store.SaveAsync(verified.Journal, verified.ContentHash, CancellationToken.None)); + await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None); + + Assert.Null(await store.LoadAsync(CancellationToken.None)); + Assert.Equal(verified, await new WindowsInstallerCleanupTransactionStore(inner, ledger).LoadAsync(CancellationToken.None)); + Assert.Equal(0, inner.SaveCalls); + Assert.Equal(0, inner.ClearCalls); + Assert.Equal(0, ledger.DeleteCalls); + } + + [Theory] + [InlineData("missing-hash")] + [InlineData("wrong-hash")] + [InlineData("transaction")] + [InlineData("target")] + [InlineData("version")] + [InlineData("payload")] + [InlineData("phase")] + [InlineData("operation")] + public async Task TerminalOnlySaveCannotAdoptAnotherTransactionOrRegress(string mutation) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + InstallerTransactionJournal requested = mutation switch + { + "missing-hash" or "wrong-hash" => verified.Journal, + "phase" => verified.Journal with { Phase = InstallerTransactionPhase.PackageCommitted, Generation = 4 }, + "operation" => verified.Journal with { Operation = InstallerOperation.Install }, + _ => ChangeIdentity(verified, mutation).Journal, + }; + string? expectedHash = mutation switch { "missing-hash" => null, "wrong-hash" => new string('d', 64), _ => verified.ContentHash }; + var inner = new MemoryStore(events, null); + var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified)); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => + store.SaveAsync(requested, expectedHash, CancellationToken.None)); + + Assert.Equal("installer.transaction.write_conflict", failure.DiagnosticCode); + Assert.Equal(0, inner.SaveCalls); + Assert.Equal(0, ledger.SaveCalls); + Assert.Equal(verified, ledger.Current!.Terminal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ClearRejectsWrongIdentityWithoutChangingEitherStore(bool wrongHash) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, null); + var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified)); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => store.ClearVerifiedAsync( + wrongHash ? verified.Journal.TransactionId : new string('d', 64), + wrongHash ? new string('d', 64) : verified.ContentHash, CancellationToken.None)); + + Assert.Equal("installer.transaction.clear_conflict", failure.DiagnosticCode); + Assert.Equal(0, inner.ClearCalls); + Assert.Equal(0, ledger.SaveCalls); + } + + [Theory] + [InlineData("before-terminal", false)] + [InlineData("after-terminal", false)] + [InlineData("before-clear", false)] + [InlineData("after-clear", true)] + [InlineData("after-clear-observation", true)] + public async Task IoFailurePreservesTheActualDurableCutPointForReopen(string cut, bool journalDeleted) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, OwnedLedger()); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + var injected = new IOException("Injected cleanup checkpoint failure."); + Action fail = () => throw injected; + Inject(cut, inner, ledger, fail); + + IOException actual = await Assert.ThrowsAsync(() => + store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None)); + + Assert.Same(injected, actual); + Assert.Equal(journalDeleted ? null : verified, inner.Current); + Assert.Equal(cut == "before-terminal" ? null : verified, ledger.Current!.Terminal); + inner.ResetFaults(); + ledger.ResetFaults(); + Assert.Equal(verified, await store.LoadAsync(CancellationToken.None)); + Assert.Equal(verified, await new WindowsInstallerCleanupTransactionStore(inner, ledger).LoadAsync(CancellationToken.None)); + Assert.Equal(0, ledger.DeleteCalls); + } + + [Theory] + [InlineData("before-terminal", false)] + [InlineData("after-terminal", false)] + [InlineData("before-clear", false)] + [InlineData("after-clear", true)] + [InlineData("after-clear-observation", true)] + public async Task CancellationNeverDiscardsTerminalRecovery(string cut, bool journalDeleted) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, OwnedLedger()); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + using var cancellation = new CancellationTokenSource(); + Inject(cut, inner, ledger, () => + { + cancellation.Cancel(); + cancellation.Token.ThrowIfCancellationRequested(); + }); + + await Assert.ThrowsAnyAsync(() => + store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, cancellation.Token)); + + Assert.Equal(journalDeleted ? null : verified, inner.Current); + Assert.Equal(cut == "before-terminal" ? null : verified, ledger.Current!.Terminal); + inner.ResetFaults(); + ledger.ResetFaults(); + Assert.Equal(verified, await new WindowsInstallerCleanupTransactionStore(inner, ledger).LoadAsync(CancellationToken.None)); + Assert.Equal(0, ledger.DeleteCalls); + } + + [Fact] + public async Task UnobservedTerminalWriteCannotDeleteTheActiveJournal() + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, OwnedLedger()); + ledger.AfterSave = () => ledger.Current = null; + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => + store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None)); + + Assert.Equal("installer.directory_ledger.terminal_not_observed", failure.DiagnosticCode); + Assert.Equal(verified, inner.Current); + Assert.Equal(0, inner.ClearCalls); + } + + [Theory] + [InlineData(InstallerOperation.Install)] + [InlineData(InstallerOperation.Repair)] + public async Task OtherOperationsUseTheInnerClearWithoutCreatingTerminalState(InstallerOperation operation) + { + List events = []; + InstallerTransactionSnapshot verified = Verified(operation); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, OwnedLedger()); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None); + + Assert.Null(await store.LoadAsync(CancellationToken.None)); + Assert.Null(ledger.Current!.Terminal); + Assert.Equal(0, ledger.SaveCalls); + Assert.Equal(1, inner.ClearCalls); + } + + [Fact] + public async Task OrdinarySavePreservesTheOriginalJournalCas() + { + List events = []; + InstallerTransactionSnapshot prepared = InstallerTransactionSnapshot.Create(Verified(InstallerOperation.Install).Journal with + { + Phase = InstallerTransactionPhase.Prepared, + Generation = 1, + }); + var inner = new MemoryStore(events, null); + var ledger = new MemoryLedger(events, OwnedLedger()); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + + Assert.Equal(prepared, await store.SaveAsync(prepared.Journal, null, CancellationToken.None)); + Assert.Equal(prepared, await store.LoadAsync(CancellationToken.None)); + await Assert.ThrowsAsync(() => store.SaveAsync(prepared.Journal, null, CancellationToken.None)); + Assert.Equal(0, ledger.SaveCalls); + } + + [Fact] + public async Task ClearedSessionDoesNotSuppressAReplacementTerminal() + { + List events = []; + InstallerTransactionSnapshot verified = Verified(); + var inner = new MemoryStore(events, verified); + var ledger = new MemoryLedger(events, OwnedLedger()); + var store = new WindowsInstallerCleanupTransactionStore(inner, ledger); + await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None); + ledger.Current = OwnedLedger().BeginTerminal(ChangeIdentity(verified, "transaction")); + + InstallerProtocolException failure = await Assert.ThrowsAsync(() => store.LoadAsync(CancellationToken.None)); + + Assert.Equal("installer.directory_ledger.terminal_identity_mismatch", failure.DiagnosticCode); + } + + private static void Inject(string cut, MemoryStore inner, MemoryLedger ledger, Action failure) + { + switch (cut) + { + case "before-terminal": ledger.BeforeSave = failure; break; + case "after-terminal": ledger.AfterSave = failure; break; + case "before-clear": inner.BeforeClear = failure; break; + case "after-clear": inner.AfterClear = failure; break; + case "after-clear-observation": inner.AfterClear = () => ledger.BeforeLoad = failure; break; + default: throw new ArgumentOutOfRangeException(nameof(cut)); + } + } + + private static InstallerTransactionSnapshot Verified(InstallerOperation operation = InstallerOperation.Uninstall) => + InstallerTransactionSnapshot.Create(new InstallerTransactionJournal(InstallerTransactionJournal.CurrentSchema, + new string('a', 64), operation, "S-1-5-21-100-200-300-1001", false, "1.0.0.0", new string('b', 64), + InstallerTransactionPhase.Verified, 5)); + + private static InstallerTransactionSnapshot ChangeIdentity(InstallerTransactionSnapshot state, string field) => + InstallerTransactionSnapshot.Create(field switch + { + "transaction" => state.Journal with { TransactionId = new string('c', 64) }, + "target" => state.Journal with { TargetSid = "S-1-5-21-100-200-300-1002" }, + "version" => state.Journal with { ExpectedPackageVersion = "1.0.1.0" }, + "payload" => state.Journal with { InstallerPayloadSha256 = new string('c', 64) }, + _ => throw new ArgumentOutOfRangeException(nameof(field)), + }); + + private static WindowsInstallerDirectoryLedger OwnedLedger() => WindowsInstallerDirectoryLedger.Empty.RecordCreated( + InstallerDirectoryRole.InstallerVersion, new WindowsFileIdentity(11, 22)); + + private sealed class MemoryStore(List events, InstallerTransactionSnapshot? initial) : IInstallerTransactionStore + { + internal InstallerTransactionSnapshot? Current { get; set; } = initial; + internal int SaveCalls { get; private set; } + internal int ClearCalls { get; private set; } + internal Action? BeforeClear { get; set; } + internal Action? AfterClear { get; set; } + + public Task LoadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + events.Add("inner.load"); + return Task.FromResult(Current); + } + + public Task SaveAsync(InstallerTransactionJournal journal, string? expectedCurrentHash, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Current?.ContentHash != expectedCurrentHash) + { + throw new InstallerProtocolException("installer.transaction.write_conflict"); + } + events.Add("inner.save"); + SaveCalls++; + Current = InstallerTransactionSnapshot.Create(journal); + return Task.FromResult(Current); + } + + public Task ClearVerifiedAsync(string transactionId, string expectedCurrentHash, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.NotNull(Current); + Assert.Equal(Current.Journal.TransactionId, transactionId); + Assert.Equal(Current.ContentHash, expectedCurrentHash); + events.Add("inner.clear"); + ClearCalls++; + BeforeClear?.Invoke(); + Current = null; + AfterClear?.Invoke(); + return Task.CompletedTask; + } + + internal void ResetFaults() { BeforeClear = null; AfterClear = null; } + } + + private sealed class MemoryLedger(List events, WindowsInstallerDirectoryLedger? initial) : IWindowsInstallerDirectoryLedgerPersistence + { + internal WindowsInstallerDirectoryLedger? Current { get; set; } = initial; + internal int SaveCalls { get; private set; } + internal int DeleteCalls { get; private set; } + internal Action? BeforeLoad { get; set; } + internal Action? BeforeSave { get; set; } + internal Action? AfterSave { get; set; } + + public Task LoadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + events.Add("ledger.load"); + BeforeLoad?.Invoke(); + return Task.FromResult(Current); + } + + public Task SaveAsync(WindowsInstallerDirectoryLedger? expected, WindowsInstallerDirectoryLedger desired, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.Same(expected, Current); + events.Add("ledger.save"); + SaveCalls++; + BeforeSave?.Invoke(); + Current = desired; + AfterSave?.Invoke(); + return Task.CompletedTask; + } + + public Task DeleteAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken) + { + DeleteCalls++; + throw new InvalidOperationException("The transaction store must not delete the terminal ledger."); + } + + internal void ResetFaults() { BeforeLoad = null; BeforeSave = null; AfterSave = null; } + } +} diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerDirectoryLedgerTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerDirectoryLedgerTests.cs new file mode 100644 index 00000000..3acbb931 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerDirectoryLedgerTests.cs @@ -0,0 +1,466 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; +using ClashSharp.Installer.Windows.Files; +using ClashSharp.Installer.Windows.Transactions; + +namespace ClashSharp.Installer.Windows.Tests; + +public sealed class WindowsInstallerDirectoryLedgerTests +{ + private const string ProgramFiles = @"C:\Program Files"; + private const string ProgramData = @"C:\ProgramData"; + private const string TargetSid = "S-1-5-21-100-200-300-1001"; + + [Fact] + public void RecordCreatedIsImmutableOrderedAndIdempotentForTheExactObject() + { + WindowsInstallerDirectoryLedger empty = WindowsInstallerDirectoryLedger.Empty; + WindowsInstallerDirectoryLedger first = empty.RecordCreated(InstallerDirectoryRole.AuthorityVersion, new(12, 22)); + WindowsInstallerDirectoryLedger second = first.RecordCreated(InstallerDirectoryRole.ProgramFilesProduct, new(12, 11)); + + Assert.Empty(empty.Directories); + Assert.Single(first.Directories); + Assert.Equal(2, second.Generation); + Assert.Equal([InstallerDirectoryRole.ProgramFilesProduct, InstallerDirectoryRole.AuthorityVersion], + second.Directories.Select(entry => entry.Role)); + Assert.Same(second, second.RecordCreated(InstallerDirectoryRole.AuthorityVersion, new(12, 22))); + + WindowsInstallerDirectoryLedger replaced = second.RecordCreated(InstallerDirectoryRole.AuthorityVersion, new(99, 33)); + Assert.Equal(3, replaced.Generation); + Assert.Equal(new WindowsFileIdentity(99, 33), replaced.Directories[1].Identity); + Assert.Equal(new WindowsFileIdentity(12, 22), second.Directories[1].Identity); + Assert.Equal(2, replaced.Directories.Count); + } + + [Fact] + public void ConstructorCopiesCallerEntriesAndDoesNotSortAnInvalidSequence() + { + WindowsInstallerOwnedDirectory[] entries = [new(InstallerDirectoryRole.InstallerRoot, new(1, 1))]; + var ledger = new WindowsInstallerDirectoryLedger(1, entries, null); + entries[0] = new(InstallerDirectoryRole.AuthorityRoot, new(2, 2)); + Assert.Equal(InstallerDirectoryRole.InstallerRoot, ledger.Directories[0].Role); + Assert.Throws(() => new WindowsInstallerDirectoryLedger(2, + [entries[0], new(InstallerDirectoryRole.ProgramFilesProduct, new(1, 1))], null)); + Assert.Throws(() => new WindowsInstallerDirectoryLedger(2, [entries[0], entries[0]], null)); + } + + [Fact] + public void InvalidIdentitiesAndGenerationAreRejectedBeforeProducingANewLedger() + { + WindowsInstallerDirectoryLedger empty = WindowsInstallerDirectoryLedger.Empty; + Assert.Throws(() => empty.RecordCreated((InstallerDirectoryRole)99, new(1, 1))); + Assert.Throws(() => empty.RecordCreated(InstallerDirectoryRole.InstallerRoot, new(1, 0))); + Assert.Throws(() => new WindowsInstallerDirectoryLedger(-1, [], null)); + Assert.Throws(() => new WindowsInstallerDirectoryLedger(7, + Enumerable.Repeat(new WindowsInstallerOwnedDirectory(InstallerDirectoryRole.InstallerRoot, new(1, 1)), 7), null)); + var maximum = new WindowsInstallerDirectoryLedger(long.MaxValue, [], null); + Assert.Throws(() => maximum.RecordCreated(InstallerDirectoryRole.InstallerRoot, new(1, 1))); + Assert.Throws(() => maximum.BeginTerminal(Verified())); + Assert.Empty(maximum.Directories); + Assert.Null(maximum.Terminal); + } + + [Fact] + public void BeginTerminalIsIdempotentOnlyForTheSameCanonicalVerifiedUninstall() + { + WindowsInstallerDirectoryLedger created = WindowsInstallerDirectoryLedger.Empty.RecordCreated( + InstallerDirectoryRole.InstallerRoot, new(10, 20)); + InstallerTransactionSnapshot state = Verified(); + WindowsInstallerDirectoryLedger terminal = created.BeginTerminal(state); + Assert.Null(created.Terminal); + Assert.Equal(created.Generation + 1, terminal.Generation); + Assert.Equal(created.Directories, terminal.Directories); + Assert.Same(terminal, terminal.BeginTerminal(InstallerTransactionSnapshot.Create(state.Journal))); + + foreach (InstallerTransactionJournal changed in new[] + { + state.Journal with { TransactionId = new string('b', 64) }, + state.Journal with { TargetSid = "S-1-5-21-100-200-300-1002" }, + state.Journal with { InstallerPayloadSha256 = new string('c', 64) }, + state.Journal with { ExpectedPackageVersion = "1.0.1.0" }, + }) + { + InstallerProtocolException failure = Assert.Throws(() => + terminal.BeginTerminal(InstallerTransactionSnapshot.Create(changed))); + Assert.Equal("installer.directory_ledger.terminal_identity_mismatch", failure.DiagnosticCode); + } + } + + [Theory] + [InlineData(InstallerOperation.Install, InstallerTransactionPhase.Verified, 5)] + [InlineData(InstallerOperation.Repair, InstallerTransactionPhase.Verified, 5)] + [InlineData(InstallerOperation.Uninstall, InstallerTransactionPhase.Prepared, 1)] + public void TerminalRequiresAnIndependentlyValidVerifiedUninstall( + InstallerOperation operation, InstallerTransactionPhase phase, int generation) + { + InstallerTransactionJournal journal = Verified().Journal with { Operation = operation, Phase = phase, Generation = generation }; + InstallerProtocolException failure = Assert.Throws(() => + WindowsInstallerDirectoryLedger.Empty.BeginTerminal(InstallerTransactionSnapshot.Create(journal))); + Assert.Equal("installer.directory_ledger.terminal_not_verified_uninstall", failure.DiagnosticCode); + } + + [Fact] + public void CodecRoundTripsAllRolesAndTheExactTerminalSnapshot() + { + WindowsInstallerDirectoryLedger ledger = CompleteLedger().BeginTerminal(Verified()); + byte[] serialized = WindowsInstallerDirectoryLedgerCodec.Serialize(ledger); + WindowsInstallerDirectoryLedger parsed = WindowsInstallerDirectoryLedgerCodec.Parse(serialized); + Assert.Equal(ledger.Generation, parsed.Generation); + Assert.Equal(ledger.Directories, parsed.Directories); + Assert.Equal(ledger.Terminal, parsed.Terminal); + Assert.Equal(serialized, WindowsInstallerDirectoryLedgerCodec.Serialize(parsed)); + Assert.True(serialized.Length < WindowsInstallerDirectoryLedgerCodec.MaximumDocumentBytes); + } + + [Theory] + [InlineData("whitespace")] + [InlineData("property-order")] + [InlineData("escaping")] + public void NoncanonicalBytesAreRejectedBeforeTheyCanPoisonCompareAndSwap(string variation) + { + byte[] canonical = WindowsInstallerDirectoryLedgerCodec.Serialize(CompleteLedger()); + string json = Encoding.UTF8.GetString(canonical); + string changed; + if (variation == "property-order") + { + var reordered = new JsonObject(); + foreach (KeyValuePair property in JsonNode.Parse(canonical)!.AsObject().Reverse()) + { + reordered.Add(property.Key, property.Value?.DeepClone()); + } + changed = reordered.ToJsonString(); + } + else + { + changed = variation == "whitespace" ? " " + json + : json.Replace("ProgramFilesProduct", "ProgramFiles\\u0050roduct", StringComparison.Ordinal); + } + + // Save(expected) compares Serialize(expected) to the actual file bytes. Accepting a + // semantically equivalent, different byte sequence here would strand future updates. + Assert.NotEqual(json, changed); + Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(Utf8(changed))); + } + + [Theory] + [InlineData("unknown-root")] + [InlineData("missing-root")] + [InlineData("duplicate-root")] + [InlineData("schema")] + [InlineData("generation-string")] + [InlineData("negative-generation")] + [InlineData("generation-overflow")] + [InlineData("null-entries")] + [InlineData("unknown-entry")] + [InlineData("duplicate-entry-property")] + [InlineData("unknown-role")] + [InlineData("numeric-role")] + [InlineData("wrong-case-role")] + [InlineData("zero-id")] + [InlineData("negative-id")] + [InlineData("id-overflow")] + [InlineData("volume-overflow")] + [InlineData("duplicate-role")] + [InlineData("unsorted-role")] + [InlineData("too-many-roles")] + [InlineData("trailing-comma")] + [InlineData("comment")] + [InlineData("non-object")] + [InlineData("too-deep")] + public void CodecRejectsAmbiguousOrOutOfScopeDocuments(string mutation) + { + JsonObject root = JsonNode.Parse(WindowsInstallerDirectoryLedgerCodec.Serialize(CompleteLedger()))!.AsObject(); + JsonArray entries = root["createdDirectories"]!.AsArray(); + JsonObject first = entries[0]!.AsObject(); + byte[]? raw = null; + switch (mutation) + { + case "unknown-root": root["extra"] = true; break; + case "missing-root": root.Remove("terminal"); break; + case "duplicate-root": raw = Utf8(root.ToJsonString().Replace("\"schema\":1", "\"schema\":1,\"schema\":1", StringComparison.Ordinal)); break; + case "schema": root["schema"] = 2; break; + case "generation-string": root["generation"] = "6"; break; + case "negative-generation": root["generation"] = -1; break; + case "generation-overflow": root["generation"] = ulong.MaxValue; break; + case "null-entries": root["createdDirectories"] = null; break; + case "unknown-entry": first["path"] = @"C:\foreign"; break; + case "duplicate-entry-property": raw = Utf8(root.ToJsonString().Replace("\"fileIndex\":1", "\"fileIndex\":1,\"fileIndex\":1", StringComparison.Ordinal)); break; + case "unknown-role": first["role"] = "ForeignRoot"; break; + case "numeric-role": first["role"] = "0"; break; + case "wrong-case-role": first["role"] = "programFilesProduct"; break; + case "zero-id": first["fileIndex"] = 0; break; + case "negative-id": first["fileIndex"] = -1; break; + case "id-overflow": first["fileIndex"] = JsonNode.Parse("18446744073709551616"); break; + case "volume-overflow": first["volumeSerialNumber"] = ulong.MaxValue; break; + case "duplicate-role": entries[1] = first.DeepClone(); break; + case "unsorted-role": + JsonNode copy = first.DeepClone(); + entries[0] = entries[1]!.DeepClone(); + entries[1] = copy; + break; + case "too-many-roles": entries.Add(first.DeepClone()); break; + case "trailing-comma": raw = Utf8(root.ToJsonString()[..^1] + ",}"); break; + case "comment": raw = Utf8("/*metadata*/" + root.ToJsonString()); break; + case "non-object": raw = Utf8("[]"); break; + case "too-deep": root["terminal"] = JsonNode.Parse("{\"nested\":{\"a\":{\"b\":{\"c\":1}}}}"); break; + default: throw new ArgumentOutOfRangeException(nameof(mutation)); + } + Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(raw ?? Utf8(root.ToJsonString()))); + } + + [Theory] + [InlineData("wrong-hash")] + [InlineData("uppercase-hash")] + [InlineData("invalid-base64")] + [InlineData("noncanonical-base64")] + [InlineData("noncanonical-journal")] + [InlineData("invalid-nonce")] + [InlineData("uppercase-nonce")] + [InlineData("wrong-phase")] + [InlineData("wrong-operation")] + [InlineData("extra-terminal-property")] + [InlineData("duplicate-terminal-property")] + public void TerminalBytesMustMatchTheirCanonicalNoncePhaseAndHash(string mutation) + { + JsonObject root = JsonNode.Parse(WindowsInstallerDirectoryLedgerCodec.Serialize( + CompleteLedger().BeginTerminal(Verified())))!.AsObject(); + JsonObject terminal = root["terminal"]!.AsObject(); + byte[]? raw = null; + switch (mutation) + { + case "wrong-hash": terminal["contentHash"] = new string('0', 64); break; + case "uppercase-hash": terminal["contentHash"] = terminal["contentHash"]!.GetValue().ToUpperInvariant(); break; + case "invalid-base64": terminal["journalBase64"] = "%%%"; break; + case "noncanonical-base64": terminal["journalBase64"] = " " + terminal["journalBase64"]!.GetValue(); break; + case "extra-terminal-property": terminal["path"] = @"C:\foreign"; break; + case "duplicate-terminal-property": + raw = Utf8(root.ToJsonString().Replace("\"contentHash\":", "\"contentHash\":\"ignored\",\"contentHash\":", StringComparison.Ordinal)); + break; + default: + JsonObject journal = JsonNode.Parse(Convert.FromBase64String(terminal["journalBase64"]!.GetValue()))!.AsObject(); + switch (mutation) + { + case "invalid-nonce": journal["transactionId"] = new string('a', 63); break; + case "uppercase-nonce": journal["transactionId"] = new string('A', 64); break; + case "wrong-phase": journal["phase"] = "prepared"; journal["generation"] = 1; break; + case "wrong-operation": journal["operation"] = "install"; break; + case "noncanonical-journal": break; + default: throw new ArgumentOutOfRangeException(nameof(mutation)); + } + byte[] bytes = Utf8((mutation == "noncanonical-journal" ? " " : string.Empty) + journal.ToJsonString()); + terminal["journalBase64"] = Convert.ToBase64String(bytes); + terminal["contentHash"] = Convert.ToHexStringLower(SHA256.HashData(bytes)); + break; + } + Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(raw ?? Utf8(root.ToJsonString()))); + } + + [Fact] + public void DocumentSizeBoundaryIsCheckedBeforeJsonParsing() + { + int maximum = WindowsInstallerDirectoryLedgerCodec.MaximumDocumentBytes; + byte[] padded = Enumerable.Repeat((byte)' ', maximum).ToArray(); + WindowsInstallerDirectoryLedgerCodec.Serialize(WindowsInstallerDirectoryLedger.Empty).CopyTo(padded, 0); + InstallerProtocolException boundaryFailure = Assert.Throws(() => + WindowsInstallerDirectoryLedgerCodec.Parse(padded)); + Assert.NotEqual("installer.directory_ledger.document_size_invalid", boundaryFailure.DiagnosticCode); + foreach (byte[] invalid in new[] { Array.Empty(), new byte[maximum + 1] }) + { + InstallerProtocolException failure = Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(invalid)); + Assert.Equal("installer.directory_ledger.document_size_invalid", failure.DiagnosticCode); + } + } + + [Fact] + public void FixedLayoutKeepsItsLedgerOutsideAllDeletionTargetsAndOrdersChildrenFirst() + { + var layout = new WindowsInstallerDirectoryCleanupLayout(ProgramFiles, ProgramData); + Assert.Equal(Path.Combine(ProgramData, WindowsInstallerDirectoryCleanupLayout.LedgerFileName), layout.LedgerPath); + InstallerDirectoryRole[] order = WindowsInstallerDirectoryCleanupLayout.DeletionOrder; + Assert.Equal(6, order.Distinct().Count()); + foreach (InstallerDirectoryRole role in Enum.GetValues()) + { + string path = layout.GetPath(role); + Assert.True(layout.TryGetRole(path.ToUpperInvariant(), out InstallerDirectoryRole observed)); + Assert.Equal(role, observed); + Assert.False(layout.LedgerPath.StartsWith(path + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); + foreach (InstallerDirectoryRole parent in order.Where(parent => + path.StartsWith(layout.GetPath(parent) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))) + { + Assert.True(Array.IndexOf(order, role) < Array.IndexOf(order, parent)); + } + } + Assert.Throws(() => layout.GetPath((InstallerDirectoryRole)99)); + } + + [Theory] + [InlineData(@"C:\ProgramData")] + [InlineData(@"C:\ProgramData\ClashSharp-other")] + [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2\child")] + [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2\..")] + [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2:stream")] + [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2\")] + [InlineData(@"\\?\C:\ProgramData\ClashSharp\Installer\v2")] + public void FixedLayoutDoesNotAuthorizeAncestorSiblingChildOrAliasPaths(string path) + { + var layout = new WindowsInstallerDirectoryCleanupLayout(ProgramFiles, ProgramData); + Assert.False(layout.TryGetRole(path, out _)); + } + + [Fact] + public async Task PersistenceUsesOnlyTheFixedLedgerAndConfirmsSaveAndDeleteWhileAnchored() + { + var fixture = new PersistenceFixture(); + WindowsInstallerDirectoryLedger desired = CompleteLedger(); + Assert.Null(await fixture.Persistence.LoadAsync(CancellationToken.None)); + await fixture.Persistence.SaveAsync(null, desired, CancellationToken.None); + WindowsInstallerDirectoryLedger? loaded = await fixture.Persistence.LoadAsync(CancellationToken.None); + Assert.NotNull(loaded); + Assert.Equal(WindowsInstallerDirectoryLedgerCodec.Serialize(desired), WindowsInstallerDirectoryLedgerCodec.Serialize(loaded)); + await fixture.Persistence.DeleteAsync(desired, CancellationToken.None); + Assert.Null(fixture.Files.Bytes); + Assert.Equal(0, fixture.ActiveAnchors); + Assert.All(fixture.Files.Paths, path => Assert.Equal(Path.Combine(ProgramData, WindowsInstallerDirectoryCleanupLayout.LedgerFileName), path)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NativeAcknowledgementWithoutItsPostconditionCannotSucceed(bool deleting) + { + var fixture = new PersistenceFixture(); + WindowsInstallerDirectoryLedger desired = CompleteLedger(); + fixture.Files.IgnoreMutation = true; + if (deleting) { fixture.Files.Bytes = WindowsInstallerDirectoryLedgerCodec.Serialize(desired); } + InstallerProtocolException failure = await Assert.ThrowsAsync(() => deleting + ? fixture.Persistence.DeleteAsync(desired, CancellationToken.None) + : fixture.Persistence.SaveAsync(null, desired, CancellationToken.None)); + Assert.Equal(deleting ? "installer.directory_ledger.delete_not_observed" : "installer.directory_ledger.write_not_observed", failure.DiagnosticCode); + Assert.Equal(0, fixture.ActiveAnchors); + } + + [Theory] + [InlineData("load")] + [InlineData("save")] + [InlineData("delete")] + public async Task StorageFailuresPropagateWithoutLosingTheAnchorLease(string operation) + { + var fixture = new PersistenceFixture(); + var failure = new IOException("Injected metadata I/O failure."); + fixture.Files.Failure = failure; + IOException observed = await Assert.ThrowsAsync(() => RunPersistenceAsync(fixture, operation)); + Assert.Same(failure, observed); + Assert.Equal(0, fixture.ActiveAnchors); + } + + [Fact] + public async Task CorruptPersistedBytesAreNotTreatedAsAMissingLedger() + { + var fixture = new PersistenceFixture(); + fixture.Files.Bytes = Utf8("{}"); + await Assert.ThrowsAsync(() => fixture.Persistence.LoadAsync(CancellationToken.None)); + Assert.Equal(0, fixture.ActiveAnchors); + Assert.Equal(Utf8("{}"), fixture.Files.Bytes); + } + + [Fact] + public async Task PreCancelledOperationsAcquireNoAnchorAndInvokeNoNativeFileWork() + { + var fixture = new PersistenceFixture(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => fixture.Persistence.LoadAsync(cancellation.Token)); + await Assert.ThrowsAnyAsync(() => fixture.Persistence.SaveAsync(null, CompleteLedger(), cancellation.Token)); + await Assert.ThrowsAnyAsync(() => fixture.Persistence.DeleteAsync(CompleteLedger(), cancellation.Token)); + Assert.Equal(0, fixture.AnchorAcquisitions); + Assert.Empty(fixture.Files.Paths); + } + + private static Task RunPersistenceAsync(PersistenceFixture fixture, string operation) => operation switch + { + "load" => fixture.Persistence.LoadAsync(CancellationToken.None), + "save" => fixture.Persistence.SaveAsync(null, CompleteLedger(), CancellationToken.None), + "delete" => fixture.Persistence.DeleteAsync(CompleteLedger(), CancellationToken.None), + _ => throw new ArgumentOutOfRangeException(nameof(operation)), + }; + + private static byte[] Utf8(string value) => Encoding.UTF8.GetBytes(value); + + private static InstallerTransactionSnapshot Verified() => InstallerTransactionSnapshot.Create(new( + InstallerTransactionJournal.CurrentSchema, new string('a', 64), InstallerOperation.Uninstall, + TargetSid, false, "1.0.0.0", new string('d', 64), InstallerTransactionPhase.Verified, 5)); + + private static WindowsInstallerDirectoryLedger CompleteLedger() => new(6, + Enum.GetValues().Select(role => new WindowsInstallerOwnedDirectory(role, new(12, (ulong)role + 1))), null); + + private sealed class PersistenceFixture + { + internal PersistenceFixture() + { + Files = new Files(this); + Persistence = new(new WindowsInstallerDirectoryCleanupLayout(ProgramFiles, ProgramData), Acquire, Files); + } + + internal WindowsInstallerDirectoryLedgerPersistence Persistence { get; } + internal Files Files { get; } + internal int ActiveAnchors { get; private set; } + internal int AnchorAcquisitions { get; private set; } + + private IDisposable Acquire() + { + ActiveAnchors++; + AnchorAcquisitions++; + return new Anchor(this); + } + + private sealed class Anchor(PersistenceFixture owner) : IDisposable + { + private bool _disposed; + public void Dispose() + { + if (!_disposed) { _disposed = true; owner.ActiveAnchors--; } + } + } + } + + private sealed class Files(PersistenceFixture fixture) : IWindowsInstallerDirectoryLedgerFileNative + { + internal byte[]? Bytes { get; set; } + internal bool IgnoreMutation { get; set; } + internal Exception? Failure { get; set; } + internal List Paths { get; } = []; + + public Task ReadAsync(string path, CancellationToken cancellationToken) + { + Observe(path, cancellationToken); + return Task.FromResult(Bytes?.ToArray()); + } + + public Task PublishAsync(string path, byte[]? expected, byte[] desired, CancellationToken cancellationToken) + { + Observe(path, cancellationToken); + Assert.Equal(Bytes, expected); + if (!IgnoreMutation) { Bytes = desired.ToArray(); } + return Task.CompletedTask; + } + + public Task DeleteAsync(string path, byte[] expected, CancellationToken cancellationToken) + { + Observe(path, cancellationToken); + Assert.Equal(Bytes, expected); + if (!IgnoreMutation) { Bytes = null; } + return Task.CompletedTask; + } + + private void Observe(string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.Equal(1, fixture.ActiveAnchors); + Paths.Add(path); + if (Failure is { } failure) { throw failure; } + } + } +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerCleanupTransactionStore.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerCleanupTransactionStore.cs new file mode 100644 index 00000000..725c51c3 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerCleanupTransactionStore.cs @@ -0,0 +1,131 @@ +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; + +namespace ClashSharp.Installer.Windows.Transactions; + +/// +/// Keeps the exact verified uninstall recoverable while its ordinary journal and owned directories +/// are finalized. The helper's existing authority lease serializes this session; neither store is owned. +/// +internal sealed class WindowsInstallerCleanupTransactionStore : IInstallerTransactionStore +{ + private readonly IInstallerTransactionStore _inner; + private readonly IWindowsInstallerDirectoryLedgerPersistence _ledger; + private InstallerTransactionSnapshot? _clearedTerminal; + + internal WindowsInstallerCleanupTransactionStore(IInstallerTransactionStore inner, + IWindowsInstallerDirectoryLedgerPersistence ledger) + { + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(ledger); + _inner = inner; + _ledger = ledger; + } + + public async Task LoadAsync(CancellationToken cancellationToken) + { + (InstallerTransactionSnapshot? active, WindowsInstallerDirectoryLedger? ledger) = + await ReadAsync(cancellationToken).ConfigureAwait(false); + if (active is not null) { return active; } + InstallerTransactionSnapshot? terminal = ledger?.Terminal; + // Only this successfully cleared helper session suppresses its exact terminal checkpoint. + // A new parent or helper wrapper still sees it until the directory finalizer deletes it. + return terminal == _clearedTerminal ? null : terminal; + } + + public async Task SaveAsync(InstallerTransactionJournal journal, + string? expectedCurrentHash, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(journal); + InstallerTransactionSnapshot desired = InstallerTransactionSnapshot.Create(journal); + (InstallerTransactionSnapshot? _, WindowsInstallerDirectoryLedger? ledger) = + await ReadAsync(cancellationToken).ConfigureAwait(false); + if (ledger?.Terminal is { } terminal) + { + if (desired != terminal + || !string.Equals(expectedCurrentHash, terminal.ContentHash, StringComparison.Ordinal) + || _clearedTerminal is not null) + { + throw new InstallerProtocolException("installer.transaction.write_conflict"); + } + // Final verification may acknowledge the same Verified checkpoint. Never resurrect + // an ordinary journal or manufacture a Prepared transaction from terminal-only state. + return terminal; + } + if (_clearedTerminal is not null) + { + throw new InstallerProtocolException("installer.transaction.write_conflict"); + } + return await _inner.SaveAsync(journal, expectedCurrentHash, cancellationToken).ConfigureAwait(false); + } + + public async Task ClearVerifiedAsync(string transactionId, string expectedCurrentHash, + CancellationToken cancellationToken) + { + InstallerProtocolValidation.ValidateLowerHex256(transactionId, "installer.transaction.id_invalid"); + InstallerProtocolValidation.ValidateLowerHex256(expectedCurrentHash, "installer.transaction.content_hash_invalid"); + (InstallerTransactionSnapshot? active, WindowsInstallerDirectoryLedger? ledger) = + await ReadAsync(cancellationToken).ConfigureAwait(false); + InstallerTransactionSnapshot? current = active ?? ledger?.Terminal; + if (current is null || current.Journal.Phase != InstallerTransactionPhase.Verified + || !string.Equals(current.Journal.TransactionId, transactionId, StringComparison.Ordinal) + || !string.Equals(current.ContentHash, expectedCurrentHash, StringComparison.Ordinal)) + { + throw new InstallerProtocolException("installer.transaction.clear_conflict"); + } + + if (current.Journal.Operation != InstallerOperation.Uninstall) + { + await _inner.ClearVerifiedAsync(transactionId, expectedCurrentHash, cancellationToken).ConfigureAwait(false); + return; + } + + WindowsInstallerDirectoryLedger pending = (ledger ?? WindowsInstallerDirectoryLedger.Empty).BeginTerminal(current); + await _ledger.SaveAsync(ledger, pending, cancellationToken).ConfigureAwait(false); + await RequireTerminalAsync(pending, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (active is not null) + { + await _inner.ClearVerifiedAsync(transactionId, expectedCurrentHash, cancellationToken).ConfigureAwait(false); + } + + InstallerTransactionSnapshot? remaining = await _inner.LoadAsync(cancellationToken).ConfigureAwait(false); + remaining?.Validate(); + if (remaining is not null) + { + throw new InstallerProtocolException("installer.transaction.clear_not_observed"); + } + await RequireTerminalAsync(pending, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + _clearedTerminal = current; + } + + private async Task<(InstallerTransactionSnapshot? Active, WindowsInstallerDirectoryLedger? Ledger)> ReadAsync( + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + InstallerTransactionSnapshot? active = await _inner.LoadAsync(cancellationToken).ConfigureAwait(false); + active?.Validate(); + WindowsInstallerDirectoryLedger? ledger = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false); + ledger?.Validate(); + if (ledger?.Terminal is { } terminal + && ((active is not null && active != terminal) + || (_clearedTerminal is not null && _clearedTerminal != terminal))) + { + throw WindowsInstallerDirectoryLedger.Failure("terminal_identity_mismatch"); + } + cancellationToken.ThrowIfCancellationRequested(); + return (active, ledger); + } + + private async Task RequireTerminalAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken) + { + WindowsInstallerDirectoryLedger? observed = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false); + observed?.Validate(); + if (observed is null || observed.Generation != expected.Generation || observed.Terminal != expected.Terminal + || !observed.Directories.SequenceEqual(expected.Directories)) + { + throw WindowsInstallerDirectoryLedger.Failure("terminal_not_observed"); + } + } +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryCleanupLayout.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryCleanupLayout.cs new file mode 100644 index 00000000..aa8b4adf --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryCleanupLayout.cs @@ -0,0 +1,54 @@ +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Windows.Machines; + +namespace ClashSharp.Installer.Windows.Transactions; + +internal sealed class WindowsInstallerDirectoryCleanupLayout +{ + internal const string LedgerFileName = "ClashSharp.InstallerDirectories.v1.json"; + private readonly IReadOnlyDictionary _paths; + + internal WindowsInstallerDirectoryCleanupLayout(string programFiles, string programData) + { + WindowsMachineDeploymentRoots roots = WindowsMachineDeploymentRoots.Create(programFiles, programData); + ProgramData = roots.CommonApplicationDataRoot; + LedgerPath = Path.Combine(ProgramData, LedgerFileName); + string productData = Path.Combine(ProgramData, "ClashSharp"); + _paths = new Dictionary + { + [InstallerDirectoryRole.ProgramFilesProduct] = Path.Combine(roots.ProgramFilesRoot, "ClashSharp"), + [InstallerDirectoryRole.ProgramDataProduct] = productData, + [InstallerDirectoryRole.InstallerRoot] = Path.Combine(productData, "Installer"), + [InstallerDirectoryRole.InstallerVersion] = Path.Combine(productData, "Installer", "v2"), + [InstallerDirectoryRole.AuthorityRoot] = Path.Combine(productData, "InstallerAuthority"), + [InstallerDirectoryRole.AuthorityVersion] = Path.Combine(productData, "InstallerAuthority", "v1"), + }; + } + + internal string ProgramData { get; } + internal string LedgerPath { get; } + internal string GetPath(InstallerDirectoryRole role) => _paths.TryGetValue(role, out string? path) + ? path : throw WindowsInstallerDirectoryLedger.Failure("role_invalid"); + + internal bool TryGetRole(string path, out InstallerDirectoryRole role) + { + foreach (KeyValuePair entry in _paths) + { + if (string.Equals(path, entry.Value, StringComparison.OrdinalIgnoreCase)) { role = entry.Key; return true; } + } + role = default; + return false; + } + + internal static WindowsInstallerDirectoryCleanupLayout CreateDefault() => new( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles, Environment.SpecialFolderOption.DoNotVerify), + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData, Environment.SpecialFolderOption.DoNotVerify)); + + internal static InstallerDirectoryRole[] DeletionOrder => + [ + InstallerDirectoryRole.ProgramFilesProduct, + InstallerDirectoryRole.InstallerVersion, InstallerDirectoryRole.InstallerRoot, + InstallerDirectoryRole.AuthorityVersion, InstallerDirectoryRole.AuthorityRoot, + InstallerDirectoryRole.ProgramDataProduct, + ]; +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedger.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedger.cs new file mode 100644 index 00000000..486a5fa7 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedger.cs @@ -0,0 +1,181 @@ +using System.Buffers; +using System.Collections.ObjectModel; +using System.Security.Cryptography; +using System.Text.Json; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; +using ClashSharp.Installer.Windows.Files; + +namespace ClashSharp.Installer.Windows.Transactions; + +internal sealed record WindowsInstallerOwnedDirectory(InstallerDirectoryRole Role, WindowsFileIdentity Identity); + +/// Only a positively observed directory creation can add an object to this ledger. +internal sealed class WindowsInstallerDirectoryLedger +{ + internal WindowsInstallerDirectoryLedger(long generation, + IEnumerable directories, InstallerTransactionSnapshot? terminal) + { + ArgumentNullException.ThrowIfNull(directories); + Generation = generation; + Directories = Array.AsReadOnly(directories.ToArray()); + Terminal = terminal; + Validate(); + } + + internal long Generation { get; } + internal ReadOnlyCollection Directories { get; } + internal InstallerTransactionSnapshot? Terminal { get; } + internal static WindowsInstallerDirectoryLedger Empty => new(0, [], null); + + internal WindowsInstallerDirectoryLedger RecordCreated(InstallerDirectoryRole role, WindowsFileIdentity identity) + { + var entry = new WindowsInstallerOwnedDirectory(role, identity); + if (Directories.Contains(entry)) { return this; } + return new(checked(Generation + 1), Directories.Where(item => item.Role != role) + .Append(entry).OrderBy(item => item.Role), Terminal); + } + + internal WindowsInstallerDirectoryLedger BeginTerminal(InstallerTransactionSnapshot state) + { + ArgumentNullException.ThrowIfNull(state); + if (Terminal is not null) + { + if (Terminal != state) { throw Failure("terminal_identity_mismatch"); } + return this; + } + return new(checked(Generation + 1), Directories, state); + } + + internal void Validate() + { + if (Generation < 0 || Directories.Count > 6) { throw Failure("shape_invalid"); } + InstallerDirectoryRole? previous = null; + foreach (WindowsInstallerOwnedDirectory entry in Directories) + { + if (entry is null || !Enum.IsDefined(entry.Role) || entry.Identity.FileIndex == 0 + || previous is { } last && entry.Role <= last) { throw Failure("directory_identity_invalid"); } + previous = entry.Role; + } + if (Terminal is { } state) + { + state.Validate(); + if (state.Journal.Operation != InstallerOperation.Uninstall + || state.Journal.Phase != InstallerTransactionPhase.Verified) + { + throw Failure("terminal_not_verified_uninstall"); + } + } + } + + internal static InstallerProtocolException Failure(string suffix) => new("installer.directory_ledger." + suffix); +} + +internal static class WindowsInstallerDirectoryLedgerCodec +{ + internal const int MaximumDocumentBytes = 4096; + + internal static byte[] Serialize(WindowsInstallerDirectoryLedger ledger) + { + ArgumentNullException.ThrowIfNull(ledger); + ledger.Validate(); + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + writer.WriteNumber("schema", 1); + writer.WriteNumber("generation", ledger.Generation); + writer.WriteStartArray("createdDirectories"); + foreach (WindowsInstallerOwnedDirectory directory in ledger.Directories) + { + writer.WriteStartObject(); + writer.WriteString("role", directory.Role.ToString()); + writer.WriteNumber("volumeSerialNumber", directory.Identity.VolumeSerialNumber); + writer.WriteNumber("fileIndex", directory.Identity.FileIndex); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + if (ledger.Terminal is not { } terminal) { writer.WriteNull("terminal"); } + else + { + writer.WriteStartObject("terminal"); + writer.WriteString("journalBase64", Convert.ToBase64String(InstallerTransactionCodec.Serialize(terminal.Journal))); + writer.WriteString("contentHash", terminal.ContentHash); + writer.WriteEndObject(); + } + writer.WriteEndObject(); + } + byte[] bytes = buffer.WrittenSpan.ToArray(); + ValidateSize(bytes); + return bytes; + } + + internal static WindowsInstallerDirectoryLedger Parse(ReadOnlyMemory bytes) + { + ValidateSize(bytes.Span); + try + { + using JsonDocument document = JsonDocument.Parse(bytes, new JsonDocumentOptions + { + MaxDepth = 4, + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + }); + JsonElement root = document.RootElement; + ExactObject(root, "schema", "generation", "createdDirectories", "terminal"); + if (root.GetProperty("schema").GetInt32() != 1) { throw new JsonException(); } + var directories = new List(); + foreach (JsonElement entry in root.GetProperty("createdDirectories").EnumerateArray()) + { + ExactObject(entry, "role", "volumeSerialNumber", "fileIndex"); + string roleText = entry.GetProperty("role").GetString() ?? throw new JsonException(); + if (!Enum.TryParse(roleText, ignoreCase: false, out InstallerDirectoryRole role) + || !Enum.IsDefined(role) || role.ToString() != roleText) { throw new JsonException(); } + directories.Add(new(role, new(entry.GetProperty("volumeSerialNumber").GetUInt32(), + entry.GetProperty("fileIndex").GetUInt64()))); + if (directories.Count > 6) { throw new JsonException(); } + } + InstallerTransactionSnapshot? terminal = null; + JsonElement terminalElement = root.GetProperty("terminal"); + if (terminalElement.ValueKind != JsonValueKind.Null) + { + ExactObject(terminalElement, "journalBase64", "contentHash"); + string encoded = terminalElement.GetProperty("journalBase64").GetString() ?? throw new JsonException(); + byte[] journalBytes = Convert.FromBase64String(encoded); + if (Convert.ToBase64String(journalBytes) != encoded) { throw new JsonException(); } + InstallerTransactionJournal journal = InstallerTransactionCodec.Parse(journalBytes); + byte[] canonical = InstallerTransactionCodec.Serialize(journal); + string contentHash = terminalElement.GetProperty("contentHash").GetString() ?? throw new JsonException(); + if (!journalBytes.AsSpan().SequenceEqual(canonical) + || Convert.ToHexStringLower(SHA256.HashData(canonical)) != contentHash) { throw new JsonException(); } + terminal = new(journal, contentHash); + } + var ledger = new WindowsInstallerDirectoryLedger(root.GetProperty("generation").GetInt64(), directories, terminal); + if (!bytes.Span.SequenceEqual(Serialize(ledger))) + { + throw WindowsInstallerDirectoryLedger.Failure("document_noncanonical"); + } + return ledger; + } + catch (Exception exception) when (exception is JsonException or InvalidOperationException or FormatException or OverflowException) + { + throw new InstallerProtocolException("installer.directory_ledger.document_invalid", exception); + } + } + + private static void ExactObject(JsonElement element, params string[] properties) + { + if (element.ValueKind != JsonValueKind.Object) { throw new JsonException(); } + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name) || !properties.Contains(property.Name, StringComparer.Ordinal)) { throw new JsonException(); } + } + if (names.Count != properties.Length) { throw new JsonException(); } + } + + private static void ValidateSize(ReadOnlySpan bytes) + { + if (bytes.Length is < 1 or > MaximumDocumentBytes) { throw WindowsInstallerDirectoryLedger.Failure("document_size_invalid"); } + } +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedgerPersistence.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedgerPersistence.cs new file mode 100644 index 00000000..f46a9db2 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedgerPersistence.cs @@ -0,0 +1,355 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Windows.Files; +using ClashSharp.Windows.FileSecurity; +using Microsoft.Win32.SafeHandles; + +namespace ClashSharp.Installer.Windows.Transactions; + +internal interface IWindowsInstallerDirectoryLedgerPersistence +{ + Task LoadAsync(CancellationToken cancellationToken); + Task SaveAsync(WindowsInstallerDirectoryLedger? expected, WindowsInstallerDirectoryLedger desired, CancellationToken cancellationToken); + Task DeleteAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken); +} + +internal interface IWindowsInstallerDirectoryLedgerFileNative +{ + Task ReadAsync(string path, CancellationToken cancellationToken); + Task PublishAsync(string path, byte[]? expected, byte[] desired, CancellationToken cancellationToken); + Task DeleteAsync(string path, byte[] expected, CancellationToken cancellationToken); +} + +internal sealed class WindowsInstallerDirectoryLedgerPersistence : IWindowsInstallerDirectoryLedgerPersistence +{ + private readonly string _path; + private readonly Func _acquireAnchor; + private readonly IWindowsInstallerDirectoryLedgerFileNative _files; + + internal WindowsInstallerDirectoryLedgerPersistence(WindowsInstallerDirectoryCleanupLayout layout, + Func acquireAnchor, IWindowsInstallerDirectoryLedgerFileNative files) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(acquireAnchor); + ArgumentNullException.ThrowIfNull(files); + _path = layout.LedgerPath; + _acquireAnchor = acquireAnchor; + _files = files; + } + + internal static WindowsInstallerDirectoryLedgerPersistence CreateDefault() + { + WindowsInstallerDirectoryCleanupLayout layout = WindowsInstallerDirectoryCleanupLayout.CreateDefault(); + return new(layout, () => WindowsInstallerDirectoryAnchor.Acquire(layout.ProgramData), + WindowsInstallerDirectoryLedgerFileNative.Instance); + } + + public async Task LoadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using IDisposable anchor = _acquireAnchor(); + byte[]? bytes = await _files.ReadAsync(_path, cancellationToken).ConfigureAwait(false); + return bytes is null ? null : WindowsInstallerDirectoryLedgerCodec.Parse(bytes); + } + + public async Task SaveAsync(WindowsInstallerDirectoryLedger? expected, WindowsInstallerDirectoryLedger desired, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(desired); + cancellationToken.ThrowIfCancellationRequested(); + using IDisposable anchor = _acquireAnchor(); + byte[] bytes = WindowsInstallerDirectoryLedgerCodec.Serialize(desired); + await _files.PublishAsync(_path, expected is null ? null : WindowsInstallerDirectoryLedgerCodec.Serialize(expected), + bytes, cancellationToken).ConfigureAwait(false); + byte[]? observed = await _files.ReadAsync(_path, cancellationToken).ConfigureAwait(false); + if (observed is null || !CryptographicOperations.FixedTimeEquals(bytes, observed)) + { + throw WindowsInstallerDirectoryLedger.Failure("write_not_observed"); + } + } + + public async Task DeleteAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(expected); + cancellationToken.ThrowIfCancellationRequested(); + using IDisposable anchor = _acquireAnchor(); + await _files.DeleteAsync(_path, WindowsInstallerDirectoryLedgerCodec.Serialize(expected), cancellationToken).ConfigureAwait(false); + if (await _files.ReadAsync(_path, cancellationToken).ConfigureAwait(false) is not null) + { + throw WindowsInstallerDirectoryLedger.Failure("delete_not_observed"); + } + } +} + +/// Only the already-existing trusted known-folder chain is pinned by this lease. +internal sealed class WindowsInstallerDirectoryAnchor : IDisposable +{ + private readonly List _leases = []; + + internal static WindowsInstallerDirectoryAnchor Acquire(string directory) + { + if (!Path.IsPathFullyQualified(directory) + || !string.Equals(directory, Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)), StringComparison.OrdinalIgnoreCase)) + { + throw WindowsInstallerDirectoryLedger.Failure("anchor_path_invalid"); + } + var owner = new WindowsInstallerDirectoryAnchor(); + try + { + string current = Path.GetPathRoot(directory) ?? throw WindowsInstallerDirectoryLedger.Failure("anchor_path_invalid"); + owner.Add(current); + foreach (string segment in Path.GetRelativePath(current, directory).Split(Path.DirectorySeparatorChar)) + { + if (segment is "." or ".." or "") { throw WindowsInstallerDirectoryLedger.Failure("anchor_path_invalid"); } + current = Path.Combine(current, segment); + owner.Add(current); + } + return owner; + } + catch { owner.Dispose(); throw; } + } + + private void Add(string path) + { + WindowsDirectoryReadLease lease = WindowsDirectoryReadLease.Open(path); + _leases.Add(lease); + WindowsDirectoryObservation observation = lease.Observe(); + if (!observation.IsDirectory || observation.IsReparsePoint + || !WindowsDirectoryAccessPolicy.IsTrustedRenameAnchor(observation.Security)) + { + throw WindowsInstallerDirectoryLedger.Failure("anchor_unsafe"); + } + } + + public void Dispose() + { + for (int index = _leases.Count - 1; index >= 0; index--) { _leases[index].Dispose(); } + _leases.Clear(); + } +} + +internal static class WindowsInstallerDirectoryLedgerSecurity +{ + internal const string UsersSid = "S-1-5-32-545"; + private const FileSystemRights ReadRights = FileSystemRights.Read | FileSystemRights.Synchronize; + + internal static FileSecurity Create() + { + var security = new FileSecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.SetOwner(new SecurityIdentifier(WindowsDirectoryAccessPolicy.AdministratorsSid)); + foreach ((string sid, FileSystemRights rights) in Rules()) + { + security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(sid), rights, AccessControlType.Allow)); + } + return security; + } + + internal static void Validate(WindowsDirectorySecuritySnapshot security) + { + if (!security.HasDacl || !security.DaclProtected || security.OwnerSid != WindowsDirectoryAccessPolicy.AdministratorsSid + || security.AccessEntries.Count != 3) { throw WindowsInstallerDirectoryLedger.Failure("file_acl_invalid"); } + foreach ((string sid, FileSystemRights rights) in Rules()) + { + WindowsDirectoryAce[] entries = security.AccessEntries.Where(entry => entry.Sid == sid).ToArray(); + if (entries.Length != 1 || entries[0].Kind != WindowsDirectoryAceKind.Allow + || entries[0].AccessMask != (int)rights || entries[0].Flags != AceFlags.None || entries[0].IsObjectSpecific) + { + throw WindowsInstallerDirectoryLedger.Failure("file_acl_invalid"); + } + } + } + + private static (string Sid, FileSystemRights Rights)[] Rules() => + [ + (WindowsDirectoryAccessPolicy.LocalSystemSid, FileSystemRights.FullControl), + (WindowsDirectoryAccessPolicy.AdministratorsSid, FileSystemRights.FullControl), + (UsersSid, ReadRights), + ]; +} + +/// Fixed ordinary file with a separately validated read-only public metadata policy. +internal sealed class WindowsInstallerDirectoryLedgerFileNative : IWindowsInstallerDirectoryLedgerFileNative +{ + internal static WindowsInstallerDirectoryLedgerFileNative Instance { get; } = new(); + + public async Task ReadAsync(string path, CancellationToken cancellationToken) + { + ValidatePath(path); + cancellationToken.ThrowIfCancellationRequested(); + using SafeFileHandle? handle = OpenIfPresent(path, deletion: false); + return handle is null ? null : await ReadHandleAsync(handle, cancellationToken).ConfigureAwait(false); + } + + public async Task PublishAsync(string path, byte[]? expected, byte[] desired, CancellationToken cancellationToken) + { + ValidatePath(path); + _ = WindowsInstallerDirectoryLedgerCodec.Parse(desired); + cancellationToken.ThrowIfCancellationRequested(); + // A protected existing object is held open throughout publication. Delete sharing permits + // this authority's atomic replacement; its exact DACL and the pinned parent prohibit user replacement. + using SafeFileHandle? original = OpenIfPresent(path, deletion: false, allowDeleteSharing: true); + byte[]? current = original is null ? null : await ReadHandleAsync(original, cancellationToken).ConfigureAwait(false); + RequireExpected(expected, current); + WindowsFileIdentity? originalIdentity = original is null ? null : WindowsFileSystemNative.GetOrdinaryFileIdentity(original); + string temporary = Path.Combine(Path.GetDirectoryName(path)!, "." + WindowsInstallerDirectoryCleanupLayout.LedgerFileName + + "." + Guid.NewGuid().ToString("N") + ".tmp"); + WindowsFileIdentity? temporaryIdentity = null; + bool published = false; + try + { + await using (FileStream stream = new FileInfo(temporary).Create(FileMode.CreateNew, FileSystemRights.FullControl, + FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.WriteThrough, WindowsInstallerDirectoryLedgerSecurity.Create())) + { + ValidateFile(stream.SafeFileHandle); + temporaryIdentity = WindowsFileSystemNative.GetOrdinaryFileIdentity(stream.SafeFileHandle); + await stream.WriteAsync(desired, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + cancellationToken.ThrowIfCancellationRequested(); + using (SafeFileHandle? final = OpenIfPresent(path, deletion: false, allowDeleteSharing: true)) + { + if ((final is null) != (originalIdentity is null) + || final is not null && WindowsFileSystemNative.GetOrdinaryFileIdentity(final) != originalIdentity) + { + throw WindowsInstallerDirectoryLedger.Failure("publication_target_changed"); + } + if (final is not null) + { + RequireExpected(expected, await ReadHandleAsync(final, cancellationToken).ConfigureAwait(false)); + } + } + // Crucially, a missing target is published WITHOUT REPLACE_EXISTING. ProgramData can + // permit unrelated users to create names; a name won in that race must never be overwritten. + if (!MoveFileEx(temporary, path, 8U | (originalIdentity is null ? 0U : 1U))) + { + throw new InstallerStateUncertainException("installer.directory_ledger.publication_uncertain"); + } + published = true; + using SafeFileHandle? persisted = OpenIfPresent(path, deletion: false); + if (persisted is null || WindowsFileSystemNative.GetOrdinaryFileIdentity(persisted) != temporaryIdentity) + { + throw WindowsInstallerDirectoryLedger.Failure("publication_identity_changed"); + } + } + finally + { + if (!published && temporaryIdentity is { } identity) { TryDeleteTemporary(temporary, identity); } + } + } + + public async Task DeleteAsync(string path, byte[] expected, CancellationToken cancellationToken) + { + ValidatePath(path); + cancellationToken.ThrowIfCancellationRequested(); + using SafeFileHandle? file = OpenIfPresent(path, deletion: true); + if (file is null) { return; } + RequireExpected(expected, await ReadHandleAsync(file, cancellationToken).ConfigureAwait(false)); + cancellationToken.ThrowIfCancellationRequested(); + DeleteHandle(file); + } + + private static void RequireExpected(byte[]? expected, byte[]? actual) + { + if (actual is not null) { _ = WindowsInstallerDirectoryLedgerCodec.Parse(actual); } + if ((expected is null) != (actual is null) + || expected is not null && !CryptographicOperations.FixedTimeEquals(expected, actual!)) + { + throw WindowsInstallerDirectoryLedger.Failure("compare_exchange_failed"); + } + } + + private static async Task ReadHandleAsync(SafeFileHandle file, CancellationToken cancellationToken) + { + long size = RandomAccess.GetLength(file); + if (size is < 1 or > WindowsInstallerDirectoryLedgerCodec.MaximumDocumentBytes) + { + throw WindowsInstallerDirectoryLedger.Failure("document_size_invalid"); + } + byte[] bytes = new byte[(int)size]; + int offset = 0; + while (offset < bytes.Length) + { + int count = await RandomAccess.ReadAsync(file, bytes.AsMemory(offset), offset, cancellationToken).ConfigureAwait(false); + if (count == 0) { throw WindowsInstallerDirectoryLedger.Failure("document_changed"); } + offset += count; + } + if (RandomAccess.GetLength(file) != size) { throw WindowsInstallerDirectoryLedger.Failure("document_changed"); } + return bytes; + } + + private static SafeFileHandle? OpenIfPresent(string path, bool deletion, bool allowDeleteSharing = false) + { + SafeFileHandle handle = CreateFile(path, 0x80000000U | (deletion ? 0x00010000U : 0U), + 1U | (allowDeleteSharing ? 4U : 0U), 0, 3, 0x00200000U, 0); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + handle.Dispose(); + if (error == 2) { return null; } + throw new Win32Exception(error); + } + try { ValidateFile(handle); return handle; } + catch { handle.Dispose(); throw; } + } + + private static void ValidateFile(SafeFileHandle file) + { + _ = WindowsFileSystemNative.GetOrdinaryFileIdentity(file); + if (WindowsFileSystemNative.GetLinkCount(file) != 1) { throw WindowsInstallerDirectoryLedger.Failure("file_links_invalid"); } + WindowsInstallerDirectoryLedgerSecurity.Validate(WindowsDirectoryReadLease.ReadSecuritySnapshot(file)); + } + + private static void TryDeleteTemporary(string path, WindowsFileIdentity identity) + { + try + { + using SafeFileHandle? temporary = OpenIfPresent(path, deletion: true); + if (temporary is not null && WindowsFileSystemNative.GetOrdinaryFileIdentity(temporary) == identity) + { + DeleteHandle(temporary); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or Win32Exception or InstallerProtocolException) + { + // Never adopt or delete a temporary from a different call, or one whose identity changed. + } + } + + internal static void DeleteHandle(SafeFileHandle handle) + { + var disposition = new FileDisposition { Delete = 1 }; + if (!SetFileInformationByHandle(handle, 4, in disposition, 1)) { throw new Win32Exception(Marshal.GetLastPInvokeError()); } + } + + private static void ValidatePath(string path) + { + if (!Path.IsPathFullyQualified(path) || Path.GetFileName(path) != WindowsInstallerDirectoryCleanupLayout.LedgerFileName + || !string.Equals(path, Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase)) + { + throw WindowsInstallerDirectoryLedger.Failure("path_invalid"); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct FileDisposition { internal byte Delete; } + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern SafeFileHandle CreateFile(string name, uint access, uint share, nint security, uint creation, uint flags, nint template); + + [DllImport("kernel32.dll", EntryPoint = "MoveFileExW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool MoveFileEx(string oldPath, string newPath, uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle(SafeFileHandle file, int informationClass, in FileDisposition disposition, uint size); +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs index 88a15b21..859e1f21 100644 --- a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs @@ -9,6 +9,8 @@ public void CreateDirectory(string path, DirectorySecurity security) { ArgumentException.ThrowIfNullOrWhiteSpace(path); ArgumentNullException.ThrowIfNull(security); + // Enable ownership recording together with terminal recovery and finalization; recording + // alone leaves durable state that the production uninstall path cannot yet consume. new DirectoryInfo(path).Create(security); } diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerEmptyDirectoryFinalizer.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerEmptyDirectoryFinalizer.cs new file mode 100644 index 00000000..11c6eb8c --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerEmptyDirectoryFinalizer.cs @@ -0,0 +1,256 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Transactions; +using ClashSharp.Installer.Windows.Files; +using ClashSharp.Windows.FileSecurity; +using Microsoft.Win32.SafeHandles; + +namespace ClashSharp.Installer.Windows.Transactions; + +internal interface IWindowsInstallerDirectoryDeletionLease : IDisposable +{ + WindowsFileIdentity Identity { get; } + bool IsEmpty { get; } + void VerifyOwnedSecurity(); + void DeleteEmpty(); +} + +internal interface IWindowsInstallerEmptyDirectoryNative +{ + IWindowsInstallerDirectoryDeletionLease? Open(string path, CancellationToken cancellationToken); +} + +/// Runs after Clear reconciliation, before a success frame, under both session-wide leases. +internal sealed class WindowsInstallerEmptyDirectoryFinalizer +{ + private readonly WindowsInstallerDirectoryCleanupLayout _layout; + private readonly IWindowsInstallerDirectoryLedgerPersistence _ledger; + private readonly IWindowsInstallerEmptyDirectoryNative _native; + + internal WindowsInstallerEmptyDirectoryFinalizer(WindowsInstallerDirectoryCleanupLayout layout, + IWindowsInstallerDirectoryLedgerPersistence ledger, IWindowsInstallerEmptyDirectoryNative native) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(ledger); + ArgumentNullException.ThrowIfNull(native); + _layout = layout; + _ledger = ledger; + _native = native; + } + + internal static WindowsInstallerEmptyDirectoryFinalizer CreateDefault() => new( + WindowsInstallerDirectoryCleanupLayout.CreateDefault(), WindowsInstallerDirectoryLedgerPersistence.CreateDefault(), + WindowsInstallerEmptyDirectoryNative.Instance); + + internal async Task CompleteAsync(InstallerTransactionSnapshot verified, + Func releaseResources, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(verified); + ArgumentNullException.ThrowIfNull(releaseResources); + verified.Validate(); + if (verified.Journal.Operation != InstallerOperation.Uninstall || verified.Journal.Phase != InstallerTransactionPhase.Verified) + { + throw WindowsInstallerDirectoryLedger.Failure("terminal_not_verified_uninstall"); + } + WindowsInstallerDirectoryLedger? before = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false); + WindowsInstallerDirectoryLedger prepared = (before ?? WindowsInstallerDirectoryLedger.Empty).BeginTerminal(verified); + if (before?.Terminal is null) + { + // A committed Clear replay may have no ledger, or only newly recreated directory IDs. + // Its authoritative native verification already succeeded; arm the same checkpoint again. + await _ledger.SaveAsync(before, prepared, cancellationToken).ConfigureAwait(false); + } + WindowsInstallerDirectoryLedger current = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false) + ?? throw WindowsInstallerDirectoryLedger.Failure("terminal_missing"); + RequireExact(prepared, current); + + // This releases machine/user certificate persistence and transaction guards. It does not + // release exclusive installer authority or the application lease owned by the outer host. + await releaseResources().ConfigureAwait(false); + var entries = new List(6); + foreach (InstallerDirectoryRole role in WindowsInstallerDirectoryCleanupLayout.DeletionOrder) + { + cancellationToken.ThrowIfCancellationRequested(); + WindowsInstallerOwnedDirectory? owned = current.Directories.SingleOrDefault(entry => entry.Role == role); + InstallerDirectoryCleanupDisposition disposition = RemoveOne(_layout.GetPath(role), owned, cancellationToken); + entries.Add(new(role, disposition)); + } + var report = new InstallerDirectoryCleanupReport(entries.OrderBy(entry => entry.Role)); + report.Validate(); + // This is the final durable mutation. Any preceding cancellation, exception or process exit + // retains the verified checkpoint outside the directories being removed. + await _ledger.DeleteAsync(current, cancellationToken).ConfigureAwait(false); + return report; + } + + private InstallerDirectoryCleanupDisposition RemoveOne(string path, WindowsInstallerOwnedDirectory? owned, + CancellationToken cancellationToken) + { + Exception? deletionFailure = null; + using (IWindowsInstallerDirectoryDeletionLease? directory = _native.Open(path, cancellationToken)) + { + if (directory is null) { return InstallerDirectoryCleanupDisposition.Missing; } + if (owned is null || owned.Identity != directory.Identity) + { + return InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership; + } + directory.VerifyOwnedSecurity(); + if (!directory.IsEmpty) { return InstallerDirectoryCleanupDisposition.RetainedNonEmpty; } + cancellationToken.ThrowIfCancellationRequested(); + try { directory.DeleteEmpty(); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or Win32Exception) + { + deletionFailure = exception; + } + } + using IWindowsInstallerDirectoryDeletionLease? after = _native.Open(path, cancellationToken); + if (after is null) { return InstallerDirectoryCleanupDisposition.Deleted; } + if (owned.Identity != after.Identity) { return InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership; } + after.VerifyOwnedSecurity(); + if (!after.IsEmpty) { return InstallerDirectoryCleanupDisposition.RetainedNonEmpty; } + if (deletionFailure is not null) + { + throw new InstallerProtocolException("installer.directory_ledger.directory_delete_failed", deletionFailure); + } + throw new InstallerStateUncertainException("installer.directory_ledger.directory_delete_not_observed"); + } + + private static void RequireExact(WindowsInstallerDirectoryLedger expected, WindowsInstallerDirectoryLedger actual) + { + if (!WindowsInstallerDirectoryLedgerCodec.Serialize(expected).AsSpan() + .SequenceEqual(WindowsInstallerDirectoryLedgerCodec.Serialize(actual))) + { + throw WindowsInstallerDirectoryLedger.Failure("terminal_changed"); + } + } +} + +internal sealed class WindowsInstallerEmptyDirectoryNative : IWindowsInstallerEmptyDirectoryNative +{ + internal static WindowsInstallerEmptyDirectoryNative Instance { get; } = new(); + + public IWindowsInstallerDirectoryDeletionLease? Open(string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + WindowsInstallerDirectoryAnchor? anchor = null; + SafeFileHandle? handle = null; + try + { + anchor = WindowsInstallerDirectoryAnchor.Acquire(Path.GetDirectoryName(path) + ?? throw WindowsInstallerDirectoryLedger.Failure("directory_path_invalid")); + // Read-only observation first. DELETE access is acquired only after ownership is proven. + handle = CreateFile(path, 0x80000000U, 3, 0, 3, 0x02200000U, 0); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + handle.Dispose(); + handle = null; + throw new Win32Exception(error); + } + WindowsFileIdentity identity = WindowsFileSystemNative.GetOrdinaryDirectoryIdentity(handle); + return new Lease(path, handle, anchor, identity); + } + catch (Win32Exception exception) when (exception.NativeErrorCode is 2 or 3) + { + handle?.Dispose(); + anchor?.Dispose(); + return null; + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + handle?.Dispose(); + anchor?.Dispose(); + return null; + } + catch { handle?.Dispose(); anchor?.Dispose(); throw; } + } + + private sealed class Lease(string path, SafeFileHandle handle, WindowsInstallerDirectoryAnchor anchor, + WindowsFileIdentity identity) : IWindowsInstallerDirectoryDeletionLease + { + private SafeFileHandle? _handle = handle; + public WindowsFileIdentity Identity => identity; + + public bool IsEmpty + { + get + { + ObjectDisposedException.ThrowIf(_handle is null, this); + using IEnumerator entries = Directory.EnumerateFileSystemEntries(path).GetEnumerator(); + return !entries.MoveNext() && !HasNamedDataStream(path); + } + } + + public void VerifyOwnedSecurity() + { + ObjectDisposedException.ThrowIf(_handle is null, this); + if (WindowsFileSystemNative.GetOrdinaryDirectoryIdentity(_handle) != identity + || !WindowsDirectoryAccessPolicy.IsTrustedRenameAnchor(WindowsDirectoryReadLease.ReadSecuritySnapshot(_handle))) + { + throw WindowsInstallerDirectoryLedger.Failure("owned_directory_unsafe"); + } + } + + public void DeleteEmpty() + { + VerifyOwnedSecurity(); + if (!IsEmpty) { throw new IOException("The owned directory is no longer empty."); } + // Reopen DELETE while the parent chain remains pinned. The current read handle must be + // released because its list access intentionally withholds delete sharing. + _handle!.Dispose(); + _handle = null; + SafeFileHandle deleting = CreateFile(path, 0x80010000U, 3, 0, 3, 0x02200000U, 0); + if (deleting.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + deleting.Dispose(); + throw new Win32Exception(error); + } + _handle = deleting; + VerifyOwnedSecurity(); + if (!IsEmpty) { throw new IOException("The owned directory is no longer empty."); } + WindowsInstallerDirectoryLedgerFileNative.DeleteHandle(deleting); + } + + public void Dispose() + { + _handle?.Dispose(); + _handle = null; + anchor.Dispose(); + } + } + + private static bool HasNamedDataStream(string path) + { + nint search = FindFirstStream(path, 0, out _, 0); + if (search != new nint(-1)) + { + _ = FindClose(search); + return true; + } + int error = Marshal.GetLastPInvokeError(); + if (error == 38) { return false; } + throw new Win32Exception(error); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StreamData + { + internal long StreamSize; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 296)] internal string StreamName; + } + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern SafeFileHandle CreateFile(string name, uint access, uint share, nint security, uint creation, uint flags, nint template); + + [DllImport("kernel32.dll", EntryPoint = "FindFirstStreamW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern nint FindFirstStream(string name, int informationLevel, out StreamData data, uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FindClose(nint search); +} diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerOwnedDirectoryCreation.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerOwnedDirectoryCreation.cs new file mode 100644 index 00000000..51f3d5b8 --- /dev/null +++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerOwnedDirectoryCreation.cs @@ -0,0 +1,128 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using ClashSharp.Installer.Contracts; +using ClashSharp.Installer.Windows.Files; +using ClashSharp.Windows.FileSecurity; +using Microsoft.Win32.SafeHandles; + +namespace ClashSharp.Installer.Windows.Transactions; + +internal interface IWindowsInstallerOwnedDirectoryCreationNative +{ + bool CreateDirectory(string path, DirectorySecurity security); + IWindowsInstallerCreatedDirectoryLease OpenCreatedDirectory(string path); +} + +internal interface IWindowsInstallerCreatedDirectoryLease : IDisposable +{ + WindowsFileIdentity Identity { get; } +} + +/// Never adopts an existing directory, including an unrecorded creation from an interrupted call. +internal sealed class WindowsInstallerOwnedDirectoryCreation +{ + private readonly WindowsInstallerDirectoryCleanupLayout _layout; + private readonly IWindowsInstallerDirectoryLedgerPersistence _ledger; + private readonly IWindowsInstallerOwnedDirectoryCreationNative _native; + + internal WindowsInstallerOwnedDirectoryCreation(WindowsInstallerDirectoryCleanupLayout layout, + IWindowsInstallerDirectoryLedgerPersistence ledger, IWindowsInstallerOwnedDirectoryCreationNative native) + { + _layout = layout; + _ledger = ledger; + _native = native; + } + + internal static WindowsInstallerOwnedDirectoryCreation CreateDefault() => new( + WindowsInstallerDirectoryCleanupLayout.CreateDefault(), WindowsInstallerDirectoryLedgerPersistence.CreateDefault(), + WindowsInstallerOwnedDirectoryCreationNative.Instance); + + internal void Create(string path, DirectorySecurity security) + { + ArgumentNullException.ThrowIfNull(security); + if (!_layout.TryGetRole(path, out InstallerDirectoryRole role)) + { + // Existing Service/MihomoService and isolated testing roots retain their original behavior. + _ = _native.CreateDirectory(path, security); + return; + } + // Reject foreign ledger state before creating anything. This executes only in the elevated + // helper's synchronous native directory operation, while it owns exclusive installer authority. + WindowsInstallerDirectoryLedger? before = _ledger.LoadAsync(CancellationToken.None).GetAwaiter().GetResult(); + if (!_native.CreateDirectory(path, security)) { return; } + using IWindowsInstallerCreatedDirectoryLease directory = _native.OpenCreatedDirectory(path); + WindowsInstallerDirectoryLedger desired = (before ?? WindowsInstallerDirectoryLedger.Empty) + .RecordCreated(role, directory.Identity); + _ledger.SaveAsync(before, desired, CancellationToken.None).GetAwaiter().GetResult(); + } +} + +internal sealed class WindowsInstallerOwnedDirectoryCreationNative : IWindowsInstallerOwnedDirectoryCreationNative +{ + internal static WindowsInstallerOwnedDirectoryCreationNative Instance { get; } = new(); + + public bool CreateDirectory(string path, DirectorySecurity security) + { + byte[] descriptor = security.GetSecurityDescriptorBinaryForm(); + GCHandle pinned = GCHandle.Alloc(descriptor, GCHandleType.Pinned); + try + { + var attributes = new SecurityAttributes + { + Length = Marshal.SizeOf(), + Descriptor = pinned.AddrOfPinnedObject(), + InheritHandle = 0, + }; + if (CreateDirectoryNative(path, ref attributes)) { return true; } + int error = Marshal.GetLastPInvokeError(); + if (error == 183) { return false; } + throw new Win32Exception(error); + } + finally { pinned.Free(); } + } + + public IWindowsInstallerCreatedDirectoryLease OpenCreatedDirectory(string path) + { + SafeFileHandle handle = CreateFile(path, 0x80000000U, 3, 0, 3, 0x02200000U, 0); + if (handle.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + handle.Dispose(); + throw new Win32Exception(error); + } + try + { + WindowsFileIdentity identity = WindowsFileSystemNative.GetOrdinaryDirectoryIdentity(handle); + if (!WindowsDirectoryAccessPolicy.IsTrustedRenameAnchor(WindowsDirectoryReadLease.ReadSecuritySnapshot(handle))) + { + throw WindowsInstallerDirectoryLedger.Failure("created_directory_unsafe"); + } + return new Lease(handle, identity); + } + catch { handle.Dispose(); throw; } + } + + private sealed class Lease(SafeFileHandle handle, WindowsFileIdentity identity) : IWindowsInstallerCreatedDirectoryLease + { + public WindowsFileIdentity Identity => identity; + public void Dispose() => handle.Dispose(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + internal int Length; + internal nint Descriptor; + internal int InheritHandle; + } + + [DllImport("kernel32.dll", EntryPoint = "CreateDirectoryW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateDirectoryNative(string path, ref SecurityAttributes attributes); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern SafeFileHandle CreateFile(string name, uint access, uint share, nint security, uint creation, uint flags, nint template); +} diff --git a/ClashSharp/ClashSharp.Installer/MainWindow.xaml b/ClashSharp/ClashSharp.Installer/MainWindow.xaml index fee5645d..a9113345 100644 --- a/ClashSharp/ClashSharp.Installer/MainWindow.xaml +++ b/ClashSharp/ClashSharp.Installer/MainWindow.xaml @@ -1,13 +1,13 @@ @@ -25,7 +25,7 @@ -