From 2172ec0553edc7e3ee3a8c985463fea78c6dbd94 Mon Sep 17 00:00:00 2001 From: Andreas Jordan Date: Sat, 12 Sep 2026 15:25:41 +0200 Subject: [PATCH 1/5] Get-DbaBackupInformation - Stop eating the caller loop when a backup header cannot be read Stop-Function -Continue runs PowerShell's continue. No loop encloses this call site inside the command, so the continue unwound out of the command and consumed an iteration of whatever loop the caller runs in: a user's foreach silently skipped an element, and Pester's runner corrupted. The escape only bites the non-EnableException path; with EnableException Stop-Function throws before it gets there. Part of #10638 (do Get-DbaBackupInformation) Co-Authored-By: Claude Fable 5.1 --- public/Get-DbaBackupInformation.ps1 | 5 ++++- tests/Get-DbaBackupInformation.Tests.ps1 | 27 +++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/public/Get-DbaBackupInformation.ps1 b/public/Get-DbaBackupInformation.ps1 index 828c71203510..b24d6ea07047 100644 --- a/public/Get-DbaBackupInformation.ps1 +++ b/public/Get-DbaBackupInformation.ps1 @@ -349,7 +349,10 @@ function Get-DbaBackupInformation { try { $FileDetails = Read-DbaBackupHeader -SqlInstance $server -Path $Files -StorageCredential $StorageCredential -EnableException } catch { - Stop-Function -Message "Failure on $($server.Name)" -ErrorRecord $PSItem -Target $server.Name -Continue + # No -Continue here: no loop encloses this catch, so the continue would escape the command + # and eat an iteration of whatever loop the caller runs in (#10638). + Stop-Function -Message "Failure on $($server.Name)" -ErrorRecord $PSItem -Target $server.Name + return } } diff --git a/tests/Get-DbaBackupInformation.Tests.ps1 b/tests/Get-DbaBackupInformation.Tests.ps1 index 4eba7ab549f9..db97ca90e894 100644 --- a/tests/Get-DbaBackupInformation.Tests.ps1 +++ b/tests/Get-DbaBackupInformation.Tests.ps1 @@ -267,4 +267,29 @@ Describe $CommandName -Tag IntegrationTests { $resultsSanLog.Count | Should -BeExactly 3 } } -} \ No newline at end of file + + Context "When a file is not a backup" { + BeforeAll { + # A text file where the instance can read it, so the header read fails on the content, not on access. + $notABackup = Join-Path -Path $TestConfig.Temp -ChildPath "dbatoolsci_notabackup_$(Get-Random).txt" + Set-Content -Path $notABackup -Value "dbatoolsci" + } + + AfterAll { + Remove-Item -Path $notABackup -Force -ErrorAction SilentlyContinue + } + + It "Warns without eating an iteration of the caller's loop" { + # The header-read catch used to run Stop-Function -Continue in the process block, where no loop + # encloses it - the continue escaped the command and consumed an iteration of this very loop, so + # the counter stayed at zero (#10638). + $loopCount = 0 + foreach ($i in 1..3) { + $null = Get-DbaBackupInformation -SqlInstance $TestConfig.InstanceSingle -Path $notABackup -WarningAction SilentlyContinue + $loopCount++ + } + $loopCount | Should -Be 3 + ($WarnVar -join " ") | Should -BeLike "*Failure on*" + } + } +} From 2a30e486ffa197abf033eb292b0faa6e2af95443 Mon Sep 17 00:00:00 2001 From: Andreas Jordan Date: Sat, 12 Sep 2026 17:13:53 +0200 Subject: [PATCH 2/5] Get-DbaBackupInformation - Report S3 folders instead of handing them to Read-DbaBackupHeader The S3 CI test expects "S3 paths cannot be enumerated using T-SQL" for an s3:// folder, but an S3 URL forces the non-xp_dirtree branch, where the folder was appended to the file list and Read-DbaBackupHeader rejected it. That rejection used a broken string literal, "Path ("$p") should be ...", which handed $p to Stop-Function as a positional argument and turned the warning into a binding error. On development the escaping continue of the header-read catch ended the CI test before its assertions, so it passed vacuously; the first commit of this branch made it fail honestly. An s3:// path without an extension is now reported through Stop-Function -Continue inside the path loop, and Read-DbaBackupHeader's literal is repaired. One regression test each. Part of #10638 (do Get-DbaBackupInformation, Read-DbaBackupHeader) Co-Authored-By: Claude Fable 5.1 --- public/Get-DbaBackupInformation.ps1 | 4 ++++ public/Read-DbaBackupHeader.ps1 | 2 +- tests/Get-DbaBackupInformation.Tests.ps1 | 16 ++++++++++++++++ tests/Read-DbaBackupHeader.Tests.ps1 | 19 ++++++++++++++++++- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/public/Get-DbaBackupInformation.ps1 b/public/Get-DbaBackupInformation.ps1 index b24d6ea07047..83d3123714f2 100644 --- a/public/Get-DbaBackupInformation.ps1 +++ b/public/Get-DbaBackupInformation.ps1 @@ -326,6 +326,10 @@ function Get-DbaBackupInformation { $Files += Get-XpDirTreeRestoreFile -Path "$f$($separator)FULL" -SqlInstance $server -NoRecurse $Files += Get-XpDirTreeRestoreFile -Path "$f$($separator)DIFF" -SqlInstance $server -NoRecurse $Files += Get-XpDirTreeRestoreFile -Path "$f$($separator)LOG" -SqlInstance $server -NoRecurse + } elseif ($f -match "^s3://" -and [System.IO.Path]::GetExtension("$f").Length -eq 0) { + # An S3 folder: T-SQL cannot list S3 objects (see Get-XpDirTreeRestoreFile), and handing the + # folder to Read-DbaBackupHeader would only get it rejected for not being a file. + Stop-Function -Message "S3 paths cannot be enumerated using T-SQL. Use explicit file paths or PowerShell-based enumeration for S3 storage." -Target $f -Continue } else { Write-Message -Level VeryVerbose -Message "File" $Files += $f diff --git a/public/Read-DbaBackupHeader.ps1 b/public/Read-DbaBackupHeader.ps1 index 237e9225021b..13bd6f9aae72 100644 --- a/public/Read-DbaBackupHeader.ps1 +++ b/public/Read-DbaBackupHeader.ps1 @@ -205,7 +205,7 @@ function Read-DbaBackupHeader { foreach ($p in $Path) { Write-Message -Level Verbose -Message "Checking: $p" if ([System.IO.Path]::GetExtension("$p").Length -eq 0) { - Stop-Function -Message "Path ("$p") should be a file, not a folder" -Category InvalidArgument + Stop-Function -Message "Path ($p) should be a file, not a folder" -Category InvalidArgument return } } diff --git a/tests/Get-DbaBackupInformation.Tests.ps1 b/tests/Get-DbaBackupInformation.Tests.ps1 index db97ca90e894..e01b7ae8f428 100644 --- a/tests/Get-DbaBackupInformation.Tests.ps1 +++ b/tests/Get-DbaBackupInformation.Tests.ps1 @@ -292,4 +292,20 @@ Describe $CommandName -Tag IntegrationTests { ($WarnVar -join " ") | Should -BeLike "*Failure on*" } } + + Context "When Path is an S3 folder" { + It "Warns that S3 folders cannot be enumerated and returns nothing" { + # An S3 URL skips xp_dirtree, and a folder then went to Read-DbaBackupHeader as if it were a file, which + # rejected it with a message about files and folders instead of the one about S3 enumeration. On top of + # that the escaping continue ended the CI test before its assertions, so nobody noticed. + $splatS3Folder = @{ + SqlInstance = $TestConfig.InstanceSingle + Path = "s3://dbatoolsci.invalid/bucket/folder/" + WarningAction = "SilentlyContinue" + } + $results = Get-DbaBackupInformation @splatS3Folder + $results | Should -BeNullOrEmpty + ($WarnVar -join " ") | Should -BeLike "*S3 paths cannot be enumerated using T-SQL*" + } + } } diff --git a/tests/Read-DbaBackupHeader.Tests.ps1 b/tests/Read-DbaBackupHeader.Tests.ps1 index 6f2853d5512c..f9ccdc6a9226 100644 --- a/tests/Read-DbaBackupHeader.Tests.ps1 +++ b/tests/Read-DbaBackupHeader.Tests.ps1 @@ -27,4 +27,21 @@ Describe $CommandName -Tag UnitTests { Integration test should appear below and are custom to the command you are writing. Read https://github.com/dataplat/dbatools/blob/development/contributing.md#tests for more guidence. -#> \ No newline at end of file +#> + +Describe $CommandName -Tag IntegrationTests { + Context "When Path is a folder" { + It "Warns that it needs a file and returns nothing" { + # The message used to be built from a broken string literal, "Path ("$p") should be a file, not a folder", + # which handed $p to Stop-Function as a positional argument and turned the warning into a binding error. + $splatFolder = @{ + SqlInstance = $TestConfig.InstanceSingle + Path = $TestConfig.Temp + WarningAction = "SilentlyContinue" + } + $results = Read-DbaBackupHeader @splatFolder + $results | Should -BeNullOrEmpty + ($WarnVar -join " ") | Should -BeLike "*should be a file, not a folder*" + } + } +} From 794f0849a1a48c080581e42000ec82816d44cc1d Mon Sep 17 00:00:00 2001 From: Andreas Jordan Date: Sun, 13 Sep 2026 11:33:39 +0200 Subject: [PATCH 3/5] Get-DbaBackupInformation - Give up on an unreadable backup without dropping the paths piped in after it A plain Stop-Function set the function-scope interrupt flag, so after one unreadable file Test-FunctionInterrupt dropped every later piped path. Throw under -EnableException, otherwise warn, and return from that process invocation only. Adds one-pipeline and -EnableException tests. (do Get-DbaBackupInformation) Co-Authored-By: Claude Fable 5.1 --- public/Get-DbaBackupInformation.ps1 | 12 +++++++++--- tests/Get-DbaBackupInformation.Tests.ps1 | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/public/Get-DbaBackupInformation.ps1 b/public/Get-DbaBackupInformation.ps1 index 83d3123714f2..a5031c8a5665 100644 --- a/public/Get-DbaBackupInformation.ps1 +++ b/public/Get-DbaBackupInformation.ps1 @@ -353,9 +353,15 @@ function Get-DbaBackupInformation { try { $FileDetails = Read-DbaBackupHeader -SqlInstance $server -Path $Files -StorageCredential $StorageCredential -EnableException } catch { - # No -Continue here: no loop encloses this catch, so the continue would escape the command - # and eat an iteration of whatever loop the caller runs in (#10638). - Stop-Function -Message "Failure on $($server.Name)" -ErrorRecord $PSItem -Target $server.Name + # This gives up on the paths of this process invocation, not on the command: a plain + # Stop-Function sets the interrupt flag that Test-FunctionInterrupt reads at the top of this + # block, which would drop every path piped in after these, and -Continue has no loop to + # continue here (#10638). So throw under -EnableException, otherwise warn, and return from + # this process invocation only. + if ($EnableException) { + Stop-Function -Message "Failure on $($server.Name)" -ErrorRecord $PSItem -Target $server.Name -EnableException $true + } + Write-Message -Level Warning -Message "Failure on $($server.Name)" -ErrorRecord $PSItem -Target $server.Name return } } diff --git a/tests/Get-DbaBackupInformation.Tests.ps1 b/tests/Get-DbaBackupInformation.Tests.ps1 index e01b7ae8f428..0f5cd370d7f0 100644 --- a/tests/Get-DbaBackupInformation.Tests.ps1 +++ b/tests/Get-DbaBackupInformation.Tests.ps1 @@ -291,6 +291,20 @@ Describe $CommandName -Tag IntegrationTests { $loopCount | Should -Be 3 ($WarnVar -join " ") | Should -BeLike "*Failure on*" } + + It "Still reads the backup piped in after the file that is not one" { + # A plain Stop-Function used to set the command-wide interrupt flag for the unreadable file, and + # Test-FunctionInterrupt then dropped every path piped in after it. + $validBackup = (Get-ChildItem -Path $DestBackupDir -Filter "$dbname*.bak" | Select-Object -First 1).FullName + $results = $notABackup, $validBackup | Get-DbaBackupInformation -SqlInstance $TestConfig.InstanceSingle -WarningAction SilentlyContinue + ($results | Measure-Object).Count | Should -Be 1 + $results.Database | Should -Be $dbname + ($WarnVar -join " ") | Should -BeLike "*Failure on*" + } + + It "Throws for the file that is not a backup under -EnableException" { + { Get-DbaBackupInformation -SqlInstance $TestConfig.InstanceSingle -Path $notABackup -EnableException } | Should -Throw "*Failure on*" + } } Context "When Path is an S3 folder" { From a75210636b2e347ae087709995f7996b8da0784e Mon Sep 17 00:00:00 2001 From: Andreas Jordan Date: Sun, 13 Sep 2026 11:50:06 +0200 Subject: [PATCH 4/5] Get-DbaBackupInformation - Loosen the two new assertions to what the fixture and Stop-Function produce The fixture backup file can hold two backup sets, and Stop-Function rethrows the inner RESTORE HEADERONLY error under -EnableException. (do Get-DbaBackupInformation) Co-Authored-By: Claude Fable 5.1 --- tests/Get-DbaBackupInformation.Tests.ps1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Get-DbaBackupInformation.Tests.ps1 b/tests/Get-DbaBackupInformation.Tests.ps1 index 0f5cd370d7f0..413c98bf30fc 100644 --- a/tests/Get-DbaBackupInformation.Tests.ps1 +++ b/tests/Get-DbaBackupInformation.Tests.ps1 @@ -297,13 +297,14 @@ Describe $CommandName -Tag IntegrationTests { # Test-FunctionInterrupt then dropped every path piped in after it. $validBackup = (Get-ChildItem -Path $DestBackupDir -Filter "$dbname*.bak" | Select-Object -First 1).FullName $results = $notABackup, $validBackup | Get-DbaBackupInformation -SqlInstance $TestConfig.InstanceSingle -WarningAction SilentlyContinue - ($results | Measure-Object).Count | Should -Be 1 - $results.Database | Should -Be $dbname + # The full and the differential of the fixture can share one file, so the file may hold two backup sets. + ($results | Measure-Object).Count | Should -BeGreaterThan 0 + $results.Database | Select-Object -Unique | Should -Be $dbname ($WarnVar -join " ") | Should -BeLike "*Failure on*" } It "Throws for the file that is not a backup under -EnableException" { - { Get-DbaBackupInformation -SqlInstance $TestConfig.InstanceSingle -Path $notABackup -EnableException } | Should -Throw "*Failure on*" + { Get-DbaBackupInformation -SqlInstance $TestConfig.InstanceSingle -Path $notABackup -EnableException } | Should -Throw } } From 13cfb33cb584d5cb4be59c89da1ca3158195404a Mon Sep 17 00:00:00 2001 From: Andreas Jordan Date: Mon, 14 Sep 2026 20:31:49 +0200 Subject: [PATCH 5/5] Get-DbaBackupInformation - Make the unreadable-header rejection an explicit warn-or-throw Under -EnableException the Stop-Function call throws before the line after it runs, so the message was never written a second time, but the shape read as if it could be. Put the two paths into one if/else. The throw test now runs the command in the test scope with its own warning variable and asserts that the failure message appears once next to the exception, and the loop test asserts the same for the last rejected call. (do Get-DbaBackupInformation) Co-Authored-By: Claude Fable 5.1 --- public/Get-DbaBackupInformation.ps1 | 3 ++- tests/Get-DbaBackupInformation.Tests.ps1 | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/public/Get-DbaBackupInformation.ps1 b/public/Get-DbaBackupInformation.ps1 index a5031c8a5665..4b6072d8d6f6 100644 --- a/public/Get-DbaBackupInformation.ps1 +++ b/public/Get-DbaBackupInformation.ps1 @@ -360,8 +360,9 @@ function Get-DbaBackupInformation { # this process invocation only. if ($EnableException) { Stop-Function -Message "Failure on $($server.Name)" -ErrorRecord $PSItem -Target $server.Name -EnableException $true + } else { + Write-Message -Level Warning -Message "Failure on $($server.Name)" -ErrorRecord $PSItem -Target $server.Name } - Write-Message -Level Warning -Message "Failure on $($server.Name)" -ErrorRecord $PSItem -Target $server.Name return } } diff --git a/tests/Get-DbaBackupInformation.Tests.ps1 b/tests/Get-DbaBackupInformation.Tests.ps1 index 413c98bf30fc..907fe20742ca 100644 --- a/tests/Get-DbaBackupInformation.Tests.ps1 +++ b/tests/Get-DbaBackupInformation.Tests.ps1 @@ -290,6 +290,8 @@ Describe $CommandName -Tag IntegrationTests { } $loopCount | Should -Be 3 ($WarnVar -join " ") | Should -BeLike "*Failure on*" + # The last call carried the message once, not twice. + @($WarnVar | Where-Object { $PSItem -like "*Failure on*" }).Count | Should -Be 1 } It "Still reads the backup piped in after the file that is not one" { @@ -304,7 +306,18 @@ Describe $CommandName -Tag IntegrationTests { } It "Throws for the file that is not a backup under -EnableException" { - { Get-DbaBackupInformation -SqlInstance $TestConfig.InstanceSingle -Path $notABackup -EnableException } | Should -Throw + $headerException = $null + $headerWarnings = $null + try { + Get-DbaBackupInformation -SqlInstance $TestConfig.InstanceSingle -Path $notABackup -EnableException -WarningVariable headerWarnings + } catch { + $headerException = $PSItem + } + # Stop-Function rethrows the inner RESTORE HEADERONLY error, so the exception text is the SQL error, not the message. + $headerException | Should -Not -BeNullOrEmpty + # Stop-Function writes the message once before it throws, as it does for every throw in dbatools; it is not + # written a second time. + @($headerWarnings | Where-Object { $PSItem -like "*Failure on*" }).Count | Should -Be 1 } }