Public/Restore-LedgerJournal.ps1

<#
.SYNOPSIS
Restores a PSLedger journal from a zip backup archive.

.DESCRIPTION
Extracts a backup archive created by Backup-LedgerJournal, recreating the journal
directory inside the destination directory. The archive is expected to contain a
single top-level '<Name>.ledger' folder holding a journal.txt file.

By default the journal is restored into the current directory and the command
refuses to overwrite an existing journal directory. Use -Force to replace an
existing journal, or -DestinationPath to restore somewhere else.

.PARAMETER ArchivePath
The path to a .zip backup archive to restore.

.PARAMETER DestinationPath
The directory into which the journal folder is extracted. Defaults to the current
directory. Created if it does not exist.

.PARAMETER Force
Replace an existing journal directory at the target location. Without this switch
the command throws if the target already exists.

.EXAMPLE
Restore-LedgerJournal -ArchivePath .\backups\MinFirma_2026-07-03_095752.zip

Restores .\MinFirma.ledger from the backup into the current directory.

.EXAMPLE
Restore-LedgerJournal -ArchivePath D:\Backuper\Konsult_2026-01-31_235500.zip -DestinationPath C:\Bokföring -Force

Restores the journal into C:\Bokföring, overwriting an existing Konsult.ledger
directory there.
#>

function Restore-LedgerJournal {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param (
        [Parameter(Mandatory)]
        [string]$ArchivePath,

        [Parameter()]
        [string]$DestinationPath,

        [Parameter()]
        [switch]$Force
    )

    if (-not (Test-Path $ArchivePath -PathType Leaf)) {
        throw "Backup archive not found: $ArchivePath"
    }

    $ArchiveFullPath = (Resolve-Path $ArchivePath).Path

    if (-not $DestinationPath) {
        $DestinationPath = (Get-Location).Path
    }

    # Inspect the archive to find the top-level journal folder and validate it.
    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $Zip = [System.IO.Compression.ZipFile]::OpenRead($ArchiveFullPath)
    try {
        $Roots = @($Zip.Entries |
            ForEach-Object { ($_.FullName -split '[\\/]', 2)[0] } |
            Where-Object { $_ } |
            Select-Object -Unique)

        if ($Roots.Count -ne 1) {
            throw "Invalid backup archive - expected a single top-level journal folder in: $ArchivePath"
        }

        $JournalFolder = $Roots[0]
        $HasJournalFile = $Zip.Entries |
            Where-Object { ($_.FullName -replace '\\', '/') -eq "$JournalFolder/journal.txt" }

        if (-not $HasJournalFile) {
            throw "Invalid backup archive - journal.txt not found in: $ArchivePath"
        }

        # Guard against path traversal (zip-slip): every entry must resolve to a
        # location inside the destination directory. Reject '..' segments, rooted
        # paths, or any entry whose resolved path escapes the destination.
        $DestRoot = [System.IO.Path]::GetFullPath($DestinationPath)
        if (-not $DestRoot.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
            $DestRoot += [System.IO.Path]::DirectorySeparatorChar
        }
        foreach ($Entry in $Zip.Entries) {
            $EntryName = $Entry.FullName
            if ([System.IO.Path]::IsPathRooted($EntryName) -or
                $EntryName -match '(^|[\\/])\.\.([\\/]|$)') {
                throw "Invalid backup archive - unsafe entry path: $EntryName"
            }
            $Resolved = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($DestRoot, $EntryName))
            if (-not $Resolved.StartsWith($DestRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
                throw "Invalid backup archive - entry escapes destination: $EntryName"
            }
        }
    }
    finally {
        $Zip.Dispose()
    }

    $TargetPath = Join-Path $DestinationPath $JournalFolder

    if (Test-Path $TargetPath) {
        if (-not $Force) {
            throw "Journal already exists: $TargetPath. Use -Force to overwrite."
        }
        if ($PSCmdlet.ShouldProcess($TargetPath, 'Remove existing journal before restore')) {
            Remove-Item -Path $TargetPath -Recurse -Force
        }
    }

    if (-not (Test-Path $DestinationPath -PathType Container)) {
        if ($PSCmdlet.ShouldProcess($DestinationPath, 'Create destination directory')) {
            New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null
        }
    }

    if ($PSCmdlet.ShouldProcess($TargetPath, "Restore journal from '$([System.IO.Path]::GetFileName($ArchiveFullPath))'")) {
        Expand-Archive -Path $ArchiveFullPath -DestinationPath $DestinationPath -Force

        if (Test-Path (Join-Path $TargetPath 'journal.txt') -PathType Leaf) {
            Get-LedgerJournal -Path $TargetPath
        }
    }
}