trash.psm1

Set-StrictMode -Version Latest

if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) {
    throw 'The trash module only supports Windows.'
}

if (-not ('TrashPs.Native' -as [type])) {
    Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;

namespace TrashPs {
    public static class Native {
        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
        private struct SHFILEOPSTRUCT {
            public IntPtr hwnd;
            public uint wFunc;
            public string pFrom;
            public string pTo;
            public ushort fFlags;
            [MarshalAs(UnmanagedType.Bool)] public bool fAnyOperationsAborted;
            public IntPtr hNameMappings;
            public string lpszProgressTitle;
        }

        [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
        private static extern int SHFileOperation(ref SHFILEOPSTRUCT op);

        [DllImport("shell32.dll", CharSet = CharSet.Unicode)]
        private static extern int SHEmptyRecycleBin(IntPtr hwnd, string root, uint flags);

        [DllImport("shell32.dll")]
        private static extern bool SHUpdateRecycleBinIcon();

        private const uint FO_DELETE = 3;
        private const ushort FOF_SILENT = 0x0004;
        private const ushort FOF_NOCONFIRMATION = 0x0010;
        private const ushort FOF_ALLOWUNDO = 0x0040;
        private const ushort FOF_NOERRORUI = 0x0400;
        // Ask before destroying a file that cannot be recycled (too large, network drive, ...).
        private const ushort FOF_WANTNUKEWARNING = 0x4000;

        private const uint SHERB_NOCONFIRMATION = 0x1;
        private const uint SHERB_NOPROGRESSUI = 0x2;
        private const uint SHERB_NOSOUND = 0x4;

        // Returns 0 on success, -1 if the user aborted, or the SHFileOperation error code.
        public static int Recycle(string fullPath) {
            var op = new SHFILEOPSTRUCT {
                wFunc = FO_DELETE,
                pFrom = fullPath + "\0\0",
                fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI | FOF_WANTNUKEWARNING,
            };
            int ret = SHFileOperation(ref op);
            if (ret == 0 && op.fAnyOperationsAborted) return -1;
            return ret;
        }

        public static int EmptyRecycleBin() {
            return SHEmptyRecycleBin(IntPtr.Zero, null,
                SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND);
        }

        public static void UpdateIcon() {
            SHUpdateRecycleBinIcon();
        }
    }
}
'@

}

Update-TypeData -TypeName 'TrashPs.Item' -DefaultDisplayPropertySet Id, DeletedAt, Size, OriginalPath -Force

#region Private helpers

# Parse a $I metadata file. Format version 1 (Vista/7) stores a fixed 260-char
# path, version 2 (Windows 10+) stores a length-prefixed path.
function Read-TrashInfo([System.IO.FileInfo] $InfoFile) {
    $bytes = [System.IO.File]::ReadAllBytes($InfoFile.FullName)
    if ($bytes.Length -lt 24) { return }
    $version = [BitConverter]::ToInt64($bytes, 0)
    $size = [BitConverter]::ToInt64($bytes, 8)
    $deletedAt = [DateTime]::FromFileTime([BitConverter]::ToInt64($bytes, 16))
    switch ($version) {
        1 { $raw = [Text.Encoding]::Unicode.GetString($bytes, 24, [Math]::Min(520, $bytes.Length - 24)) }
        2 {
            if ($bytes.Length -lt 28) { return }
            $chars = [BitConverter]::ToInt32($bytes, 24)
            $raw = [Text.Encoding]::Unicode.GetString($bytes, 28, [Math]::Min($chars * 2, $bytes.Length - 28))
        }
        default { return }
    }
    $originalPath = $raw.Split([char]0)[0]

    $dataPath = Join-Path $InfoFile.DirectoryName ('$R' + $InfoFile.Name.Substring(2))
    if (-not (Test-Path -LiteralPath $dataPath)) { return }

    [pscustomobject]@{
        PSTypeName   = 'TrashPs.Item'
        Id           = $InfoFile.BaseName.Substring(2)
        Name         = Split-Path $originalPath -Leaf
        OriginalPath = $originalPath
        DeletedAt    = $deletedAt
        Size         = $size
        IsDirectory  = Test-Path -LiteralPath $dataPath -PathType Container
        DataPath     = $dataPath
        InfoPath     = $InfoFile.FullName
    }
}

function Test-TrashItemMatch($Item, [string[]] $Patterns) {
    foreach ($p in $Patterns) {
        if ($Item.Id -eq $p -or $Item.Name -like $p -or $Item.OriginalPath -like $p) { return $true }
    }
    $false
}

# Expand wildcards, but prefer a literal match so names containing [] still work.
function Resolve-TargetPath([string] $InputPath, [switch] $Literal) {
    if (Test-Path -LiteralPath $InputPath) {
        return (Get-Item -LiteralPath $InputPath -Force).FullName
    }
    $resolved = @(if (-not $Literal) { Resolve-Path -Path $InputPath -ErrorAction SilentlyContinue })
    if ($resolved.Count -eq 0) {
        Write-Error -Message "Cannot find path '$InputPath' because it does not exist." `
            -Category ObjectNotFound -TargetObject $InputPath
        return
    }
    $resolved | ForEach-Object { $_.ProviderPath }
}

# Resolve the items a Restore/Remove call acts on, from pipeline objects or patterns.
function Resolve-TrashItem($InputObject, [string[]] $Filter) {
    if ($InputObject) { return $InputObject }
    $items = @(Get-TrashItem -Filter $Filter)
    if ($items.Count -eq 0) {
        Write-Warning "No Recycle Bin item matches: $($Filter -join ', ')"
    }
    $items
}

#endregion

<#
.SYNOPSIS
    Move files or directories to the Recycle Bin.
.EXAMPLE
    Move-ItemToTrash foo.txt, *.log
.EXAMPLE
    Get-ChildItem *.tmp | Move-ItemToTrash
#>

function Move-ItemToTrash {
    [CmdletBinding(DefaultParameterSetName = 'Path', SupportsShouldProcess)]
    param(
        # Paths to recycle. Wildcards are supported.
        [Parameter(ParameterSetName = 'Path', Mandatory, Position = 0, ValueFromPipeline)]
        [string[]] $Path,

        # Paths to recycle, used exactly as typed.
        [Parameter(ParameterSetName = 'LiteralPath', Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('PSPath', 'FullName', 'LP')]
        [string[]] $LiteralPath
    )

    process {
        $targets = if ($PSCmdlet.ParameterSetName -eq 'Path') {
            $Path | ForEach-Object { Resolve-TargetPath $_ }
        }
        else {
            $LiteralPath | ForEach-Object { Resolve-TargetPath (Convert-Path -LiteralPath $_) -Literal }
        }
        foreach ($target in $targets) {
            if (-not $PSCmdlet.ShouldProcess($target, 'Move to Recycle Bin')) { continue }
            $ret = [TrashPs.Native]::Recycle($target)
            if ($ret -eq -1) {
                Write-Warning "Aborted: $target"
            }
            elseif ($ret -ne 0) {
                Write-Error -Message ("Failed to move '{0}' to Recycle Bin (code 0x{1:X})." -f $target, $ret) `
                    -TargetObject $target
            }
        }
    }
}

<#
.SYNOPSIS
    List items in the Recycle Bin of the current user.
.EXAMPLE
    Get-TrashItem *.log
    List items whose name or original path matches *.log.
#>

function Get-TrashItem {
    [CmdletBinding()]
    [OutputType('TrashPs.Item')]
    param(
        # Patterns matched against the Id, name or original path. Lists everything when omitted.
        [Parameter(Position = 0)]
        [string[]] $Filter
    )

    $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
    $items = foreach ($drive in [System.IO.DriveInfo]::GetDrives()) {
        if (-not $drive.IsReady) { continue }
        $bin = Join-Path $drive.RootDirectory.FullName "`$Recycle.Bin\$sid"
        if (-not (Test-Path -LiteralPath $bin)) { continue }
        Get-ChildItem -LiteralPath $bin -Force -File -Filter '$I*' -ErrorAction SilentlyContinue |
            ForEach-Object { Read-TrashInfo $_ }
    }
    if ($Filter) { $items = $items | Where-Object { Test-TrashItemMatch $_ $Filter } }
    $items | Sort-Object DeletedAt -Descending
}

<#
.SYNOPSIS
    Restore Recycle Bin items to their original location.
.EXAMPLE
    Restore-TrashItem 'C:\work\*.txt'
.EXAMPLE
    Get-TrashItem report.docx | Select-Object -First 1 | Restore-TrashItem
    Restore the most recently deleted report.docx.
#>

function Restore-TrashItem {
    [CmdletBinding(DefaultParameterSetName = 'Filter', SupportsShouldProcess)]
    param(
        # Patterns matched against the Id, name or original path.
        [Parameter(ParameterSetName = 'Filter', Mandatory, Position = 0)]
        [string[]] $Filter,

        # Items from Get-TrashItem.
        [Parameter(ParameterSetName = 'InputObject', Mandatory, ValueFromPipeline)]
        [PSTypeName('TrashPs.Item')]
        [object[]] $InputObject
    )

    process {
        $restored = $false
        foreach ($item in Resolve-TrashItem $InputObject $Filter) {
            $dest = $item.OriginalPath
            if (Test-Path -LiteralPath $dest) {
                Write-Error -Message "Cannot restore '$dest' because it already exists." `
                    -Category ResourceExists -TargetObject $dest
                continue
            }
            if (-not $PSCmdlet.ShouldProcess($dest, 'Restore from Recycle Bin')) { continue }
            $parent = Split-Path $dest -Parent
            if (-not (Test-Path -LiteralPath $parent)) {
                New-Item -ItemType Directory -Path $parent -Force | Out-Null
            }
            Move-Item -LiteralPath $item.DataPath -Destination $dest -ErrorAction Stop
            Remove-Item -LiteralPath $item.InfoPath -Force
            $restored = $true
        }
        if ($restored) { [TrashPs.Native]::UpdateIcon() }
    }
}

<#
.SYNOPSIS
    Permanently delete items from the Recycle Bin.
.EXAMPLE
    Remove-TrashItem 'C:\tmp\*' -Force
.EXAMPLE
    Get-TrashItem | Where-Object DeletedAt -lt (Get-Date).AddDays(-30) | Remove-TrashItem
#>

function Remove-TrashItem {
    [CmdletBinding(DefaultParameterSetName = 'Filter', SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        # Patterns matched against the Id, name or original path.
        [Parameter(ParameterSetName = 'Filter', Mandatory, Position = 0)]
        [string[]] $Filter,

        # Items from Get-TrashItem.
        [Parameter(ParameterSetName = 'InputObject', Mandatory, ValueFromPipeline)]
        [PSTypeName('TrashPs.Item')]
        [object[]] $InputObject,

        # Do not ask for confirmation.
        [switch] $Force
    )

    begin {
        if ($Force -and -not $PSBoundParameters.ContainsKey('Confirm')) { $ConfirmPreference = 'None' }
    }

    process {
        $removed = $false
        foreach ($item in Resolve-TrashItem $InputObject $Filter) {
            if (-not $PSCmdlet.ShouldProcess($item.OriginalPath, 'Permanently delete from Recycle Bin')) { continue }
            Remove-Item -LiteralPath $item.DataPath -Recurse -Force
            Remove-Item -LiteralPath $item.InfoPath -Force
            $removed = $true
        }
        if ($removed) { [TrashPs.Native]::UpdateIcon() }
    }
}

<#
.SYNOPSIS
    Empty the Recycle Bin on all drives.
#>

function Clear-Trash {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        # Do not ask for confirmation.
        [switch] $Force
    )

    if ($Force -and -not $PSBoundParameters.ContainsKey('Confirm')) { $ConfirmPreference = 'None' }
    if (-not $PSCmdlet.ShouldProcess('Recycle Bin', 'Empty')) { return }
    $ret = [TrashPs.Native]::EmptyRecycleBin()
    # E_UNEXPECTED (0x8000FFFF) is returned when the Recycle Bin is already empty.
    if ($ret -ne 0 -and $ret -ne -2147418113) {
        Write-Error ("Failed to empty Recycle Bin (HRESULT 0x{0:X8})." -f $ret)
    }
}

<#
.SYNOPSIS
    Short command for everyday Recycle Bin operations, with Unix-style options.

.DESCRIPTION
    trash [OPTION]... [--] [PATH|PATTERN]...

    By default every given path is moved to the Recycle Bin. Options select
    another operation, each forwarding to the matching command of this module:

        trash <path...> Move-ItemToTrash
        trash --permanent <path...> Remove-Item -Recurse -Force
        trash -l, --list [pattern...] Get-TrashItem
        trash --restore <pattern...> Restore-TrashItem
        trash --purge <pattern...> Remove-TrashItem
        trash --empty Clear-Trash

    Other options:

        -f, --force Ignore nonexistent paths; do not ask with --purge and --empty
        -n, --dry-run Show what would happen without doing it (same as -WhatIf)
        -v, --verbose Explain what is being done
        -h, --help Show this help
        -r, -R, -d Accepted for rm compatibility; directories are always recycled whole
        '--' Treat all following arguments as paths. PowerShell consumes a bare --
                        and splits unquoted -name.ext, so write trash '--' '-name.ext',
                        or simply trash ./-name.ext

    Short options can be combined (-rf), and long options may also be written
    PowerShell-style with a single dash (-List, -Restore, -Force, -WhatIf).
    Patterns are matched against the Id, name or original path of Recycle Bin items.

.EXAMPLE
    trash -rf build\ *.log

.EXAMPLE
    trash -l *.log | trash --restore
#>

function trash {
    # Arguments are parsed by hand instead of with param(), so that GNU-style
    # options (--list, -rf, --) work and unknown options are rejected.
    $longOptions = @{
        'list' = 'List'; 'restore' = 'Restore'; 'purge' = 'Purge'; 'empty' = 'Empty'
        'permanent' = 'Permanent'; 'force' = 'Force'; 'dry-run' = 'DryRun'; 'whatif' = 'DryRun'
        'verbose' = 'Verbose'; 'help' = 'Help'; 'recursive' = 'Recursive'
    }
    $shortOptions = [System.Collections.Generic.Dictionary[char, string]]::new()
    $shortOptions['l'] = 'List'; $shortOptions['e'] = 'Empty'; $shortOptions['f'] = 'Force'
    $shortOptions['n'] = 'DryRun'; $shortOptions['v'] = 'Verbose'; $shortOptions['h'] = 'Help'
    $shortOptions['r'] = 'Recursive'; $shortOptions['R'] = 'Recursive'; $shortOptions['d'] = 'Recursive'

    $flags = [System.Collections.Generic.HashSet[string]]::new()
    $operands = [System.Collections.Generic.List[string]]::new()
    $endOfOptions = $false

    foreach ($arg in @($input) + @($args | ForEach-Object { $_ })) {
        if ($null -eq $arg) { continue }
        if ($arg -isnot [string]) {
            if ($arg.PSObject.TypeNames -contains 'TrashPs.Item') { $flags.Add('TrashItemInput') | Out-Null; $operands.Add($arg.Id) }
            elseif ($arg -is [System.IO.FileSystemInfo]) { $operands.Add($arg.FullName) }
            else { $operands.Add("$arg") }
            continue
        }
        if ($endOfOptions -or $arg -eq '-' -or -not $arg.StartsWith('-')) {
            $operands.Add($arg)
            continue
        }
        if ($arg -eq '--') {
            $endOfOptions = $true
            continue
        }
        $name = $arg.TrimStart('-')
        if ($arg.StartsWith('--') -or $longOptions.ContainsKey($name)) {
            if (-not $longOptions.ContainsKey($name)) { throw "trash: unrecognized option '$arg'. Try 'trash --help'." }
            $flags.Add($longOptions[$name]) | Out-Null
            continue
        }
        foreach ($c in $name.ToCharArray()) {
            if (-not $shortOptions.ContainsKey($c)) { throw "trash: invalid option -- '$c'. Try 'trash --help'." }
            $flags.Add($shortOptions[$c]) | Out-Null
        }
    }

    # Piping an empty result (e.g. no matching Get-TrashItem) is not an error.
    if ($MyInvocation.ExpectingInput -and $operands.Count -eq 0 -and -not $flags.Contains('Help')) { return }

    if ($flags.Contains('Help')) {
        Get-Help trash -Full
        return
    }

    $modes = @('List', 'Restore', 'Purge', 'Empty' | Where-Object { $flags.Contains($_) })
    if ($modes.Count -gt 1) { throw "trash: --$(($modes -join ', --').ToLower()) cannot be used together." }
    $mode = if ($modes) { $modes[0] } else { 'Recycle' }
    $force = $flags.Contains('Force')
    if ($flags.Contains('Permanent') -and $mode -ne 'Recycle') { throw "trash: --permanent cannot be used with --$($mode.ToLower())." }
    if ($mode -eq 'Empty' -and $operands.Count) { throw 'trash: --empty does not take any argument.' }
    if ($mode -in 'Restore', 'Purge' -and $operands.Count -eq 0) { throw "trash: --$($mode.ToLower()) requires at least one Id or pattern." }

    $common = @{}
    if ($flags.Contains('DryRun')) { $common.WhatIf = $true }
    if ($flags.Contains('Verbose')) { $common.Verbose = $true }

    switch ($mode) {
        'Recycle' {
            if ($flags.Contains('TrashItemInput')) { throw 'trash: Recycle Bin items cannot be recycled again; use --restore or --purge.' }
            if ($operands.Count -eq 0) { throw "trash: missing operand. Try 'trash --help'." }
            $paths = @($operands | Where-Object {
                    # Like rm -f, silently skip paths that do not exist.
                    -not $force -or (Test-Path -LiteralPath $_) -or (Test-Path -Path $_)
                })
            if ($paths.Count -eq 0) { return }
            if ($flags.Contains('Permanent')) {
                foreach ($target in $paths | ForEach-Object { Resolve-TargetPath $_ }) {
                    Remove-Item -LiteralPath $target -Recurse -Force @common
                }
            }
            else {
                Move-ItemToTrash -Path $paths @common
            }
        }
        'List' { Get-TrashItem -Filter $operands }
        'Restore' { Restore-TrashItem -Filter $operands @common }
        'Purge' { Remove-TrashItem -Filter $operands -Force:$force @common }
        'Empty' { Clear-Trash -Force:$force @common }
    }
}

Export-ModuleMember -Function Move-ItemToTrash, Get-TrashItem, Restore-TrashItem, Remove-TrashItem, Clear-Trash, trash