Public/Unprotect-SensitiveData.ps1

function Unprotect-SensitiveData {
    <#
    .SYNOPSIS
        Reverses Protect-SensitiveData, restoring masked data back to the original values.

    .DESCRIPTION
        Unprotect-SensitiveData inverts the replacement map (masked value -> original term) and
        applies it with the same case-aware engine used by Protect-SensitiveData. This is a
        best-effort inverse: matching is case-insensitive and the original casing is restored
        heuristically (an ALL-CAPS masked token comes back ALL-CAPS, etc.), which is correct for
        typical mask/unmask round-trips but may differ when the original used unusual casing.

        Like Protect-SensitiveData it works in two modes:

        - Path mode (-Path): unmasks a single file or every file in a folder. By default the
          output is written alongside the source using -Suffix; use -InPlace to overwrite.

        - String mode (-InputString): unmasks a piped string and returns it to the pipeline.

        If two original terms were masked to the same value the inverse is ambiguous; the
        function warns and keeps the first mapping.

    .PARAMETER Path
        Path to a file or folder to unmask. Belongs to the 'Path' parameter set (default mode).

    .PARAMETER InputString
        A masked string to restore. Accepts pipeline input; the result is written to the
        pipeline. Belongs to the 'String' parameter set.

    .PARAMETER MappingFile
        Path to the JSON mapping file used for the original masking. Defaults to
        MaskSensitiveData_Map.json in the user's profile folder. Ignored when -Replacements
        is supplied.

    .PARAMETER Replacements
        The original (un-inverted) mask hashtable (key = original term, value = masked value).
        Takes precedence over -MappingFile. The function inverts it internally.

    .PARAMETER InPlace
        Path mode only. Overwrite the source files in place instead of writing copies.

    .PARAMETER Suffix
        Path mode only. Suffix appended to the output file/folder when not using -InPlace.
        Defaults to '.unmasked'.

    .PARAMETER Include
        Path mode only. Wildcard patterns to filter files when -Path is a folder. Defaults to '*.*'.

    .PARAMETER Recurse
        Path mode only. When -Path is a folder, also process files in subfolders.

    .EXAMPLE
        Unprotect-SensitiveData -Path .\report.txt.masked

        Restores the masked file using the default mapping, writing report.txt.masked.unmasked.

    .EXAMPLE
        'Contact alice at Acme' | Unprotect-SensitiveData -Replacements @{ 'john' = 'alice'; 'Contoso' = 'Acme' }

        Inverts the mask and returns the restored string to the pipeline.

    .LINK
        Protect-SensitiveData
    #>

    [CmdletBinding(DefaultParameterSetName = 'Path')]
    param(
        [Parameter(Mandatory, Position = 0, ParameterSetName = 'Path')]
        [string]$Path,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'String')]
        [string]$InputString,

        [string]$MappingFile = (Join-Path $env:USERPROFILE 'MaskSensitiveData_Map.json'),
        [hashtable]$Replacements,

        [Parameter(ParameterSetName = 'Path')]
        [switch]$InPlace,

        [Parameter(ParameterSetName = 'Path')]
        [string]$Suffix = '.unmasked',

        [Parameter(ParameterSetName = 'Path')]
        [string[]]$Include = @('*.*'),

        [Parameter(ParameterSetName = 'Path')]
        [switch]$Recurse
    )

    begin {
        $maskMap = Resolve-ReplacementMap -Replacements $Replacements -MappingFile $MappingFile

        # Invert the map: masked value -> original term.
        $map = @{}
        foreach ($key in $maskMap.Keys) {
            $maskedValue = $maskMap[$key]
            if ($map.ContainsKey($maskedValue)) {
                Write-Warning "Ambiguous inverse: '$maskedValue' maps back to both '$($map[$maskedValue])' and '$key'. Keeping '$($map[$maskedValue])'."
                continue
            }
            $map[$maskedValue] = $key
        }

        if ($PSCmdlet.ParameterSetName -eq 'Path') {
            Write-Host "Loaded $($map.Count) replacement(s) (inverted)."
        }
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'String') {
            Invoke-Replacements -Text $InputString -Map $map
            return
        }

        Invoke-MaskPath -Path $Path -Map $map -InPlace:$InPlace -Suffix $Suffix `
            -Include $Include -Recurse:$Recurse
    }
}