Public/vss/Remove-ShadowCopy.ps1

#Requires -Version 5.1
function Remove-ShadowCopy {
    <#
        .SYNOPSIS
            Remove one or more VSS shadow copies from target computers
 
        .DESCRIPTION
            Deletes Volume Shadow Copy Service snapshots either by their specific shadow copy ID
            or by drive letter with an optional age filter. Supports pipeline input from
            Get-ShadowCopy for streamlined bulk removal workflows.
 
        .PARAMETER ShadowCopyId
            One or more shadow copy GUIDs to remove. Accepts pipeline input by property name
            from Get-ShadowCopy output objects.
 
        .PARAMETER DriveLetter
            Single letter (A-Z) identifying the volume whose shadow copies should be removed.
            Used with the ByDrive parameter set.
 
        .PARAMETER OlderThanDays
            When used with DriveLetter, only removes shadow copies older than the specified
            number of days. Valid range is 1 to 3650.
 
        .PARAMETER ComputerName
            One or more target computer names. Defaults to the local machine.
            Accepts pipeline input by property name.
 
        .PARAMETER Credential
            Optional credential for remote execution. When omitted the current
            user context is used.
 
        .EXAMPLE
            Remove-ShadowCopy -ShadowCopyId '{AB12CD34-EF56-7890-AB12-CD34EF567890}'
 
            Removes a specific shadow copy by ID on the local computer.
 
        .EXAMPLE
            Remove-ShadowCopy -DriveLetter 'C' -ComputerName 'SRV01'
 
            Removes all shadow copies for volume C: on the remote server SRV01.
 
        .EXAMPLE
            Get-ShadowCopy -ComputerName 'SRV01' | Remove-ShadowCopy
 
            Pipes shadow copy objects from Get-ShadowCopy to remove them.
 
        .EXAMPLE
            Remove-ShadowCopy -DriveLetter 'D' -OlderThanDays 30 -ComputerName 'SRV01', 'SRV02'
 
            Removes shadow copies older than 30 days on volume D: across two remote servers.
 
        .OUTPUTS
            PSWinOps.ShadowCopyRemoveResult
            Returns one object per shadow copy processed with ComputerName, ShadowCopyId,
            DriveLetter, Removed, ErrorMessage and Timestamp properties.
 
        .NOTES
            Author: Franck SALLET
            Version: 1.0.0
            Last Modified: 2026-04-10
            Requires: PowerShell 5.1+ / Windows only
            Requires: Administrator privileges
 
        .LINK
            https://github.com/k9fr4n/PSWinOps
 
        .LINK
            https://learn.microsoft.com/en-us/windows/win32/vss/volume-shadow-copy-service-overview
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'ById')]
    [OutputType('PSWinOps.ShadowCopyRemoveResult')]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'ById', ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$ShadowCopyId,

        [Parameter(Mandatory = $true, ParameterSetName = 'ByDrive')]
        [ValidatePattern('^[A-Za-z]$')]
        [string]$DriveLetter,

        [Parameter(Mandatory = $false, ParameterSetName = 'ByDrive')]
        [ValidateRange(1, 3650)]
        [int]$OlderThanDays,

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'Name', 'DNSHostName')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $false)]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $Credential
    )

    begin {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting - ParameterSet: $($PSCmdlet.ParameterSetName)"

        $scriptBlock = {
            param(
                [string]$Mode,
                [string]$DataJson,
                [int]$AgeDays
            )

            $resultList = [System.Collections.Generic.List[hashtable]]::new()

            try {
                if ($Mode -eq 'ById') {
                    $idArray = $DataJson | ConvertFrom-Json

                    foreach ($shadowId in $idArray) {
                        $entry = @{
                            ShadowCopyId = $shadowId
                            DriveLetter  = ''
                            Removed      = $false
                            ErrorMessage = ''
                        }

                        try {
                            $shadow = Get-CimInstance -ClassName Win32_ShadowCopy -ErrorAction Stop |
                                Where-Object -Property ID -EQ -Value $shadowId

                            if (-not $shadow) {
                                $entry['ErrorMessage'] = "Shadow copy not found: ${shadowId}"
                                $resultList.Add($entry)
                                continue
                            }

                            $volFilter = "DeviceID='$($shadow.VolumeName.Replace('\','\\'))'"
                            $vol = Get-CimInstance -ClassName Win32_Volume -Filter $volFilter -ErrorAction SilentlyContinue
                            if ($vol -and $vol.DriveLetter) {
                                $entry['DriveLetter'] = $vol.DriveLetter.TrimEnd(':')
                            }

                            $shadow | Remove-CimInstance -ErrorAction Stop
                            $entry['Removed'] = $true
                        } catch {
                            $entry['ErrorMessage'] = $_.ToString()
                        }

                        $resultList.Add($entry)
                    }
                } elseif ($Mode -eq 'ByDrive') {
                    $drvLetter = $DataJson
                    $filterString = "DriveLetter='${drvLetter}:'"
                    $volume = Get-CimInstance -ClassName Win32_Volume -Filter $filterString -ErrorAction Stop

                    if (-not $volume) {
                        $resultList.Add(@{
                                ShadowCopyId = ''
                                DriveLetter  = $drvLetter
                                Removed      = $false
                                ErrorMessage = "Volume not found: ${drvLetter}:"
                            })
                        return $resultList.ToArray()
                    }

                    $shadows = Get-CimInstance -ClassName Win32_ShadowCopy -ErrorAction Stop |
                        Where-Object -Property VolumeName -EQ -Value $volume.DeviceID

                    if ($AgeDays -gt 0) {
                        $cutoffDate = (Get-Date).AddDays(-$AgeDays)
                        $shadows = $shadows | Where-Object -FilterScript { $_.InstallDate -lt $cutoffDate }
                    }

                    if (-not $shadows) {
                        $resultList.Add(@{
                                ShadowCopyId = ''
                                DriveLetter  = $drvLetter
                                Removed      = $false
                                ErrorMessage = 'No matching shadow copies found'
                            })
                        return $resultList.ToArray()
                    }

                    foreach ($shadow in $shadows) {
                        $entry = @{
                            ShadowCopyId = $shadow.ID
                            DriveLetter  = $drvLetter
                            Removed      = $false
                            ErrorMessage = ''
                        }

                        try {
                            $shadow | Remove-CimInstance -ErrorAction Stop
                            $entry['Removed'] = $true
                        } catch {
                            $entry['ErrorMessage'] = $_.ToString()
                        }

                        $resultList.Add($entry)
                    }
                }
            } catch {
                $resultList.Add(@{
                        ShadowCopyId = ''
                        DriveLetter  = ''
                        Removed      = $false
                        ErrorMessage = $_.ToString()
                    })
            }

            return $resultList.ToArray()
        }
    }

    process {
        foreach ($machine in $ComputerName) {
            switch ($PSCmdlet.ParameterSetName) {
                'ById' {
                    $confirmedIds = [System.Collections.Generic.List[string]]::new()
                    foreach ($shadowId in $ShadowCopyId) {
                        if ($PSCmdlet.ShouldProcess($machine, "Remove shadow copy ${shadowId}")) {
                            $confirmedIds.Add($shadowId)
                        }
                    }

                    if ($confirmedIds.Count -eq 0) {
                        continue 
                    }

                    try {
                        $idsJson = $confirmedIds.ToArray() | ConvertTo-Json -Compress
                        if ($confirmedIds.Count -eq 1) {
                            $idsJson = "[${idsJson}]"
                        }

                        $invokeParams = @{
                            ComputerName = $machine
                            ScriptBlock  = $scriptBlock
                            ArgumentList = @('ById', $idsJson, 0)
                        }
                        if ($PSBoundParameters.ContainsKey('Credential')) {
                            $invokeParams['Credential'] = $Credential
                        }

                        $rawResults = Invoke-RemoteOrLocal @invokeParams

                        foreach ($entry in $rawResults) {
                            [PSCustomObject]@{
                                PSTypeName   = 'PSWinOps.ShadowCopyRemoveResult'
                                ComputerName = $machine
                                ShadowCopyId = $entry.ShadowCopyId
                                DriveLetter  = if ($entry.DriveLetter) {
                                    $entry.DriveLetter.ToUpper() 
                                } else {
                                    '' 
                                }
                                Removed      = $entry.Removed
                                ErrorMessage = $entry.ErrorMessage
                                Timestamp    = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                            }
                        }
                    } catch {
                        Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '${machine}': $_"

                        foreach ($shadowId in $confirmedIds) {
                            [PSCustomObject]@{
                                PSTypeName   = 'PSWinOps.ShadowCopyRemoveResult'
                                ComputerName = $machine
                                ShadowCopyId = $shadowId
                                DriveLetter  = ''
                                Removed      = $false
                                ErrorMessage = "Exception: $_"
                                Timestamp    = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                            }
                        }
                    }
                }

                'ByDrive' {
                    $driveUpper = $DriveLetter.ToUpper()
                    $ageFilter = if ($PSBoundParameters.ContainsKey('OlderThanDays')) {
                        $OlderThanDays 
                    } else {
                        0 
                    }
                    $shouldProcessMsg = "Remove shadow copies for volume ${driveUpper}:"
                    if ($ageFilter -gt 0) {
                        $shouldProcessMsg = "${shouldProcessMsg} older than ${ageFilter} days"
                    }

                    if (-not $PSCmdlet.ShouldProcess($machine, $shouldProcessMsg)) {
                        continue 
                    }

                    try {
                        $invokeParams = @{
                            ComputerName = $machine
                            ScriptBlock  = $scriptBlock
                            ArgumentList = @('ByDrive', $driveUpper, $ageFilter)
                        }
                        if ($PSBoundParameters.ContainsKey('Credential')) {
                            $invokeParams['Credential'] = $Credential
                        }

                        $rawResults = Invoke-RemoteOrLocal @invokeParams

                        foreach ($entry in $rawResults) {
                            [PSCustomObject]@{
                                PSTypeName   = 'PSWinOps.ShadowCopyRemoveResult'
                                ComputerName = $machine
                                ShadowCopyId = $entry.ShadowCopyId
                                DriveLetter  = if ($entry.DriveLetter) {
                                    $entry.DriveLetter.ToUpper() 
                                } else {
                                    $driveUpper 
                                }
                                Removed      = $entry.Removed
                                ErrorMessage = $entry.ErrorMessage
                                Timestamp    = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                            }
                        }
                    } catch {
                        Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '${machine}': $_"

                        [PSCustomObject]@{
                            PSTypeName   = 'PSWinOps.ShadowCopyRemoveResult'
                            ComputerName = $machine
                            ShadowCopyId = ''
                            DriveLetter  = $driveUpper
                            Removed      = $false
                            ErrorMessage = "Exception: $_"
                            Timestamp    = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                        }
                    }
                }
            }
        }
    }

    end {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed"
    }
}