HashCopy.psm1

function Compare-FileHash {
    <#
        .SYNOPSIS
            Compares files from one location to another based on determining change via computed hash value.
 
        .DESCRIPTION
            The Compare-FileHash cmdlet uses the Get-FileHash cmdlet to compute the hash value of one or more files and then returns any changed
            and new files from the specified source path. If you use the -Recurse parameter the cmdlet will synchronise a full directory
            tree, preserving the structure and creating any missing directories in the destination path as required.
 
            The purpose of this cmdlet is to compare specific file changes between two paths in situations where you cannot rely on the modified
            date of the files to determine if a file has changed. This can occur in situations where file modified dates have been changed, such
            as when cloning a set of files from a source control system.
 
        .PARAMETER Path
            The path to the source file/s or folder/s to copy any new or changed files from.
 
        .PARAMETER LiteralPath
            The literal path to the source file/s or folder/s to copy any new or changed files from. Unlike the Path parameter, the value of
            LiteralPath is used exactly as it is typed. No characters are interpreted as wildcards.
 
        .PARAMETER Destination
            The Destination folder to compare to -Path or -LiteralPath and return any changed or new files.
 
        .PARAMETER Algorithm
            Specifies the cryptographic hash function to use for computing the hash value of the contents of the specified file. A cryptographic
            hash function includes the property that it is not possible to find two distinct inputs that generate the same hash values. Hash
            functions are commonly used with digital signatures and for data integrity. The acceptable values for this parameter are:
 
            SHA1 | SHA256 | SHA384 | SHA512 | MACTripleDES | MD5 | RIPEMD160
 
            If no value is specified, or if the parameter is omitted, the default value is SHA256.
 
        .PARAMETER Exclude
            Exclude one or more files from being compared.
 
        .PARAMETER Recurse
            Indicates that this cmdlet performs a recursive comparison.
 
        .EXAMPLE
            Compare-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Recurse
 
            Compares the files between the two trees and returns any where they have different contents as determined via hash value comparison.
    #>

    [cmdletbinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'Path')]
        [ValidateScript( {if (Test-Path $_) {$True} Else { Throw '-Path must be a valid path.'} })]
        [String[]]
        $Path,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'LiteralPath')]
        [ValidateScript( {if (Test-Path $_) {$True} Else { Throw '-LiteralPath must be a valid path.'} })]
        [String[]]
        $LiteralPath,

        [Parameter(Mandatory)]
        [ValidateScript( {if (Test-Path $_ -PathType Container -IsValid) {$True} Else { Throw '-Destination must be a valid path.' } })]
        [String]
        $Destination,

        [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MACTripleDES', 'MD5', 'RIPEMD160')]
        [String]
        $Algorithm = 'SHA256',

        [string[]]
        $Exclude,

        [switch]
        $Recurse
    )
    begin {
        try {
            if (-Not (Test-Path $Destination)) {
                throw "$Destination does not exist"
            }
            else {
                $Destination = Join-Path ((Resolve-Path -Path $Destination).Path) -ChildPath '/'
            }
        }
        catch {
            throw $_
        }
    }
    process {
        #Path/LiteralPath are resolved here rather than in begin, since Mandatory pipeline-bound
        #parameters are not yet populated when begin runs.
        try {
            $SourcePath = if ($PSBoundParameters.ContainsKey('LiteralPath')) {
                (Resolve-Path -LiteralPath $LiteralPath).Path
            }
            else {
                (Resolve-Path -Path $Path).Path
            }
        }
        catch {
            throw $_
        }

        foreach ($Source in $SourcePath) {
            #Get-ChildItem's own -Exclude is filtered manually here rather than passed through, since combined
            #with -LiteralPath it silently fails to filter anything on Windows PowerShell 5.1 (though it works
            #correctly on PowerShell 7+) when -Recurse is not also specified.
            $SourceFiles = (Get-ChildItem -LiteralPath $Source -Recurse:$Recurse -File |
                    Where-Object { -not (Test-ExcludeMatch -Name $_.Name -Exclude $Exclude) }).FullName

            foreach ($SourceFile in $SourceFiles) {
                $DestFile = Get-DestinationFilePath -File $SourceFile -Source $Source -Destination $Destination
                $SourceHash = (Get-FileHash $SourceFile -Algorithm $Algorithm).hash

                if (Test-Path $DestFile) {
                    $DestHash = (Get-FileHash $DestFile -Algorithm $Algorithm).hash
                }
                else {
                    Write-Verbose "New file: $SourceFile"
                    $DestHash = $null
                }

                if ($SourceHash -ne $DestHash) {
                    Get-ChildItem -Path $SourceFile
                }
            }
        }
    }
}
function Copy-FileHash {
    <#
        .SYNOPSIS
            Copies files from one location to another based on determining change via computed hash value.
 
        .DESCRIPTION
            The Copy-FileHash cmdlet uses the Get-FileHash cmdlet to compute the hash value of one or more files and then copies any changed
            and new files to the specified destination path. If you use the -Recurse parameter the cmdlet will synchronise a full directory
            tree, preserving the structure and creating any missing directories in the destination path as required.
 
            The purpose of this cmdlet is to copy specific file changes between two paths in situations where you cannot rely on the modified
            date of the files to determine if a file has changed. This can occur in situations where file modified dates have been changed, such
            as when cloning a set of files from a source control system.
 
        .PARAMETER Path
            The path to the source file/s or folder/s to copy any new or changed files from.
 
        .PARAMETER LiteralPath
            The literal path to the source file/s or folder/s to copy any new or changed files from. Unlike the Path parameter, the value of
            LiteralPath is used exactly as it is typed. No characters are interpreted as wildcards.
 
        .PARAMETER Destination
            The Destination folder to compare to -Path and overwrite with any changed or new files from -Path. If the folder does not exist
            It will be created.
 
        .PARAMETER Algorithm
            Specifies the cryptographic hash function to use for computing the hash value of the contents of the specified file. A cryptographic
            hash function includes the property that it is not possible to find two distinct inputs that generate the same hash values. Hash
            functions are commonly used with digital signatures and for data integrity. The acceptable values for this parameter are:
 
            SHA1 | SHA256 | SHA384 | SHA512 | MACTripleDES | MD5 | RIPEMD160
 
            If no value is specified, or if the parameter is omitted, the default value is SHA256.
 
        .PARAMETER Exclude
            Exclude one or more files from being copied.
 
        .PARAMETER PassThru
            Returns the output of the file copy as an object. By default, this cmdlet does not generate any output.
 
        .PARAMETER Recurse
            Indicates that this cmdlet performs a recursive copy.
 
        .PARAMETER Mirror
            Use to remove files from the Destination path that are no longer in any of the Source paths.
 
        .PARAMETER Force
            Indicates that this cmdlet will copy items that cannot otherwise be changed, such as copying over a read-only file or alias.
 
        .EXAMPLE
            Copy-FileHash -Path C:\Some\Files -Destination D:\Some\Other\Files -Recurse
 
            Compares the files between the two trees and replaces in the destination any where they have different contents as determined
            via hash value comparison.
    #>

    [cmdletbinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'Path')]
        [ValidateScript( { if (Test-Path -Path $_) { $True } Else { Throw '-Path must be a valid path.' } })]
        [string[]]
        $Path,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'LiteralPath')]
        [ValidateScript( { if (Test-Path -LiteralPath $_) { $True } Else { Throw '-LiteralPath must be a valid path.' } })]
        [string[]]
        $LiteralPath,

        [Parameter(Mandatory)]
        [ValidateScript( { if (Test-Path -Path $_ -PathType Container -IsValid) { $True } Else { Throw '-Destination must be a valid path.' } })]
        [string]
        $Destination,

        [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MACTripleDES', 'MD5', 'RIPEMD160')]
        [string]
        $Algorithm = 'SHA256',

        [string[]]
        $Exclude,

        [switch]
        $PassThru,

        [switch]
        $Recurse,

        [switch]
        $Mirror,

        [switch]
        $Force
    )
    begin {
        try {
            #Everything below operates on already-resolved, concrete paths, so -LiteralPath is used
            #throughout to prevent characters such as [ ] in those paths being misread as wildcards.
            if (-Not (Test-Path -LiteralPath $Destination)) {
                New-Item -Path $Destination -ItemType Container | Out-Null
                Write-Warning "$Destination did not exist and has been created as a folder path."
            }

            $Destination = Join-Path ((Resolve-Path -LiteralPath $Destination).Path) -ChildPath '/'
        }
        catch {
            throw $_
        }
    }
    process {
        #Path/LiteralPath are resolved here rather than in begin, since Mandatory pipeline-bound
        #parameters are not yet populated when begin runs.
        try {
            $SourcePath = if ($PSBoundParameters.ContainsKey('LiteralPath')) {
                (Resolve-Path -LiteralPath $LiteralPath).Path
            }
            else {
                (Resolve-Path -Path $Path).Path
            }
        }
        catch {
            throw $_
        }

        if ($Mirror -and ($SourcePath -is [array])) {
            throw 'Cannot use -Mirror with an array of Paths. Specify a single Source path only.'
        }

        foreach ($Source in $SourcePath) {
            #Get-ChildItem's own -Exclude is filtered manually here rather than passed through, since combined
            #with -LiteralPath it silently fails to filter anything on Windows PowerShell 5.1 (though it works
            #correctly on PowerShell 7+) when -Recurse is not also specified.
            $SourceFiles = (Get-ChildItem -LiteralPath $Source -Recurse:$Recurse -File |
                    Where-Object { -not (Test-ExcludeMatch -Name $_.Name -Exclude $Exclude) }).FullName

            foreach ($SourceFile in $SourceFiles) {
                $DestFile = Get-DestinationFilePath -File $SourceFile -Source $Source -Destination $Destination
                $SourceHash = (Get-FileHash -LiteralPath $SourceFile -Algorithm $Algorithm).hash

                if (Test-Path -LiteralPath $DestFile) {
                    $DestHash = (Get-FileHash -LiteralPath $DestFile -Algorithm $Algorithm).hash
                }
                else {
                    #Using New-Item -Force creates an initial destination file along with any folders missing from its path.
                    #We use (Get-Date).Ticks to give the file a random value so that it is copied even if the source file is
                    #empty, so that if -PassThru has been used it is returned.
                    if ($PSCmdlet.ShouldProcess($DestFile, 'New-Item')) {
                        New-Item -Path $DestFile -Value (Get-Date).Ticks -Force -ItemType 'file' | Out-Null
                    }
                    $DestHash = $null
                }

                if (($SourceHash -ne $DestHash) -and $PSCmdlet.ShouldProcess($SourceFile, 'Copy-Item')) {
                    Copy-Item -LiteralPath $SourceFile -Destination $DestFile -Force:$Force -PassThru:$PassThru
                }
            }

            if ($Mirror) {
                $DestFiles = (Get-ChildItem -LiteralPath $Destination -Recurse:$Recurse -File).FullName

                foreach ($DestFile in $DestFiles) {
                    $SourceFile = Get-DestinationFilePath -File $DestFile -Source $Destination -Destination $Source

                    if (-not (Test-Path -LiteralPath $SourceFile)) {
                        if ($PSCmdlet.ShouldProcess($DestFile, 'Remove-Item')) {
                            Remove-Item -LiteralPath $DestFile
                        }
                    }
                }
            }
        }
    }
}
function Get-DestinationFilePath {
    <#
        .SYNOPSIS
            Accepts a source and destination file paths and a file (that from the source path) and returns the equivalent destination path (regardless of whether it exists).
 
        .PARAMETER File
            The file to modify.
 
        .PARAMETER Source
            The source directory or file path.
 
        .PARAMETER Destination
            The destination path.
 
        .EXAMPLE
            Get-DestinationFilePath -File (Get-ChildItem c:\temp\somefile.txt) -Source c:\temp -Destination d:\example
    #>

    [cmdletbinding()]
    param(
        [Parameter(Mandatory)]
        [System.IO.FileInfo]
        $File,

        [Parameter(Mandatory)]
        [String]
        $Source,

        [Parameter(Mandatory)]
        [String]
        $Destination
    )

    #Source and File are already-resolved, concrete paths (not user-typed wildcard patterns), so
    #-LiteralPath is used throughout to avoid characters like [ ] being misinterpreted as wildcards.
    if (Test-Path -LiteralPath $Source -PathType leaf) {
        $Source = Join-Path (Split-Path -Parent $Source) -ChildPath '/'
    }

    $ResolvedSource = Convert-Path -LiteralPath $Source

    $DestFile = Join-Path (Split-Path -Parent $File) -ChildPath '/'
    $DestFile = $DestFile -Replace "^$([Regex]::Escape($ResolvedSource))", $Destination
    $DestFile = Join-Path -Path $DestFile -ChildPath (Split-Path -Leaf $File)

    Return $DestFile
}
function Test-ExcludeMatch {
    <#
        .SYNOPSIS
            Returns whether a file name matches any of the given -Exclude wildcard patterns.
 
        .PARAMETER Name
            The file name to test.
 
        .PARAMETER Exclude
            One or more wildcard patterns to match the name against.
 
        .EXAMPLE
            Test-ExcludeMatch -Name 'somefile.txt' -Exclude 'some*'
    #>

    [cmdletbinding()]
    param(
        [Parameter(Mandatory)]
        [string]
        $Name,

        [string[]]
        $Exclude
    )

    foreach ($Pattern in $Exclude) {
        if ($Name -like $Pattern) {
            return $true
        }
    }

    return $false
}