Private/Remove-PatSyncedFile.ps1

function Remove-PatSyncedFile {
    <#
    .SYNOPSIS
        Removes a synced media file and cleans up empty parent directories.

    .DESCRIPTION
        Internal helper function that safely removes a file from a sync destination.
        Validates that the file is within the destination directory before deletion
        (security check to prevent path traversal attacks). After removing the file,
        cleans up any empty parent directories up to the destination root.

    .PARAMETER FilePath
        The full path to the file to remove.

    .PARAMETER Destination
        The root destination directory. Files outside this directory will not be removed.

    .OUTPUTS
        None. Writes warnings for security violations or errors.

    .EXAMPLE
        Remove-PatSyncedFile -FilePath 'E:\Movies\Old Movie (2020)\Old Movie (2020).mkv' -Destination 'E:\'

        Removes the file and cleans up empty parent directories.

    .EXAMPLE
        $syncPlan.RemoveOperations | ForEach-Object { Remove-PatSyncedFile -FilePath $_.Path -Destination 'E:\' }

        Removes multiple files from remove operations.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $FilePath,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Destination
    )

    process {
        # Resolve destination to absolute path for validation
        $resolvedDestination = [System.IO.Path]::GetFullPath($Destination)
        # Ensure destination path ends with separator for proper prefix matching
        if (-not $resolvedDestination.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
            $resolvedDestination += [System.IO.Path]::DirectorySeparatorChar
        }

        # Security: Validate file is within destination directory before deletion
        $resolvedFilePath = [System.IO.Path]::GetFullPath($FilePath)
        if (-not $resolvedFilePath.StartsWith($resolvedDestination, [System.StringComparison]::OrdinalIgnoreCase)) {
            Write-Warning "Skipping removal of '$FilePath' - path is outside destination directory"
            return
        }

        Write-Verbose "Removing: $FilePath"
        Remove-Item -Path $resolvedFilePath -Force -ErrorAction SilentlyContinue

        # Clean up empty parent directories (but stay within destination)
        $parent = Split-Path -Path $resolvedFilePath -Parent
        $maxIterations = 100  # Prevent infinite loop
        $iterations = 0

        while ($parent -and (Test-Path -Path $parent) -and $iterations -lt $maxIterations) {
            $iterations++

            # Stop if we've reached the destination root
            $resolvedParent = [System.IO.Path]::GetFullPath($parent)
            if (-not $resolvedParent.StartsWith($resolvedDestination, [System.StringComparison]::OrdinalIgnoreCase)) {
                break
            }

            $items = Get-ChildItem -Path $parent -Force -ErrorAction SilentlyContinue
            if (-not $items) {
                Remove-Item -Path $parent -Force -ErrorAction SilentlyContinue
                $newParent = Split-Path -Path $parent -Parent

                # Ensure we're actually moving up the directory tree
                if ($newParent -eq $parent) {
                    break
                }
                $parent = $newParent
            }
            else {
                break
            }
        }
    }
}