Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 0 additions & 22 deletions bootstrap.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs/BACKUP-FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file-or-folder>` 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
Expand Down
120 changes: 108 additions & 12 deletions modules/BackupManifest.ps1
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions restore-backup.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
Loading
Loading