diff --git a/.gitignore b/.gitignore index 2a1ffd1..50124dc 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ publish/ # The rest of .claude/ - hooks, skills, agents - is shared project tooling and is tracked. .claude/settings.local.json WATCHDOG.yml +/.logs +/.superpowers diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eff854..cb351f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,28 @@ version numbers, because that is what those releases were called. ## [Unreleased] +### Design + +- Started a three-direction visual identity and WinUI 3 Home-screen exploration around the + positioning "Backup and retrieve the little details that make your system yours." The light Fluent + direction now pairs granular selection of Windows and app settings with a quiet solid-color system: + warm neutral surfaces, softened slate navigation, muted mineral-blue actions, gentler type, and + calmer spacing. Logo exploration remains unselected and no runtime or backup-format behavior has + changed. + +### Changed + +- Rebuilt the app shell and all primary views with the Industry design system: bundled Barlow, Barlow Condensed, and IBM Plex Mono typography; Voltage, Flux, and Follow system palettes; blueprint frames; icon rail navigation; and a dedicated progress view. +- Added snapshot display names, selectable destination folders, Fast and Max archive compression, archive-backed restore discovery, live registry drift detection, rich backup progress metrics, and safe pause or cancel controls. +- Reworked backup, restore, History, Home, and About around real manifest and module data. Existing backup folders and frozen manifest keys remain compatible. + ## [0.0.1] - 2026-08-02 +### Changed + +- Rebuilt the app shell and all primary views with the Industry design system: bundled Barlow, Barlow Condensed, and IBM Plex Mono typography; Voltage, Flux, and Follow system palettes; blueprint frames; icon rail navigation; and a dedicated progress view. +- Added snapshot display names, selectable destination folders, Fast and Max archive compression, archive-backed restore discovery, live registry drift detection, rich backup progress metrics, and safe pause or cancel controls. +- Reworked backup, restore, History, Home, and About around real manifest and module data. Existing backup folders and frozen manifest keys remain compatible. + First WinRestoreKit version. The application was renamed from Appcopier and moved to a standalone repository; the version series restarts here rather than continuing Appcopier's, because this is a diff --git a/src/WinRestoreKit.Core/BackupBase.cs b/src/WinRestoreKit.Core/BackupBase.cs index 6bb0121..2b5831b 100644 --- a/src/WinRestoreKit.Core/BackupBase.cs +++ b/src/WinRestoreKit.Core/BackupBase.cs @@ -62,6 +62,76 @@ public abstract class BackupBase /// public virtual bool? HasArtifactIn(string backupPath) => null; + /// + /// Whether this module's live state differs from the state captured in + /// . + /// + /// + /// A module that cannot compare its live state with the backup returns null rather than + /// guessing. Callers render only confirmed drift. + /// + public virtual bool? HasDriftedFrom(string backupPath) => null; + + /// + /// Compares a recorded registry export with a fresh, temporary export of the live key. + /// + /// + /// The fresh export is deliberately outside the backup directory. Drift reads must never + /// clear, replace, or add to an existing snapshot. A missing or unreadable recorded artifact, + /// an indeterminate probe, and a failed fresh export all return null because none establishes + /// a comparison. + /// + protected static bool? HasDriftedFromRegistryArtifact(string artifactPath, string registryPath) + { + if (RegFile.Validate(artifactPath) != RegFileCheck.Valid) + return null; + + KeyProbe probe = Utils.ProbeKey(registryPath); + + if (probe == KeyProbe.Indeterminate) + return null; + + // A valid export records a present key. The live key's confirmed absence is therefore + // real drift, without relying on a file timestamp. + if (probe == KeyProbe.Absent) + return true; + + string currentArtifact = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".reg"); + + if (IsInSameDirectory(currentArtifact, artifactPath)) + return null; + + try + { + StepResult export = Utils.ExportRegistryKey(currentArtifact, registryPath, absenceIsNormal: false); + + if (export.State != ResultState.Succeeded) + return null; + + bool? same = RegFile.HasSameCanonicalContent(artifactPath, currentArtifact); + return same.HasValue ? !same.Value : null; + } + finally + { + try { File.Delete(currentArtifact); } catch { } + } + } + + private static bool IsInSameDirectory(string firstPath, string secondPath) + { + try + { + string firstDirectory = Path.GetDirectoryName(Path.GetFullPath(firstPath)); + string secondDirectory = Path.GetDirectoryName(Path.GetFullPath(secondPath)); + + return string.Equals(firstDirectory, secondDirectory, StringComparison.OrdinalIgnoreCase); + } + catch + { + return true; + } + } + /// /// The backup file this module writes for one registry key. /// diff --git a/src/WinRestoreKit.Core/CompressionLevel.cs b/src/WinRestoreKit.Core/CompressionLevel.cs new file mode 100644 index 0000000..0fcc4de --- /dev/null +++ b/src/WinRestoreKit.Core/CompressionLevel.cs @@ -0,0 +1,9 @@ +namespace WinRestoreKit +{ + public enum SnapshotCompression + { + None, + Fast, + Max + } +} diff --git a/src/WinRestoreKit.Core/Conf/AppStoreApps.cs b/src/WinRestoreKit.Core/Conf/AppStoreApps.cs index 3275aa8..eadbd7e 100644 --- a/src/WinRestoreKit.Core/Conf/AppStoreApps.cs +++ b/src/WinRestoreKit.Core/Conf/AppStoreApps.cs @@ -49,23 +49,22 @@ public AppStoreApps() // HasBackupIn is deliberately NOT overridden to test for ExportPathIn(restorePath). // - // It looks like the obvious use of the new seam, and it would be wrong twice over. This - // module's restore does not read the export at all - it opens RestAppsForm, which lets the - // user pick ANY backup folder from its own dropdown, not the one being restored. Answering - // "no" here would make RestoreScope drop the module, so the dialog would never open for a - // user whose selected folder happens to hold no export, while the dialog itself would have - // been perfectly able to offer every other backup. It would also break - // RestoreDeclarationTests.ModulesThatCloseNothing_AssumeTheBackupHasSomethingForThem. + // This module opens RestAppsForm, which starts with the selected restore source but also + // lets the user choose another backup folder from its own dropdown. Answering "no" here + // would make RestoreScope drop the module, so the dialog would never open for a user whose + // selected folder happens to hold no export, while the dialog could have offered another + // backup. It would also break RestoreDeclarationTests.ModulesThatCloseNothing_AssumeTheBackupHasSomethingForThem. // // The same reasoning binds HasArtifactIn, added later for the restore wizard, and binds it // HARDER: where HasBackupIn made RestoreScope drop the module after the fact, the wizard // greys the checkbox out in front of the user and labels it "(nothing in this backup)". - // That would be a false statement about a dialog that reads a folder of its own choosing. + // That would be a false statement about a dialog that can read a folder of the user's + // choosing. // // So it is overridden to a flat TRUE rather than left at the null default. Null would mean // "cannot tell", and the wizard resolves that to false whenever a manifest exists without - // naming this module - which happens on any second backup within one app session. This is - // not uncertainty; it is a module for which folder contents are the wrong question. + // naming this module, which happens on any second backup within one app session. This is + // not uncertainty; it is a module for which folder contents are not the only question. public override bool? HasArtifactIn(string backupPath) => true; public override IReadOnlyList RestoreTargets @@ -199,8 +198,8 @@ public override Task RestoreAsync(string path) => Task.FromResult(Restore(path)); /// - /// Opens the app reinstall dialog. Registered by the app at startup; null in any process - /// that has no UI to open it with. + /// Opens the app reinstall dialog for . Registered by the app at + /// startup; null in any process that has no UI to open it with. /// /// /// A delegate rather than a constructor argument because this module is constructed by @@ -208,10 +207,10 @@ public override Task RestoreAsync(string path) /// the app is enumerated that way. A parameterless constructor is not negotiable here. /// /// Registration happens in Program.Main before the message pump starts, so the unregistered - /// path below is not reachable from the running app - it exists for the test suite and for + /// path below is not reachable from the running app. It exists for the test suite and for /// any future headless host, where failing closed is the point. /// - internal static Action RestoreDialog; + internal static Action RestoreDialog; /// /// This module restores nothing itself. It opens the app restore dialog, and the installs @@ -231,7 +230,7 @@ public override ModuleResult Restore(string path) { // Read the delegate once: it is static and mutable, and a null check against one read // followed by an invoke of another is a race with whatever cleared it. - Action dialog = RestoreDialog; + Action dialog = RestoreDialog; if (dialog == null) { @@ -243,7 +242,7 @@ public override ModuleResult Restore(string path) }); } - dialog(); + dialog(path); return ModuleResult.Aggregate(new[] { diff --git a/src/WinRestoreKit.Core/Conf/EEnvironmentFiltered.cs b/src/WinRestoreKit.Core/Conf/EEnvironmentFiltered.cs index 80901e9..94593a3 100644 --- a/src/WinRestoreKit.Core/Conf/EEnvironmentFiltered.cs +++ b/src/WinRestoreKit.Core/Conf/EEnvironmentFiltered.cs @@ -81,6 +81,10 @@ public override ModuleResult Backup(string path) return ModuleResult.Aggregate(new[] { export, Describe(outcome) }); } + // The recorded export deliberately omits values selected by RegSecretFilter. Comparing it + // with the unfiltered live key would manufacture drift, so this module remains unknown. + public override bool? HasDriftedFrom(string backupPath) => null; + /// /// Deletes the export the filter could not process, and fails the step. /// diff --git a/src/WinRestoreKit.Core/Conf/MultiKeyRegistryModule.cs b/src/WinRestoreKit.Core/Conf/MultiKeyRegistryModule.cs index 16b0403..5118655 100644 --- a/src/WinRestoreKit.Core/Conf/MultiKeyRegistryModule.cs +++ b/src/WinRestoreKit.Core/Conf/MultiKeyRegistryModule.cs @@ -111,5 +111,37 @@ public override ModuleResult Restore(string path) return false; } + + /// + /// Reports confirmed drift from any recorded key, while keeping incomplete comparisons unknown. + /// + public override bool? HasDriftedFrom(string backupPath) + { + if (string.IsNullOrWhiteSpace(backupPath)) + return null; + + bool comparedAny = false; + bool hasUnknown = false; + + foreach (string key in Keys) + { + bool? drift = HasDriftedFromRegistryArtifact( + Path.Combine(backupPath, RegFileNameFor(key)), + key); + + if (!drift.HasValue) + { + hasUnknown = true; + continue; + } + + comparedAny = true; + + if (drift.Value) + return true; + } + + return comparedAny && !hasUnknown ? false : null; + } } } diff --git a/src/WinRestoreKit.Core/Conf/RegistryModule.cs b/src/WinRestoreKit.Core/Conf/RegistryModule.cs index 51cbbd0..c614299 100644 --- a/src/WinRestoreKit.Core/Conf/RegistryModule.cs +++ b/src/WinRestoreKit.Core/Conf/RegistryModule.cs @@ -52,6 +52,17 @@ public override ModuleResult Restore(string path) public override bool? HasArtifactIn(string backupPath) => !string.IsNullOrWhiteSpace(backupPath) && File.Exists(FileFor(backupPath)); + /// + /// Compares the one exported key this module records with its current live representation. + /// + public override bool? HasDriftedFrom(string backupPath) + { + if (string.IsNullOrWhiteSpace(backupPath)) + return null; + + return HasDriftedFromRegistryArtifact(FileFor(backupPath), Key); + } + // One key, so one file, and the name does not need to encode which key it holds. Overriding // rather than inheriting the key-derived default keeps the filenames these ten modules have // always written, so existing backups stay restorable. diff --git a/src/WinRestoreKit.Core/Results/BackupLog.cs b/src/WinRestoreKit.Core/Results/BackupLog.cs index 52d2212..e6ed7f3 100644 --- a/src/WinRestoreKit.Core/Results/BackupLog.cs +++ b/src/WinRestoreKit.Core/Results/BackupLog.cs @@ -19,6 +19,9 @@ internal static class BackupLog // Older backups on disk still carry the Appcopier header, which is fine because readers never parse it. internal const string VersionHeader = "# WinRestoreKit backup log v2"; + /// The human-readable backup log stored at the backup root. + internal const string FileName = "backup_log.txt"; + /// /// Written verbatim between the version header and the timestamp, or null for none. Verbatim /// because the caller - RestoreLog - owns how its lines read; prefixing them here would put diff --git a/src/WinRestoreKit.Core/Results/BackupManifest.cs b/src/WinRestoreKit.Core/Results/BackupManifest.cs index 53bc621..82a2b69 100644 --- a/src/WinRestoreKit.Core/Results/BackupManifest.cs +++ b/src/WinRestoreKit.Core/Results/BackupManifest.cs @@ -57,7 +57,10 @@ internal static string Compose(IReadOnlyList modules, string machineName, string userName, string osBuild, - string appVersion) + string appVersion, + string snapshotName = null, + SnapshotCompression compression = SnapshotCompression.None, + string payloadFile = null) { JArray moduleRows = new JArray(); @@ -96,6 +99,15 @@ internal static string Compose(IReadOnlyList modules, ["modules"] = moduleRows }; + if (!string.IsNullOrEmpty(snapshotName)) + root["snapshot_name"] = snapshotName; + + if (!string.IsNullOrEmpty(payloadFile)) + { + root["compression"] = compression.ToString().ToLowerInvariant(); + root["payload_file"] = payloadFile; + } + return root.ToString(Formatting.Indented); } @@ -179,6 +191,9 @@ internal static ManifestData TryParse(string json) Text(root["machine_name"]), Text(root["user_name"]), Text(root["os_build"]), + Text(root["snapshot_name"]), + Text(root["compression"]), + Text(root["payload_file"]), modules); } @@ -262,6 +277,21 @@ internal sealed class ManifestData { internal ManifestData(int manifestVersion, string appVersion, string created, string machineName, string userName, string osBuild, IReadOnlyList modules) + : this(manifestVersion, appVersion, created, machineName, userName, osBuild, null, null, null, modules) + { + } + + internal ManifestData(int manifestVersion, string appVersion, string created, string machineName, + string userName, string osBuild, string snapshotName, + IReadOnlyList modules) + : this(manifestVersion, appVersion, created, machineName, userName, osBuild, + snapshotName, null, null, modules) + { + } + + internal ManifestData(int manifestVersion, string appVersion, string created, string machineName, + string userName, string osBuild, string snapshotName, + string compression, string payloadFile, IReadOnlyList modules) { ManifestVersion = manifestVersion; AppVersion = appVersion; @@ -269,6 +299,9 @@ internal ManifestData(int manifestVersion, string appVersion, string created, st MachineName = machineName; UserName = userName; OsBuild = osBuild; + SnapshotName = snapshotName; + Compression = compression; + PayloadFile = payloadFile; Modules = modules; } @@ -285,6 +318,18 @@ internal ManifestData(int manifestVersion, string appVersion, string created, st internal string OsBuild { get; } + /// + /// Optional user-supplied name for the backup folder. A missing value identifies a legacy + /// timestamp-named folder and callers must display the folder name instead. + /// + internal string SnapshotName { get; } + + + /// Optional archive compression used for the payload, when one exists. + internal string Compression { get; } + + /// Optional archive file that holds this backup's module artifacts. + internal string PayloadFile { get; } internal IReadOnlyList Modules { get; } } diff --git a/src/WinRestoreKit.Core/Results/BackupNaming.cs b/src/WinRestoreKit.Core/Results/BackupNaming.cs new file mode 100644 index 0000000..0da2ac0 --- /dev/null +++ b/src/WinRestoreKit.Core/Results/BackupNaming.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; + +namespace WinRestoreKit +{ + /// + /// Validates an optional name for a user-created backup folder. + /// + /// + /// Custom names are stored and used verbatim. Validation deliberately rejects rather than changes + /// input, because silently changing a name would make the destination shown to the user differ + /// from the folder written to disk. Empty input means the caller keeps the legacy timestamp name. + /// + internal static class BackupNaming + { + private const int MaxSegmentLength = 120; + + internal static bool TryValidateCustomName(string value, out string name) + { + name = null; + + if (string.IsNullOrEmpty(value)) + return true; + + if (string.IsNullOrWhiteSpace(value) + || value.Length > MaxSegmentLength + || value == "." + || value == ".." + || value.EndsWith(" ", StringComparison.Ordinal) + || value.EndsWith(".", StringComparison.Ordinal) + || value.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || value.IndexOf('/') >= 0 + || value.IndexOf('\\') >= 0 + || IsReservedDeviceName(value)) + { + return false; + } + + name = value; + return true; + } + + private static bool IsReservedDeviceName(string value) + { + int extension = value.IndexOf('.'); + string baseName = extension < 0 ? value : value.Substring(0, extension); + + if (baseName.Equals("CON", StringComparison.OrdinalIgnoreCase) + || baseName.Equals("PRN", StringComparison.OrdinalIgnoreCase) + || baseName.Equals("AUX", StringComparison.OrdinalIgnoreCase) + || baseName.Equals("NUL", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return baseName.Length == 4 + && (baseName.StartsWith("COM", StringComparison.OrdinalIgnoreCase) + || baseName.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) + && baseName[3] >= '1' + && baseName[3] <= '9'; + } + } +} diff --git a/src/WinRestoreKit.Core/Results/BackupPayload.cs b/src/WinRestoreKit.Core/Results/BackupPayload.cs new file mode 100644 index 0000000..3dfa919 --- /dev/null +++ b/src/WinRestoreKit.Core/Results/BackupPayload.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; + +namespace WinRestoreKit +{ + /// + /// Creates and opens the optional archive payload for a backup folder. + /// + /// + /// The manifest and human-readable log remain at the backup root so existing readers retain + /// their metadata path. Module artifacts move into the archive only after its entries have been + /// reopened and checked against the source file list. Legacy folders without this file are read + /// directly and never require extraction. + /// + internal static class BackupPayload + { + internal const string FileName = "payload.zip"; + + private static readonly HashSet RootMetadata = new HashSet(StringComparer.OrdinalIgnoreCase) + { + BackupManifest.FileName, + BackupLog.FileName, + FileName, + + // BackupRestoreOrchestrator.OwnershipMarkerFileName (".run-owner"), duplicated here + // because that constant lives in the app project and Core cannot reference it: Core is + // the lower layer and the app depends on Core, never the reverse. The marker is a race + // -detection artifact the orchestrator creates beside every fresh backup folder; nothing + // in a restore ever reads it, and it must never be archived as if it were backed-up data. + ".run-owner" + }; + + internal static bool TryArchive(string backupPath, SnapshotCompression compression, out string error) + { + error = null; + + if (compression == SnapshotCompression.None) + return false; + + if (compression != SnapshotCompression.Fast && compression != SnapshotCompression.Max) + { + error = "The requested compression mode is not supported."; + return false; + } + + string temporaryPath = null; + + try + { + List sourceFiles = ListPayloadFiles(backupPath); + List emptyDirectories = ListEmptyPayloadDirectories(backupPath); + string payloadPath = Path.Combine(backupPath, FileName); + temporaryPath = Path.Combine(backupPath, ".payload-" + + Guid.NewGuid().ToString("N") + ".tmp"); + + using (FileStream stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + using (ZipArchive archive = new ZipArchive(stream, ZipArchiveMode.Create, false)) + { + CompressionLevel level = compression == SnapshotCompression.Fast + ? CompressionLevel.Fastest + : CompressionLevel.SmallestSize; + + foreach (SourceFile source in sourceFiles) + archive.CreateEntryFromFile(source.FullPath, source.EntryName, level); + + foreach (string directory in emptyDirectories) + archive.CreateEntry(directory + "/", CompressionLevel.NoCompression); + } + + if (!ArchiveMatches(temporaryPath, sourceFiles, emptyDirectories)) + { + error = "The compressed payload could not be verified."; + return false; + } + + File.Move(temporaryPath, payloadPath, true); + + foreach (SourceFile source in sourceFiles) + File.Delete(source.FullPath); + + RemoveEmptyDirectories(backupPath); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + finally + { + if (!string.IsNullOrEmpty(temporaryPath)) + { + try + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + catch (Exception) + { + } + } + } + } + + internal static bool TryPrepareForRead(string backupPath, out ReadScope payload, out string error) + => TryPrepareForRead(backupPath, null, out payload, out error); + + internal static bool TryPrepareForRead(string backupPath, Func shouldExtract, + out ReadScope payload, out string error) + { + payload = null; + error = null; + + if (string.IsNullOrWhiteSpace(backupPath)) + { + error = "The backup folder is empty."; + return false; + } + + string payloadPath = Path.Combine(backupPath, FileName); + + if (!File.Exists(payloadPath)) + { + // Read guarded, exactly like BackupFolder.ReadManifest and every other manifest + // read in this codebase: File.ReadAllText can throw on a sharing violation, an ACL + // denial, or a TOCTOU delete between the File.Exists check above and this read, which + // is reachable in the same-minute concurrent-instance case this code acknowledges + // elsewhere. An unreadable manifest is treated as no manifest, so this run cannot + // prove an archive was declared and falls back to the loose-folder scope rather than + // throwing into callers that build the wizard page with no catch of their own. + ManifestData manifest = TryReadManifest(Path.Combine(backupPath, BackupManifest.FileName)); + + bool declaresPayload = manifest != null + && (!string.IsNullOrWhiteSpace(manifest.PayloadFile) + || (!string.IsNullOrWhiteSpace(manifest.Compression) + && !string.Equals(manifest.Compression, SnapshotCompression.None.ToString(), + StringComparison.OrdinalIgnoreCase))); + + if (declaresPayload) + { + error = "The backup's compressed payload file is missing, so it cannot be restored."; + return false; + } + + payload = new ReadScope(backupPath, null); + return true; + } + + string extractionPath = Path.Combine(Path.GetTempPath(), "WinRestoreKit", "payload-" + + Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(extractionPath); + string extractionRoot = Path.GetFullPath(extractionPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + using (ZipArchive archive = ZipFile.OpenRead(payloadPath)) + { + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (shouldExtract != null && !shouldExtract(entry.FullName)) + continue; + + string destination = Path.GetFullPath(Path.Combine(extractionPath, entry.FullName)); + + if (!destination.StartsWith(extractionRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The payload contains an unsafe entry path."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destination); + continue; + } + + string directory = Path.GetDirectoryName(destination); + + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + using (Stream input = entry.Open()) + using (FileStream output = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + input.CopyTo(output); + } + } + + payload = new ReadScope(extractionPath, extractionPath); + return true; + } + catch (Exception ex) + { + error = ex.Message; + + try + { + if (Directory.Exists(extractionPath)) + Directory.Delete(extractionPath, true); + } + catch (Exception) + { + } + + return false; + } + } + + /// + /// Parses the manifest at , or null when it is absent, + /// unreadable, or not valid. + /// + /// + /// Absent file, unreadable file and a document TryParse refuses all collapse to null, + /// because they are the same answer to the caller: this run cannot prove what the folder + /// declares. The same shape as BackupFolder.ReadManifest, kept here so the payload reader + /// never throws a raw IO exception into a caller that builds a wizard page with no catch. + /// + private static ManifestData TryReadManifest(string manifestPath) + { + try + { + if (!File.Exists(manifestPath)) + return null; + + return BackupManifest.TryParse(File.ReadAllText(manifestPath)); + } + catch (Exception) + { + return null; + } + } + + private static List ListPayloadFiles(string backupPath) + { + List files = new List(); + string root = Path.GetFullPath(backupPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + foreach (string path in Directory.EnumerateFiles(backupPath, "*", SearchOption.AllDirectories)) + { + string fullPath = Path.GetFullPath(path); + string relative = fullPath.Substring(root.Length); + + if (relative.IndexOf(Path.DirectorySeparatorChar) < 0 + && (RootMetadata.Contains(relative) + || relative.StartsWith(".payload-", StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + files.Add(new SourceFile(fullPath, relative.Replace(Path.DirectorySeparatorChar, '/'))); + } + + return files; + } + + private static List ListEmptyPayloadDirectories(string backupPath) + { + List directories = new List(); + string root = Path.GetFullPath(backupPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + foreach (string path in Directory.EnumerateDirectories(backupPath, "*", SearchOption.AllDirectories)) + { + bool isEmpty = true; + + foreach (string ignored in Directory.EnumerateFileSystemEntries(path)) + { + isEmpty = false; + break; + } + + if (isEmpty) + { + string relative = Path.GetFullPath(path).Substring(root.Length) + .Replace(Path.DirectorySeparatorChar, '/'); + directories.Add(relative); + } + } + + return directories; + } + + private static bool ArchiveMatches(string archivePath, IReadOnlyList files, + IReadOnlyList emptyDirectories) + { + using (ZipArchive archive = ZipFile.OpenRead(archivePath)) + { + if (archive.Entries.Count != files.Count + emptyDirectories.Count) + return false; + + foreach (SourceFile source in files) + { + ZipArchiveEntry entry = archive.GetEntry(source.EntryName); + + if (entry == null || entry.Length != new FileInfo(source.FullPath).Length) + return false; + } + + foreach (string directory in emptyDirectories) + { + if (archive.GetEntry(directory + "/") == null) + return false; + } + } + + return true; + } + + private static void RemoveEmptyDirectories(string root) + { + List directories = new List( + Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories)); + + directories.Sort((left, right) => right.Length.CompareTo(left.Length)); + + foreach (string directory in directories) + { + if (!Directory.EnumerateFileSystemEntries(directory).GetEnumerator().MoveNext()) + Directory.Delete(directory); + } + } + + private sealed class SourceFile + { + internal SourceFile(string fullPath, string entryName) + { + FullPath = fullPath; + EntryName = entryName; + } + + internal string FullPath { get; } + + internal string EntryName { get; } + } + + internal sealed class ReadScope : IDisposable + { + private readonly string ownedPath; + + internal ReadScope(string path, string ownedPath) + { + Path = path; + this.ownedPath = ownedPath; + } + + internal string Path { get; } + + public void Dispose() + { + if (ownedPath == null) + return; + + try + { + if (Directory.Exists(ownedPath)) + Directory.Delete(ownedPath, true); + } + catch (Exception) + { + } + } + } + } +} diff --git a/src/WinRestoreKit.Core/Results/DriftDetector.cs b/src/WinRestoreKit.Core/Results/DriftDetector.cs new file mode 100644 index 0000000..69706cf --- /dev/null +++ b/src/WinRestoreKit.Core/Results/DriftDetector.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +namespace WinRestoreKit +{ + internal sealed class DriftItem + { + internal DriftItem(string name, string path, DateTime? changedAt) + { + Name = name; + Path = path; + ChangedAt = changedAt; + } + + internal string Name { get; } + + internal string Path { get; } + + internal DateTime? ChangedAt { get; } + } + + /// + /// Compares modules with a snapshot without collapsing an unavailable comparison into a clean result. + /// + /// + /// The return value contains only modules whose drift is confirmed. + /// contains modules whose comparison returned null. A module that returned false is in neither + /// collection because its state is confirmed unchanged. + /// + internal static class DriftDetector + { + internal static IReadOnlyList Detect( + string backupPath, + IReadOnlyList modules, + out IReadOnlyList unavailable) + { + List drifted = new List(); + List unavailableItems = new List(); + + foreach (BackupBase module in modules) + { + bool? hasDrifted = module.HasDriftedFrom(backupPath); + + if (hasDrifted == true) + drifted.Add(new DriftItem(module.Title, backupPath, null)); + else if (!hasDrifted.HasValue) + unavailableItems.Add(new DriftItem(module.Title, backupPath, null)); + } + + unavailable = unavailableItems; + return drifted; + } + } +} diff --git a/src/WinRestoreKit.Core/Results/RegFile.cs b/src/WinRestoreKit.Core/Results/RegFile.cs index f6af1a1..3a58f65 100644 --- a/src/WinRestoreKit.Core/Results/RegFile.cs +++ b/src/WinRestoreKit.Core/Results/RegFile.cs @@ -65,5 +65,44 @@ internal static RegFileCheck Validate(string path, out string error) ? RegFileCheck.Valid : RegFileCheck.BadHeader; } + + /// + /// Compares two valid registry exports after normalizing their text encoding and line endings. + /// + /// + /// Returns null when either artifact is absent, unreadable, or not a valid registry export. + /// That is deliberately not drift evidence: a comparison is only meaningful when both sides + /// are known representations of registry state. + /// + internal static bool? HasSameCanonicalContent(string capturedPath, string currentPath) + { + string captured = ReadCanonicalContent(capturedPath); + string current = ReadCanonicalContent(currentPath); + + if (captured == null || current == null) + return null; + + return string.Equals(captured, current, StringComparison.Ordinal); + } + + private static string ReadCanonicalContent(string path) + { + if (Validate(path) != RegFileCheck.Valid) + return null; + + try + { + // File.ReadAllText handles the UTF-16LE BOM written by regedit. The content, rather + // than its encoding or line-ending convention, is what captures registry state. + return File.ReadAllText(path) + .Replace("\r\n", "\n") + .Replace("\r", "\n") + .TrimEnd('\n'); + } + catch + { + return null; + } + } } } diff --git a/src/WinRestoreKit.Core/Results/RestoreContents.cs b/src/WinRestoreKit.Core/Results/RestoreContents.cs index 4060c27..c4d59bf 100644 --- a/src/WinRestoreKit.Core/Results/RestoreContents.cs +++ b/src/WinRestoreKit.Core/Results/RestoreContents.cs @@ -45,18 +45,39 @@ internal static IReadOnlyList For(IReadOnlyList if (modules == null) return rows; - foreach (BackupBase module in modules) - { - if (module == null) - continue; - - string state = ManifestStateFor(manifest, module.GetType().Name); + string probePath = restoreSourcePath; + BackupPayload.ReadScope payload = null; + bool payloadPrepared = false; - rows.Add(new RestoreContentsRow( - module, - HoldsSomethingFor(module, restoreSourcePath, manifest, state), - state, - module.WarningMessage ?? "")); + try + { + foreach (BackupBase module in modules) + { + if (module == null) + continue; + + string state = ManifestStateFor(manifest, module.GetType().Name); + if (!payloadPrepared + && state != BackupManifest.StateSucceeded + && state != BackupManifest.StateSkipped + && state != BackupManifest.StateFailed) + { + payloadPrepared = true; + if (BackupPayload.TryPrepareForRead(restoreSourcePath, out payload, out string ignoredError)) + probePath = payload.Path; + } + + rows.Add(new RestoreContentsRow( + module, + HoldsSomethingFor(module, probePath, manifest, state), + state, + module.WarningMessage ?? "")); + } + } + finally + { + if (payload != null) + payload.Dispose(); } return rows; diff --git a/src/WinRestoreKit.Tests/AppRestoreDialogTests.cs b/src/WinRestoreKit.Tests/AppRestoreDialogTests.cs index 32f7d2c..6d05102 100644 --- a/src/WinRestoreKit.Tests/AppRestoreDialogTests.cs +++ b/src/WinRestoreKit.Tests/AppRestoreDialogTests.cs @@ -58,9 +58,9 @@ private static string ExportWith(params string[] ids) /// private sealed class DialogHook : IDisposable { - private readonly Action previous; + private readonly Action previous; - public DialogHook(Action replacement) + public DialogHook(Action replacement) { previous = AppStoreApps.RestoreDialog; AppStoreApps.RestoreDialog = replacement; @@ -85,18 +85,32 @@ public void Restore_WithNoDialogRegistered_FailsRatherThanClaimingSkipped() } [Fact] - public void Restore_WithADialogRegistered_OpensItOnceAndReportsSkipped() + public void Restore_WithADialogRegistered_OpensItForTheSelectedSourceAndReportsSkipped() { int opened = 0; + string source = NewTempDir(); + string openedFor = null; - using (new DialogHook(() => opened++)) + try { - ModuleResult result = new AppStoreApps().Restore(NewTempDir()); + using (new DialogHook(path => + { + opened++; + openedFor = path; + })) + { + ModuleResult result = new AppStoreApps().Restore(source); - Assert.Equal(1, opened); - Assert.Equal(ResultState.Skipped, result.State); - Assert.Equal("handled interactively in the app restore dialog", - result.Steps[0].Reason, StringComparer.Ordinal); + Assert.Equal(1, opened); + Assert.Equal(Path.GetFullPath(source), Path.GetFullPath(openedFor), StringComparer.OrdinalIgnoreCase); + Assert.Equal(ResultState.Skipped, result.State); + Assert.Equal("handled interactively in the app restore dialog", + result.Steps[0].Reason, StringComparer.Ordinal); + } + } + finally + { + try { Directory.Delete(source, true); } catch { } } } @@ -108,7 +122,7 @@ public void RestoreAsync_RunsTheDialogOnTheCallersThread() { int dialogThread = 0; - using (new DialogHook(() => dialogThread = Environment.CurrentManagedThreadId)) + using (new DialogHook(_ => dialogThread = Environment.CurrentManagedThreadId)) { System.Threading.Tasks.Task task = new AppStoreApps().RestoreAsync(NewTempDir()); @@ -150,7 +164,7 @@ public void ProducerPathAndDialogPath_ResolveToTheSameFile() string written = AppStoreApps.ExportPathIn(backupDir); File.WriteAllText(written, ExportWith("Microsoft.PowerToys")); - string read = RestAppsForm.ExportPathFor(backupName); + string read = RestAppsForm.ExportPathFor(backupDir); Assert.True(File.Exists(read), "the dialog's path did not find the file the module wrote: " + read); Assert.Equal(Path.GetFullPath(written), Path.GetFullPath(read), StringComparer.OrdinalIgnoreCase); @@ -166,6 +180,39 @@ public void ProducerPathAndDialogPath_ResolveToTheSameFile() } } + [Fact] + public void BackupSources_PrefersTheExtractedRestoreSourceForPackageExport() + { + string extractedSource = NewTempDir(); + string otherBackup = NewTempDir(); + + try + { + File.WriteAllText( + AppStoreApps.ExportPathIn(extractedSource), + ExportWith("From.Extracted.Source")); + File.WriteAllText( + AppStoreApps.ExportPathIn(otherBackup), + ExportWith("From.Other.Backup")); + + IReadOnlyList sources = RestAppsForm.BackupSources( + extractedSource, + new[] { otherBackup }); + + Assert.Equal(new[] { extractedSource, otherBackup }, sources, StringComparer.OrdinalIgnoreCase); + + RestAppsForm.AppExport export = RestAppsForm.AppExport.Read( + RestAppsForm.ExportPathFor(sources[0])); + + Assert.Equal(new[] { "From.Extracted.Source" }, export.PackageIdentifiers); + } + finally + { + try { Directory.Delete(extractedSource, true); } catch { } + try { Directory.Delete(otherBackup, true); } catch { } + } + } + // ---- Parse ----------------------------------------------------------------------- [Fact] diff --git a/src/WinRestoreKit.Tests/ArchiveProgressTests.cs b/src/WinRestoreKit.Tests/ArchiveProgressTests.cs new file mode 100644 index 0000000..e7b5a93 --- /dev/null +++ b/src/WinRestoreKit.Tests/ArchiveProgressTests.cs @@ -0,0 +1,67 @@ +using DataHelper; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Windows.Forms; +using Xunit; + +namespace WinRestoreKit.Tests +{ + public class ArchiveProgressTests + { + [Fact] + public async Task RunBackup_FastCompressionReportsArchivingAndWritesPayloadManifest() + { + using (BackupRunIsolation isolation = new BackupRunIsolation()) + { + TestRunUi ui = new TestRunUi(); + BackupRestoreOrchestrator runner = new BackupRestoreOrchestrator(ui); + + await runner.RunBackup(new BackupBase[] { new ArtifactModule() }, isolation.DestinationRoot, + "archive-progress", SnapshotCompression.Fast); + + string backupPath = Path.Combine(isolation.DestinationRoot, Data.NowShort); + ManifestData manifest = BackupManifest.TryParse( + File.ReadAllText(Path.Combine(backupPath, BackupManifest.FileName))); + + Assert.Contains("Archiving backup payload", ui.ProgressTexts); + Assert.True(File.Exists(Path.Combine(backupPath, BackupPayload.FileName))); + Assert.NotNull(manifest); + Assert.Equal("fast", manifest.Compression); + Assert.Equal(BackupPayload.FileName, manifest.PayloadFile); + } + } + + private sealed class ArtifactModule : BackupBase + { + internal ArtifactModule() + { + Title = "Artifact"; + } + + public override ModuleResult Backup(string path) + { + File.WriteAllText(Path.Combine(path, "artifact.txt"), "payload"); + return ModuleResult.Aggregate(new[] { StepResult.Succeeded(Title, "wrote payload") }); + } + } + + private sealed class TestRunUi : IRunUi + { + internal List ProgressTexts { get; } = new List(); + + public IWin32Window Owner => null; + + public void SetProgressText(string text) => ProgressTexts.Add(text); + public void SetProgressPercent(int percent) { } + public void SetProgressDetail(string groupInfo, string elapsed, string remaining, string throughput, + long bytesWritten, int errors, int warnings) { } + public void ShowSummary(RunSummary summary, string caption, IReadOnlyList outcomes) { } + public IReadOnlyList ShowConsentDialog(RestorePlan plan) => null; + public bool ConfirmSnapshotOverride(string text, string caption) => false; + public void ShowPlanCompositionError(string text, string caption) { } + public void SetExplorerRestartVisible(bool visible) { } + } + } +} diff --git a/src/WinRestoreKit.Tests/AssemblyInfo.cs b/src/WinRestoreKit.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..349f247 --- /dev/null +++ b/src/WinRestoreKit.Tests/AssemblyInfo.cs @@ -0,0 +1,16 @@ +using Xunit; + +// Many tests in this assembly mutate process-wide mutable state that has no per-test isolation +// seam: Data.DataRootDir is a public static field, Theme.Current is a static instance, and several +// tests write directly to the real HKCU\Software\WinRestoreKit registry key. xUnit parallelizes +// test CLASSES by default (each class not grouped into a shared collection runs as its own +// collection, concurrently with every other collection), so two classes racing to set +// Data.DataRootDir to different temporary directories, or to read and restore the same registry +// value, produce failures that never reproduce when either class runs alone. Every fixer in this +// repository's history has verified green running its own filtered test class; the failures this +// attribute prevents were interference between classes, not defects in any one of them. +// +// Disabling collection-level parallelism trades away concurrent test execution for correctness. +// The whole suite runs in about a second either way, so the trade costs nothing observable and +// removes an entire category of order-dependent, hardware-dependent flakiness. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/WinRestoreKit.Tests/BackupDestinationContainmentTests.cs b/src/WinRestoreKit.Tests/BackupDestinationContainmentTests.cs new file mode 100644 index 0000000..2e09266 --- /dev/null +++ b/src/WinRestoreKit.Tests/BackupDestinationContainmentTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Windows.Forms; +using Conf; +using DataHelper; +using WinRestoreKit; +using Xunit; + +namespace WinRestoreKit.Tests +{ + /// + /// Regression coverage for the PR #4 bot finding at BackupRestoreOrchestrator.cs:105: choosing a + /// destination inside a folder being backed up makes the timestamped backup a descendant of that + /// source, and WindowsHelper.CopyFolderInto then copies the backup into itself until the path + /// length limit or the disk is exhausted. + /// + public sealed class BackupDestinationContainmentTests + { + private sealed class FolderSourceModule : FolderModule + { + internal FolderSourceModule(string folder) : base(folder) + { + Title = "Source"; + } + } + + [Fact] + public async Task RunBackup_DestinationInsideASelectedSourceFolder_IsRejectedBeforeAnyCopy() + { + string source = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", Guid.NewGuid().ToString("N"))).FullName; + string destination = Path.Combine(source, "backups-here"); + + try + { + TestRunUi ui = new TestRunUi(); + BackupRestoreOrchestrator runner = new BackupRestoreOrchestrator(ui); + + await runner.RunBackup(new BackupBase[] { new FolderSourceModule(source) }, + destination, "nested", SnapshotCompression.None); + + Assert.NotNull(ui.LastSummary); + Assert.Equal(RunState.DidNotRun, ui.LastSummary.State); + // No timestamp folder was created under the destination, so no copy began. + Assert.False(Directory.Exists(Path.Combine(destination, Data.NowShort))); + } + finally + { + if (Directory.Exists(source)) + Directory.Delete(source, true); + } + } + + [Fact] + public async Task RunBackup_DestinationOutsideEverySource_IsAccepted() + { + string source = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", Guid.NewGuid().ToString("N"))).FullName; + string destination = Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(destination); + + object originalRoots = null; + Microsoft.Win32.RegistryValueKind? originalRootsKind = null; + using (Microsoft.Win32.RegistryKey key = + Microsoft.Win32.Registry.CurrentUser.OpenSubKey(@"Software\WinRestoreKit")) + { + if (key != null && Array.IndexOf(key.GetValueNames(), "BackupRoots") >= 0) + { + originalRoots = key.GetValue("BackupRoots", null, + Microsoft.Win32.RegistryValueOptions.DoNotExpandEnvironmentNames); + originalRootsKind = key.GetValueKind("BackupRoots"); + } + } + + try + { + TestRunUi ui = new TestRunUi(); + BackupRestoreOrchestrator runner = new BackupRestoreOrchestrator(ui); + + await runner.RunBackup(new BackupBase[] { new FolderSourceModule(source) }, + destination, "outside", SnapshotCompression.None); + + Assert.NotNull(ui.LastSummary); + Assert.NotEqual(RunState.DidNotRun, ui.LastSummary.State); + Assert.True(Directory.Exists(Path.Combine(destination, Data.NowShort))); + } + finally + { + using (Microsoft.Win32.RegistryKey key = + Microsoft.Win32.Registry.CurrentUser.CreateSubKey(@"Software\WinRestoreKit")) + { + if (originalRoots == null) + key.DeleteValue("BackupRoots", throwOnMissingValue: false); + else + key.SetValue("BackupRoots", originalRoots, originalRootsKind.Value); + } + + if (Directory.Exists(source)) + Directory.Delete(source, true); + if (Directory.Exists(destination)) + Directory.Delete(destination, true); + } + } + + private sealed class TestRunUi : IRunUi + { + internal RunSummary LastSummary { get; private set; } + + public IWin32Window Owner => null; + public void SetProgressText(string text) { } + public void SetProgressPercent(int percent) { } + public void SetProgressDetail(string groupInfo, string elapsed, string remaining, + string throughput, long bytesWritten, int errors, int warnings) { } + public void ShowSummary(RunSummary summary, string caption, IReadOnlyList outcomes) + => LastSummary = summary; + public IReadOnlyList ShowConsentDialog(RestorePlan plan) => null; + public bool ConfirmSnapshotOverride(string text, string caption) => false; + public void ShowPlanCompositionError(string text, string caption) { } + public void SetExplorerRestartVisible(bool visible) { } + } + } +} diff --git a/src/WinRestoreKit.Tests/BackupDestinationLifecycleTests.cs b/src/WinRestoreKit.Tests/BackupDestinationLifecycleTests.cs new file mode 100644 index 0000000..4e3449c --- /dev/null +++ b/src/WinRestoreKit.Tests/BackupDestinationLifecycleTests.cs @@ -0,0 +1,139 @@ +using DataHelper; +using Microsoft.Win32; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using Views; +using Xunit; + +namespace WinRestoreKit.Tests +{ + public class BackupDestinationLifecycleTests + { + [Fact] + public async Task RunBackup_RemembersCustomDestinationBeforeFirstModuleRuns() + { + string root = Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", Guid.NewGuid().ToString("N")); + object originalRoots = null; + RegistryValueKind? originalRootsKind = null; + + using (RegistryKey key = Registry.CurrentUser.OpenSubKey(@"Software\WinRestoreKit")) + { + if (key != null && key.GetValueNames().Contains("BackupRoots")) + { + originalRoots = key.GetValue("BackupRoots", null, + RegistryValueOptions.DoNotExpandEnvironmentNames); + originalRootsKind = key.GetValueKind("BackupRoots"); + } + } + + Directory.CreateDirectory(root); + + try + { + RootObservingModule module = new RootObservingModule(root); + BackupRestoreOrchestrator runner = new BackupRestoreOrchestrator(new TestRunUi()); + + await runner.RunBackup(new BackupBase[] { module }, root, "remember-early", SnapshotCompression.None); + + Assert.True(module.RootWasRememberedWhenBackupStarted); + } + finally + { + using (RegistryKey key = Registry.CurrentUser.CreateSubKey(@"Software\WinRestoreKit")) + { + if (originalRoots == null) + key.DeleteValue("BackupRoots", throwOnMissingValue: false); + else + key.SetValue("BackupRoots", originalRoots, originalRootsKind.Value); + } + + if (Directory.Exists(root)) + Directory.Delete(root, true); + } + } + + [Fact] + public async Task RunBackup_CancelledInExistingFolderRetainsTheExistingFolder() + { + string root = Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", Guid.NewGuid().ToString("N")); + string backupPath = Path.Combine(root, Data.NowShort); + Directory.CreateDirectory(backupPath); + string sentinelPath = Path.Combine(backupPath, "preexisting.txt"); + File.WriteAllText(sentinelPath, "preserve existing folder"); + + try + { + using (RunControl control = new RunControl()) + { + BackupRestoreOrchestrator runner = new BackupRestoreOrchestrator(new TestRunUi(), control); + + await runner.RunBackup(new BackupBase[] { new CancellingModule(control) }, backupPath); + } + + Assert.True(Directory.Exists(backupPath)); + Assert.True(File.Exists(sentinelPath)); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, true); + } + } + + private sealed class RootObservingModule : BackupBase + { + private readonly string root; + + internal RootObservingModule(string root) + { + this.root = root; + Title = "Observe root"; + } + + internal bool RootWasRememberedWhenBackupStarted { get; private set; } + + public override ModuleResult Backup(string path) + { + RootWasRememberedWhenBackupStarted = BackupRootRegistry.Read().Any(candidate => + string.Equals(candidate, root, StringComparison.OrdinalIgnoreCase)); + return ModuleResult.Aggregate(new[] { StepResult.Succeeded(Title, "observed root") }); + } + } + + private sealed class CancellingModule : BackupBase + { + private readonly RunControl control; + + internal CancellingModule(RunControl control) + { + this.control = control; + Title = "Cancel"; + } + + public override ModuleResult Backup(string path) + { + control.RequestCancellation(); + return ModuleResult.Aggregate(new[] { StepResult.Succeeded(Title, "requested cancellation") }); + } + } + + private sealed class TestRunUi : IRunUi + { + public IWin32Window Owner => null; + + public void SetProgressText(string text) { } + public void SetProgressPercent(int percent) { } + public void SetProgressDetail(string groupInfo, string elapsed, string remaining, string throughput, + long bytesWritten, int errors, int warnings) { } + public void ShowSummary(RunSummary summary, string caption, IReadOnlyList outcomes) { } + public IReadOnlyList ShowConsentDialog(RestorePlan plan) => null; + public bool ConfirmSnapshotOverride(string text, string caption) => false; + public void ShowPlanCompositionError(string text, string caption) { } + public void SetExplorerRestartVisible(bool visible) { } + } + } +} diff --git a/src/WinRestoreKit.Tests/BackupFolderOwnershipTests.cs b/src/WinRestoreKit.Tests/BackupFolderOwnershipTests.cs new file mode 100644 index 0000000..1a2268a --- /dev/null +++ b/src/WinRestoreKit.Tests/BackupFolderOwnershipTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using WinRestoreKit; +using Xunit; + +namespace WinRestoreKit.Tests +{ + /// + /// Regression coverage for the PR #4 bot finding at BackupRestoreOrchestrator.cs:185: two app + /// instances racing to the same minute-granularity backup path could both observe the folder as + /// absent, both create it, and then whichever one canceled first would delete the other's + /// output. The fix rests on a single atomic primitive, TryClaimExclusiveFolderOwnership, which + /// must elect exactly one winner no matter how many callers race. These tests pin that primitive + /// directly, because the full two-process interleaving cannot be forced deterministically in + /// a single-process test without reintroducing the very timing gap being closed. + /// + public sealed class BackupFolderOwnershipTests + { + [Fact] + public void Claim_FirstCallWins_SecondCallOnSameFolderLoses() + { + string folder = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", Guid.NewGuid().ToString("N"))).FullName; + + try + { + Assert.True(BackupRestoreOrchestrator.TryClaimExclusiveFolderOwnership(folder)); + Assert.False(BackupRestoreOrchestrator.TryClaimExclusiveFolderOwnership(folder)); + } + finally + { + Directory.Delete(folder, true); + } + } + + [Fact] + public void Claim_UnwritableFolder_LosesRatherThanThrows() + { + // A path with no folder behind it stands in for any claim that cannot be established. + // The safe direction is to lose the race, never to throw into the caller and never to + // report ownership this run cannot back up with an actual marker on disk. + string missing = Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", + Guid.NewGuid().ToString("N"), "does-not-exist"); + + Assert.False(BackupRestoreOrchestrator.TryClaimExclusiveFolderOwnership(missing)); + } + + [Fact] + public async Task Claim_ManyConcurrentCallers_ElectExactlyOneWinner() + { + string folder = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", Guid.NewGuid().ToString("N"))).FullName; + + try + { + const int racers = 32; + using ManualResetEventSlim gate = new ManualResetEventSlim(false); + Task[] attempts = new Task[racers]; + + for (int i = 0; i < racers; i++) + { + attempts[i] = Task.Run(() => + { + // Every racer blocks here, so the claims land as close to simultaneously as + // the scheduler allows rather than serializing by construction. + gate.Wait(); + return BackupRestoreOrchestrator.TryClaimExclusiveFolderOwnership(folder); + }); + } + + gate.Set(); + bool[] results = await Task.WhenAll(attempts); + + Assert.Equal(1, results.Count(won => won)); + } + finally + { + Directory.Delete(folder, true); + } + } + } +} diff --git a/src/WinRestoreKit.Tests/BackupFoldersReadTests.cs b/src/WinRestoreKit.Tests/BackupFoldersReadTests.cs new file mode 100644 index 0000000..697c77f --- /dev/null +++ b/src/WinRestoreKit.Tests/BackupFoldersReadTests.cs @@ -0,0 +1,265 @@ +using DataHelper; +using Microsoft.Win32; +using System; +using System.IO; +using System.Linq; +using Views; +using Xunit; + +namespace WinRestoreKit.Tests +{ + public class BackupFoldersReadTests + { + [Fact] + public void Read_UnreadableCustomRootKeepsReadableDefaultRoot() + { + string parent = NewTempDirectory(); + string defaultRoot = Directory.CreateDirectory(Path.Combine(parent, "default")).FullName; + string unavailableCustomRoot = Path.Combine(parent, "custom-root-file"); + string backup = Directory.CreateDirectory(Path.Combine(defaultRoot, "legacy-backup")).FullName; + File.WriteAllText(unavailableCustomRoot, "not a directory"); + + try + { + RunWithConfiguredRoots(defaultRoot, new[] { unavailableCustomRoot }, () => + { + BackupFolders folders = BackupFolders.Read(); + + Assert.Null(folders.UnreadableReason); + Assert.Contains(folders.Backups, folder => + string.Equals(folder.Path, backup, StringComparison.OrdinalIgnoreCase)); + }); + } + finally + { + Directory.Delete(parent, true); + } + } + + [Fact] + public void Read_UnreadableDefaultRootReportsFatalReason() + { + string parent = NewTempDirectory(); + string unreadableDefaultRoot = Path.Combine(parent, "default-root-file"); + File.WriteAllText(unreadableDefaultRoot, "not a directory"); + + try + { + RunWithConfiguredRoots(unreadableDefaultRoot, Array.Empty(), () => + { + BackupFolders folders = BackupFolders.Read(); + + Assert.NotNull(folders.UnreadableReason); + }); + } + finally + { + Directory.Delete(parent, true); + } + } + + [Fact] + public void Read_CustomRootExcludesUnrelatedDirectory() + { + RunWithRoots((defaultRoot, customRoot) => + { + string unrelated = Directory.CreateDirectory(Path.Combine(customRoot, "Taxes")).FullName; + + BackupFolders folders = BackupFolders.Read(); + + Assert.DoesNotContain(folders.Backups, folder => + string.Equals(folder.Path, unrelated, StringComparison.OrdinalIgnoreCase)); + }); + } + + [Fact] + public void Read_CustomRootIncludesTimestampAndManifestFolders() + { + RunWithRoots((defaultRoot, customRoot) => + { + string timestamp = Directory.CreateDirectory( + Path.Combine(customRoot, "2024-01-02 - 03.04 (3)")).FullName; + string manifested = Directory.CreateDirectory(Path.Combine(customRoot, "named-backup")).FullName; + File.WriteAllText(Path.Combine(manifested, BackupManifest.FileName), "{}"); + + BackupFolders folders = BackupFolders.Read(); + + Assert.Contains(folders.Backups, folder => + string.Equals(folder.Path, timestamp, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(folders.Backups, folder => + string.Equals(folder.Path, manifested, StringComparison.OrdinalIgnoreCase)); + }); + } + + [Fact] + public void Read_CustomRootClassifiesPreRestoreSnapshotSeparately() + { + RunWithRoots((defaultRoot, customRoot) => + { + string snapshot = Directory.CreateDirectory(Path.Combine(customRoot, + SnapshotNaming.NameFor(new DateTime(2024, 1, 2, 3, 4, 5)) + " (2)")).FullName; + + BackupFolders folders = BackupFolders.Read(); + + Assert.Contains(folders.Snapshots, folder => + string.Equals(folder.Path, snapshot, StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(folders.Backups, folder => + string.Equals(folder.Path, snapshot, StringComparison.OrdinalIgnoreCase)); + }); + } + + [Fact] + public void Read_DefaultRootKeepsLooseLegacyFolder() + { + RunWithRoots((defaultRoot, customRoot) => + { + string legacy = Directory.CreateDirectory(Path.Combine(defaultRoot, "loose-legacy-folder")).FullName; + + BackupFolders folders = BackupFolders.Read(); + + Assert.Contains(folders.Backups, folder => + string.Equals(folder.Path, legacy, StringComparison.OrdinalIgnoreCase)); + }); + } + + [Fact] + public void Read_NestedRememberedRootExcludesDefaultRootContainerAndListsBackupOnce() + { + string parent = NewTempDirectory(); + string defaultRoot = Directory.CreateDirectory(Path.Combine(parent, "default")).FullName; + string archive = Directory.CreateDirectory(Path.Combine(defaultRoot, "Archive")).FullName; + string customRoot = Directory.CreateDirectory(Path.Combine(archive, "remembered-root")).FullName; + string backup = Directory.CreateDirectory( + Path.Combine(customRoot, "2024-01-02 - 03.04")).FullName; + + try + { + RunWithConfiguredRoots(defaultRoot, new[] { customRoot }, () => + { + BackupFolders folders = BackupFolders.Read(); + + Assert.DoesNotContain(folders.Backups, folder => + string.Equals(folder.Path, archive, StringComparison.OrdinalIgnoreCase)); + Assert.Equal(1, folders.Backups.Count(folder => + string.Equals(folder.Path, backup, StringComparison.OrdinalIgnoreCase))); + }); + } + finally + { + Directory.Delete(parent, true); + } + } + + [Fact] + public void Read_NestedRememberedRootKeepsOrdinaryDefaultRootBackup() + { + string parent = NewTempDirectory(); + string defaultRoot = Directory.CreateDirectory(Path.Combine(parent, "default")).FullName; + string archive = Directory.CreateDirectory(Path.Combine(defaultRoot, "Archive")).FullName; + string customRoot = Directory.CreateDirectory(Path.Combine(archive, "remembered-root")).FullName; + string ordinaryBackup = Directory.CreateDirectory( + Path.Combine(defaultRoot, "2024-01-03 - 04.05")).FullName; + + try + { + RunWithConfiguredRoots(defaultRoot, new[] { customRoot }, () => + { + BackupFolders folders = BackupFolders.Read(); + + Assert.Contains(folders.Backups, folder => + string.Equals(folder.Path, ordinaryBackup, StringComparison.OrdinalIgnoreCase)); + }); + } + finally + { + Directory.Delete(parent, true); + } + } + + [Fact] + public void Read_RememberedRootOutsideDefaultRootIsStillListed() + { + string parent = NewTempDirectory(); + string defaultRoot = Directory.CreateDirectory(Path.Combine(parent, "default")).FullName; + string customRoot = Directory.CreateDirectory(Path.Combine(parent, "custom")).FullName; + string backup = Directory.CreateDirectory( + Path.Combine(customRoot, "2024-01-04 - 05.06")).FullName; + + try + { + RunWithConfiguredRoots(defaultRoot, new[] { customRoot }, () => + { + BackupFolders folders = BackupFolders.Read(); + + Assert.Equal(1, folders.Backups.Count(folder => + string.Equals(folder.Path, backup, StringComparison.OrdinalIgnoreCase))); + }); + } + finally + { + Directory.Delete(parent, true); + } + } + + private static void RunWithRoots(Action action) + { + string parent = NewTempDirectory(); + string defaultRoot = Directory.CreateDirectory(Path.Combine(parent, "default")).FullName; + string customRoot = Directory.CreateDirectory(Path.Combine(parent, "custom")).FullName; + + try + { + RunWithConfiguredRoots(defaultRoot, new[] { customRoot }, () => action(defaultRoot, customRoot)); + } + finally + { + Directory.Delete(parent, true); + } + } + + private static void RunWithConfiguredRoots(string defaultRoot, string[] customRoots, Action action) + { + string originalDefaultRoot = Data.DataRootDir; + object originalRoots = null; + RegistryValueKind? originalRootsKind = null; + + try + { + using (RegistryKey key = Registry.CurrentUser.OpenSubKey(@"Software\WinRestoreKit")) + { + if (key != null && key.GetValueNames().Contains("BackupRoots")) + { + originalRoots = key.GetValue("BackupRoots", null, + RegistryValueOptions.DoNotExpandEnvironmentNames); + originalRootsKind = key.GetValueKind("BackupRoots"); + } + } + + Data.DataRootDir = defaultRoot; + + using (RegistryKey key = Registry.CurrentUser.CreateSubKey(@"Software\WinRestoreKit")) + key.SetValue("BackupRoots", customRoots, RegistryValueKind.MultiString); + + action(); + } + finally + { + Data.DataRootDir = originalDefaultRoot; + + using (RegistryKey key = Registry.CurrentUser.CreateSubKey(@"Software\WinRestoreKit")) + { + if (originalRoots == null) + key.DeleteValue("BackupRoots", throwOnMissingValue: false); + else + key.SetValue("BackupRoots", originalRoots, originalRootsKind.Value); + } + } + } + + private static string NewTempDirectory() + { + string path = Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", Guid.NewGuid().ToString("N")); + return Directory.CreateDirectory(path).FullName; + } + } +} diff --git a/src/WinRestoreKit.Tests/BackupManifestTests.cs b/src/WinRestoreKit.Tests/BackupManifestTests.cs index 3450ad4..724e874 100644 --- a/src/WinRestoreKit.Tests/BackupManifestTests.cs +++ b/src/WinRestoreKit.Tests/BackupManifestTests.cs @@ -159,6 +159,45 @@ public void TryParse_ReadsBackWhatComposeWrote() CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind).ToUniversalTime()); } + [Fact] + public void TryParse_ExistingManifestWithoutSnapshotName_LeavesItNull() + { + ManifestData data = BackupManifest.TryParse( + @"{ ""manifest_version"": 1, ""modules"": [] }"); + + Assert.NotNull(data); + Assert.Null(data.SnapshotName); + } + + [Fact] + public void ComposeAndTryParse_RoundTripOptionalSnapshotName() + { + string json = BackupManifest.Compose( + Modules(), new List { Ok(), Skip() }, When, + "DESKTOP-NB01", "nicol", "Build 26100.4652", "0.0.1", + snapshotName: "before-driver-update"); + + JObject root = JObject.Parse(json); + ManifestData data = BackupManifest.TryParse(json); + + Assert.Equal("before-driver-update", root["snapshot_name"].Value()); + Assert.Equal("before-driver-update", data.SnapshotName); + } + + [Fact] + public void ComposeAndTryParse_RoundTripArchiveMetadata() + { + string json = BackupManifest.Compose( + Modules(), new List { Ok(), Skip() }, When, + "DESKTOP-NB01", "nicol", "Build 26100.4652", "0.0.1", + compression: SnapshotCompression.Fast, payloadFile: BackupPayload.FileName); + + ManifestData data = BackupManifest.TryParse(json); + + Assert.Equal("fast", data.Compression); + Assert.Equal(BackupPayload.FileName, data.PayloadFile); + } + [Fact] public void TryParse_ReturnsTheTimestampExactlyAsWritten() { diff --git a/src/WinRestoreKit.Tests/BackupPageViewTests.cs b/src/WinRestoreKit.Tests/BackupPageViewTests.cs new file mode 100644 index 0000000..221adb9 --- /dev/null +++ b/src/WinRestoreKit.Tests/BackupPageViewTests.cs @@ -0,0 +1,61 @@ +using System; +using Conf; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Forms; +using Views; +using WinRestoreKit; +using Xunit; + +namespace WinRestoreKit.Tests +{ + public class BackupPageViewTests + { + [Fact] + public void Capture_RequestsSelectedConcreteScopeModulesWithoutOwningRunUi() + { + using (BackupPageView view = new BackupPageView()) + { + Assert.False(view is IRunUi); + + foreach (CustomCheckbox scope in Descendants(view).OfType()) + scope.Checked = false; + + CustomCheckbox explorerScope = view.Controls + .Find("scopeToggle1", true) + .OfType() + .Single(); + explorerScope.Checked = true; + + IReadOnlyList requestedModules = null; + view.StartBackupRequested = (modules, snapshotName, compression, destination) => + requestedModules = modules; + + view.Controls.Find("captureButton", true).OfType