Utils/shred.ps1

Register-GnuUtil @{
    Name         = 'shred'
    Category     = 'File and directory operations'
    Synopsis     = 'Overwrite a file to hide its contents, and optionally delete it.'
    Usage        = @('shred [OPTION]... FILE...')
    Description  = @('Overwrite the specified FILE(s) repeatedly, in order to make it harder', 'for even very expensive hardware probing to recover the data.')
    FileOperands = $true
    Options      = @(
        @{ Short = 'f'; Long = 'force'; Help = 'change permissions to allow writing if necessary' }
        @{ Short = 'n'; Long = 'iterations'; Arg = 'N'; Help = 'overwrite N times instead of the default (3)' }
        @{ Long = 'random-source'; Arg = 'FILE'; Help = 'get random bytes from FILE' }
        @{ Short = 's'; Long = 'size'; Arg = 'N'; Help = 'shred this many bytes (suffixes like K, M, G accepted)' }
        @{ Short = 'u'; Key = 'remove'; Value = 'wipesync'; Help = 'deallocate and remove file after overwriting' }
        @{ Long = 'remove'; Arg = 'HOW'; OptionalArg = $true; Help = 'like -u but give control on HOW to delete; See below' }
        @{ Short = 'v'; Long = 'verbose'; Help = 'show progress' }
        @{ Short = 'x'; Long = 'exact'; Help = 'do not round file sizes up to the next full block; this is the default for non-regular files' }
        @{ Short = 'z'; Long = 'zero'; Help = 'add a final overwrite with zeros to hide shredding' }
    )
    Notes        = @(
        'Delete FILE(s) if --remove (-u) is specified. The default is not to remove',
        'the files because it is common to operate on device files like /dev/hda,',
        'and those files usually should not be removed.',
        'The optional HOW parameter indicates how to remove a directory entry:',
        '''unlink'' => use a standard unlink call.',
        '''wipe'' => also first obfuscate bytes in the name.',
        '''wipesync'' => also sync each obfuscated byte to the device.',
        'The default mode is ''wipesync'', but note it can be expensive.',
        '',
        'CAUTION: shred assumes the file system and hardware overwrite data in place.',
        'Although this is common, many platforms operate otherwise. Also, backups',
        'and mirrors may contain unremovable copies that will let a shredded file',
        'be recovered later. On SSDs and copy-on-write file systems (and NTFS with',
        'compression, encryption or shadow copies) shred cannot guarantee anything.',
        '',
        'PowerShell note: every pass writes random data (GNU mixes in fixed patterns);',
        '--random-source seeds the generator from FILE. Device files are not supported.'
    )
}



function script:Invoke-GnuShredFile {
    <#
    .SYNOPSIS
    Implementation of shred.
    #>

    param([Parameter(Mandatory)][hashtable]$Context)

    $operands = @($Context.Operands)
    if ($operands.Count -eq 0) { Write-GnuUsageError -Context $Context -Message 'missing file operand'; return }
    $passes = 3
    if (Test-GnuOption -Context $Context -Key 'iterations') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'iterations')
        if ($text -notmatch '^\d+$') { Write-GnuError -Context $Context -Message "invalid number of passes: '$text'"; return }
        $passes = [int]$text
    }
    $size = $null
    if (Test-GnuOption -Context $Context -Key 'size') {
        $text = [string](Get-GnuOptionValue -Context $Context -Key 'size')
        $parsed = ConvertFrom-GnuSize -Text $text
        if ($null -eq $parsed) { Write-GnuError -Context $Context -Message "invalid file size: '$text'"; return }
        $size = ConvertTo-GnuInt64 -Value $parsed
    }
    $remove = $null
    if (Test-GnuOption -Context $Context -Key 'remove') {
        $remove = [string](Get-GnuOptionValue -Context $Context -Key 'remove')
        if ([string]::IsNullOrEmpty($remove)) { $remove = 'wipesync' }
        if (@('unlink', 'wipe', 'wipesync') -notcontains $remove) {
            Write-GnuUsageError -Context $Context -Message "invalid argument '$remove' for '--remove'`nValid arguments are:`n - 'unlink'`n - 'wipe'`n - 'wipesync'"
            return
        }
    }
    $random = New-Object System.Random
    if (Test-GnuOption -Context $Context -Key 'random-source') {
        $sourceName = [string](Get-GnuOptionValue -Context $Context -Key 'random-source')
        $seedBytes = Get-GnuInputBytes -Context $Context -Operand $sourceName
        if ($null -eq $seedBytes) { return }
        $hasher = [System.Security.Cryptography.SHA256]::Create()
        $random = New-Object System.Random -ArgumentList ([System.BitConverter]::ToInt32($hasher.ComputeHash([byte[]]$seedBytes), 0))
        $hasher.Dispose()
    }
    $report = Test-GnuOption -Context $Context -Key 'verbose'
    $zero = Test-GnuOption -Context $Context -Key 'zero'
    $exact = Test-GnuOption -Context $Context -Key 'exact'
    $total = $passes
    if ($zero) { $total++ }

    foreach ($operand in $operands) {
        if ($operand -eq '-') { Write-GnuError -Context $Context -Message '-: shredding standard output is not supported'; continue }
        $path = Resolve-GnuPath -Path $operand
        if ([System.IO.Directory]::Exists($path)) { Write-GnuError -Context $Context -Message "$($operand): failed to open for writing: Is a directory"; continue }
        if ((Test-GnuOption -Context $Context -Key 'force') -and [System.IO.File]::Exists($path)) {
            $attributes = [System.IO.File]::GetAttributes($path)
            if ($attributes -band [System.IO.FileAttributes]::ReadOnly) { [System.IO.File]::SetAttributes($path, $attributes -bxor [System.IO.FileAttributes]::ReadOnly) }
        }
        try { $stream = New-Object System.IO.FileStream -ArgumentList $path, 'Open', 'Write', 'None' }
        catch { Write-GnuError -Context $Context -Message "$($operand): failed to open for writing: $(Get-GnuErrorReason -Exception $_.Exception)"; continue }
        try {
            $length = $stream.Length
            if ($null -ne $size) { $length = $size }
            elseif (-not $exact -and $length % 4096 -ne 0) { $length += 4096 - ($length % 4096) }
            $buffer = New-Object byte[] 65536
            for ($pass = 1; $pass -le $total; $pass++) {
                $isZero = $zero -and $pass -eq $total
                if ($report) {
                    $label = '(random)'
                    if ($isZero) { $label = '(000000)' }
                    Write-GnuWarning -Context $Context -Message "$($operand): pass $pass/$total $label..."
                }
                $stream.Position = 0
                $left = $length
                while ($left -gt 0) {
                    $chunk = [int][Math]::Min([long]$buffer.Length, $left)
                    if ($isZero) { [System.Array]::Clear($buffer, 0, $chunk) } else { $random.NextBytes($buffer) }
                    $stream.Write($buffer, 0, $chunk)
                    $left -= $chunk
                }
                $stream.Flush($true)
            }
        }
        finally { $stream.Dispose() }
        if ($null -ne $remove) { Remove-GnuShreddedFile -Context $Context -Operand $operand -How $remove -Report:$report }
    }
}

function script:Remove-GnuShreddedFile {
    <#
    .SYNOPSIS
    Removes a shredded file; 'wipe' / 'wipesync' first rename it to ever shorter names of zeros.
    #>

    param([Parameter(Mandatory)][hashtable]$Context, [Parameter(Mandatory)][string]$Operand, [Parameter(Mandatory)][string]$How, [switch]$Report)

    if ($Report) { Write-GnuWarning -Context $Context -Message "$($Operand): removing" }
    $current = $Operand
    if ($How -ne 'unlink') {
        $directory = Get-GnuDirName -Path $Operand
        $prefix = ''
        if ($Operand -match '[\\/]') { $prefix = $directory.TrimEnd('/', '\') + $Operand.Substring($directory.TrimEnd('/', '\').Length, 1) }
        for ($length = (Get-GnuBaseName -Path $Operand).Length; $length -ge 1; $length--) {
            $candidate = $prefix + ('0' * $length)
            if ($candidate -eq $current -or [System.IO.File]::Exists((Resolve-GnuPath -Path $candidate))) { continue }
            try { [System.IO.File]::Move((Resolve-GnuPath -Path $current), (Resolve-GnuPath -Path $candidate)) }
            catch { break }
            if ($Report) { Write-GnuWarning -Context $Context -Message "$($current): renamed to $candidate" }
            $current = $candidate
        }
    }
    try { [System.IO.File]::Delete((Resolve-GnuPath -Path $current)) }
    catch { Write-GnuError -Context $Context -Message "$($Operand): failed to remove: $(Get-GnuErrorReason -Exception $_.Exception)"; return }
    if ($Report) { Write-GnuWarning -Context $Context -Message "$($Operand): removed" }
}