using System.IO; using System.Security.AccessControl; using System.Security.Cryptography; using System.Security.Principal; using System.Text.Json; using KronaStaffTerminal.Models; using Microsoft.Win32; namespace KronaStaffTerminal.Services; public sealed class TerminalStore { public const string OperatorGroupName = "KronaStaffTerminalOperators"; private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray(); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private const string RegistryPath = @"Software\Krona\StaffTerminal"; private const string InitializedValueName = "StateInitialized"; private static readonly TimeSpan AutomaticRecoveryPointInterval = TimeSpan.FromHours(12); private readonly SemaphoreSlim _saveGate = new(1, 1); private readonly string _filePath; public static string MachineFilePath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Krona", "StaffTerminal", "terminal-state.dat"); public static string LegacyUserFilePath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Krona", "StaffTerminal", "terminal-state.dat"); public string FilePath => _filePath; public string? RecoveryNotice { get; private set; } public TerminalStore(string? filePath = null) { _filePath = filePath ?? MachineFilePath; } public async Task LoadAsync() { RecoveryNotice = null; var sourcePath = File.Exists(_filePath) ? _filePath : null; if (sourcePath is null && File.Exists(LegacyUserFilePath)) { sourcePath = LegacyUserFilePath; } if (sourcePath is null) { sourcePath = FindLegacyPackagedState(); } if (sourcePath is null) { var recovered = await TryRecoverFromBackupAsync(); if (recovered is not null) { RecoveryNotice = "Основной файл отсутствовал. Данные восстановлены из защищённой резервной копии."; await SaveAsync(recovered); return recovered; } if (WasPreviouslyInitialized() || HasRecoveryArtifacts()) { throw new InvalidDataException( "Основной файл состояния отсутствует, но терминал уже был настроен. Первичная настройка заблокирована до восстановления данных."); } return await InitializeFreshStateAsync(); } try { if (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { EnsureDirectoryWithRestrictedAcl(Path.GetDirectoryName(_filePath)!); } var state = await ReadStateAsync(sourcePath); var migrated = MigrateEmployees(state) | MigrateSecurity(state); if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { await SaveAsync(state); } else { MarkInitialized(); } return state; } catch (Exception primaryException) when ( string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase) && IsCorruptStateException(primaryException)) { var corruptPath = _filePath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}"; try { File.Move(_filePath, corruptPath, false); ApplyRestrictedFileAcl(corruptPath); } catch (Exception moveException) { throw new InvalidDataException( "Хранилище терминала повреждено и не может быть сохранено отдельно. Работа заблокирована, чтобы не потерять данные.", new AggregateException(primaryException, moveException)); } var recovered = await TryRecoverFromBackupAsync(); if (recovered is not null) { RecoveryNotice = $"Повреждённый файл сохранён как {Path.GetFileName(corruptPath)}. Данные восстановлены из защищённой резервной копии."; await SaveAsync(recovered); return recovered; } throw new InvalidDataException( $"Хранилище терминала повреждено. Исходный файл сохранён как {Path.GetFileName(corruptPath)}. Работа заблокирована до восстановления резервной копии.", primaryException); } catch (IOException exception) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { throw new IOException( "Хранилище терминала временно недоступно. Файл не изменён; закройте программу, которая его удерживает, и повторите запуск.", exception); } catch (UnauthorizedAccessException exception) when (string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException( "Нет доступа к хранилищу терминала. Файл не изменён; проверьте права текущего пользователя.", exception); } } private async Task TryRecoverFromBackupAsync() { foreach (var backupPath in BackupPaths()) { if (!File.Exists(backupPath)) continue; try { var recovered = await ReadStateAsync(backupPath); MigrateEmployees(recovered); return recovered; } catch (Exception backupException) when (IsCorruptStateException(backupException)) { CrashLogService.Write(backupException); } } return null; } private static bool IsCorruptStateException(Exception exception) => exception is CryptographicException or JsonException or InvalidDataException or NotSupportedException; private static async Task ReadStateAsync(string path) { var encrypted = await File.ReadAllBytesAsync(path); byte[] clear; try { // Primary: LocalMachine (accessible by both Service and Terminal UI on this machine) clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.LocalMachine); } catch (CryptographicException) { // Legacy fallback: CurrentUser (if upgrading an older installation) clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser); } var state = JsonSerializer.Deserialize(clear, JsonOptions) ?? throw new InvalidDataException("Файл состояния не содержит данных."); if (state.Employees is null || state.Attendance is null || state.SecurityAudit is null || state.Settings is null) { throw new InvalidDataException("Файл состояния имеет неполную структуру."); } return state; } private static bool MigrateEmployees(TerminalState state) { var changed = false; foreach (var employee in state.Employees) { employee.Fingerprints ??= []; if (employee.FingerprintId > 0 && !string.IsNullOrWhiteSpace(employee.FingerprintTemplate)) { if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId)) { employee.Fingerprints.Add(new FingerprintRecord { Id = employee.FingerprintId, Template = employee.FingerprintTemplate, Label = "Отпечаток 1", EnrolledAtUtc = DateTimeOffset.UtcNow }); } employee.FingerprintId = 0; employee.FingerprintTemplate = null; changed = true; } // Populate FIO if empty and DisplayName is available if (string.IsNullOrWhiteSpace(employee.LastName) && !string.IsNullOrWhiteSpace(employee.DisplayName)) { var parts = employee.DisplayName.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length > 0) employee.LastName = parts[0]; if (parts.Length > 1) employee.FirstName = parts[1]; if (parts.Length > 2) employee.Patronymic = string.Join(" ", parts.Skip(2)); changed = true; } } return changed; } private static bool MigrateSecurity(TerminalState state) { if (!string.IsNullOrWhiteSpace(state.Settings.IpcAuthenticationKey)) return false; state.Settings.IpcAuthenticationKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); return true; } private async Task InitializeFreshStateAsync() { var directory = Path.GetDirectoryName(_filePath)!; EnsureDirectoryWithRestrictedAcl(directory); var lockPath = _filePath + ".init.lock"; var deadline = DateTimeOffset.UtcNow.AddSeconds(15); FileStream? initializationLock = null; while (initializationLock is null) { try { initializationLock = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, 1, FileOptions.WriteThrough); } catch (IOException) when (DateTimeOffset.UtcNow < deadline) { await Task.Delay(100); } } await using (initializationLock) { ApplyRestrictedFileAcl(lockPath); if (File.Exists(_filePath)) return await LoadAsync(); var state = new TerminalState(); await SaveAsync(state); return state; } } public async Task SaveAsync(TerminalState state, bool createRecoveryPoint = false) { await _saveGate.WaitAsync(); try { var directory = Path.GetDirectoryName(_filePath)!; EnsureDirectoryWithRestrictedAcl(directory); var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions); var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.LocalMachine); var temporaryPath = _filePath + ".tmp"; await using (var stream = new FileStream( temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.WriteThrough)) { await stream.WriteAsync(encrypted); await stream.FlushAsync(); stream.Flush(true); } string? previousStatePath = null; if (File.Exists(_filePath) && ShouldCreateRecoveryPoint(createRecoveryPoint)) { previousStatePath = _filePath + ".previous"; File.Copy(_filePath, previousStatePath, true); } File.Move(temporaryPath, _filePath, true); ApplyRestrictedFileAcl(_filePath); MarkInitialized(); if (previousStatePath is not null) { try { RotateBackups(previousStatePath); } finally { File.Delete(previousStatePath); } } } finally { _saveGate.Release(); } } private static void EnsureDirectoryWithRestrictedAcl(string directoryPath) { Directory.CreateDirectory(directoryPath); var identities = AllowedIdentities(directoryPath).ToArray(); var directorySecurity = new DirectorySecurity(); directorySecurity.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); foreach (var identity in identities) { directorySecurity.AddAccessRule(new FileSystemAccessRule( identity, FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); } new DirectoryInfo(directoryPath).SetAccessControl(directorySecurity); foreach (var stateFile in Directory.EnumerateFiles(directoryPath, "terminal-state.dat*")) ApplyRestrictedFileAcl(stateFile); } private static IEnumerable AllowedIdentities(string? existingDirectory = null) { yield return new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); yield return new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); SecurityIdentifier? operatorSid = null; try { operatorSid = (SecurityIdentifier)new NTAccount(Environment.MachineName, OperatorGroupName) .Translate(typeof(SecurityIdentifier)); } catch (IdentityNotMappedException) { } if (operatorSid is not null) yield return operatorSid; using var current = WindowsIdentity.GetCurrent(); if (current.User is not null && !current.User.IsWellKnown(WellKnownSidType.LocalSystemSid)) yield return current.User; if (existingDirectory is null || !Directory.Exists(existingDirectory)) yield break; var existing = new DirectoryInfo(existingDirectory).GetAccessControl(AccessControlSections.Access); foreach (FileSystemAccessRule rule in existing.GetAccessRules( includeExplicit: true, includeInherited: false, typeof(SecurityIdentifier))) { if (rule.AccessControlType != AccessControlType.Allow || rule.IdentityReference is not SecurityIdentifier sid || !sid.IsAccountSid()) continue; yield return sid; } } private static void ApplyRestrictedFileAcl(string path) { var security = new FileSecurity(); security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); foreach (var identity in AllowedIdentities(Path.GetDirectoryName(path))) security.AddAccessRule(new FileSystemAccessRule(identity, FileSystemRights.FullControl, AccessControlType.Allow)); new FileInfo(path).SetAccessControl(security); } private bool ShouldCreateRecoveryPoint(bool requested) { var latestBackup = BackupPaths().First(); var minimumInterval = requested ? TimeSpan.FromHours(24) : AutomaticRecoveryPointInterval; return !File.Exists(latestBackup) || DateTimeOffset.UtcNow - File.GetLastWriteTimeUtc(latestBackup) >= minimumInterval; } private IEnumerable BackupPaths() { for (var index = 1; index <= 3; index++) yield return _filePath + $".bak{index}"; } private void RotateBackups(string previousStatePath) { var backups = BackupPaths().ToArray(); for (var index = backups.Length - 1; index > 0; index--) { if (File.Exists(backups[index - 1])) File.Copy(backups[index - 1], backups[index], true); } File.Copy(previousStatePath, backups[0], true); } private bool HasRecoveryArtifacts() { if (BackupPaths().Any(File.Exists) || File.Exists(_filePath + ".tmp") || File.Exists(_filePath + ".previous")) return true; var directory = Path.GetDirectoryName(_filePath)!; return Directory.Exists(directory) && Directory.EnumerateFiles(directory, "terminal-state.dat.corrupt-*").Any(); } private static bool WasPreviouslyInitialized() { using var lmKey = Registry.LocalMachine.OpenSubKey(RegistryPath); if (lmKey?.GetValue(InitializedValueName) is int lmValue && lmValue == 1) return true; using var cuKey = Registry.CurrentUser.OpenSubKey(RegistryPath); return cuKey?.GetValue(InitializedValueName) is int cuValue && cuValue == 1; } private static void MarkInitialized() { try { using var lmKey = Registry.LocalMachine.CreateSubKey(RegistryPath, writable: true); lmKey?.SetValue(InitializedValueName, 1, RegistryValueKind.DWord); } catch (UnauthorizedAccessException) { // Best effort for non-elevated sessions } using var key = Registry.CurrentUser.CreateSubKey(RegistryPath, writable: true) ?? throw new InvalidOperationException("Не удалось открыть защищённый маркер настройки терминала."); key.SetValue(InitializedValueName, 1, RegistryValueKind.DWord); } private static string? FindLegacyPackagedState() { var packagesPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Packages"); if (!Directory.Exists(packagesPath)) return null; return Directory.EnumerateDirectories(packagesPath, "223A9635-262B-4298-9F19-1D2FE0A6B5F7_*") .Select(directory => Path.Combine(directory, "LocalCache", "Local", "Krona", "StaffTerminal", "terminal-state.dat")) .FirstOrDefault(File.Exists); } }