diff --git a/CHANGELOG.md b/CHANGELOG.md index bace430..4e05459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ version numbers, because that is what those releases were called. ## [Unreleased] +### Fixed: restore and snapshot integrity + +- Verified successful manifest rows against their physical artifacts and kept unavailable modules out + of restore sets, preventing stale metadata from authorizing an unsafe restore. +- Read the selected app export directly from its prepared payload and exposed friendly source names in + the reinstall picker. +- Created a fresh, second-precision folder for every user backup and reserved collision suffixes + atomically so repeated or concurrent runs cannot reuse an existing restore point. + ### Fixed: WPF Timeline interaction and accessibility - Added mouse double-click activation alongside Enter for Timeline restore points, with duplicate-open diff --git a/src/WinRestoreKit.Application/AppRestore/AppRestoreService.cs b/src/WinRestoreKit.Application/AppRestore/AppRestoreService.cs index 5df9b35..e966419 100644 --- a/src/WinRestoreKit.Application/AppRestore/AppRestoreService.cs +++ b/src/WinRestoreKit.Application/AppRestore/AppRestoreService.cs @@ -24,16 +24,21 @@ internal enum AppRestoreProblemRouting internal sealed class AppRestoreSource { - internal AppRestoreSource(string path, string displayName, bool isSelectedRestoreSource) + internal AppRestoreSource(string path, string displayName, bool isSelectedRestoreSource, + bool isPreparedPayload) { Path = path ?? string.Empty; DisplayName = displayName ?? string.Empty; IsSelectedRestoreSource = isSelectedRestoreSource; + IsPreparedPayload = isPreparedPayload; } public string Path { get; } public string DisplayName { get; } public bool IsSelectedRestoreSource { get; } + internal bool IsPreparedPayload { get; } + + public override string ToString() => DisplayName; } internal sealed class AppExport @@ -88,14 +93,14 @@ internal static IReadOnlyList BuildSources(string selectedRest IReadOnlyList snapshots) { var sources = new List(); - AddDistinct(sources, selectedRestorePath, "Selected restore source", true); + AddDistinct(sources, selectedRestorePath, "Selected restore source", true, true); foreach (SnapshotEvent snapshot in snapshots ?? Array.Empty()) { if (snapshot == null || !snapshot.IsRestorable) continue; - AddDistinct(sources, snapshot.CanonicalPath, snapshot.DisplayName, false); + AddDistinct(sources, snapshot.CanonicalPath, snapshot.DisplayName, false, false); } return Array.AsReadOnly(sources.ToArray()); @@ -123,6 +128,16 @@ internal static AppExport ReadFromSource(string sourcePath) return ReadExport(AppStoreApps.ExportPathIn(scope.Path)); } + internal static AppExport ReadFromSourceEntry(AppRestoreSource source) + { + if (source == null) + return AppExport.Unreadable("No app backup source is selected."); + + return source.IsPreparedPayload + ? ReadExport(AppStoreApps.ExportPathIn(source.Path)) + : ReadFromSource(source.Path); + } + internal static Task InstallAsync(IReadOnlyList packageIdentifiers, Func stopRequested) => InstallAsync(packageIdentifiers, id => Utils.RunWingetAsync(true, "install", "--id", id, @@ -197,7 +212,7 @@ internal static AppRestoreOutcome ComposeOutcome(int requested, int attempted, } private static void AddDistinct(List sources, string path, string displayName, - bool isSelectedRestoreSource) + bool isSelectedRestoreSource, bool isPreparedPayload) { if (string.IsNullOrWhiteSpace(path)) return; @@ -216,7 +231,8 @@ private static void AddDistinct(List sources, string path, str StringComparison.OrdinalIgnoreCase))) return; - sources.Add(new AppRestoreSource(canonicalPath, displayName, isSelectedRestoreSource)); + sources.Add(new AppRestoreSource(canonicalPath, displayName, isSelectedRestoreSource, + isPreparedPayload)); } private static AppExport ReadExport(string path) diff --git a/src/WinRestoreKit.Application/Comparison/ModuleComparison.cs b/src/WinRestoreKit.Application/Comparison/ModuleComparison.cs index 233277f..541c4e4 100644 --- a/src/WinRestoreKit.Application/Comparison/ModuleComparison.cs +++ b/src/WinRestoreKit.Application/Comparison/ModuleComparison.cs @@ -20,6 +20,10 @@ internal ModuleComparison(BackupBase module, ComparisonState state, public bool HasUsableArtifact { get; } + public bool CanRestore => HasUsableArtifact + && State != ComparisonState.Unavailable + && State != ComparisonState.NotCaptured; + public string ArtifactSummary { get; } public string Reason { get; } diff --git a/src/WinRestoreKit.Application/Comparison/SnapshotComparisonService.cs b/src/WinRestoreKit.Application/Comparison/SnapshotComparisonService.cs index 3d99dfd..acdf68c 100644 --- a/src/WinRestoreKit.Application/Comparison/SnapshotComparisonService.cs +++ b/src/WinRestoreKit.Application/Comparison/SnapshotComparisonService.cs @@ -161,35 +161,38 @@ private static ModuleComparison CompareOne(BackupBase module, string payloadPath if (IsManifestWithoutArtifact(entry)) return NotCapturedFromManifest(module, entry); - bool usableArtifact; - string artifactSummary; - if (entry?.State == BackupManifest.StateSucceeded) + bool manifestSucceeded = entry?.State == BackupManifest.StateSucceeded; + bool? probe; + try { - usableArtifact = true; - artifactSummary = "The snapshot manifest records this module as captured."; + probe = module.HasArtifactIn(payloadPath); } - else + catch (Exception ex) { - bool? probe; - try - { - probe = module.HasArtifactIn(payloadPath); - } - catch (Exception ex) - { - LogHelper.Instance.LogMessage("Comparison artifact probe failed for " + module.Title - + ": " + ex.Message); - return new ModuleComparison(module, ComparisonState.Unavailable, false, - "Artifact presence could not be determined.", ex.Message); - } + LogHelper.Instance.LogMessage("Comparison artifact probe failed for " + module.Title + + ": " + ex.Message); + return new ModuleComparison(module, ComparisonState.Unavailable, false, + "Artifact presence could not be determined.", ex.Message); + } + + if (probe == false) + { + string reason = manifestSucceeded + ? "The manifest records this module as captured, but its restore artifact is missing." + : "The module proved that this snapshot has no restore artifact."; + return new ModuleComparison(module, ComparisonState.NotCaptured, false, reason, string.Empty); + } - if (probe == false) + bool usableArtifact; + string artifactSummary; + if (!probe.HasValue) + { + if (manifestSucceeded) { - return new ModuleComparison(module, ComparisonState.NotCaptured, false, - "The module proved that this snapshot has no restore artifact.", string.Empty); + usableArtifact = true; + artifactSummary = "The manifest records this module as captured; the module has no physical artifact probe."; } - - if (!probe.HasValue) + else { if (manifest != null) { @@ -202,11 +205,13 @@ private static ModuleComparison CompareOne(BackupBase module, string payloadPath usableArtifact = true; artifactSummary = "No manifest is available and the module cannot disprove a legacy artifact."; } - else - { - usableArtifact = true; - artifactSummary = "The module verified a captured artifact."; - } + } + else + { + usableArtifact = true; + artifactSummary = manifestSucceeded + ? "The manifest and the module both verify a captured artifact." + : "The module verified a captured artifact."; } try diff --git a/src/WinRestoreKit.Application/Orchestration/BackupRestoreOrchestrator.cs b/src/WinRestoreKit.Application/Orchestration/BackupRestoreOrchestrator.cs index 6c7daa9..a3a20dc 100644 --- a/src/WinRestoreKit.Application/Orchestration/BackupRestoreOrchestrator.cs +++ b/src/WinRestoreKit.Application/Orchestration/BackupRestoreOrchestrator.cs @@ -79,9 +79,9 @@ internal Task RunBackup(IReadOnlyList selection, string backupPath) /// Runs a user backup below with an optional display name. /// /// - /// The physical folder always retains the frozen Data.NowShort shape. A present custom name - /// is validated and stored only in the manifest for display after a backup folder is copied - /// or renamed. + /// The physical folder keeps the established timestamp shape, with seconds and a numeric + /// collision suffix when needed. A present custom name is validated and stored only in the + /// manifest for display after a backup folder is copied or renamed. /// internal Task RunBackup(IReadOnlyList selection, string destinationPath, string snapshotName, SnapshotCompression compression) @@ -111,7 +111,7 @@ internal Task RunBackup(IReadOnlyList selection, string destinationP } SnapshotCompression = compression; - string backupPath = Path.Combine(destinationPath, Data.NowShort); + string backupPath = Path.Combine(destinationPath, BackupNaming.TimestampNameFor(DateTime.Now)); BackupOutputPath = backupPath; if (DestinationInsideSelectedSource(backupPath, selection, out string containingSource)) @@ -242,34 +242,52 @@ private async Task RunBackupCore(IReadOnlyList selection, string bac string snapshotName, SnapshotCompression compression, string destinationRoot) { - bool folderExistedBeforeRun = Directory.Exists(backupPath); string createError; + bool folderExistedBeforeRun; + bool exclusivelyOwned; - if (!TryCreateBackupFolder(backupPath, out createError)) + if (destinationRoot != null) { - // Reported as a run that DID NOT RUN, not as a crash and not as a silent - // no-op: the user asked for a backup and got nothing, and they need to be - // told which of those two it was. - ui.ShowSummary(RunSummary.For(new List(), false, RunVerb.Backup, - "the backup folder could not be created: " + createError), "Backup", - new List()); - return; + string baseFolderName = Path.GetFileName(backupPath); + if (!TryCreateFreshBackupFolder(destinationRoot, baseFolderName, + out backupPath, out createError)) + { + BackupOutputPath = backupPath; + ui.ShowSummary(RunSummary.For(new List(), false, RunVerb.Backup, + "the backup folder could not be reserved: " + createError), "Backup", + new List()); + return; + } + + BackupOutputPath = backupPath; + folderExistedBeforeRun = false; + exclusivelyOwned = true; } + else + { + folderExistedBeforeRun = Directory.Exists(backupPath); + if (!TryCreateBackupFolder(backupPath, out createError)) + { + // Reported as a run that DID NOT RUN, not as a crash and not as a silent + // no-op: the user asked for a backup and got nothing, and they need to be + // told which of those two it was. + ui.ShowSummary(RunSummary.For(new List(), false, RunVerb.Backup, + "the backup folder could not be created: " + createError), "Backup", + new List()); + return; + } - // Claimed only when this run itself observed the folder as absent: a folder that - // already existed is never this run's to claim, exclusively or otherwise, and is - // handled by the folderExistedBeforeRun branch below regardless of this flag. - bool exclusivelyOwned = !folderExistedBeforeRun && TryClaimExclusiveFolderOwnership(backupPath); + // Claimed only when this run itself observed the folder as absent: a folder that + // already existed is never this run's to claim, exclusively or otherwise. + exclusivelyOwned = !folderExistedBeforeRun && TryClaimExclusiveFolderOwnership(backupPath); + } if (destinationRoot != null) BackupRootRegistry.Remember(destinationRoot); - // Before a single module writes anything. The backup path is built from Data.NowShort - // by the caller, stamped once per process, so a second Backup click in the same session - // runs into the SAME folder - and the first run's manifest would otherwise survive - // alongside files the second run has already replaced. That is the confidently-green - // failure the whole-and-last write exists to prevent, arriving by a different route: an - // interrupted second run must read as unknown, not as the first run's verdict. + // Before a single module writes anything. The direct-path overload may intentionally + // reuse a folder, and old builds also reused one process-scoped timestamp. In either + // case the first run's manifest must not survive beside files a later run replaces. if (InvalidateBackupManifest(backupPath)) { ui.ShowSummary(RunSummary.For(new List(), false, RunVerb.Backup, @@ -392,6 +410,50 @@ private bool TryCreateBackupFolder(string path, out string error) } } + /// + /// Creates and exclusively claims a new timestamped child below a user-selected root. + /// + /// + /// Directory creation alone is not exclusive on Windows. Two processes can both observe a + /// missing directory and both receive success from Directory.CreateDirectory. The owner + /// marker uses FileMode.CreateNew, so only one contender may keep a candidate. The other + /// advances to a numeric suffix without touching the first run's files. + /// + private bool TryCreateFreshBackupFolder(string destinationRoot, string baseFolderName, + out string backupPath, out string error) + { + backupPath = Path.Combine(destinationRoot, baseFolderName); + error = null; + + for (int candidateNumber = 1; candidateNumber <= 999; candidateNumber++) + { + string folderName = candidateNumber == 1 + ? baseFolderName + : baseFolderName + " (" + candidateNumber.ToString(CultureInfo.InvariantCulture) + ")"; + string candidatePath = Path.Combine(destinationRoot, folderName); + backupPath = candidatePath; + + if (Directory.Exists(candidatePath)) + continue; + + if (!TryCreateBackupFolder(candidatePath, out error)) + return false; + + if (TryClaimExclusiveFolderOwnership(candidatePath)) + return true; + + string markerPath = Path.Combine(candidatePath, OwnershipMarkerFileName); + if (!File.Exists(markerPath)) + { + error = "exclusive ownership of " + candidatePath + " could not be established"; + return false; + } + } + + error = "no unused timestamped folder name remained below " + destinationRoot; + return false; + } + /// /// Marker filename used to break the folder-creation race between two app instances. /// @@ -407,11 +469,11 @@ private bool TryCreateBackupFolder(string path, out string error) /// this folder as its own to delete on cancellation. /// /// - /// Data.NowShort is minute-granularity, so two app instances started in the same minute and - /// targeting the same destination compute the identical backup path. Both then observe - /// Directory.Exists as false and both succeed at Directory.CreateDirectory, which is - /// idempotent and tells neither of them anything about the other. Left unresolved, whichever - /// one cancels first would delete a folder the other is actively writing into. + /// Two app instances can choose the same second-resolution candidate before either creates + /// it. Both then observe Directory.Exists as false and both succeed at + /// Directory.CreateDirectory, which is idempotent and tells neither of them anything about + /// the other. Left unresolved, whichever one cancels first could delete a folder the other + /// is actively writing into. /// /// FileMode.CreateNew is the one filesystem primitive here that is genuinely atomic: it /// throws if the file already exists, so at most one caller across any number of racing @@ -613,11 +675,10 @@ private void LogBackedUpElements(string backupFolderPath, IReadOnlyList /// /// The whole-and-last write guarantees a manifest describes a COMPLETED run, but only for a - /// folder that started empty. Backing up twice in one session reuses the folder, because - /// the backup path is built from Data.NowShort and that is stamped once per process. Then - /// the first run's manifest sits beside files the second run has already overwritten, and if - /// the second run is interrupted the reader trusts a verdict for data that is no longer - /// there - a stale green, which is the failure this file exists to make impossible. + /// folder that started empty. The direct-path overload and folders produced by older builds + /// can still be reused intentionally. Then the first run's manifest can sit beside files a + /// later run already overwrote, and an interruption would leave a verdict for data that is + /// no longer there. That stale green is the failure this method makes impossible. /// /// Deleting up front means the window between "modules started" and "manifest published" has /// no manifest in it at all, which is exactly the state the reader renders as unknown. @@ -700,13 +761,11 @@ private bool TryRemove(string path) /// The scratch path the manifest is written to before being moved into place. /// /// - /// Process-scoped. Data.NowShort has minute precision and there is no single-instance guard, - /// so two copies of the app started in the same minute address the same backup folder. They - /// would otherwise share one .tmp and overwrite each other's half-written document, which is - /// the one way this write can publish a well-formed manifest describing neither run. Their - /// racing over the FINAL file is a pre-existing hazard of the shared folder - two processes - /// writing one backup was already unsound before this file existed - but the temp collision - /// is created here, so it is closed here. + /// Process-scoped. Direct-path callers and older builds can address the same backup folder. + /// They would otherwise share one .tmp and overwrite each other's half-written document, + /// which can publish a well-formed manifest describing neither run. Their racing over the + /// final file is a pre-existing hazard of a shared folder, but this method does not add a + /// second collision at the temporary path. /// private static string TempManifestPath(string finalPath) => finalPath + "." + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + ".tmp"; diff --git a/src/WinRestoreKit.Core/Conf/AppStoreApps.cs b/src/WinRestoreKit.Core/Conf/AppStoreApps.cs index 8b8a1d5..4d56ee8 100644 --- a/src/WinRestoreKit.Core/Conf/AppStoreApps.cs +++ b/src/WinRestoreKit.Core/Conf/AppStoreApps.cs @@ -47,25 +47,12 @@ public AppStoreApps() Info = "This will export all installed winget package identifiers as a .json file.\nThe import process allows you to restore specific apps themselves based on this file."; } - // HasBackupIn is deliberately NOT overridden to test for ExportPathIn(restorePath). - // - // 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 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 not the only question. - public override bool? HasArtifactIn(string backupPath) => true; + // HasBackupIn keeps the base fail-open restore behavior because this module closes no + // processes and writes no settings itself. HasArtifactIn serves a different purpose: it + // decides whether Compare may claim that the selected snapshot contains a usable app list. + // That answer must describe this snapshot, not another source the later dialog might offer. + public override bool? HasArtifactIn(string backupPath) + => !string.IsNullOrWhiteSpace(backupPath) && File.Exists(ExportPathIn(backupPath)); public override IReadOnlyList RestoreTargets => new[] @@ -87,12 +74,10 @@ public override async Task BackupAsync(string path) // Execute winget command to list installed apps string outputFilePath = ExportPathIn(path); - // Clear the target before running winget. ConfPageView reuses one timestamped folder for - // every Backup click in an app session, so a second click can find a valid export from - // the first still sitting there. winget has a documented-here failure mode of exiting 0 - // having written nothing (no source configured), and Verify would then be handed last - // run's file: every check passes, the run reports success, and the user keeps an - // outdated package list believing it was refreshed. + // Clear the target before running winget. A direct-path caller or a folder from an older + // build can be reused, so a later run can find a valid export from the first still + // sitting there. winget can exit 0 while writing nothing when no source is configured. + // Without this clear, Verify would accept the previous package list as the new one. try { if (File.Exists(outputFilePath)) diff --git a/src/WinRestoreKit.Core/Conf/WPowerPlans.cs b/src/WinRestoreKit.Core/Conf/WPowerPlans.cs index 48083b7..1828bfa 100644 --- a/src/WinRestoreKit.Core/Conf/WPowerPlans.cs +++ b/src/WinRestoreKit.Core/Conf/WPowerPlans.cs @@ -433,10 +433,9 @@ private async Task ExportSchemeAsync(string path, PowerSchemeEntry s string filePath = Path.Combine(path, PowFileNameFor(scheme.Guid)); // Cleared before the export, for the two reasons ExportRegistryKey documents: the - // artifact check below could otherwise be satisfied by a file this run did not write, - // and ConfPageView reuses one timestamped folder for every Backup click in an app - // session - so a failed re-export would leave the previous run's .pow sitting there, - // restorable, while the row says Failed. + // artifact check below could otherwise be satisfied by a file this run did not write. + // A direct-path caller or a folder from an older build can be reused, so a failed + // re-export must not leave the previous run's .pow restorable while the row says Failed. string clearError = TryClear(filePath); if (clearError != null) diff --git a/src/WinRestoreKit.Core/Helpers/WindowsHelper.cs b/src/WinRestoreKit.Core/Helpers/WindowsHelper.cs index 83daf59..96aecf3 100644 --- a/src/WinRestoreKit.Core/Helpers/WindowsHelper.cs +++ b/src/WinRestoreKit.Core/Helpers/WindowsHelper.cs @@ -303,13 +303,11 @@ internal static StepResult ExportRegistryKey(string filePath, string registryPat // 1. Provenance. The verification below could otherwise be satisfied by a file this run // did not write, making this method's promise to verify what it produced false. // - // 2. Stale artifacts across runs. ConfPageView reuses one timestamped folder for every - // Backup click in an app session, so clicking Backup twice writes into the same - // place. If the key existed on the first click and is gone on the second, returning - // early on Absent leaves the FIRST run's .reg file sitting there while the log says - // the item was skipped. A later restore then imports registry state the user was - // told had not been captured - the same landmine as a part-written export, arriving - // by a different route. + // 2. Stale artifacts across runs. A direct-path caller or a folder from an older build + // can be reused. If the key existed on the first run and is gone on the second, + // returning early on Absent leaves the first run's .reg file sitting there while the + // log says the item was skipped. A later restore then imports registry state the user + // was told had not been captured. // // A delete failure fails the step even when absence is normal: a file we cannot remove // is a file we cannot vouch for, and "skipped" would imply the folder holds nothing. @@ -1243,4 +1241,4 @@ internal static async Task RunWingetAsync(bool showWindow, param }).ConfigureAwait(false); } } -} \ No newline at end of file +} diff --git a/src/WinRestoreKit.Core/Results/BackupNaming.cs b/src/WinRestoreKit.Core/Results/BackupNaming.cs index 0da2ac0..225e151 100644 --- a/src/WinRestoreKit.Core/Results/BackupNaming.cs +++ b/src/WinRestoreKit.Core/Results/BackupNaming.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; namespace WinRestoreKit @@ -14,6 +15,10 @@ namespace WinRestoreKit internal static class BackupNaming { private const int MaxSegmentLength = 120; + private const string TimestampFormat = "yyyy-MM-dd - HH.mm.ss"; + + internal static string TimestampNameFor(DateTime now) + => now.ToString(TimestampFormat, CultureInfo.InvariantCulture); internal static bool TryValidateCustomName(string value, out string name) { diff --git a/src/WinRestoreKit.Core/Results/RestoreContents.cs b/src/WinRestoreKit.Core/Results/RestoreContents.cs index 70045a5..31f97d2 100644 --- a/src/WinRestoreKit.Core/Results/RestoreContents.cs +++ b/src/WinRestoreKit.Core/Results/RestoreContents.cs @@ -28,9 +28,9 @@ internal RestoreContentsRow(BackupBase module, bool hasBackup, string manifestSt } /// - /// Builds the wizard's step-2 rows and the provenance banner: the parsed manifest for state and - /// for presence, with answering for whatever the manifest - /// does not mention. + /// Builds the wizard's step-2 rows and the provenance banner. The parsed manifest supplies run + /// state, while verifies physical presence whenever a + /// module can answer. A succeeded manifest remains the fallback for modules without a probe. /// /// /// Unknown renders as unknown, never inferred: a module absent from the manifest (every backup @@ -61,8 +61,7 @@ internal static IReadOnlyList For(IReadOnlyList continue; string state = ManifestStateFor(manifest, module.GetType().Name); - bool needsPayload = state != BackupManifest.StateSucceeded - && state != BackupManifest.StateSkipped + bool needsPayload = state != BackupManifest.StateSkipped && state != BackupManifest.StateFailed; if (!payloadPrepared && needsPayload) @@ -102,8 +101,9 @@ internal static IReadOnlyList For(IReadOnlyList } /// - /// Whether the folder has anything this module could restore. The manifest decides for the - /// modules it names; the module's own artifact probe decides for the rest. + /// Whether the folder has anything this module could restore. Failed and skipped manifest + /// rows refuse the restore. Otherwise the module's physical probe decides when available, + /// with manifest success as the fallback for modules that cannot inspect their own shape. /// /// /// This deliberately does NOT ask , which was the first @@ -123,18 +123,20 @@ internal static IReadOnlyList For(IReadOnlyList private static bool HoldsSomethingFor(BackupBase module, string backupPath, ManifestData manifest, string manifestState) { - if (manifestState == BackupManifest.StateSucceeded) - return true; - if (manifestState == BackupManifest.StateSkipped || manifestState == BackupManifest.StateFailed) return false; - // No manifest, or one that does not mention this module. Ask the module to look. + // A successful manifest says what the run intended to write. The module's physical + // probe still gets the final word when it can answer, so deleted or stale artifacts are + // not offered for restore merely because the manifest survived. bool? probe = Probe(module, backupPath); if (probe != null) return probe.Value; + if (manifestState == BackupManifest.StateSucceeded) + return true; + // It cannot tell. With a manifest present, silence means it was not part of that run. // Without one there is nothing left to consult, and refusing a restore we cannot // disprove is the worse error - so offer it and let the run report honestly. diff --git a/src/WinRestoreKit.Tests/AppRestoreServiceTests.cs b/src/WinRestoreKit.Tests/AppRestoreServiceTests.cs index b01c4f5..8359833 100644 --- a/src/WinRestoreKit.Tests/AppRestoreServiceTests.cs +++ b/src/WinRestoreKit.Tests/AppRestoreServiceTests.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using WinRestoreKit.Wpf.ViewModels; using Xunit; namespace WinRestoreKit.Tests @@ -28,10 +29,51 @@ public void BuildSources_SelectedSourcePrecedesDistinctRestorableCatalogEntries( Assert.Equal(2, sources.Count); Assert.True(sources[0].IsSelectedRestoreSource); + Assert.True(sources[0].IsPreparedPayload); + Assert.Equal("Selected restore source", sources[0].ToString()); Assert.Equal(Path.GetFullPath(selected), sources[0].Path, StringComparer.OrdinalIgnoreCase); + Assert.False(sources[1].IsPreparedPayload); Assert.Equal(Path.GetFullPath(alternate), sources[1].Path, StringComparer.OrdinalIgnoreCase); } + [Fact] + public void ReadFromSource_PreparedPayloadDoesNotTryToExtractItAgain() + { + string root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), + Guid.NewGuid().ToString("N"))).FullName; + try + { + File.WriteAllText(AppStoreApps.ExportPathIn(root), + "{\"Sources\":[{\"Packages\":[{\"PackageIdentifier\":\"A.One\"}]}]}"); + File.WriteAllText(Path.Combine(root, BackupManifest.FileName), BackupManifest.Compose( + new BackupBase[] { new AppStoreApps() }, + new[] { ModuleResult.Aggregate(new[] { StepResult.Succeeded("Apps", "captured") }) }, + DateTime.UtcNow, "TEST-PC", "tester", "test-os", "0.0.1", + compression: SnapshotCompression.Fast, payloadFile: BackupPayload.FileName)); + + AppRestoreSource prepared = new AppRestoreSource( + root, "Prepared payload", true, isPreparedPayload: true); + AppRestoreSource archiveRoot = new AppRestoreSource( + root, "Archive root", false, isPreparedPayload: false); + + AppExport preparedExport = AppRestoreService.ReadFromSourceEntry(prepared); + AppExport archiveExport = AppRestoreService.ReadFromSourceEntry(archiveRoot); + + Assert.Equal(AppExportState.Ok, preparedExport.State); + Assert.Equal(new[] { "A.One" }, preparedExport.PackageIdentifiers); + Assert.Equal(AppExportState.Unreadable, archiveExport.State); + Assert.Contains("payload", archiveExport.Message, StringComparison.OrdinalIgnoreCase); + + var viewModel = new AppRestoreDialogViewModel(root, Array.Empty()); + Assert.Equal("Selected restore source", viewModel.SelectedSource.ToString()); + Assert.Equal("A.One", Assert.Single(viewModel.Packages).Identifier); + } + finally + { + Directory.Delete(root, true); + } + } + [Fact] public void ReadFromSource_MissingExportIsAbsentButMalformedExportIsUnreadable() { diff --git a/src/WinRestoreKit.Tests/ArchiveProgressTests.cs b/src/WinRestoreKit.Tests/ArchiveProgressTests.cs index 65e031a..c0000a2 100644 --- a/src/WinRestoreKit.Tests/ArchiveProgressTests.cs +++ b/src/WinRestoreKit.Tests/ArchiveProgressTests.cs @@ -1,4 +1,3 @@ -using DataHelper; using System; using System.Collections.Generic; using System.IO; @@ -21,7 +20,8 @@ public async Task RunBackup_FastCompressionReportsArchivingAndWritesPayloadManif "archive-progress", SnapshotCompression.Fast); string backupPath = runner.BackupOutputPath; - Assert.Equal(Path.Combine(isolation.DestinationRoot, Data.NowShort), backupPath); + Assert.Equal(Path.GetFullPath(isolation.DestinationRoot), Path.GetDirectoryName(backupPath), + StringComparer.OrdinalIgnoreCase); ManifestData manifest = BackupManifest.TryParse( File.ReadAllText(Path.Combine(backupPath, BackupManifest.FileName))); @@ -56,7 +56,8 @@ private sealed class TestRunUi : IRunUi 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) { } + 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; diff --git a/src/WinRestoreKit.Tests/ArtifactProbeTests.cs b/src/WinRestoreKit.Tests/ArtifactProbeTests.cs index ca1c4f7..4ea2f1c 100644 --- a/src/WinRestoreKit.Tests/ArtifactProbeTests.cs +++ b/src/WinRestoreKit.Tests/ArtifactProbeTests.cs @@ -154,16 +154,17 @@ public void FolderAndKeyModules_EmptyCopiedFolderIsNotABackup() } [Fact] - public void AppStoreApps_SaysYesOnPurposeRatherThanCannotTell() + public void AppStoreApps_ReportsOnlyItsOwnExportAsAnArtifact() { - // Its restore opens a dialog with its own folder picker, so folder contents are the - // wrong question. It must answer a literal true, never null - null would let the - // manifest-silence rule grey out a dialog that was perfectly able to open. string dir = NewTempDir(); try { - Assert.True(ByName("AppStoreApps").HasArtifactIn(dir)); + BackupBase module = ByName("AppStoreApps"); + Assert.False(module.HasArtifactIn(dir)); + + File.WriteAllText(AppStoreApps.ExportPathIn(dir), "{\"Sources\":[{\"Packages\":[]}]}"); + Assert.True(module.HasArtifactIn(dir)); } finally { diff --git a/src/WinRestoreKit.Tests/BackupDestinationContainmentTests.cs b/src/WinRestoreKit.Tests/BackupDestinationContainmentTests.cs index 095ecbf..9aa3fab 100644 --- a/src/WinRestoreKit.Tests/BackupDestinationContainmentTests.cs +++ b/src/WinRestoreKit.Tests/BackupDestinationContainmentTests.cs @@ -3,7 +3,6 @@ using System.IO; using System.Threading.Tasks; using Conf; -using DataHelper; using WinRestoreKit; using Xunit; @@ -39,12 +38,13 @@ public async Task RunBackup_DestinationInsideASelectedSourceFolder_IsRejectedBef await runner.RunBackup(new BackupBase[] { new FolderSourceModule(source) }, destination, "nested", SnapshotCompression.None); - Assert.Equal(Path.Combine(destination, Data.NowShort), runner.BackupOutputPath); + Assert.Equal(Path.GetFullPath(destination), Path.GetDirectoryName(runner.BackupOutputPath), + StringComparer.OrdinalIgnoreCase); 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))); + Assert.False(Directory.Exists(runner.BackupOutputPath)); } finally { @@ -112,7 +112,8 @@ private sealed class TestRunUi : IRunUi 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) { } + 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; diff --git a/src/WinRestoreKit.Tests/BackupFoldersReadTests.cs b/src/WinRestoreKit.Tests/BackupFoldersReadTests.cs index 228ea71..59da33a 100644 --- a/src/WinRestoreKit.Tests/BackupFoldersReadTests.cs +++ b/src/WinRestoreKit.Tests/BackupFoldersReadTests.cs @@ -113,7 +113,7 @@ public void Read_CustomRootIncludesTimestampAndManifestFolders() RunWithRoots((defaultRoot, customRoot) => { string timestamp = Directory.CreateDirectory( - Path.Combine(customRoot, "2024-01-02 - 03.04 (3)")).FullName; + Path.Combine(customRoot, "2024-01-02 - 03.04.05 (3)")).FullName; string manifested = Directory.CreateDirectory(Path.Combine(customRoot, "named-backup")).FullName; File.WriteAllText(Path.Combine(manifested, BackupManifest.FileName), "{}"); diff --git a/src/WinRestoreKit.Tests/BackupOutputPathTests.cs b/src/WinRestoreKit.Tests/BackupOutputPathTests.cs index 4068192..0bd7214 100644 --- a/src/WinRestoreKit.Tests/BackupOutputPathTests.cs +++ b/src/WinRestoreKit.Tests/BackupOutputPathTests.cs @@ -1,5 +1,4 @@ using Conf; -using DataHelper; using System; using System.Collections.Generic; using System.IO; @@ -38,7 +37,6 @@ public async Task RunBackup_DestinationOverload_RetainsComputedPathWhenContainme string source = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", Guid.NewGuid().ToString("N"))).FullName; string destination = Path.Combine(source, "backups"); - string expectedPath = Path.Combine(destination, Data.NowShort); var ui = new TestRunUi(); try @@ -48,7 +46,10 @@ public async Task RunBackup_DestinationOverload_RetainsComputedPathWhenContainme await runner.RunBackup(new BackupBase[] { new FolderSourceModule(source) }, destination, "contained", SnapshotCompression.None); - Assert.Equal(expectedPath, runner.BackupOutputPath); + Assert.Equal(Path.GetFullPath(destination), + Path.GetDirectoryName(runner.BackupOutputPath), StringComparer.OrdinalIgnoreCase); + Assert.Matches(@"^\d{4}-\d{2}-\d{2} - \d{2}\.\d{2}\.\d{2}$", + Path.GetFileName(runner.BackupOutputPath)); Assert.Equal(RunState.DidNotRun, ui.Summary.State); } finally @@ -58,6 +59,50 @@ public async Task RunBackup_DestinationOverload_RetainsComputedPathWhenContainme } } + [Fact] + public async Task RunBackup_TwoUserBackupsNeverReuseTheFirstRestorePoint() + { + using (BackupRunIsolation isolation = new BackupRunIsolation()) + { + var runner = new BackupRestoreOrchestrator(new TestRunUi()); + + await runner.RunBackup(new BackupBase[] { new EmptyModule() }, isolation.DestinationRoot, + "first", SnapshotCompression.None); + string first = runner.BackupOutputPath; + + await runner.RunBackup(new BackupBase[] { new EmptyModule() }, isolation.DestinationRoot, + "second", SnapshotCompression.None); + string second = runner.BackupOutputPath; + + Assert.NotEqual(first, second, StringComparer.OrdinalIgnoreCase); + Assert.NotNull(BackupManifest.TryParse( + File.ReadAllText(Path.Combine(first, BackupManifest.FileName)))); + Assert.NotNull(BackupManifest.TryParse( + File.ReadAllText(Path.Combine(second, BackupManifest.FileName)))); + } + } + + [Fact] + public async Task RunBackup_ConcurrentUserBackupsClaimDifferentFolders() + { + using (BackupRunIsolation isolation = new BackupRunIsolation()) + { + var firstRunner = new BackupRestoreOrchestrator(new TestRunUi()); + var secondRunner = new BackupRestoreOrchestrator(new TestRunUi()); + + await Task.WhenAll( + firstRunner.RunBackup(new BackupBase[] { new EmptyModule() }, isolation.DestinationRoot, + "first", SnapshotCompression.None), + secondRunner.RunBackup(new BackupBase[] { new EmptyModule() }, isolation.DestinationRoot, + "second", SnapshotCompression.None)); + + Assert.NotEqual(firstRunner.BackupOutputPath, secondRunner.BackupOutputPath, + StringComparer.OrdinalIgnoreCase); + Assert.True(Directory.Exists(firstRunner.BackupOutputPath)); + Assert.True(Directory.Exists(secondRunner.BackupOutputPath)); + } + } + private sealed class EmptyModule : BackupBase { internal EmptyModule() @@ -82,7 +127,8 @@ private sealed class TestRunUi : IRunUi 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) { } + long bytesWritten, int errors, int warnings) + { } public void ShowSummary(RunSummary summary, string caption, IReadOnlyList outcomes) => Summary = summary; public IReadOnlyList ShowConsentDialog(RestorePlan plan) => null; diff --git a/src/WinRestoreKit.Tests/ComparisonWorkspaceViewModelTests.cs b/src/WinRestoreKit.Tests/ComparisonWorkspaceViewModelTests.cs index 01cfc50..eba7982 100644 --- a/src/WinRestoreKit.Tests/ComparisonWorkspaceViewModelTests.cs +++ b/src/WinRestoreKit.Tests/ComparisonWorkspaceViewModelTests.cs @@ -28,13 +28,13 @@ await WpfTestHost.RunAsync(async () => Assert.Equal(ComparisonFilter.All, workspace.SelectedFilter); Assert.Equal(4, workspace.VisibleRows.Count); - workspace.RestoreSet.Add(workspace.Rows[2].Comparison); + workspace.RestoreSet.Add(workspace.Rows[1].Comparison); workspace.SelectedFilter = ComparisonFilter.ChangedOnly; Assert.Single(workspace.VisibleRows); Assert.Equal("Changed", workspace.VisibleRows[0].Title); - Assert.True(workspace.RestoreSet.Contains(workspace.Rows[2].Comparison.Module)); + Assert.True(workspace.RestoreSet.Contains(workspace.Rows[1].Comparison.Module)); }); } @@ -98,7 +98,7 @@ await WpfTestHost.RunAsync(async () => ModuleComparison selected = new ModuleComparison( navigator.CurrentWorkspace.Rows[0].Registration.Module, - ComparisonState.Unavailable, true, "Artifact captured.", "Comparison unavailable."); + ComparisonState.Changed, true, "Artifact captured.", "Current state differs."); navigator.CurrentWorkspace.RestoreSet.Add(selected); string incomingOwnedPath = Path.Combine(Path.GetTempPath(), "WinRestoreKit.Tests", Guid.NewGuid().ToString("N")); @@ -134,7 +134,7 @@ await WpfTestHost.RunAsync(async () => ComparisonWorkspaceViewModel firstWorkspace = navigator.CurrentWorkspace; firstWorkspace.RestoreSet.Add(new ModuleComparison( firstWorkspace.Rows[0].Registration.Module, - ComparisonState.Unavailable, true, "Artifact captured.", "Comparison unavailable.")); + ComparisonState.Changed, true, "Artifact captured.", "Current state differs.")); await navigator.LeaveCompareAsync(); diff --git a/src/WinRestoreKit.Tests/LockedPayloadBackupTests.cs b/src/WinRestoreKit.Tests/LockedPayloadBackupTests.cs index 9a76261..6980cc6 100644 --- a/src/WinRestoreKit.Tests/LockedPayloadBackupTests.cs +++ b/src/WinRestoreKit.Tests/LockedPayloadBackupTests.cs @@ -26,8 +26,7 @@ public async Task RunBackup_LockedPayloadInReusedFolder_DoesNotRunOrPublishManif { BackupRestoreOrchestrator runner = new BackupRestoreOrchestrator(ui); - await runner.RunBackup(new BackupBase[] { module }, isolation.DestinationRoot, - "locked-payload", SnapshotCompression.Fast); + await runner.RunBackup(new BackupBase[] { module }, backupPath); Assert.Equal(backupPath, runner.BackupOutputPath); Assert.NotNull(ui.Summary); @@ -54,8 +53,7 @@ public async Task RunBackup_ReusableFolderWithoutPayload_CompletesAndWritesManif ArtifactModule module = new ArtifactModule(); BackupRestoreOrchestrator runner = new BackupRestoreOrchestrator(ui); - await runner.RunBackup(new BackupBase[] { module }, isolation.DestinationRoot, - "unlocked-payload", SnapshotCompression.None); + await runner.RunBackup(new BackupBase[] { module }, backupPath); Assert.Equal(backupPath, runner.BackupOutputPath); Assert.NotNull(ui.Summary); @@ -92,7 +90,8 @@ private sealed class TestRunUi : IRunUi 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) { } + long bytesWritten, int errors, int warnings) + { } public void ShowSummary(RunSummary summary, string caption, IReadOnlyList outcomes) { diff --git a/src/WinRestoreKit.Tests/RestoreContentsPayloadTests.cs b/src/WinRestoreKit.Tests/RestoreContentsPayloadTests.cs index 6f48fe8..8c09f56 100644 --- a/src/WinRestoreKit.Tests/RestoreContentsPayloadTests.cs +++ b/src/WinRestoreKit.Tests/RestoreContentsPayloadTests.cs @@ -108,7 +108,7 @@ private static string CreateCompressedBackup() } [Fact] - public void For_CompleteManifest_DoesNotExtractPayloadOrProbeArtifacts() + public void For_CompleteManifest_ExtractsOnceAndVerifiesSucceededArtifacts() { string root = CreateCompressedBackup(); @@ -128,9 +128,10 @@ public void For_CompleteManifest_DoesNotExtractPayloadOrProbeArtifacts() Entry(skipped, BackupManifest.StateSkipped), Entry(failed, BackupManifest.StateFailed))); - Assert.False(monitor.WaitForExtraction()); - Assert.Equal(0, monitor.ExtractionCount); - Assert.Empty(succeeded.ProbePaths); + Assert.True(monitor.WaitForExtraction()); + Assert.Equal(1, monitor.ExtractionCount); + Assert.Single(succeeded.ProbePaths); + Assert.True(succeeded.SawPayloadArtifact); Assert.Empty(skipped.ProbePaths); Assert.Empty(failed.ProbePaths); Assert.True(rows[0].HasBackup); @@ -192,9 +193,11 @@ public void For_MixedManifest_ExtractsOneSharedPayloadForUnlistedModules() Assert.True(monitor.WaitForExtraction()); Assert.Equal(1, monitor.ExtractionCount); - Assert.Empty(succeeded.ProbePaths); + Assert.Single(succeeded.ProbePaths); + Assert.True(succeeded.SawPayloadArtifact); Assert.Single(first.ProbePaths); Assert.Single(second.ProbePaths); + Assert.Equal(succeeded.ProbePaths[0], first.ProbePaths[0]); Assert.Equal(first.ProbePaths[0], second.ProbePaths[0]); Assert.True(first.SawPayloadArtifact); Assert.True(second.SawPayloadArtifact); diff --git a/src/WinRestoreKit.Tests/RestoreContentsTests.cs b/src/WinRestoreKit.Tests/RestoreContentsTests.cs index 3d9b7b5..a24f465 100644 --- a/src/WinRestoreKit.Tests/RestoreContentsTests.cs +++ b/src/WinRestoreKit.Tests/RestoreContentsTests.cs @@ -65,7 +65,7 @@ public void For_NoManifestAndModuleCannotTell_IsOffered() } [Fact] - public void For_ManifestSucceeded_OverridesAProbeThatSaysNo() + public void For_ManifestSucceeded_DoesNotOverrideAProbeThatSaysNo() { AbsentModule module = new AbsentModule(); ManifestData manifest = Manifest("M", "U", Entry(module, BackupManifest.StateSucceeded)); @@ -73,7 +73,7 @@ public void For_ManifestSucceeded_OverridesAProbeThatSaysNo() IReadOnlyList rows = RestoreContents.For( new BackupBase[] { module }, @"X:\backup\", manifest); - Assert.True(rows[0].HasBackup); + Assert.False(rows[0].HasBackup); } [Theory] diff --git a/src/WinRestoreKit.Tests/RestoreSetViewModelTests.cs b/src/WinRestoreKit.Tests/RestoreSetViewModelTests.cs index 7d71278..3b7574b 100644 --- a/src/WinRestoreKit.Tests/RestoreSetViewModelTests.cs +++ b/src/WinRestoreKit.Tests/RestoreSetViewModelTests.cs @@ -7,23 +7,28 @@ namespace WinRestoreKit.Tests public sealed class RestoreSetViewModelTests { [Fact] - public void RestoreSet_OnlyAcceptsRowsWithUsableArtifacts() + public void RestoreSet_AcceptsOnlyComparableRowsWithUsableArtifacts() { WpfTestHost.Run(() => { RestoreSetViewModel restoreSet = new RestoreSetViewModel(); - TestModule usableModule = new TestModule("Terminal"); + TestModule unavailableModule = new TestModule("Terminal"); + TestModule usableModule = new TestModule("Mouse"); TestModule absentModule = new TestModule("Fonts"); ModuleComparison unavailableButUsable = new ModuleComparison( - usableModule, ComparisonState.Unavailable, true, "Artifact captured.", "Unable to compare."); + unavailableModule, ComparisonState.Unavailable, true, "Artifact captured.", "Unable to compare."); + ModuleComparison changed = new ModuleComparison( + usableModule, ComparisonState.Changed, true, "Artifact captured.", "Changed."); ModuleComparison absent = new ModuleComparison( absentModule, ComparisonState.NotCaptured, false, "No artifact captured.", "Not captured."); restoreSet.Add(unavailableButUsable); + restoreSet.Add(changed); restoreSet.Add(absent); Assert.Single(restoreSet.Modules); Assert.Same(usableModule, restoreSet.Modules[0]); + Assert.False(restoreSet.Contains(unavailableModule)); Assert.False(restoreSet.Contains(absentModule)); }); } diff --git a/src/WinRestoreKit.Tests/SnapshotComparisonServiceTests.cs b/src/WinRestoreKit.Tests/SnapshotComparisonServiceTests.cs index 11188b4..3f0c8f9 100644 --- a/src/WinRestoreKit.Tests/SnapshotComparisonServiceTests.cs +++ b/src/WinRestoreKit.Tests/SnapshotComparisonServiceTests.cs @@ -14,12 +14,12 @@ public sealed class SnapshotComparisonServiceTests [Theory] [InlineData(true, ComparisonState.Changed)] [InlineData(false, ComparisonState.Same)] - public async Task CompareAsync_ManifestSucceeded_MapsDriftWithoutArtifactProbe( + public async Task CompareAsync_ManifestSucceeded_MapsDriftAfterArtifactProbe( bool drifted, ComparisonState expected) { using (TestDirectory backup = TestDirectory.Create()) { - ProbeModule module = new ProbeModule("Display", artifact: false, drifted: drifted); + ProbeModule module = new ProbeModule("Display", artifact: true, drifted: drifted); SnapshotEvent snapshot = Snapshot(backup.Path, Manifest(Entry(module, BackupManifest.StateSucceeded))); ModuleComparison row = Assert.Single(await new SnapshotComparisonService() @@ -27,7 +27,28 @@ public async Task CompareAsync_ManifestSucceeded_MapsDriftWithoutArtifactProbe( Assert.Equal(expected, row.State); Assert.True(row.HasUsableArtifact); - Assert.Equal(0, module.ArtifactProbeCount); + Assert.True(row.CanRestore); + Assert.Equal(1, module.ArtifactProbeCount); + } + } + + [Fact] + public async Task CompareAsync_ManifestSucceededButArtifactMissing_IsNotRestorable() + { + using (TestDirectory backup = TestDirectory.Create()) + { + ProbeModule module = new ProbeModule("Missing", artifact: false, drifted: true); + + ModuleComparison row = Assert.Single(await new SnapshotComparisonService().CompareAsync( + Snapshot(backup.Path, Manifest(Entry(module, BackupManifest.StateSucceeded))), + new[] { (BackupBase)module }, CancellationToken.None)); + + Assert.Equal(ComparisonState.NotCaptured, row.State); + Assert.False(row.HasUsableArtifact); + Assert.False(row.CanRestore); + Assert.Contains("artifact is missing", row.ArtifactSummary); + Assert.Equal(1, module.ArtifactProbeCount); + Assert.Equal(0, module.DriftProbeCount); } } @@ -134,6 +155,7 @@ public async Task CompareAsync_IndeterminateDrift_StaysUnavailableWithUsableArti Assert.Equal(ComparisonState.Unavailable, row.State); Assert.True(row.HasUsableArtifact); + Assert.False(row.CanRestore); } } diff --git a/src/WinRestoreKit.Tests/SnapshotFolderPathTests.cs b/src/WinRestoreKit.Tests/SnapshotFolderPathTests.cs index 3c42695..9b9358a 100644 --- a/src/WinRestoreKit.Tests/SnapshotFolderPathTests.cs +++ b/src/WinRestoreKit.Tests/SnapshotFolderPathTests.cs @@ -1,4 +1,3 @@ -using DataHelper; using Microsoft.Win32; using System; using System.Collections.Generic; @@ -13,7 +12,7 @@ namespace WinRestoreKit.Tests public class SnapshotFolderPathTests { [Fact] - public async Task RunBackup_CustomSnapshotNameKeepsTheFrozenTimestampFolderName() + public async Task RunBackup_CustomSnapshotNameKeepsATimestampedPhysicalFolderName() { string root = Path.Combine(Path.GetTempPath(), "WinRestoreKitTests", Guid.NewGuid().ToString("N")); object originalRoots = null; @@ -39,7 +38,10 @@ public async Task RunBackup_CustomSnapshotNameKeepsTheFrozenTimestampFolderName( SnapshotCompression.None); string timestampFolder = runner.BackupOutputPath; - Assert.Equal(Path.Combine(root, Data.NowShort), timestampFolder); + Assert.Equal(Path.GetFullPath(root), Path.GetDirectoryName(timestampFolder), + StringComparer.OrdinalIgnoreCase); + Assert.Matches(@"^\d{4}-\d{2}-\d{2} - \d{2}\.\d{2}\.\d{2}( \(\d+\))?$", + Path.GetFileName(timestampFolder)); Assert.True(Directory.Exists(timestampFolder)); Assert.False(Directory.Exists(Path.Combine(root, "before-driver-update"))); @@ -127,7 +129,8 @@ private sealed class TestRunUi : IRunUi 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) { } + 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; diff --git a/src/WinRestoreKit.Tests/SnapshotNameTests.cs b/src/WinRestoreKit.Tests/SnapshotNameTests.cs index c033eda..6bf81bc 100644 --- a/src/WinRestoreKit.Tests/SnapshotNameTests.cs +++ b/src/WinRestoreKit.Tests/SnapshotNameTests.cs @@ -5,6 +5,15 @@ namespace WinRestoreKit.Tests { public class SnapshotNameTests { + [Fact] + public void TimestampNameFor_UsesFreshSecondPrecisionInvariantShape() + { + string name = BackupNaming.TimestampNameFor( + new DateTime(2026, 8, 10, 7, 8, 9, DateTimeKind.Local)); + + Assert.Equal("2026-08-10 - 07.08.09", name); + } + [Theory] [InlineData("before-driver-update")] [InlineData("2026-08-03 baseline")] diff --git a/src/WinRestoreKit.Wpf/ViewModels/AppRestoreDialogViewModel.cs b/src/WinRestoreKit.Wpf/ViewModels/AppRestoreDialogViewModel.cs index a36e1b6..c918705 100644 --- a/src/WinRestoreKit.Wpf/ViewModels/AppRestoreDialogViewModel.cs +++ b/src/WinRestoreKit.Wpf/ViewModels/AppRestoreDialogViewModel.cs @@ -164,7 +164,7 @@ private void ReadSelectedSource() return; } - AppExport export = AppRestoreService.ReadFromSource(SelectedSource.Path); + AppExport export = AppRestoreService.ReadFromSourceEntry(SelectedSource); AppRestoreListState listState = AppRestoreService.ComposeListState(export); foreach (string identifier in listState.Items) packages.Add(new AppRestorePackageViewModel(identifier, RefreshInstallAvailability)); diff --git a/src/WinRestoreKit.Wpf/ViewModels/ModuleComparisonRowViewModel.cs b/src/WinRestoreKit.Wpf/ViewModels/ModuleComparisonRowViewModel.cs index d8de2a0..981206c 100644 --- a/src/WinRestoreKit.Wpf/ViewModels/ModuleComparisonRowViewModel.cs +++ b/src/WinRestoreKit.Wpf/ViewModels/ModuleComparisonRowViewModel.cs @@ -22,7 +22,7 @@ internal ModuleComparisonRowViewModel(BackupModuleRegistration registration, Res public string Category => Registration.Category; public ModuleImpactViewModel Impact { get; } public bool IsChecking => Comparison == null; - public bool CanChangeRestoreSet => Comparison != null && Comparison.HasUsableArtifact; + public bool CanChangeRestoreSet => Comparison?.CanRestore == true; public bool IsInRestoreSet => restoreSet.Contains(Registration.Module); public string StateLabel => IsChecking ? "Checking" : Comparison.State.ToString(); public string ArtifactSummary => IsChecking ? "Comparison has not finished." : Comparison.ArtifactSummary; diff --git a/src/WinRestoreKit.Wpf/ViewModels/RestoreSetViewModel.cs b/src/WinRestoreKit.Wpf/ViewModels/RestoreSetViewModel.cs index c3eb076..280bd89 100644 --- a/src/WinRestoreKit.Wpf/ViewModels/RestoreSetViewModel.cs +++ b/src/WinRestoreKit.Wpf/ViewModels/RestoreSetViewModel.cs @@ -20,7 +20,7 @@ internal RestoreSetViewModel() internal void Add(ModuleComparison comparison) { - if (comparison == null || !comparison.HasUsableArtifact || modules.Contains(comparison.Module)) + if (comparison?.CanRestore != true || modules.Contains(comparison.Module)) return; modules.Add(comparison.Module);