diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 63ef2cb..4673c79 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -1224,28 +1224,6 @@ function Invoke-PostInstallTweaks { return $allSucceeded } -function Find-BackupManifest { - $drives = Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue | Where-Object { - $_.Root -ne "$($env:SystemDrive)\" - } - - $matches = foreach ($drive in $drives) { - $candidateRoot = Join-Path $drive.Root "declarative-windows-backup" - if (-not (Test-Path $candidateRoot)) { - continue - } - - Get-ChildItem -Path $candidateRoot -Filter "backup-manifest.json" -Recurse -File -ErrorAction SilentlyContinue - } - - $newestMatch = $matches | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 - if ($newestMatch) { - return $newestMatch.FullName - } - - return $null -} - function Get-BackupManifestData { if (-not $script:BackupManifestPath) { $script:BackupManifestPath = Find-BackupManifest diff --git a/docs/BACKUP-FORMAT.md b/docs/BACKUP-FORMAT.md index da05226..e627fb2 100644 --- a/docs/BACKUP-FORMAT.md +++ b/docs/BACKUP-FORMAT.md @@ -20,6 +20,36 @@ link root, configure its physical target folder. Backup copying excludes junctio restore rejects reparse points inside selected trees. Backup sources, restore destinations, and recovered-file locations must not overlap the selected backup. +## Discovery and backup selection + +Automatic discovery searches recursively beneath `declarative-windows-backup` +on each non-system filesystem drive. If it finds nothing, it prints the searched +locations. Use `restore-backup.ps1 -ManifestPath ` for a moved +backup or a different container. Folder selection includes nested backups; an +explicit file may have any filename. + +Discovery displays each candidate's path, recorded machine and profile, recorded +creation/completion times when present, schema compatibility, and completeness. +These are reported metadata, not authenticated identity. Current project manifests +record creation time but no separate completion time. An empty `failures` array +with no failed, unskipped rules indicates recorded completion. Missing failure +metadata means unknown completeness. Failed or malformed verification records and +missing declared payload paths mark a candidate incomplete. Discovery checks path presence, +not file contents or hashes; restore planning still verifies content before writes. + +Automatic selection requires exactly one discovered candidate that is compatible +and recorded complete, with no search errors. Multiple candidates require an +explicit manifest file, even if only one is supported or filesystem timestamps +differ. The same rule applies to a selected folder and unattended runs. Bootstrap +uses this policy too and keeps its staged setup fallback when no backup is selected. + +An explicit supported file can select a legacy or partial backup; the restore +planner still checks the requested content. An explicit unsupported file fails +with its compatibility error and never substitutes an older supported backup. +Discovery does not modify either backup. The separate September snapshot format +with a string `machine` field is unsupported; a reader for that format is separate +work from this selection policy. + ## Restore mappings In backup configuration, `restoreTargets.repoPath` selects the repository restore diff --git a/modules/BackupManifest.ps1 b/modules/BackupManifest.ps1 index ca42e5e..f3fd943 100644 --- a/modules/BackupManifest.ps1 +++ b/modules/BackupManifest.ps1 @@ -1,19 +1,113 @@ -function Find-BackupManifest { - $drives = Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue | Where-Object { - $_.Root -ne "$($env:SystemDrive)\" +function Get-BackupCandidate { + param([Parameter(Mandatory)][string]$ManifestPath) + + $candidate = [pscustomobject]@{ + ManifestPath = $ManifestPath + Machine = 'Not recorded' + Profile = 'Not recorded' + CreatedAt = 'Not recorded' + CompletedAt = 'Not recorded' + Compatibility = 'Unsupported' + Completeness = 'Unknown' + Verification = 'Content not verified during discovery' + Details = '' + } + try { + Assert-NoBackupReparsePoint $ManifestPath + $manifest = Get-Content -LiteralPath $ManifestPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + # Identity is reported metadata, not proof of ownership or authenticity. + if ($manifest.machine -is [string]) { $candidate.Machine = $manifest.machine } + elseif ($manifest.machine.computerName -is [string]) { $candidate.Machine = $manifest.machine.computerName } + if ($manifest.machine.userProfile -is [string]) { $candidate.Profile = $manifest.machine.userProfile } + if ($manifest.createdAt -is [string]) { $candidate.CreatedAt = $manifest.createdAt } + elseif ($manifest.createdAt -is [datetime]) { $candidate.CreatedAt = $manifest.createdAt.ToString('o') } + if ($manifest.completedAt -is [string]) { $candidate.CompletedAt = $manifest.completedAt } + elseif ($manifest.completedAt -is [datetime]) { $candidate.CompletedAt = $manifest.completedAt.ToString('o') } + Assert-BackupManifest $manifest + $candidate.Compatibility = 'Supported' + } + catch { + $candidate.Details = $_.Exception.Message + return $candidate } - $candidates = foreach ($drive in $drives) { - $root = $drive.Root - $container = Join-Path $root "declarative-windows-backup" - if (-not (Test-Path $container)) { - continue + if ($manifest.failures -is [array]) { + $candidate.Completeness = if ($manifest.failures.Count) { 'Incomplete' } else { 'Recorded complete' } + } + if (@($manifest.rules | Where-Object { -not $_.success -and $_.skipped -ne $true }).Count -or + ($null -ne $manifest.verification -and $manifest.verification.status -ne 'verified')) { + $candidate.Completeness = 'Incomplete' + } + try { + $root = Split-Path -Parent $ManifestPath + foreach ($entry in $manifest.repoFiles) { + $source = Resolve-BackupSourcePath $entry.backupPath $manifest.backup.backupRoot $root + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "Missing backup file: $source" } + } + foreach ($entry in $manifest.rules) { + if (-not $entry.success) { continue } + $source = Resolve-BackupSourcePath $entry.backupPath $manifest.backup.backupRoot $root + if (-not (Test-Path -LiteralPath $source -PathType Container)) { throw "Missing backup folder: $source" } } + if ($null -ne $manifest.verification) { + Assert-BackupHashes -Manifest $manifest -BackupRoot $root -MetadataOnly + } + } + catch { + $candidate.Completeness = 'Incomplete' + $candidate.Details = $_.Exception.Message + } + return $candidate +} - Get-ChildItem -Path $container -Filter "backup-manifest.json" -Recurse -File -ErrorAction SilentlyContinue +function Find-BackupManifest { + param([string]$Path) + + $searchErrors = @() + $explicitFile = $false + if ($Path) { + $selected = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + $roots = @($selected.FullName) + $explicitFile = -not $selected.PSIsContainer + } + else { + $roots = @(Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue -ErrorVariable +searchErrors | + Where-Object { $_.Root -ne "$($env:SystemDrive)\" } | + ForEach-Object { Join-Path $_.Root 'declarative-windows-backup' }) } - return ($candidates | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).FullName + $paths = @(foreach ($root in $roots) { + if ($explicitFile) { $root; continue } + if (Test-Path -LiteralPath $root -ErrorAction SilentlyContinue -ErrorVariable +searchErrors) { + Get-ChildItem -LiteralPath $root -Filter 'backup-manifest.json' -Recurse -File -Force -ErrorAction SilentlyContinue -ErrorVariable +searchErrors | + ForEach-Object { $_.FullName } + } + }) + $candidates = @($paths | Sort-Object -Unique | ForEach-Object { Get-BackupCandidate $_ }) + if ($candidates.Count) { $candidates | Format-List | Out-Host } + else { + $searched = if ($roots.Count) { $roots -join ', ' } else { 'No non-system filesystem drives available' } + Write-Warning "No backup manifests found. Searched: $searched. Pass -ManifestPath with a backup file or folder." + } + if ($explicitFile) { + if ($candidates[0].Compatibility -ne 'Supported') { + throw "Selected backup is unsupported: $Path. $($candidates[0].Details) No other backup was selected." + } + # An explicit file retains legacy and partial-restore support. Restore planning + # still validates the selected content before any writes. + return $candidates[0].ManifestPath + } + if ($searchErrors.Count) { + Write-Warning "Backup discovery could not inspect every search location: $($searchErrors -join '; '). Pass -ManifestPath with an explicit manifest file." + return $null + } + if ($candidates.Count -eq 1 -and $candidates[0].Compatibility -eq 'Supported' -and $candidates[0].Completeness -eq 'Recorded complete') { + return $candidates[0].ManifestPath + } + if ($candidates.Count) { + Write-Warning 'Backup selection requires an explicit manifest file. Automatic selection requires exactly one candidate that is supported and recorded complete. Pass -ManifestPath with the chosen file.' + } + return $null } function Get-BackupManifestRoot { @@ -312,8 +406,9 @@ function Get-VerifiedBackupFile { } function Assert-BackupHashes { - param([object]$Manifest, [string]$BackupRoot) + param([object]$Manifest, [string]$BackupRoot, [switch]$MetadataOnly) if ($null -eq $Manifest.verification) { + if ($MetadataOnly) { return } # Older manifests recorded only repository-file hashes, without source comparison. foreach ($entry in $Manifest.repoFiles) { if ($null -ne $entry.sha256) { @@ -335,7 +430,8 @@ function Assert-BackupHashes { if ($entry.sha256 -isnot [string] -or $entry.sha256 -notmatch '^[A-Fa-f0-9]{64}$') { throw 'verification.files.sha256 must contain a SHA256 digest.' } $path = Resolve-ContainedBackupPath $entry.path $BackupRoot if ($verifiedPaths.ContainsKey($path)) { throw "Duplicate verified backup path: $path" } - if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or (Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash -ne $entry.sha256) { throw "Backup hash validation failed: $path" } + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing recorded backup file: $path" } + if (-not $MetadataOnly -and (Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash -ne $entry.sha256) { throw "Backup hash validation failed: $path" } $verifiedPaths[$path] = $true } # Added files must not silently join a verified restore, including hidden files. diff --git a/restore-backup.ps1 b/restore-backup.ps1 index 5f31e84..7ae9c38 100644 --- a/restore-backup.ps1 +++ b/restore-backup.ps1 @@ -20,8 +20,8 @@ if ($WorkingDirectory) { Set-Location -LiteralPath $WorkingDirectory -ErrorActio . (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.' } +$ManifestPath = Find-BackupManifest -Path $ManifestPath +if (-not $ManifestPath) { throw 'No backup selected. Pass -ManifestPath with an explicit manifest file from the candidates or another backup location.' } $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)" diff --git a/tests/BackupDiscovery.Tests.ps1 b/tests/BackupDiscovery.Tests.ps1 new file mode 100644 index 0000000..0d18088 --- /dev/null +++ b/tests/BackupDiscovery.Tests.ps1 @@ -0,0 +1,158 @@ +BeforeAll { + $repository = Split-Path $PSScriptRoot -Parent + . (Join-Path $repository 'modules\BackupManifest.ps1') + function New-DiscoveryFixture { + param([string]$Root) + $null = New-Item -ItemType Directory -Path (Join-Path $Root 'files') -Force + Set-Content -LiteralPath (Join-Path $Root 'files\saved.txt') 'backup content' + $manifest = New-BackupManifest -Machine @{ computerName = 'fixture-machine'; userProfile = 'C:\Users\fixture'; osDrive = 'C:' } ` + -Repo @{ restorePath = 'C:\repo' } -Backup @{ backupRoot = 'E:\original-backup' } -Config @{} ` + -Rules @(@{ success = $true; backupPath = 'files'; restorePath = 'C:\Users\fixture\Documents' }) ` + -RepoFiles @() -Exports @{} -Failures @() + $path = Join-Path $Root 'backup-manifest.json' + $manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $path + return $path + } +} + +Describe 'backup discovery and selection' { + BeforeEach { + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString()) + $null = New-Item -ItemType Directory -Path $root + Mock Out-Host {} + Mock Write-Warning {} + Mock Get-FileHash { throw 'Discovery must not claim fresh hash verification' } + } + + It 'searches the existing layout on non-system drives and reports empty locations' { + Mock Get-PSDrive { @([pscustomobject]@{ Root = "$env:SystemDrive\" }, [pscustomobject]@{ Root = $root }) } + Find-BackupManifest | Should -BeNullOrEmpty + Should -Invoke Write-Warning -ParameterFilter { $Message.Contains((Join-Path $root 'declarative-windows-backup')) } + $path = New-DiscoveryFixture (Join-Path $root 'declarative-windows-backup\session') + Find-BackupManifest | Should -Be $path + } + + It 'finds a nested moved backup under a selected container' { + $path = New-DiscoveryFixture (Join-Path $root 'different-container\nested\session') + Find-BackupManifest -Path $root | Should -Be $path + (Get-BackupCandidate $path).Completeness | Should -Be 'Recorded complete' + } + + It 'reports identity and absent completion time without hashing content' { + $path = New-DiscoveryFixture (Join-Path $root 'session') + $candidate = Get-BackupCandidate $path + $candidate.Machine | Should -Be 'fixture-machine' + $candidate.Profile | Should -Be 'C:\Users\fixture' + $candidate.CreatedAt | Should -Not -Be 'Not recorded' + $candidate.CompletedAt | Should -Be 'Not recorded' + $candidate.Compatibility | Should -Be 'Supported' + $candidate.Verification | Should -Be 'Content not verified during discovery' + Should -Invoke Get-FileHash -Times 0 -Exactly + } + + It 'requires explicit selection for metadata' -ForEach @( + @{ Kind = 'failures'; Expected = 'Incomplete' } + @{ Kind = 'failed-rule'; Expected = 'Incomplete' } + @{ Kind = 'failed-verification'; Expected = 'Incomplete' } + @{ Kind = 'legacy-unknown'; Expected = 'Unknown' } + ) { + $path = New-DiscoveryFixture (Join-Path $root 'session') + $manifest = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + switch ($Kind) { + 'failures' { $manifest.failures = @('copy failed') } + 'failed-rule' { $manifest.rules[0].success = $false } + 'failed-verification' { $manifest | Add-Member verification @{ status = 'failed' } } + 'legacy-unknown' { $manifest.PSObject.Properties.Remove('failures') } + } + $manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $path + (Get-BackupCandidate $path).Completeness | Should -Be $Expected + Find-BackupManifest -Path $root | Should -BeNullOrEmpty + # Explicit files retain legacy/partial restore support; the restore planner + # is responsible for validating the requested content before copying. + Find-BackupManifest -Path $path | Should -Be $path + } + + It 'marks missing declared payload incomplete' -ForEach @( + @{ Kind = 'folder' }, @{ Kind = 'repository-file' }, @{ Kind = 'verified-file' } + ) { + $path = New-DiscoveryFixture (Join-Path $root 'session') + $manifest = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + switch ($Kind) { + 'folder' { $manifest.rules[0].backupPath = 'missing' } + 'repository-file' { $manifest.repoFiles = @(@{ relativePath = 'apps.json'; backupPath = 'missing.json' }) } + 'verified-file' { $manifest | Add-Member verification @{ algorithm = 'SHA256'; status = 'verified'; files = @(@{ path = 'files\missing.txt'; sha256 = ('A' * 64) }) } } + } + $manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $path + $candidate = Get-BackupCandidate $path + $candidate.Completeness | Should -Be 'Incomplete' + $candidate.Details | Should -Match 'Missing' + Find-BackupManifest -Path $root | Should -BeNullOrEmpty + } + + It 'never chooses by modification time when a backup contains another backup' { + $outer = New-DiscoveryFixture (Join-Path $root 'session') + $inner = New-DiscoveryFixture (Join-Path $root 'session\older') + (Get-Item -LiteralPath $outer).LastWriteTimeUtc = [datetime]'2026-09-14' + (Get-Item -LiteralPath $inner).LastWriteTimeUtc = [datetime]'2026-09-01' + Find-BackupManifest -Path $root | Should -BeNullOrEmpty + Find-BackupManifest -Path $inner | Should -Be $inner + } + + It 'validates recorded verification metadata without hashing content: ' -ForEach @( + @{ Kind = 'valid'; Expected = 'Recorded complete' } + @{ Kind = 'wrong-algorithm'; Expected = 'Incomplete' } + @{ Kind = 'missing-files'; Expected = 'Incomplete' } + @{ Kind = 'empty-files'; Expected = 'Incomplete' } + @{ Kind = 'invalid-digest'; Expected = 'Incomplete' } + ) { + $path = New-DiscoveryFixture (Join-Path $root 'session') + $null = New-Item -ItemType Directory -Path (Join-Path $root 'session\repo-files'), (Join-Path $root 'session\exports') + $manifest = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $verification = @{ algorithm = 'SHA256'; status = 'verified'; files = @(@{ path = 'files\saved.txt'; sha256 = ('A' * 64) }) } + switch ($Kind) { + 'wrong-algorithm' { $verification.algorithm = 'MD5' } + 'missing-files' { $verification.Remove('files') } + 'empty-files' { $verification.files = @() } + 'invalid-digest' { $verification.files[0].sha256 = 'not-a-digest' } + } + $manifest | Add-Member verification $verification + $manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $path + (Get-BackupCandidate $path).Completeness | Should -Be $Expected + $selected = Find-BackupManifest -Path $root + if ($Kind -eq 'valid') { $selected | Should -Be $path } + else { $selected | Should -BeNullOrEmpty } + Should -Invoke Get-FileHash -Times 0 -Exactly + } + + It 'reports candidates and never silently falls back to a supported backup' -ForEach @( + @{ Kind = 'unsupported-version'; Json = '{"manifestVersion":99,"machine":"newer-machine","createdAt":"2026-09-14"}' } + @{ Kind = 'snapshot-format'; Json = '{"machine":"newer-machine","createdAt":"2026-09-14"}' } + @{ Kind = 'unrelated'; Json = '{"unrelated":true}' } + @{ Kind = 'malformed'; Json = '{' } + ) { + $supported = New-DiscoveryFixture (Join-Path $root 'older') + $unsupported = Join-Path $root 'backup-manifest.json' + Set-Content -LiteralPath $unsupported $Json + $before = Get-Content -LiteralPath $unsupported -Raw + (Get-BackupCandidate $unsupported).Compatibility | Should -Be 'Unsupported' + Find-BackupManifest -Path $root | Should -BeNullOrEmpty + { Find-BackupManifest -Path $unsupported } | Should -Throw '*Selected backup is unsupported*No other backup was selected*' + Get-Content -LiteralPath $unsupported -Raw | Should -Be $before + Test-Path -LiteralPath $supported | Should -BeTrue + } + + It 'accepts an explicitly selected manifest with another filename and rejects a missing path' { + $path = New-DiscoveryFixture (Join-Path $root 'session') + $renamed = Join-Path $root 'session\chosen.json' + Move-Item -LiteralPath $path -Destination $renamed + Find-BackupManifest -Path $renamed | Should -Be $renamed + { Find-BackupManifest -Path (Join-Path $root 'missing') } | Should -Throw + } + + It 'does not auto-select after an incomplete directory search' { + $unreadableSearchFixture = New-DiscoveryFixture (Join-Path $root 'session') + Mock Get-ChildItem { Get-Item -LiteralPath $unreadableSearchFixture; Write-Error 'Synthetic unreadable subfolder' } + Find-BackupManifest -Path $root | Should -BeNullOrEmpty + Should -Invoke Write-Warning -ParameterFilter { $Message -like '*could not inspect every search location*' } + } +} diff --git a/tests/BackupRestore.Tests.ps1 b/tests/BackupRestore.Tests.ps1 index 7a67d8e..d3f54a8 100644 --- a/tests/BackupRestore.Tests.ps1 +++ b/tests/BackupRestore.Tests.ps1 @@ -34,7 +34,6 @@ Describe "backup and restore static checks" { It "supports restore manifest autodetection" { $restoreAndModuleContent | Should -Match "Find-BackupManifest" $restoreAndModuleContent | Should -Match "declarative-windows-backup" - $restoreAndModuleContent | Should -Match 'Sort-Object LastWriteTimeUtc -Descending' } It "remaps backup paths when drive letter differs from manifest" {