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