DSCResources/MSFT_xFileUpload/MSFT_xFileUpload.schema.psm1

Configuration xFileUpload
{
    <#
    .SYNOPSIS
        Configuration uploads file or folder to the smb share
    .DESCRIPTION
    .EXAMPLE
        xFileUpload -destinationPath "\\machine\share" -sourcePath "C:\folder\file" -username "domain\user" -password "password"
    .PARAMETER destinationPath
        Upload destination (has to point to a share or it's existing subfolder) e.g. \\machinename\sharename\destinationfolder
    .PARAMETER sourcePath
        Upload source e.g. C:\folder\file.txt
    .PARAMETER credential
        Credentials to access share where file/folder should be uploaded
    .PARAMETER certificateThumbprint
        Thumbprint of the certificate which should be used for encryption/decryption
    .NOTES
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")]

    param (
        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [String]
        $destinationPath,
        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [String]        
        $sourcePath,
        [PSCredential]
        $credential,
        [String]
        $certificateThumbprint
    )

    $cacheLocation = "$env:ProgramData\Microsoft\Windows\PowerShell\Configuration\BuiltinProvCache\MSFT_xFileUpload"
    
    if ($credential)
    {
        $username = $credential.UserName

        # Encrypt password
        $password = Invoke-Command -ScriptBlock ([ScriptBlock]::Create($getEncryptedPassword)) -ArgumentList $credential, $certificateThumbprint

    }
    
    Script FileUpload 
    {
        # Get script is not implemented cause reusing Script resource's schema does not make sense
        GetScript = { 
            $returnValue = @{
                   
            }

            $returnValue
        };
            
        SetScript = { 

            # Generating credential object if password and username are specified
            $credential = $null
            if (($using:password) -and ($using:username))
            {
                # Validate that certificate thumbprint is specified
                if(-not $using:certificateThumbprint)
                {
                    $errorMessage = "Certificate thumbprint has to be specified if credentials are present."
                    Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "CertificateThumbprintIsRequired", $errorMessage, "InvalidData"
                }

                Write-Debug "Username and password specified."

                # Decrypt password
                $decryptedPassword = Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:getDecryptedPassword)) -ArgumentList $using:password, $using:certificateThumbprint
                
                # Generate credential
                $securePassword = ConvertTo-SecureString $decryptedPassword -AsPlainText -Force
                $credential = New-Object System.Management.Automation.PSCredential ($using:username, $securePassword)
            }

            # Validate DestinationPath is UNC path
            if (!($using:destinationPath -as [System.Uri]).isUnc)
            {
                $errorMessage = "Destination path $using:destinationPath is not a valid UNC path."
                Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "DestinationPathIsNotUNCFailure", $errorMessage, "InvalidData"
            }

            # Verify source is localpath
            if (!(($using:sourcePath -as [System.Uri]).Scheme -match "file"))
            {
                $errorMessage = "Source path $using:sourcePath has to be local path."
                Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "SourcePathIsNotLocalFailure", $errorMessage, "InvalidData"
            }

            # Check whether source path is existing file or directory
            $sourcePathType = $null
            if (!(Test-Path $using:sourcePath))
            {
                $errorMessage = "Source path $using:sourcePath does not exist."
                Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "SourcePathDoesNotExistFailure", $errorMessage, "InvalidData"
            }
            else
            {
                $item = Get-Item $using:sourcePath
                switch ($item.GetType().Name)
                {
                    "FileInfo" {
                        $sourcePathType = "File"
                    }

                    "DirectoryInfo" {
                        $sourcePathType = "Directory"
                    }
                }
            }
            Write-Debug "SourcePath $using:sourcePath is of type: $sourcePathType"

            $psDrive = $null

            # Mount the drive only if credentials are specified and it's currently not accessible
            if ($credential)
            {
                if (Test-Path $using:destinationPath -ErrorAction Ignore)
                {
                    Write-Debug "Destination path $using:destinationPath is already accessible. No mount needed."
                }
                else
                {
                    $psDriveArgs = @{ Name = ([guid]::NewGuid()); PSProvider = "FileSystem"; Root = $using:destinationPath; Scope = "Private"; Credential = $credential }
                    try
                    {
                        Write-Debug "Create psdrive with destination path $using:destinationPath..."
                        $psDrive = New-PSDrive @psDriveArgs -ErrorAction Stop
                    }
                    catch
                    {
                        $errorMessage = "Cannot access destination path $using:destinationPath with given Credential"
                        Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "DestinationPathNotAccessibleFailure", $errorMessage, "InvalidData"
                    }
                }
            }

            try
            {
                # Get expected destination path
                $expectedDestinationPath = $null
                if (!(Test-Path $using:destinationPath))
                {
                    # DestinationPath has to exist
                    $errorMessage = "Invalid parameter values: DestinationPath doesn't exist, but has to be existing directory."
                    Throw-TerminatingError -errorMessage $errorMessage -errorCategory "InvalidData" -errorId "DestinationPathDoesNotExistFailure"
                }
                else
                {
                    $item = Get-Item $using:destinationPath
                    switch ($item.GetType().Name)
                    {
                        "FileInfo" {
                            # DestinationPath cannot be file
                            $errorMessage = "Invalid parameter values: DestinationPath is file, but has to be existing directory."
                            Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "DestinationPathCannotBeFileFailure", $errorMessage, "InvalidData"
                        }

                        "DirectoryInfo" {
                            $expectedDestinationPath = Join-Path $using:destinationPath (Split-Path $using:sourcePath -Leaf)
                        }
                    }
                    Write-Debug "ExpectedDestinationPath is $expectedDestinationPath"
                }

                # Copy destination path
                try
                {
                    Write-Debug "Copying $using:sourcePath to $using:destinationPath"
                    Copy-Item -path $using:sourcePath -Destination $using:destinationPath -Recurse -Force -ErrorAction Stop
                }
                catch
                {
                    $errorMessage = "Couldn't copy source path $using:sourcePath to $using:destinationPath : $($_.Exception)"
                    Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "CopyDirectoryOverFileFailure", $errorMessage, "InvalidData"
                }

                # Verify whether expectedDestinationPath was created
                if (!(Test-Path $expectedDestinationPath))
                {
                    $errorMessage = "Destination path $using:destinationPath could not be created"
                    Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "DestinationPathNotCreatedFailure", $errorMessage, "InvalidData"
                }
                # If expectedDestinationPath exists
                else
                {
                    Write-Verbose "$sourcePathType $expectedDestinationPath has been successfully created"

                    # Update cache
                    $uploadedItem = Get-Item $expectedDestinationPath
                    $lastWriteTime = $uploadedItem.LastWriteTimeUtc
                    $inputObject = @{}
                    $inputObject["LastWriteTimeUtc"] = $lastWriteTime
                    $key = [string]::Join("", @($using:destinationPath, $using:sourcePath, $expectedDestinationPath)).GetHashCode().ToString()
                    $path = Join-Path $using:cacheLocation $key
                    if(-not (Test-Path $using:cacheLocation))
                    {
                        mkdir $using:cacheLocation | Out-Null
                    }

                    Write-Debug "Updating cache for DestinationPath = $using:destinationPath and SourcePath = $using:sourcePath. CacheKey = $key"
                    Export-CliXml -Path $path -InputObject $inputObject -Force
                }
            }
            finally
            {
                # Remove PSDrive
                if($psDrive)
                {
                    Write-Debug "Removing PSDrive on root $($psDrive.Root)"
                    Remove-PSDrive $psDrive -Force
                }
            }
        };
            
        TestScript = { 
            
            # Generating credential object if password and username are specified
            $credential = $null
            if (($using:password) -and ($using:username))
            {
                # Validate that certificate thumbprint is specified
                if(-not $using:certificateThumbprint)
                {
                    $errorMessage = "Certificate thumbprint has to be specified if credentials are present."
                    Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "CertificateThumbprintIsRequired", $errorMessage, "InvalidData"
                }

                Write-Debug "Username and password specified. Generating credential"
                
                # Decrypt password
                $decryptedPassword = Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:getDecryptedPassword)) -ArgumentList $using:password, $using:certificateThumbprint

                # Generate credential
                $securePassword = ConvertTo-SecureString $decryptedPassword -AsPlainText -Force
                $credential = New-Object System.Management.Automation.PSCredential ($using:username, $securePassword)
            }
            else
            {
                Write-Debug "No credentials specified"
            }

            # Validate DestinationPath is UNC path
            if (!($using:destinationPath -as [System.Uri]).isUnc)
            {
                $errorMessage = "Destination path $using:destinationPath is not a valid UNC path."
                Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "DestinationPathIsNotUNCFailure", $errorMessage, "InvalidData"

            }

            # Check whether source path is existing file or directory (needed for expectedDestinationPath)
            $sourcePathType = $null
            if (!(Test-Path $using:sourcePath))
            {
                $errorMessage = "Source path $using:sourcePath does not exist."
                Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "SourcePathDoesNotExistFailure", $errorMessage, "InvalidData"
            }
            else
            {
                $item = Get-Item $using:sourcePath
                switch ($item.GetType().Name)
                {
                    "FileInfo" {
                        $sourcePathType = "File"
                    }

                    "DirectoryInfo" {
                        $sourcePathType = "Directory"
                    }
                }
            }
            Write-Debug "SourcePath $using:sourcePath is of type: $sourcePathType"

            $psDrive = $null

            # Mount the drive only if credentials are specified and it's currently not accessible
            if ($credential)
            {
                if (Test-Path $using:destinationPath -ErrorAction Ignore)
                {
                    Write-Debug "Destination path $using:destinationPath is already accessible. No mount needed."
                }
                else
                {
                    $psDriveArgs = @{ Name = ([guid]::NewGuid()); PSProvider = "FileSystem"; Root = $using:destinationPath; Scope = "Private"; Credential = $credential }
                    try
                    {
                        Write-Debug "Create psdrive with destination path $using:destinationPath..."
                        $psDrive = New-PSDrive @psDriveArgs -ErrorAction Stop
                    }
                    catch
                    {
                        $errorMessage = "Cannot access destination path $using:destinationPath with given Credential"
                        Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "DestinationPathNotAccessibleFailure", $errorMessage, "InvalidData"
                    }
                }
            }

            try
            {
                # Get expected destination path
                $expectedDestinationPath = $null
                if (!(Test-Path $using:destinationPath))
                {
                    # DestinationPath has to exist
                    $errorMessage = "Invalid parameter values: DestinationPath doesn't exist or is not accessible. DestinationPath has to be existing directory."
                    Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "DestinationPathDoesNotExistFailure", $errorMessage, "InvalidData"
                }
                else
                {
                    $item = Get-Item $using:destinationPath
                    switch ($item.GetType().Name)
                    {
                        "FileInfo" {
                            # DestinationPath cannot be file
                            $errorMessage = "Invalid parameter values: DestinationPath is file, but has to be existing directory."
                            Invoke-Command -ScriptBlock ([ScriptBlock]::Create($using:throwTerminatingError)) -ArgumentList "DestinationPathCannotBeFileFailure", $errorMessage, "InvalidData"
                        }

                        "DirectoryInfo" {
                            $expectedDestinationPath = Join-Path $using:destinationPath (Split-Path $using:sourcePath -Leaf)
                        }
                    }
                    Write-Debug "ExpectedDestinationPath is $expectedDestinationPath"
                }

                # Check whether ExpectedDestinationPath exists and has expected type
                $itemExists = $false
                if (!(Test-Path $expectedDestinationPath))
                {
                    Write-Debug "Expected destination path doesn't exist or is not accessible"
                }
                # If expectedDestinationPath exists
                else
                {
                    $expectedItem = Get-Item $expectedDestinationPath
                    $expectedItemType = $expectedItem.GetType().Name
                        
                    # If expectedDestinationPath has same type as sourcePathType, we need to verify cache to determine whether no upload is needed
                    if ((($expectedItemType -eq "FileInfo") -and ($sourcePathType -eq "File")) -or (($expectedItemType -eq "DirectoryInfo") -and ($sourcePathType -eq "Directory")))
                    {
                        # Get cache
                        Write-Debug "Getting cache for $expectedDestinationPath"
                        $cacheContent = $null
                        $key = [string]::Join("", @($using:destinationPath, $using:sourcePath, $expectedDestinationPath)).GetHashCode().ToString()
                        $path = Join-Path $using:cacheLocation $key
                        Write-Debug "Looking for cache under $path"
                        if (!(Test-Path $path))
                        {
                            Write-Debug "No cache found for DestinationPath = $using:destinationPath and SourcePath = $using:sourcePath. CacheKey = $key"
                        }
                        else
                        {
                            $cacheContent = Import-CliXml $path
                            Write-Debug "Found cache for DestinationPath = $using:destinationPath and SourcePath = $using:sourcePath. CacheKey = $key"
                        }

                        # Verify whether cache reflects current state or upload is needed
                        if ($cacheContent -ne $null -and ($cacheContent.LastWriteTimeUtc -eq $expectedItem.LastWriteTimeUtc))
                        {
                            # No upload needed
                            Write-Debug "Cache reflects current state. No need for upload."
                            $itemExists = $true
                        }
                        else
                        {
                            Write-Debug "Cache is empty or it doesn't reflect current state. Upload will be performed."
                        }    
                    }
                    else
                    {
                        Write-Debug "Expected destination path: $expectedDestinationPath is of type $expectedItemType, although source path is $sourcePathType"
                    }
                }
            }
            finally
            {
                # Remove PSDrive
                if($psDrive)
                {
                    Write-Debug "Removing PSDrive on root $($psDrive.Root)"
                    Remove-PSDrive $psDrive -Force
                }
            }

            return $itemExists

        };
    }
}

# Encrypts password using the defined public key
$getEncryptedPassword = @'
    param (
            [Parameter(Mandatory = $true)]
            [PSCredential] $credential,
            [Parameter(Mandatory = $true)]
            [String] $certificateThumbprint
        )
 
    $value = $credential.GetNetworkCredential().Password
     
    $cert = Invoke-Command -ScriptBlock ([ScriptBlock]::Create($getCertificate)) -ArgumentList $certificateThumbprint
     
    $encryptedPassword = $null
 
    if($cert)
    {
        # Cast the public key correctly
        $rsaProvider = [System.Security.Cryptography.RSACryptoServiceProvider]$cert.PublicKey.Key
         
        if($rsaProvider -eq $null)
        {
            $errorMessage = "Could not get public key from certificate with thumbprint: $certificateThumbprint . Please verify certificate is valid for encryption."
            Invoke-Command -ScriptBlock ([ScriptBlock]::Create($throwTerminatingError)) -ArgumentList "DecryptionCertificateNotFound", $errorMessage, "InvalidOperation"
        }
 
        # Convert to a byte array
        $keybytes = [System.Text.Encoding]::UNICODE.GetBytes($value)
 
        # Add a null terminator to the byte array
        $keybytes += 0
        $keybytes += 0
 
        # Encrypt using the public key
        $encbytes = $rsaProvider.Encrypt($keybytes, $false)
 
        # Return a string
        $encryptedPassword = [Convert]::ToBase64String($encbytes)
    }
    else
    {
        $errorMessage = "Could not find certificate which matches thumbprint: $certificateThumbprint . Could not encrypt password"
        Invoke-Command -ScriptBlock ([ScriptBlock]::Create($throwTerminatingError)) -ArgumentList "EncryptionCertificateNot", $errorMessage, "InvalidOperation"
    }
 
    return $encryptedPassword
'@


# Retrieves certificate by thumbprint
$getCertificate = @'
    param(
        [Parameter(Mandatory = $true)]
        [string] $certificateThumbprint
    )
 
    $cert = $null
 
    foreach($certIndex in Get-Childitem cert:\LocalMachine\My)
    {
        if($certIndex.Thumbprint -match $certificateThumbprint)
        {
            $cert = $certIndex
            break
        }
    }
 
    if(-not $cert)
    {
        $errorMessage = "Error Reading certificate store for {0}. Please verify thumbprint is correct and certificate belongs to cert:\LocalMachine\My store." -f ${certificateThumbprint};
        Invoke-Command -ScriptBlock ([ScriptBlock]::Create($throwTerminatingError)) -ArgumentList "InvalidPathSpecified", $errorMessage, "InvalidOperation"
    }
    else
    {
        $cert
    }
'@


# Throws terminating error specified errorCategory, errorId and errorMessage
$throwTerminatingError = @'
    param(
        [parameter(Mandatory = $true)]
        [System.String]
        $errorId,
        [parameter(Mandatory = $true)]
        [System.String]
        $errorMessage,
        [parameter(Mandatory = $true)]
        $errorCategory
    )
 
    $exception = New-Object System.InvalidOperationException $errorMessage
    $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, $errorId, $errorCategory, $null
    throw $errorRecord
'@


# Decrypts password using the defined private key
$getDecryptedPassword = @'
    param (
            [Parameter(Mandatory = $true)]
            [String] $value,
            [Parameter(Mandatory = $true)]
            [String] $certificateThumbprint
        )
 
    $cert = $null
 
    foreach($certIndex in Get-Childitem cert:\LocalMachine\My)
    {
        if($certIndex.Thumbprint -match $certificateThumbprint)
        {
            $cert = $certIndex
            break
        }
    }
 
    if(-not $cert)
    {
        $errorMessage = "Error Reading certificate store for {0}. Please verify thumbprint is correct and certificate belongs to cert:\LocalMachine\My store." -f ${certificateThumbprint};
        $exception = New-Object System.InvalidOperationException $errorMessage
        $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, "InvalidPathSpecified", "InvalidOperation", $null
        throw $errorRecord
    }
 
    $decryptedPassword = $null
 
    # Get RSA provider
    $rsaProvider = [System.Security.Cryptography.RSACryptoServiceProvider]$cert.PrivateKey
 
    if($rsaProvider -eq $null)
    {
        $errorMessage = "Could not get private key from certificate with thumbprint: $certificateThumbprint . Please verify certificate is valid for decryption."
        $exception = New-Object System.InvalidOperationException $errorMessage
        $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, "DecryptionCertificateNotFound", "InvalidOperation", $null
        throw $errorRecord
    }
 
    # Convert to bytes array
    $encBytes = [Convert]::FromBase64String($value)
 
    # Decrypt bytes
    $decryptedBytes = $rsaProvider.Decrypt($encBytes, $false)
         
    # Convert to string
    $decryptedPassword = [System.Text.Encoding]::Unicode.GetString($decryptedBytes)
 
    return $decryptedPassword
'@