diff --git a/README.md b/README.md index 6ebc3e7..fcf02d1 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,12 @@ winget export -o apps.json --source winget For personal usage, keep `apps.json`, `optional-apps.json`, and `config\backup.json` out of git. The repo ships `apps-template.json`, `optional-apps-template.json`, and `config\backup.template.json`, and the backup workflow preserves your personal files so they can be restored into the cloned repo after reinstall. +Normal restore keeps current personal files and saves differing backup copies in +`Recovered from backup`. Backup configuration requires `-UseBackupSettings` and +confirmation after preview; bootstrap does not apply it automatically. Application +state requires an explicit app selection with the app closed. See +[restore modes and selections](docs/BACKUP-FORMAT.md#restore-conflicts-and-selections). + If you want a second-stage app list, create `optional-apps.json` alongside `apps.json`. Start from `optional-apps-template.json` if you want an example. `apps.json` installs automatically during bootstrap, while `optional-apps.json` is offered with a yes/no prompt after first login and can also be installed later from a desktop shortcut. ### Backup Before Reinstall diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 68b0398..63ef2cb 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -1314,54 +1314,6 @@ function Ensure-CanonicalRepo { return $false } -function Restore-RepoFilesFromManifest { - param( - [object]$Manifest, - [string]$ManifestPath = $script:BackupManifestPath - ) - - if (-not $Manifest) { - return $false - } - - if (-not $Manifest.repoFiles) { - return $true - } - - try { - $actualBackupRoot = Split-Path -Parent (Resolve-Path -LiteralPath $ManifestPath -ErrorAction Stop).Path - $manifestBackupRoot = Get-BackupManifestRoot -Manifest $Manifest - } - catch { - Write-Log "Cannot resolve the selected backup manifest: $($_.Exception.Message)" -Level WARNING - return $false - } - - $restored = $true - foreach ($repoFile in $Manifest.repoFiles) { - try { - $source = Resolve-BackupSourcePath -Path $repoFile.backupPath -ManifestBackupRoot $manifestBackupRoot -ActualBackupRoot $actualBackupRoot - if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { - throw "Backup source file is missing: $source" - } - - $destination = Join-Path $CanonicalRepoPath $repoFile.relativePath - $destinationParent = Split-Path -Path $destination -Parent - if (-not (Test-Path -LiteralPath $destinationParent)) { - New-Item -Path $destinationParent -ItemType Directory -Force -ErrorAction Stop | Out-Null - } - - Copy-Item -LiteralPath $source -Destination $destination -Force -ErrorAction Stop - } - catch { - Write-Log "Failed to restore repo file '$($repoFile.relativePath)': $($_.Exception.Message)" -Level WARNING - $restored = $false - } - } - - return $restored -} - function Get-RunBootstrapTarget { if (Test-Path $CanonicalBootstrap) { return $CanonicalBootstrap @@ -1460,14 +1412,9 @@ try { Set-StepState -StepId $stepId -Status "skipped" -Message "Backup manifest not found; using C:\Setup fallback" } elseif (Ensure-CanonicalRepo -Manifest $manifest) { - if (Restore-RepoFilesFromManifest -Manifest $manifest) { - Add-SummaryItem -Step "Repo" -Status "OK" -Message "Canonical repo ready at $CanonicalRepoPath" - Set-StepState -StepId $stepId -Status "done" -Message "Canonical repo ready" - } - else { - Add-SummaryItem -Step "Repo" -Status "FAIL" -Message "Personal repo file restore incomplete; retry setup after correcting the backup" - Set-StepState -StepId $stepId -Status "failed" -Message "Personal repo file restore incomplete" - } + Write-Log "Backup settings remain separate. Use Restore My Files with -UseBackupSettings to preview and select them." -Level INFO + Add-SummaryItem -Step "Repo" -Status "OK" -Message "Canonical repo ready at $CanonicalRepoPath; backup settings require explicit restore" + Set-StepState -StepId $stepId -Status "done" -Message "Canonical repo ready; backup settings not applied" } else { Write-Log "Canonical repo unavailable; continuing with C:\Setup assets" -Level WARNING diff --git a/docs/BACKUP-FORMAT.md b/docs/BACKUP-FORMAT.md index 5a32c00..da05226 100644 --- a/docs/BACKUP-FORMAT.md +++ b/docs/BACKUP-FORMAT.md @@ -16,8 +16,9 @@ Content destinations may be explicit absolute paths, including redirected folder All selected restore paths and source existence checks run before the first restore write. Traversal, rooted repository-relative paths, alternate streams, and reparse points in path ancestors are rejected. For a junction or symbolic -link root, configure its physical target folder. Robocopy excludes junctions -within copied trees. Backup source and session paths must not overlap. +link root, configure its physical target folder. Backup copying excludes junctions; +restore rejects reparse points inside selected trees. Backup sources, restore +destinations, and recovered-file locations must not overlap the selected backup. ## Restore mappings @@ -50,3 +51,94 @@ Legacy manifests without this object remain restorable with an explicit warning that complete verification is unavailable. Any existing repository `sha256` values are validated before restore. Hashes detect corruption; the manifest is not signed and is not an authentication mechanism. + +## Restore conflicts and selections + +`restore-backup.ps1` resolves a file plan, displays it, and executes those same +decisions. `-Preview` and `-WhatIf` return the plan without creating directories, +copying files, prompting, writing a report, or opening Explorer. The shared +`New-RestorePlan` function is the restore-policy input for the broader console +preview tracked in #83; that issue's setup preview remains separate work. + +| Selection | Personal files | Project settings | Application state | +| --- | --- | --- | --- | +| Default, or `-Mode Merge` | Restore missing files, keep identical files, save differing backups separately | Keep current configuration; retain differing or missing backup settings separately | Skip until the app is explicitly selected | +| `-Mode SkipExisting` | Leave every existing file untouched without making a conflict copy | Skip existing files; retain missing backup settings separately | Still requires a separate app selection | +| `-Mode Overwrite` | Preview every replacement and require `YES` before applying it | Still requires `-UseBackupSettings` | Still requires `-RestoreApp` | +| `-UseBackupSettings` | Uses the selected personal-file mode | Preview and confirm applying backup configuration, including `apps.json`, optional apps, and Windows preferences | Does not select app state | +| `-RestoreApp ` | Uses the selected personal-file mode | Does not select project settings | Preview and confirm restoring all complete roots for that app, with its processes closed | + +`-Force` is retained for command compatibility and does not alter these rules or +bypass confirmation. `-Confirm:$false` also cannot bypass the explicit `YES`. +Declining confirmation cancels the entire run before writes. `SkipExisting` +continues to skip existing project files even with `-UseBackupSettings`. +`-IncludeTags` filters ordinary content rules; repository settings remain visible +and separately selected. Explicit app selections include every related app rule, +independently of tags, so a database cannot be partly restored by tag filtering. + +Project files inside the canonical repository are protected even when they were +incidentally included in a content rule. Bootstrap may clone the repository but +never copies backup configuration into it automatically. Applying backup settings +can change what a later setup run installs or configures; review their contents +before selecting them. In particular, `config\backup.json` contains old-machine paths. + +Recovered copies are stored beneath +`\Recovered from backup\`, with +the original drive or UNC share and folder structure preserved underneath. Moving +an unchanged backup does not change its grouping. An identical recovered copy is +reused on a rerun. An edited recovered copy is kept; the backup is saved to a +stable `.backup-` alternate. If that alternate was also edited, +restore stops during planning rather than replacing either copy. + +Results distinguish `restored`, `already-present`, `conflicting-copy-saved`, +`skipped`, and `failed`. The JSON report is written inside that recovery grouping, +outside the original backup. Results print an **Open recovered files** command; +`-OpenRecoveredFiles` opens the same folder in Explorer after execution. + +Each personal or project file is copied to a temporary sibling and hash checked +against the planned backup content before publication. Existing destinations are +checked again against the preview. Replacement uses the filesystem's file-replace +operation; copy or hash failures leave the current file intact. Legacy backups +still warn that original backup-time verification is unavailable, even though +the new copy is checked against the selected backup bytes. + +## Application-state metadata + +An application-state `knownFolders` or `extraPaths` entry can declare: + +```json +"application": { + "id": "example-app", + "processNames": ["ExampleApp", "ExampleHelper"] +} +``` + +Use exact Windows process names without `.exe`, paths, or wildcards. Include all +processes that write the state. Backup persists this object on each corresponding +manifest rule. Give related settings, session, and database roots the same app ID. +Application backup consistency requires closing the application during backup too. + +Restore stages and verifies all selected roots before replacing any of them. It +checks processes during planning and again before applying state, and refuses an +incomplete app backup or changed destination. A failed application of one root +rolls back the roots already applied. Previous state is retained in uniquely named +`.restore--previous` sibling directories, recorded in `previousPaths` in the +results. Keep the app closed until restoration finishes. This is a coordinated +restore with rollback, not a transaction across disks or protection from power loss. + +Unclassified files beneath the recorded or destination profile's `AppData`, and +rules tagged `app-state` without application metadata, are skipped with an +explanation. State stored elsewhere must be declared with application metadata; +file extensions alone cannot reliably identify application databases. Legacy +app-state backups need this classification before an explicit app restore. App +installation and sign-in are separate from restoring files and saved state. + +Examples, using the selected backup and destination profile: + +```powershell +.\restore-backup.ps1 -ManifestPath E:\backup\backup-manifest.json -DestinationProfileRoot C:\Users\NewUser -Preview +.\restore-backup.ps1 -ManifestPath E:\backup\backup-manifest.json -DestinationProfileRoot C:\Users\NewUser -OpenRecoveredFiles +.\restore-backup.ps1 -ManifestPath E:\backup\backup-manifest.json -DestinationProfileRoot C:\Users\NewUser -Mode Overwrite +.\restore-backup.ps1 -ManifestPath E:\backup\backup-manifest.json -DestinationProfileRoot C:\Users\NewUser -UseBackupSettings +.\restore-backup.ps1 -ManifestPath E:\backup\backup-manifest.json -DestinationProfileRoot C:\Users\NewUser -RestoreApp example-app +``` diff --git a/modules/BackupManifest.ps1 b/modules/BackupManifest.ps1 index 378e41a..ca42e5e 100644 --- a/modules/BackupManifest.ps1 +++ b/modules/BackupManifest.ps1 @@ -199,6 +199,7 @@ function Assert-BackupConfiguration { if ($Config.$collection -isnot [array]) { throw "$collection must be an array." } foreach ($entry in $Config.$collection) { Assert-BackupObject $entry $collection + Assert-BackupApplication $entry.application if ($entry.enabled -isnot [bool]) { throw "$collection.enabled must be a boolean." } if ($null -ne $entry.required -and $entry.required -isnot [bool]) { throw "$collection.required must be a boolean." } Assert-BackupStringList $entry.tags "$collection.tags" @@ -225,6 +226,18 @@ function Assert-BackupConfiguration { } } +function Assert-BackupApplication { + param([object]$Application) + if ($null -eq $Application) { return } + Assert-BackupObject $Application 'application' + if ($Application.id -isnot [string] -or [string]::IsNullOrWhiteSpace($Application.id)) { throw 'application.id must be a non-empty string.' } + Assert-BackupStringList $Application.processNames 'application.processNames' + if (-not $Application.processNames.Count) { throw 'application.processNames must name the processes that must be closed.' } + foreach ($name in $Application.processNames) { + if ($name -match '[\\/:*?"<>|]' -or $name.EndsWith('.exe', [StringComparison]::OrdinalIgnoreCase)) { throw 'application.processNames must contain exact process names without paths, wildcards, or .exe.' } + } +} + function Assert-BackupManifest { param([object]$Manifest) Assert-BackupObject $Manifest 'Backup manifest' @@ -239,6 +252,7 @@ function Assert-BackupManifest { foreach ($entry in $Manifest.$name) { Assert-BackupObject $entry $name if ($name -eq 'rules') { + Assert-BackupApplication $entry.application if ($entry.success -isnot [bool]) { throw 'rules.success must be a boolean.' } # Earlier v1 producers serialized an absent optional tag list as [null]. if ($entry.tags -is [array] -and $entry.tags.Count -eq 1 -and $null -eq $entry.tags[0]) { $entry.tags = @() } diff --git a/modules/RestorePlan.ps1 b/modules/RestorePlan.ps1 new file mode 100644 index 0000000..35a169c --- /dev/null +++ b/modules/RestorePlan.ps1 @@ -0,0 +1,312 @@ +function Get-RestoreFileHash { + param([string]$Path) + Assert-NoBackupReparsePoint $Path + if (-not (Test-Path -LiteralPath $Path)) { return $null } + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Expected a file: $Path" } + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash +} + +function Get-RestoreRecoveryPath { + param([string]$OriginalPath, [string]$RecoveryRoot) + $original = Get-CanonicalBackupPath $OriginalPath + $relative = if ($original.StartsWith('\\')) { 'UNC\' + $original.TrimStart('\') } else { $original.Replace(':', '') } + return Resolve-ContainedBackupPath $relative $RecoveryRoot +} + +function New-RestoreFileDecision { + param([string]$Source, [string]$Destination, [string]$OriginalPath, [string]$RecoveryRoot, [string]$Kind, [string]$Mode, [bool]$UseBackupSettings) + $hash = Get-RestoreFileHash $Source + if (-not $hash) { throw "Backup file missing: $Source" } + $currentHash = Get-RestoreFileHash $Destination + $action = 'restore' + $outputPath = $Destination + $outputHash = $currentHash + if ($Mode -eq 'SkipExisting' -and $currentHash) { $action = 'skip' } + elseif ($hash -eq $currentHash) { $action = 'already-present' } + elseif (($Kind -eq 'project-settings' -and -not $UseBackupSettings) -or ($currentHash -and $Mode -ne 'Overwrite' -and $Kind -eq 'personal')) { + $action = 'recover' + $outputPath = Get-RestoreRecoveryPath $OriginalPath $RecoveryRoot + $outputHash = Get-RestoreFileHash $outputPath + if ($outputHash -and $outputHash -ne $hash) { + # An edited recovered copy is user data too. Keep it and use a stable alternate. + $outputPath += '.backup-' + $hash + $outputHash = Get-RestoreFileHash $outputPath + if ($outputHash -and $outputHash -ne $hash) { throw "Recovered destination has changed: $outputPath" } + } + if ($outputHash -eq $hash) { $action = 'already-present' } + } + elseif ($currentHash) { $action = 'replace' } + [pscustomobject]@{ + kind = $Kind; source = $Source; path = $Destination; outputPath = $outputPath + sha256 = $hash; currentHash = $currentHash; outputHash = $outputHash; action = $action + } +} + +function Get-RestoreTreeSnapshot { + param([string]$Root) + if (-not (Test-Path -LiteralPath $Root)) { return } + foreach ($file in Get-BackupTreeFiles $Root) { + [pscustomobject]@{ relativePath = $file.FullName.Substring($Root.TrimEnd('\').Length).TrimStart('\'); sha256 = Get-RestoreFileHash $file.FullName } + } +} + +function Assert-RestoreTreeSnapshot { + param([string]$Root, [object[]]$Files) + $current = @(Get-RestoreTreeSnapshot $Root) + if ($current.Count -ne $Files.Count) { throw "Application state changed since preview: $Root" } + $expected = @{} + foreach ($file in $Files) { $expected[$file.relativePath] = $file.sha256 } + foreach ($file in $current) { + if ($expected[$file.relativePath] -ne $file.sha256) { throw "Application state changed since preview: $Root" } + } +} + +function Assert-RestoreApplicationClosed { + param([object]$Application) + $running = @(Get-Process -ErrorAction Stop | Where-Object { $Application.processNames -contains $_.ProcessName }) + if ($running.Count) { throw "Close application '$($Application.id)' before restoring its state: $($running.ProcessName -join ', ')" } +} + +function New-RestorePlan { + param([string]$ManifestPath, [string]$DestinationProfileRoot, [string]$Mode = 'Merge', [string[]]$IncludeTags, [string[]]$RestoreApp, [switch]$UseBackupSettings) + $resolvedManifestPath = (Resolve-Path -LiteralPath $ManifestPath -ErrorAction Stop).Path + $manifest = Get-Content -LiteralPath $resolvedManifestPath -Raw | ConvertFrom-Json + Assert-BackupManifest $manifest + $actualBackupRoot = Get-CanonicalBackupPath (Split-Path -Parent $resolvedManifestPath) + $manifestBackupRoot = Get-BackupManifestRoot $manifest + Assert-BackupHashes $manifest $actualBackupRoot + $recordedHashes = @{} + foreach ($file in $manifest.verification.files) { + $recordedHashes[(Resolve-ContainedBackupPath $file.path $actualBackupRoot)] = $file.sha256 + } + foreach ($file in $manifest.repoFiles) { + if ($file.sha256) { $recordedHashes[(Resolve-BackupSourcePath $file.backupPath $manifestBackupRoot $actualBackupRoot)] = $file.sha256 } + } + $profile = Get-CanonicalBackupPath $DestinationProfileRoot + $recoveryRoot = Join-Path (Join-Path $profile 'Recovered from backup') (Get-RestoreFileHash $resolvedManifestPath) + Assert-BackupPathsDisjoint $actualBackupRoot $recoveryRoot + $originalOsDrive = $manifest.machine.osDrive + $restoreTargetMap = Get-RestoreTargetMap $manifest + $repoTargetRoot = Get-CanonicalBackupPath (Resolve-RestoreTargetPath -Path $manifest.repo.restorePath -ProfileRoot $profile -OriginalOsDrive $originalOsDrive -RestoreTargetMap $restoreTargetMap -OriginalProfileRoot $manifest.machine.userProfile) + $items = New-Object 'System.Collections.Generic.List[object]' + $destinations = @{} + $protectedAppPaths = @( + foreach ($rule in $manifest.rules) { + if ($rule.application -and $rule.restorePath) { + Get-CanonicalBackupPath (Resolve-RestoreTargetPath -Path $rule.restorePath -ProfileRoot $profile -OriginalOsDrive $originalOsDrive -RestoreTargetMap $restoreTargetMap -OriginalProfileRoot $manifest.machine.userProfile) + } + } + ) + $rules = @( + foreach ($rule in $manifest.rules) { + if (-not $rule.success) { + $items.Add([pscustomobject]@{ kind = 'unavailable'; path = $rule.restorePath; action = 'skip'; message = 'Backup rule was not completed.' }) + continue + } + $sourcePath = Resolve-BackupSourcePath -Path $rule.backupPath -ManifestBackupRoot $manifestBackupRoot -ActualBackupRoot $actualBackupRoot + if (-not (Test-Path -LiteralPath $sourcePath -PathType Container)) { throw "Backup content missing: $sourcePath" } + $targetPath = Get-CanonicalBackupPath (Resolve-RestoreTargetPath -Path $rule.restorePath -ProfileRoot $profile -OriginalOsDrive $originalOsDrive -RestoreTargetMap $restoreTargetMap -OriginalProfileRoot $manifest.machine.userProfile) + Assert-BackupPathsDisjoint $actualBackupRoot $targetPath + Assert-BackupPathsDisjoint $recoveryRoot $targetPath + [pscustomobject]@{ rule = $rule; source = $sourcePath; path = $targetPath } + } + ) + $appRules = @($rules | Where-Object { $null -ne $_.rule.application }) + foreach ($id in $RestoreApp) { + if (-not @($appRules | Where-Object { $_.rule.application.id -eq $id }).Count) { throw "No complete application backup found for '$id'." } + if (@($manifest.rules | Where-Object { $_.application.id -eq $id -and -not $_.success }).Count) { throw "Application backup is incomplete: $id" } + } + foreach ($appRule in $appRules) { + foreach ($other in $appRules) { + if ($appRule -ne $other) { Assert-BackupPathsDisjoint $appRule.path $other.path } + } + Assert-BackupPathsDisjoint $appRule.path $repoTargetRoot + } + foreach ($group in @($appRules | Group-Object { $_.rule.application.id })) { + $application = [pscustomobject]@{ id = $group.Name; processNames = @($group.Group.rule.application.processNames | Sort-Object -Unique) } + $selected = $RestoreApp -contains $application.id + if ($selected) { Assert-RestoreApplicationClosed $application } + $roots = @( + foreach ($entry in $group.Group) { + [pscustomobject]@{ + source = $entry.source; path = $entry.path; existed = (Test-Path -LiteralPath $entry.path) + files = @(Get-RestoreTreeSnapshot $entry.source) + currentFiles = if ($selected) { @(Get-RestoreTreeSnapshot $entry.path) } else { @() } + } + } + ) + $items.Add([pscustomobject]@{ + kind = 'application'; application = $application; roots = $roots; path = ($roots.path -join ', ') + action = if ($selected) { 'restore-application' } else { 'skip' } + message = if ($selected) { 'Replace related state together with the app closed.' } else { "Select -RestoreApp '$($application.id)' to restore this app's state." } + }) + } + foreach ($repoFile in $manifest.repoFiles) { + $destination = Resolve-ContainedBackupPath $repoFile.relativePath $repoTargetRoot + Assert-BackupPathsDisjoint $actualBackupRoot $destination + Assert-BackupPathsDisjoint $recoveryRoot $destination + $repoFileSource = Resolve-BackupSourcePath -Path $repoFile.backupPath -ManifestBackupRoot $manifestBackupRoot -ActualBackupRoot $actualBackupRoot + $original = Resolve-ContainedBackupPath $repoFile.relativePath (Get-CanonicalBackupPath $manifest.repo.restorePath) + $decision = New-RestoreFileDecision $repoFileSource $destination $original $recoveryRoot 'project-settings' $Mode $UseBackupSettings.IsPresent + $items.Add($decision) + $destinations[$destination] = $decision + } + foreach ($entry in $rules) { + if ($entry.rule.application) { continue } + if ($IncludeTags -and @($entry.rule.tags | Where-Object { $IncludeTags -contains $_ }).Count -eq 0) { + $items.Add([pscustomobject]@{ kind = 'personal'; path = $entry.path; action = 'skip'; message = 'Excluded by IncludeTags.' }) + continue + } + foreach ($file in Get-BackupTreeFiles $entry.source) { + $relative = $file.FullName.Substring($entry.source.TrimEnd('\').Length).TrimStart('\') + $destination = Resolve-ContainedBackupPath $relative $entry.path + $original = Resolve-ContainedBackupPath $relative (Get-CanonicalBackupPath $entry.rule.restorePath) + # Explicit app roots also protect state incidentally included in a parent-folder backup. + if (@($protectedAppPaths | Where-Object { Test-BackupPathWithin $destination $_ }).Count) { continue } + $kind = if (Test-BackupPathWithin $destination $repoTargetRoot) { 'project-settings' } else { 'personal' } + $originalAppData = $manifest.machine.userProfile -and (Test-BackupPathWithin $original (Join-Path $manifest.machine.userProfile 'AppData')) + if ($originalAppData -or (Test-BackupPathWithin $destination (Join-Path $profile 'AppData')) -or $entry.rule.tags -contains 'app-state') { + $items.Add([pscustomobject]@{ kind = 'application'; path = $destination; action = 'skip'; message = 'App state needs application metadata before it can be restored.' }) + continue + } + $decision = New-RestoreFileDecision $file.FullName $destination $original $recoveryRoot $kind $Mode $UseBackupSettings.IsPresent + if ($destinations.ContainsKey($destination)) { + if ($destinations[$destination].sha256 -ne $decision.sha256) { throw "Backup entries disagree for destination: $destination" } + continue + } + $destinations[$destination] = $decision + $items.Add($decision) + } + } + # Pin planned content to the recorded hashes, including changes during plan construction. + foreach ($item in $items) { + $files = if ($item.source) { @([pscustomobject]@{ source = $item.source; sha256 = $item.sha256 }) } else { + foreach ($root in $item.roots) { + foreach ($file in $root.files) { [pscustomobject]@{ source = (Resolve-ContainedBackupPath $file.relativePath $root.source); sha256 = $file.sha256 } } + } + } + foreach ($file in $files) { + if (($manifest.verification -or $recordedHashes.ContainsKey($file.source)) -and $recordedHashes[$file.source] -ne $file.sha256) { + throw "Backup content changed during planning: $($file.source)" + } + } + } + [pscustomobject]@{ + manifestPath = $resolvedManifestPath; backupRoot = $actualBackupRoot; profileRoot = $profile; recoveryRoot = $recoveryRoot + verification = if ($manifest.verification) { 'Recorded hashes verified' } else { 'Legacy backup: complete original verification unavailable; copies will be hash checked' } + items = $items.ToArray() + } +} + +function Copy-RestoreVerifiedFile { + param([string]$Source, [string]$Destination, [string]$ExpectedHash) + Assert-NoBackupReparsePoint $Source + Assert-NoBackupReparsePoint $Destination + Copy-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop + if ((Get-RestoreFileHash $Destination) -ne $ExpectedHash -or (Get-RestoreFileHash $Source) -ne $ExpectedHash) { throw "Backup or copied content changed: $Source" } +} + +function Invoke-RestoreFileDecision { + param([object]$Item) + if ($Item.action -in @('skip', 'already-present')) { return $Item.action } + if ((Get-RestoreFileHash $Item.path) -ne $Item.currentHash -or (Get-RestoreFileHash $Item.outputPath) -ne $Item.outputHash) { throw "Destination changed since preview: $($Item.path)" } + $parent = Split-Path -Parent $Item.outputPath + $null = [IO.Directory]::CreateDirectory($parent) + $temporary = Join-Path $parent ('.restore-' + [guid]::NewGuid().ToString()) + try { + Copy-RestoreVerifiedFile $Item.source $temporary $Item.sha256 + Assert-NoBackupReparsePoint $Item.outputPath + if ((Get-RestoreFileHash $Item.outputPath) -ne $Item.outputHash) { throw "Destination changed since preview: $($Item.outputPath)" } + if ($Item.action -eq 'replace') { [IO.File]::Replace($temporary, $Item.outputPath, [NullString]::Value) } + else { [IO.File]::Move($temporary, $Item.outputPath) } + } + finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force -ErrorAction Stop } + } + if ($Item.action -eq 'recover') { return 'conflicting-copy-saved' } + return 'restored' +} + +function Move-RestoreDirectory { + param([string]$Source, [string]$Destination) + Assert-NoBackupReparsePoint $Source + Assert-NoBackupReparsePoint $Destination + # Directory.Move refuses an occupied target, unlike a merge into an existing directory. + [IO.Directory]::Move($Source, $Destination) +} + +function Invoke-RestoreApplication { + param([object]$Item) + Assert-RestoreApplicationClosed $Item.application + $staged = New-Object 'System.Collections.Generic.List[object]' + try { + # Verify every root before replacing any part of this application's state. + foreach ($root in $Item.roots) { + Assert-NoBackupReparsePoint $root.path + Assert-RestoreTreeSnapshot $root.source $root.files + $parent = Split-Path -Parent $root.path + $null = [IO.Directory]::CreateDirectory($parent) + $stage = Join-Path $parent ('.restore-' + [guid]::NewGuid().ToString()) + $record = [pscustomobject]@{ root = $root; stage = $stage; previous = $stage + '-previous'; moved = $false; applied = $false } + $staged.Add($record) + Copy-Item -LiteralPath $root.source -Destination $stage -Recurse -ErrorAction Stop + Assert-RestoreTreeSnapshot $stage $root.files + Assert-RestoreTreeSnapshot $root.source $root.files + } + Assert-RestoreApplicationClosed $Item.application + foreach ($record in $staged) { + $root = $record.root + Assert-NoBackupReparsePoint $root.path + if ((Test-Path -LiteralPath $root.path) -ne $root.existed) { throw "Application destination changed since preview: $($root.path)" } + Assert-RestoreTreeSnapshot $root.path $root.currentFiles + } + foreach ($record in $staged) { + if ($record.root.existed) { + Move-RestoreDirectory $record.root.path $record.previous + $record.moved = $true + } + Move-RestoreDirectory $record.stage $record.root.path + $record.applied = $true + } + } + catch { + for ($index = $staged.Count - 1; $index -ge 0; $index--) { + $record = $staged[$index] + if ($record.applied) { Move-RestoreDirectory $record.root.path $record.stage } + if ($record.moved) { Move-RestoreDirectory $record.previous $record.root.path } + } + throw + } + finally { + foreach ($record in $staged) { + if (Test-Path -LiteralPath $record.stage) { + # This is the unique staging sibling created above, never a user-supplied root. + Assert-BackupPathsDisjoint $record.stage $record.root.path + Remove-Item -LiteralPath $record.stage -Recurse -Force -ErrorAction Stop + } + } + } + # Retain the old app state for recovery; report its exact location. + return @($staged | Where-Object moved | ForEach-Object { $_.previous }) +} + +function Invoke-RestorePlan { + [CmdletBinding(SupportsShouldProcess)] + param([object]$Plan) + foreach ($item in $Plan.items) { + $result = [ordered]@{ type = $item.kind; path = $item.path; outputPath = $item.outputPath; status = 'skipped'; message = $item.message } + try { + if ($item.action -eq 'skip') { } + elseif ($item.action -eq 'already-present') { $result.status = 'already-present' } + elseif ($PSCmdlet.ShouldProcess($item.path, $item.action)) { + if ($item.action -eq 'restore-application') { + $result.previousPaths = @(Invoke-RestoreApplication $item) + $result.status = 'restored' + } + else { $result.status = Invoke-RestoreFileDecision $item } + } + } + catch { $result.status = 'failed'; $result.message = $_.Exception.Message } + [pscustomobject]$result + } +} diff --git a/preflight-backup.ps1 b/preflight-backup.ps1 index e816a62..ac60255 100644 --- a/preflight-backup.ps1 +++ b/preflight-backup.ps1 @@ -162,6 +162,7 @@ function Get-BackupRules { id = "known-$((Normalize-RuleId -Value $entry.name))" source = $folderPath kind = "knownFolder" + application = $entry.application label = $entry.name required = [bool]$entry.required tags = @($entry.tags | Where-Object { $null -ne $_ }) @@ -180,6 +181,7 @@ function Get-BackupRules { id = "extra-$((Normalize-RuleId -Value $label))" source = $expandedPath kind = "extraPath" + application = $entry.application label = $label required = [bool]$entry.required tags = @($entry.tags | Where-Object { $null -ne $_ }) @@ -411,6 +413,7 @@ foreach ($rule in $rules) { source = $rule.source restorePath = $rule.restorePath kind = $rule.kind + application = $rule.application tags = $rule.tags backupPath = Get-RelativePath -Path $ruleDestination -BasePath $sessionRoot success = $copyResult.Success diff --git a/restore-backup.ps1 b/restore-backup.ps1 index ce409e0..5f31e84 100644 --- a/restore-backup.ps1 +++ b/restore-backup.ps1 @@ -3,198 +3,49 @@ [CmdletBinding(SupportsShouldProcess = $true)] param( [string]$ManifestPath, - - [string]$DestinationProfileRoot, - - [ValidateSet("Merge", "SkipExisting", "Overwrite")] - [string]$Mode = "Merge", - + [string]$DestinationProfileRoot = $env:USERPROFILE, + [ValidateSet('Merge', 'SkipExisting', 'Overwrite')] + [string]$Mode = 'Merge', [string[]]$IncludeTags, - + [string[]]$RestoreApp, + [switch]$UseBackupSettings, + [switch]$Preview, + [switch]$OpenRecoveredFiles, [switch]$Force, - [string]$WorkingDirectory ) -$ErrorActionPreference = "Stop" - -if ($WorkingDirectory) { - Set-Location -LiteralPath $WorkingDirectory -ErrorAction Stop -} - -function Write-Info { - param([string]$Message) - Write-Host "[INFO] $Message" -} - -function Write-Success { - param([string]$Message) - Write-Host "[ OK ] $Message" -ForegroundColor Green -} - -$actualBackupRoot = $null - -function Copy-Tree { - param( - [string]$Source, - [string]$Destination, - [string]$RobocopyMode - ) - - $destinationParent = Split-Path -Path $Destination -Parent - if ($destinationParent -and -not (Test-Path $destinationParent)) { - New-Item -Path $destinationParent -ItemType Directory -Force | Out-Null - } - - if (-not $PSCmdlet.ShouldProcess($Destination, "Restore files from $Source")) { - return $true - } - - if (-not (Test-Path $Destination)) { - New-Item -Path $Destination -ItemType Directory -Force | Out-Null - } - - $robocopyArgs = @( - $Source, - $Destination, - "/E", - "/R:1", - "/W:1", - "/XJ", - "/NFL", - "/NDL", - "/NJH", - "/NJS", - "/NP" - ) - - switch ($RobocopyMode) { - "SkipExisting" { $robocopyArgs += "/XC"; $robocopyArgs += "/XN"; $robocopyArgs += "/XO" } - "Overwrite" { } - default { $robocopyArgs += "/XO" } - } - - $null = & robocopy @robocopyArgs - return $LASTEXITCODE -lt 8 -} - -$ModuleRoot = Join-Path $PSScriptRoot "modules" -$BackupManifestModule = Join-Path $ModuleRoot "BackupManifest.ps1" -if (Test-Path $BackupManifestModule) { - . $BackupManifestModule -} - -if (-not $ManifestPath) { - $ManifestPath = Find-BackupManifest -} - -if (-not $ManifestPath) { - throw "Backup manifest not found automatically. Pass -ManifestPath explicitly." -} - -$resolvedManifestPath = (Resolve-Path $ManifestPath).Path -$manifest = Get-Content -Path $resolvedManifestPath -Raw | ConvertFrom-Json -Assert-BackupManifest $manifest -$actualBackupRoot = Split-Path -Parent $resolvedManifestPath -$manifestBackupRoot = Get-BackupManifestRoot -Manifest $manifest -Assert-BackupHashes -Manifest $manifest -BackupRoot $actualBackupRoot - -if (-not $DestinationProfileRoot) { - $DestinationProfileRoot = $env:USERPROFILE -} - -$restoreReport = New-Object System.Collections.Generic.List[object] - -# Resolve and validate the complete selected plan before the first filesystem write. -$originalOsDrive = $manifest.machine.osDrive -$restoreTargetMap = Get-RestoreTargetMap -Manifest $manifest -$repoTargetRoot = Get-CanonicalBackupPath (Resolve-RestoreTargetPath -Path $manifest.repo.restorePath -ProfileRoot $DestinationProfileRoot -OriginalOsDrive $originalOsDrive -RestoreTargetMap $restoreTargetMap -OriginalProfileRoot $manifest.machine.userProfile) -$repoPlan = @( - foreach ($repoFile in $manifest.repoFiles) { - $destination = Resolve-ContainedBackupPath -Path $repoFile.relativePath -Root $repoTargetRoot - $repoFileSource = Resolve-BackupSourcePath -Path $repoFile.backupPath -ManifestBackupRoot $manifestBackupRoot -ActualBackupRoot $actualBackupRoot - if (-not (Test-Path -LiteralPath $repoFileSource -PathType Leaf)) { throw "Backup file missing: $repoFileSource" } - [pscustomobject]@{ relativePath = $repoFile.relativePath; source = $repoFileSource; destination = $destination } - } -) -$contentPlan = @( - foreach ($rule in $manifest.rules) { - if (-not $rule.success -or ($IncludeTags -and @($rule.tags | Where-Object { $IncludeTags -contains $_ }).Count -eq 0)) { continue } - $sourcePath = Resolve-BackupSourcePath -Path $rule.backupPath -ManifestBackupRoot $manifestBackupRoot -ActualBackupRoot $actualBackupRoot - $targetPath = Get-CanonicalBackupPath (Resolve-RestoreTargetPath -Path $rule.restorePath -ProfileRoot $DestinationProfileRoot -OriginalOsDrive $originalOsDrive -RestoreTargetMap $restoreTargetMap -OriginalProfileRoot $manifest.machine.userProfile) - Assert-NoBackupReparsePoint $targetPath - if (-not (Test-Path -LiteralPath $sourcePath -PathType Container)) { throw "Backup content missing: $sourcePath" } - [pscustomobject]@{ source = $sourcePath; destination = $targetPath } - } -) - -# Check if backup.json is in the repo files list -$backupJsonEntry = $manifest.repoFiles | Where-Object { $_.relativePath -eq "config\backup.json" } -if ($backupJsonEntry) { - $restoreBackupJson = $false - if ($PSCmdlet.ShouldProcess("config\backup.json", "Prompt for restore")) { - $response = Read-Host "Restore config\backup.json? This file contains paths from your OLD machine. It is recommended to customize from backup.template.json on the new machine instead. [Y] Restore, [N] Skip (default: N)" - $restoreBackupJson = $response -eq 'Y' - } - - if (-not $restoreBackupJson) { - Write-Host "Skipping config\backup.json restore. Customize from backup.template.json on the new machine" - # Remove from list so it's not processed in the loop below - $repoPlan = @($repoPlan | Where-Object { $_.relativePath -ne "config\backup.json" }) - } -} - -foreach ($repoFile in $repoPlan) { - $destination = $repoFile.destination - try { - $destinationParent = Split-Path -Path $destination -Parent - - if ($destinationParent -and -not (Test-Path $destinationParent)) { - if ($PSCmdlet.ShouldProcess($destinationParent, "Create repo restore directory")) { - New-Item -Path $destinationParent -ItemType Directory -Force | Out-Null - } - } - - $repoFileSource = $repoFile.source - - if ((Test-Path $destination) -and $Mode -eq "SkipExisting") { - $restoreReport.Add([pscustomobject]@{ type = "repoFile"; path = $destination; status = "skipped" }) - continue - } - - if ($PSCmdlet.ShouldProcess($destination, "Restore repo file")) { - Copy-Item -LiteralPath $repoFileSource -Destination $destination -Force:($Mode -eq "Overwrite") - } - - $restoreReport.Add([pscustomobject]@{ type = "repoFile"; path = $destination; status = "restored" }) - } - catch { - $restoreReport.Add([pscustomobject]@{ type = "repoFile"; path = $destination; status = "failed"; message = $_.Exception.Message }) - } -} - -foreach ($rule in $contentPlan) { - $sourcePath = $rule.source - $targetPath = $rule.destination - try { - $success = Copy-Tree -Source $sourcePath -Destination $targetPath -RobocopyMode $Mode - $message = if ($success) { $null } else { "robocopy exit code $LASTEXITCODE" } - } - catch { - $success = $false - $message = $_.Exception.Message - } - $restoreReport.Add([pscustomobject]@{ - type = "content" - path = $targetPath - status = if ($success) { "restored" } else { "failed" } - message = $message - }) -} - -$reportPath = Join-Path (Split-Path -Path $resolvedManifestPath -Parent) "restore-report.json" -ConvertTo-Json -InputObject $restoreReport.ToArray() -Depth 5 | Set-Content -Path $reportPath -Force -if (@($restoreReport | Where-Object { $_.status -eq 'failed' }).Count -gt 0) { - throw "Restore completed with failures. Review $reportPath before retrying." -} -Write-Success "Restore report written to $reportPath" +$ErrorActionPreference = 'Stop' +if ($WorkingDirectory) { Set-Location -LiteralPath $WorkingDirectory -ErrorAction Stop } +. (Join-Path $PSScriptRoot 'modules\BackupManifest.ps1') +. (Join-Path $PSScriptRoot 'modules\RestorePlan.ps1') + +if (-not $ManifestPath) { $ManifestPath = Find-BackupManifest } +if (-not $ManifestPath) { throw 'Backup manifest not found automatically. Pass -ManifestPath explicitly.' } +$plan = New-RestorePlan -ManifestPath $ManifestPath -DestinationProfileRoot $DestinationProfileRoot -Mode $Mode -IncludeTags $IncludeTags -RestoreApp $RestoreApp -UseBackupSettings:$UseBackupSettings +Write-Host "Backup: $($plan.manifestPath)" +Write-Host "Destination profile: $($plan.profileRoot)" +Write-Host $plan.verification +$plan.items | Select-Object @{n='Action'; e={$_.action}}, @{n='Type'; e={$_.kind}}, @{n='Current path'; e={$_.path}}, @{n='Recovered path'; e={if ($_.outputPath -ne $_.path) { $_.outputPath }}}, @{n='Details'; e={$_.message}} | Format-List | Out-Host + +if ($Preview -or $WhatIfPreference) { return $plan } +if ($Mode -eq 'Overwrite' -or $UseBackupSettings -or $RestoreApp) { + $choice = Read-Host 'Apply the selections shown above, including any replacements? Type YES to continue' + if ($choice -cne 'YES') { Write-Host 'Restore cancelled. No files changed.'; return } +} +# Force never selects replacement and cannot bypass the preview confirmation. +$report = @(Invoke-RestorePlan $plan) +$reportPath = Join-Path $plan.recoveryRoot 'restore-report.json' +if ($PSCmdlet.ShouldProcess($reportPath, 'Write restore results')) { + Assert-NoBackupReparsePoint $reportPath + $null = [IO.Directory]::CreateDirectory($plan.recoveryRoot) + ConvertTo-Json -InputObject $report -Depth 8 | Set-Content -LiteralPath $reportPath -ErrorAction Stop +} +$report | Format-List type, path, outputPath, status, message, previousPaths | Out-Host +Write-Host "Restore report: $reportPath" +Write-Host "Open recovered files: explorer.exe `"$($plan.recoveryRoot)`"" +if ($OpenRecoveredFiles -and $PSCmdlet.ShouldProcess($plan.recoveryRoot, 'Open recovered files')) { + Start-Process explorer.exe -ArgumentList "`"$($plan.recoveryRoot)`"" +} +if (@($report | Where-Object status -eq 'failed').Count) { throw "Restore completed with failures. Review $reportPath before retrying." } +$report diff --git a/tests/BackupHashes.Tests.ps1 b/tests/BackupHashes.Tests.ps1 index 19061ce..a49db5f 100644 --- a/tests/BackupHashes.Tests.ps1 +++ b/tests/BackupHashes.Tests.ps1 @@ -29,6 +29,26 @@ BeforeAll { Describe 'complete backup file verification' { BeforeEach { Mock Get-Command { $null } -ParameterFilter { $Name -in @('git', 'winget') } + Mock Out-Host {} + } + + It 'carries validated application identity and process names into the backup manifest' { + $root = Join-Path $TestDrive 'application-metadata' + $repo = New-HashFixture $root + $configPath = Join-Path $repo 'config\backup.template.json' + $config = Get-Content $configPath -Raw | ConvertFrom-Json + $config.extraPaths[0] | Add-Member -NotePropertyName application -NotePropertyValue @{ id = 'fixture-app'; processNames = @('fixture-app', 'fixture-helper') } + $config | ConvertTo-Json -Depth 8 | Set-Content $configPath + & (Join-Path $repo 'preflight-backup.ps1') -DestinationRoot (Join-Path $root 'backup') -BackupName 'session' -VerifyHashes -Force + $manifestPath = Join-Path $root 'backup\declarative-windows-backup\session\backup-manifest.json' + $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json + $manifest.rules[0].application.id | Should -Be 'fixture-app' + $manifest.rules[0].application.processNames | Should -Be @('fixture-app', 'fixture-helper') + { Assert-BackupManifest $manifest } | Should -Not -Throw + $manifest.rules[0].application.processNames = @() + { Assert-BackupManifest $manifest } | Should -Throw '*processNames*' + $config.extraPaths[0].application.processNames = @('fixture*') + { Assert-BackupConfiguration $config } | Should -Throw '*exact process names*' } It 'compares copied content, records every payload hash, and restores after serialization' { @@ -47,7 +67,10 @@ Describe 'complete backup file verification' { $manifest.verification.files.path | Should -Contain 'files\extra-payload\hidden.txt' $manifest.verification.files.path | Should -Contain 'repo-files\apps.json' $manifest.verification.files.path | Should -Contain 'exports\apps.json' - & (Join-Path $repo 'restore-backup.ps1') -ManifestPath $manifestPath + $manifest.machine.userProfile = $root + $manifest | ConvertTo-Json -Depth 10 | Set-Content $manifestPath + Mock Read-Host { 'YES' } + & (Join-Path $repo 'restore-backup.ps1') -ManifestPath $manifestPath -DestinationProfileRoot $root -UseBackupSettings Get-Content (Join-Path $root 'restored-content\nested\file.txt') | Should -Be 'original content' Get-Content (Join-Path $root 'restored-repo\apps.json') | Should -Be '{}' Test-Path (Join-Path $root 'restored-content\excluded.tmp') | Should -BeFalse diff --git a/tests/BackupOutcomes.Tests.ps1 b/tests/BackupOutcomes.Tests.ps1 index 781dece..32bfc1c 100644 --- a/tests/BackupOutcomes.Tests.ps1 +++ b/tests/BackupOutcomes.Tests.ps1 @@ -1,5 +1,6 @@ BeforeAll { $repository = Split-Path $PSScriptRoot -Parent + $realCopy = Get-Command Copy-Item # Run production orchestration without elevation; all writes use disposable fixtures. foreach ($name in @('preflight-backup', 'restore-backup')) { $ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repository "$name.ps1"), [ref]$null, [ref]$null) @@ -54,58 +55,59 @@ Describe 'backup source outcomes' { Describe 'restore copy outcomes' { BeforeEach { - $session = Join-Path $TestDrive 'session' + . (Join-Path $repository 'modules\BackupManifest.ps1') + . (Join-Path $repository 'modules\RestorePlan.ps1') + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString()) + $session = Join-Path $root 'session' + $profile = Join-Path $root 'profile' New-Item -ItemType Directory -Path (Join-Path $session 'first'), (Join-Path $session 'second') -Force | Out-Null Set-Content (Join-Path $session 'apps.json') '{}' + Set-Content (Join-Path $session 'first\file.txt') 'first' + Set-Content (Join-Path $session 'second\file.txt') 'second' $manifestPath = Join-Path $session 'backup-manifest.json' @{ manifestVersion = 1 - machine = @{ userProfile = $TestDrive; osDrive = $env:SystemDrive } + machine = @{ userProfile = $profile; osDrive = $env:SystemDrive } backup = @{ backupRoot = $session } - repo = @{ restorePath = (Join-Path $TestDrive 'repo') } + repo = @{ restorePath = (Join-Path $profile 'repo') } repoFiles = @(@{ relativePath = 'apps.json'; backupPath = 'apps.json' }) rules = @( - @{ success = $true; tags = @(); backupPath = 'first'; restorePath = (Join-Path $TestDrive 'restored-first') } - @{ success = $true; tags = @(); backupPath = 'second'; restorePath = (Join-Path $TestDrive 'restored-second') } + @{ success = $true; tags = @(); backupPath = 'first'; restorePath = (Join-Path $profile 'first') } + @{ success = $true; tags = @(); backupPath = 'second'; restorePath = (Join-Path $profile 'second') } ) } | ConvertTo-Json -Depth 5 | Set-Content $manifestPath Mock Write-Warning {} + Mock Out-Host {} + Mock Copy-Item { & $realCopy -LiteralPath $LiteralPath -Destination $Destination -Recurse:$Recurse -ErrorAction Stop } } - It 'retains mixed results and succeeds on retry after ' -ForEach @( - @{ Fault = 'robocopy failure'; Reason = 'robocopy exit code 8' } - @{ Fault = 'copy exception'; Reason = 'Synthetic content copy failure' } - ) { - Mock robocopy { - if ($args[0] -eq (Join-Path $session 'first')) { - if ($Fault -eq 'copy exception') { throw 'Synthetic content copy failure' } - $global:LASTEXITCODE = 8 - } - else { $global:LASTEXITCODE = 1 } - } - { & $restorebackup -ManifestPath $manifestPath } | Should -Throw '*Restore completed with failures*' - $reportPath = Join-Path $session 'restore-report.json' + It 'retains mixed results and succeeds on retry after a copy failure' { + Mock Copy-Item { throw 'Synthetic content copy failure' } -ParameterFilter { $LiteralPath -eq (Join-Path $session 'first\file.txt') } + { & $restorebackup -ManifestPath $manifestPath -DestinationProfileRoot $profile } | Should -Throw '*Restore completed with failures*' + $plan = New-RestorePlan -ManifestPath $manifestPath -DestinationProfileRoot $profile + $reportPath = Join-Path $plan.recoveryRoot 'restore-report.json' $report = Get-Content $reportPath -Raw | ConvertFrom-Json $report.Count | Should -Be 3 - $report[0].status | Should -Be 'restored' + $report[0].status | Should -Be 'conflicting-copy-saved' -Because ($report | ConvertTo-Json) $report[1].status | Should -Be 'failed' - $report[1].message | Should -Be $Reason + $report[1].message | Should -Be 'Synthetic content copy failure' $report[2].status | Should -Be 'restored' - Mock robocopy { $global:LASTEXITCODE = 1 } - & $restorebackup -ManifestPath $manifestPath + Mock Copy-Item { & $realCopy -LiteralPath $LiteralPath -Destination $Destination -ErrorAction Stop } -ParameterFilter { $LiteralPath -eq (Join-Path $session 'first\file.txt') } + $null = & $restorebackup -ManifestPath $manifestPath -DestinationProfileRoot $profile $report = Get-Content $reportPath -Raw | ConvertFrom-Json - $report.Count | Should -Be 3 - @($report | Where-Object { $_.status -ne 'restored' }).Count | Should -Be 0 + $report[0].status | Should -Be 'already-present' + $report[1].status | Should -Be 'restored' + $report[2].status | Should -Be 'already-present' } - It 'reports a repository copy error and still attempts content copies' { - Mock Copy-Item { throw 'Synthetic repository copy failure' } - Mock robocopy { $global:LASTEXITCODE = 1 } - { & $restorebackup -ManifestPath $manifestPath } | Should -Throw '*Restore completed with failures*' - $report = Get-Content (Join-Path $session 'restore-report.json') -Raw | ConvertFrom-Json + It 'reports a repository recovery copy error and still attempts personal files' { + Mock Copy-Item { throw 'Synthetic repository copy failure' } -ParameterFilter { $LiteralPath -eq (Join-Path $session 'apps.json') } + { & $restorebackup -ManifestPath $manifestPath -DestinationProfileRoot $profile } | Should -Throw '*Restore completed with failures*' + $plan = New-RestorePlan -ManifestPath $manifestPath -DestinationProfileRoot $profile + $report = Get-Content (Join-Path $plan.recoveryRoot 'restore-report.json') -Raw | ConvertFrom-Json $report[0].status | Should -Be 'failed' $report[0].message | Should -Be 'Synthetic repository copy failure' - @($report | Where-Object { $_.status -eq 'restored' }).Count | Should -Be 2 + @($report | Where-Object status -eq 'restored').Count | Should -Be 2 -Because ($report | ConvertTo-Json) } } diff --git a/tests/BackupRestore.Tests.ps1 b/tests/BackupRestore.Tests.ps1 index 9066e39..7a67d8e 100644 --- a/tests/BackupRestore.Tests.ps1 +++ b/tests/BackupRestore.Tests.ps1 @@ -6,7 +6,7 @@ Describe "backup and restore static checks" { $backupManifestModulePath = Resolve-Path (Join-Path $PSScriptRoot "..\modules\BackupManifest.ps1") $backupScriptContent = Get-Content $backupScriptPath -Raw - $restoreScriptContent = Get-Content $restoreScriptPath -Raw + $restoreScriptContent = (Get-Content $restoreScriptPath -Raw) + (Get-Content (Join-Path $PSScriptRoot "..\modules\RestorePlan.ps1") -Raw) $backupConfigContent = Get-Content $backupConfigPath -Raw $backupManifestModuleContent = Get-Content $backupManifestModulePath -Raw $restoreAndModuleContent = $restoreScriptContent + "`n" + $backupManifestModuleContent @@ -58,7 +58,7 @@ Describe "backup and restore static checks" { $restoreScriptContent | Should -Match 'repoFileSource = Resolve-BackupSourcePath' $restoreScriptContent | Should -Match 'sourcePath = Resolve-BackupSourcePath' $restoreAndModuleContent | Should -Match 'IsPathRooted' - $restoreScriptContent | Should -Match 'Resolve-RestoreTargetPath -Path \$rule\.restorePath -ProfileRoot \$DestinationProfileRoot -OriginalOsDrive \$originalOsDrive -RestoreTargetMap \$restoreTargetMap' + $restoreScriptContent | Should -Match 'Resolve-RestoreTargetPath -Path \$rule\.restorePath -ProfileRoot \$profile -OriginalOsDrive \$originalOsDrive -RestoreTargetMap \$restoreTargetMap' } It "shares backup manifest implementation through a module" { diff --git a/tests/CanonicalRepoRestore.Tests.ps1 b/tests/CanonicalRepoRestore.Tests.ps1 index 56a914d..e961f94 100644 --- a/tests/CanonicalRepoRestore.Tests.ps1 +++ b/tests/CanonicalRepoRestore.Tests.ps1 @@ -10,7 +10,7 @@ Describe "canonical repo file restore" { } $bootstrapText = Get-Content -LiteralPath (Join-Path $repoRoot 'bootstrap.ps1') -Raw -Encoding UTF8 $bootstrapAst = [System.Management.Automation.Language.Parser]::ParseInput($bootstrapText, [ref]$null, [ref]$null) - foreach ($name in @('Restore-RepoFilesFromManifest', 'Get-BackupManifestData', 'Ensure-CanonicalRepo')) { + foreach ($name in @('Get-BackupManifestData', 'Ensure-CanonicalRepo')) { $definition = $bootstrapAst.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true) . ([scriptblock]::Create($definition.Extent.Text)) } @@ -42,45 +42,6 @@ Describe "canonical repo file restore" { Mock Write-Log {} } - It "restores a relative source from the selected manifest independently of the working directory" { - $manifest = Get-Content -LiteralPath $script:BackupManifestPath -Raw | ConvertFrom-Json - Restore-RepoFilesFromManifest -Manifest $manifest | Should -BeTrue - Get-Content -LiteralPath (Join-Path $CanonicalRepoPath 'apps.json') | Should -Be 'personal configuration' - } - - It "remaps a moved legacy absolute source through the shared resolver" { - $fixtureManifest.repoFiles[0].backupPath = Join-Path $fixtureManifest.backup.backupRoot 'repo-files\apps.json' - Restore-RepoFilesFromManifest -Manifest $fixtureManifest | Should -BeTrue - Get-Content -LiteralPath (Join-Path $CanonicalRepoPath 'apps.json') | Should -Be 'personal configuration' - } - - It "returns failure when any listed file is missing even if another file restores" { - $fixtureManifest.repoFiles = @( - [pscustomobject]@{ relativePath = 'missing.json'; backupPath = 'repo-files\missing.json' } - $fixtureManifest.repoFiles[0] - ) - Restore-RepoFilesFromManifest -Manifest $fixtureManifest | Should -BeFalse - Get-Content -LiteralPath (Join-Path $CanonicalRepoPath 'apps.json') | Should -Be 'personal configuration' - Should -Invoke Write-Log -Times 1 -ParameterFilter { $Level -eq 'WARNING' -and $Message -like '*missing.json*' } - } - - It "returns failure and logs a copy error" { - Mock Copy-Item { Write-Error 'Synthetic copy failure' } - Restore-RepoFilesFromManifest -Manifest $fixtureManifest | Should -BeFalse - Should -Invoke Write-Log -Times 1 -ParameterFilter { $Level -eq 'WARNING' -and $Message -like '*Synthetic copy failure*' } - } - - It "fails when the selected manifest cannot be located" { - Restore-RepoFilesFromManifest -Manifest $fixtureManifest -ManifestPath (Join-Path $TestDrive 'missing-manifest.json') | Should -BeFalse - Test-Path -LiteralPath $CanonicalRepoPath | Should -BeFalse - } - - It "accepts an empty personal file list but rejects a missing manifest" { - $fixtureManifest.repoFiles = @() - Restore-RepoFilesFromManifest -Manifest $fixtureManifest | Should -BeTrue - Restore-RepoFilesFromManifest -Manifest $null | Should -BeFalse - } - It "preserves repo edits when running with non-staged configuration" { $ConfigRoot = $CanonicalRepoPath $OptionalAppsOnly = $false @@ -106,7 +67,7 @@ Describe "canonical repo file restore" { Test-Path -LiteralPath (Join-Path $CanonicalRepoPath '.git') | Should -BeTrue } - It "keeps incomplete repo restoration retryable and marks it done after a successful retry" { + It "clones without applying backup settings over current configuration" { $stepId = 'repo' $OptionalAppsOnly = $false $DryRun = $false @@ -114,20 +75,16 @@ Describe "canonical repo file restore" { $SetupState = @{ steps = [ordered]@{} } $StateFile = Join-Path $TestDrive 'state.json' $SummaryItems = New-Object 'System.Collections.Generic.List[object]' + New-Item -ItemType Directory -Path $CanonicalRepoPath -Force | Out-Null + $currentFile = Join-Path $CanonicalRepoPath 'apps.json' + Set-Content $currentFile 'current settings' Mock Get-BackupManifestData { $fixtureManifest } Mock Ensure-CanonicalRepo { $true } - $fixtureManifest.repoFiles[0].backupPath = 'repo-files\missing.json' - - . $runRepoStep - $SetupState.steps.repo.status | Should -Be 'failed' - (Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json).steps.repo.status | Should -Be 'failed' - $SummaryItems[0].Status | Should -Be 'FAIL' - Should-RunStep -StepId 'repo' | Should -BeTrue - - $fixtureManifest.repoFiles[0].backupPath = 'repo-files\apps.json' + Mock Copy-Item { throw 'Bootstrap must not copy backup settings' } . $runRepoStep + Get-Content $currentFile | Should -Be 'current settings' $SetupState.steps.repo.status | Should -Be 'done' - $SummaryItems[1].Status | Should -Be 'OK' - Should-RunStep -StepId 'repo' | Should -BeFalse + $SummaryItems[0].Message | Should -Match 'explicit restore' + Should -Invoke Copy-Item -Times 0 -Exactly } -} +} \ No newline at end of file diff --git a/tests/PortableBackup.Tests.ps1 b/tests/PortableBackup.Tests.ps1 index 477236f..ce86173 100644 --- a/tests/PortableBackup.Tests.ps1 +++ b/tests/PortableBackup.Tests.ps1 @@ -82,7 +82,10 @@ Describe "portable backup paths" { Move-Item -LiteralPath $session -Destination $movedSession Remove-Item -LiteralPath (Join-Path $source 'example.txt') - & (Join-Path $fixtureRepo 'restore-backup.ps1') -ManifestPath (Join-Path $movedSession 'backup-manifest.json') + $manifest.machine.userProfile = $TestDrive + $manifest | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $movedSession 'backup-manifest.json') + Mock Read-Host { 'YES' } + & (Join-Path $fixtureRepo 'restore-backup.ps1') -ManifestPath (Join-Path $movedSession 'backup-manifest.json') -DestinationProfileRoot $TestDrive -UseBackupSettings Get-Content -LiteralPath (Join-Path $restoredContent 'example.txt') -Raw | Should -Be "portable content`r`n" Get-Content -LiteralPath (Join-Path $restoredRepo 'apps.json') -Raw | Should -Be "{`"packages`":[]}`r`n" } diff --git a/tests/RestoreConflicts.Tests.ps1 b/tests/RestoreConflicts.Tests.ps1 new file mode 100644 index 0000000..a5e222a --- /dev/null +++ b/tests/RestoreConflicts.Tests.ps1 @@ -0,0 +1,259 @@ +BeforeAll { + $repository = Split-Path $PSScriptRoot -Parent + $realCopy = Get-Command Copy-Item + . (Join-Path $repository 'modules\BackupManifest.ps1') + . (Join-Path $repository 'modules\RestorePlan.ps1') + $ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repository 'restore-backup.ps1'), [ref]$null, [ref]$null) + $restore = [scriptblock]::Create('[CmdletBinding(SupportsShouldProcess)]' + $ast.ParamBlock.Extent.Text + "`n" + '$PSScriptRoot = ''' + $repository.Replace("'", "''") + "'`n" + (($ast.EndBlock.Statements | ForEach-Object { $_.Extent.Text }) -join "`n")) + + function Save-FixtureManifest { + $null = New-Item -ItemType Directory -Path (Join-Path $session 'exports') -Force + $manifest.verification = @{ algorithm = 'SHA256'; status = 'verified'; files = @( + foreach ($folder in @('files', 'repo-files')) { + foreach ($file in Get-BackupTreeFiles (Join-Path $session $folder)) { + Get-VerifiedBackupFile $file.FullName $session + } + } + ) } + $manifest | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $manifestPath + } + function Add-FixtureApplication { + foreach ($name in @('settings', 'database')) { + $source = Join-Path $session "files\app-$name" + $target = Join-Path $profile "AppData\Local\fixture-$name" + $null = New-Item -ItemType Directory -Path $source, $target -Force + Set-Content (Join-Path $source 'state') "backup $name" + Set-Content (Join-Path $target 'state') "current $name" + Set-Content (Join-Path $target 'obsolete') 'old state must not be merged' + $manifest.rules += @{ + success = $true; backupPath = "files\app-$name"; restorePath = "C:\Users\previous\AppData\Local\fixture-$name" + application = @{ id = 'fixture-app'; processNames = @('fixture-app', 'fixture-helper') } + } + } + Save-FixtureManifest + } +} + +Describe 'safe restore conflicts' { + BeforeEach { + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString()) + $session = Join-Path $root 'backup' + $profile = Join-Path $root 'restored' + $manifestPath = Join-Path $session 'backup-manifest.json' + $null = New-Item -ItemType Directory -Path (Join-Path $session 'files\documents\nested'), (Join-Path $session 'repo-files'), (Join-Path $profile 'Documents\nested'), (Join-Path $profile 'repo') -Force + $sourceFile = Join-Path $session 'files\documents\nested\conflict.txt' + $currentFile = Join-Path $profile 'Documents\nested\conflict.txt' + Set-Content $sourceFile 'backup content' + Set-Content $currentFile 'current content' + Set-Content (Join-Path $session 'files\documents\missing.txt') 'missing content' + Set-Content (Join-Path $session 'files\documents\identical.txt') 'identical' + Set-Content (Join-Path $profile 'Documents\identical.txt') 'identical' + Set-Content (Join-Path $session 'repo-files\apps.json') 'backup settings' + Set-Content (Join-Path $profile 'repo\apps.json') 'current settings' + $manifest = @{ + manifestVersion = 1; machine = @{ userProfile = 'C:\Users\previous'; osDrive = 'C:' } + backup = @{ backupRoot = $session }; repo = @{ restorePath = 'C:\Users\previous\repo' } + repoFiles = @(@{ relativePath = 'apps.json'; backupPath = 'repo-files\apps.json' }) + rules = @(@{ success = $true; backupPath = 'files\documents'; restorePath = 'C:\Users\previous\Documents'; tags = @('documents') }) + } + Save-FixtureManifest + $parameters = @{ ManifestPath = $manifestPath; DestinationProfileRoot = $profile } + Mock Out-Host {} + Mock Copy-Item { & $realCopy -LiteralPath $LiteralPath -Destination $Destination -Recurse:$Recurse -ErrorAction Stop } + } + + It 'keeps differing current content regardless of timestamps and reuses recovered files' -ForEach @( + @{ Age = 'older'; Offset = -1 }, @{ Age = 'equal'; Offset = 0 }, @{ Age = 'newer'; Offset = 1 } + ) { + (Get-Item $sourceFile).LastWriteTimeUtc = (Get-Item $currentFile).LastWriteTimeUtc.AddDays($Offset) + $plan = New-RestorePlan @parameters + $conflict = $plan.items | Where-Object path -eq $currentFile + $conflict.action | Should -Be 'recover' + $conflict.outputPath | Should -Be (Join-Path $plan.recoveryRoot 'C\Users\previous\Documents\nested\conflict.txt') + $report = @(Invoke-RestorePlan $plan) + Get-Content $currentFile | Should -Be 'current content' + Get-Content $conflict.outputPath | Should -Be 'backup content' + Get-Content (Join-Path $profile 'Documents\missing.txt') | Should -Be 'missing content' + @($report | Where-Object status -eq 'restored').Count | Should -Be 1 + @($report | Where-Object status -eq 'already-present').Count | Should -Be 1 + @($report | Where-Object status -eq 'conflicting-copy-saved').Count | Should -Be 2 + $second = New-RestorePlan @parameters + $second.recoveryRoot | Should -Be $plan.recoveryRoot + @(Invoke-RestorePlan $second | Where-Object status -ne 'already-present').Count | Should -Be 0 + @(Get-BackupTreeFiles $plan.recoveryRoot).Count | Should -Be 2 + Get-Content $sourceFile | Should -Be 'backup content' + Test-Path (Join-Path $session 'restore-report.json') | Should -BeFalse + } + + It 'preserves an edited recovered conflict and saves a stable separate copy' { + $plan = New-RestorePlan @parameters + $null = Invoke-RestorePlan $plan + $conflict = $plan.items | Where-Object path -eq $currentFile + Set-Content $conflict.outputPath 'edited recovery' + $next = New-RestorePlan @parameters + $alternate = $next.items | Where-Object path -eq $currentFile + $alternate.outputPath | Should -Not -Be $conflict.outputPath + $null = Invoke-RestorePlan $next + Get-Content $conflict.outputPath | Should -Be 'edited recovery' + Get-Content $alternate.outputPath | Should -Be 'backup content' + $again = New-RestorePlan @parameters + ($again.items | Where-Object path -eq $currentFile).action | Should -Be 'already-present' + } + + It 'keeps SkipExisting distinct from recovering conflicts' { + $plan = New-RestorePlan @parameters -Mode SkipExisting + $report = @(Invoke-RestorePlan $plan) + @($report | Where-Object status -eq 'skipped').Count | Should -Be 3 + @($report | Where-Object status -eq 'restored').Count | Should -Be 1 + Test-Path $plan.recoveryRoot | Should -BeFalse + } + + It 'previews without writes or prompts for