DSCResources/MSFT_xRemoteFile/MSFT_xRemoteFile.psm1

$moduleRoot = Split-Path `
    -Path $MyInvocation.MyCommand.Path `
    -Parent

#region LocalizedData
$Culture = 'en-us'
if (Test-Path -Path (Join-Path -Path $moduleRoot -ChildPath $PSUICulture))
{
    $Culture = $PSUICulture
}
Import-LocalizedData `
    -BindingVariable LocalizedData `
    -Filename MSFT_xRemoteFile.psd1 `
    -BaseDirectory $moduleRoot `
    -UICulture $Culture
#endregion

# Path where cache will be stored. It's cleared whenever LCM gets new configuration.
$script:cacheLocation = "$env:ProgramData\Microsoft\Windows\PowerShell\Configuration\BuiltinProvCache\MSFT_xRemoteFile"

<#
.Synopsis
The Get-TargetResource function is used to fetch the status of file specified in DestinationPath on the target machine.
#>

function Get-TargetResource
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param
    (
        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $DestinationPath,

        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Uri
    )

    # Check whether DestinationPath is existing file
    $ensure = "Absent"
    $pathItemType = Get-PathItemType -path $DestinationPath
    switch($pathItemType)
    {
        "File"
        {
            Write-Verbose -Message $($LocalizedData.DestinationPathIsExistingFile `
                -f ${DestinationPath})
            $ensure = "Present"
        }

        "Directory"
        {
            Write-Verbose -Message $($LocalizedData.DestinationPathIsExistingPath `
                -f ${DestinationPath})

            # If it's existing directory, let's check whether expectedDestinationPath exists
            $uriFileName = Split-Path $Uri -Leaf
            $expectedDestinationPath = Join-Path $DestinationPath $uriFileName
            if (Test-Path $expectedDestinationPath)
            {
                Write-Verbose -Message $($LocalizedData.FileExistsInDestinationPath `
                    -f ${uriFileName})
                $ensure = "Present"
            }
        }

        "Other"
        {
            Write-Verbose -Message  $($LocalizedData.DestinationPathUnknownType `
                -f ${DestinationPath},${pathItemType})
        }

        "NotExists"
        {
            Write-Verbose -Message  $($LocalizedData.DestinationPathDoesNotExist `
                -f ${DestinationPath})
        }
    }

    $returnValue = @{
        DestinationPath = $DestinationPath
        Uri = $Uri
        Ensure = $ensure
    }

    $returnValue
}

<#
.Synopsis
The Set-TargetResource function is used to download file found under Uri location to DestinationPath
Additional parameters can be specified to configure web request
#>

function Set-TargetResource
{
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $DestinationPath,

        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Uri,

        [System.String]
        $UserAgent,

        [Microsoft.Management.Infrastructure.CimInstance[]]
        $Headers,

        [System.Management.Automation.PSCredential]
        $Credential,

        [parameter(Mandatory = $false)]
        [System.Boolean]
        $MatchSource = $true,

        [Uint32]
        $TimeoutSec,

        [System.String]
        $Proxy,

        [System.Management.Automation.PSCredential]
        $ProxyCredential
    )

    # Validate Uri
    if (-not (Test-UriScheme -uri $Uri -scheme "http|https|file"))
    {
        $errorMessage = $($LocalizedData.InvalidWebUriError) `
            -f ${Uri}
        New-InvalidDataException `
            -errorId "UriValidationFailure" `
            -errorMessage $errorMessage
    }

    # Validate DestinationPath scheme
    if (-not (Test-UriScheme -uri $DestinationPath -scheme "file"))
    {
        $errorMessage = $($LocalizedData.InvalidDestinationPathSchemeError `
            -f ${DestinationPath})
        New-InvalidDataException `
            -errorId "DestinationPathSchemeValidationFailure" `
            -errorMessage $errorMessage
    }

    # Validate DestinationPath is not UNC path
    if ($DestinationPath.StartsWith("\\"))
    { 
        $errorMessage = $($LocalizedData.DestinationPathIsUncError `
            -f ${DestinationPath})
        New-InvalidDataException `
            -errorId "DestinationPathIsUncFailure" `
            -errorMessage $errorMessage
    }

    # Validate DestinationPath does not contain invalid characters
    @('*','?','"','<','>','|') | % { 
        if ($DestinationPath.Contains($_) ){
            $errorMessage = $($LocalizedData.DestinationPathHasInvalidCharactersError `
                -f ${DestinationPath})
            New-InvalidDataException `
                -errorId "DestinationPathHasInvalidCharactersError" `
                -errorMessage $errorMessage
        }
    }

    # Validate DestinationPath does not end with / or \ (Invoke-WebRequest requirement)
    if ($DestinationPath.EndsWith('/') -or $DestinationPath.EndsWith('\')){
        $errorMessage = $($LocalizedData.DestinationPathEndsWithInvalidCharacterError `
            -f ${DestinationPath})
        New-InvalidDataException `
            -errorId "DestinationPathEndsWithInvalidCharacterError" `
            -errorMessage $errorMessage
    }

    # Check whether DestinationPath's parent directory exists. Create if it doesn't.
    $destinationPathParent = Split-Path $DestinationPath -Parent
    if (-not (Test-Path $destinationPathParent))
    {
        $null = New-Item -ItemType Directory -Path $destinationPathParent -Force
    }

    # Check whether DestinationPath's leaf is an existing folder
    $uriFileName = Split-Path $Uri -Leaf
    if (Test-Path $DestinationPath -PathType Container)
    {
        $DestinationPath = Join-Path $DestinationPath $uriFileName
    }

    # Remove DestinationPath and MatchSource from parameters as they are not parameters of Invoke-WebRequest
    $null = $PSBoundParameters.Remove("DestinationPath")
    $null = $PSBoundParameters.Remove("MatchSource")

    # Convert headers to hashtable
    $null = $PSBoundParameters.Remove("Headers")
    $headersHashtable = $null

    if ($Headers -ne $null)
    {
        $headersHashtable = Convert-KeyValuePairArrayToHashtable -array $Headers
    }

    # Invoke web request
    try
    {
        Write-Verbose -Message $($LocalizedData.DownloadingURI `
            -f ${DestinationPath},${URI})
        Invoke-WebRequest @PSBoundParameters -Headers $headersHashtable -outFile $DestinationPath
    }
    catch [System.OutOfMemoryException]
    {
        $errorMessage = $($LocalizedData.DownloadOutOfMemoryException `
            -f $_)
        New-InvalidDataException `
            -errorId "SystemOutOfMemoryException" `
            -errorMessage $errorMessage
    }
    catch [System.Exception]
    {
        $errorMessage = $($LocalizedData.DownloadException `
            -f $_)
        New-InvalidDataException `
            -errorId "SystemException" `
            -errorMessage $errorMessage
    }

    # Update cache
    if (Test-Path -Path $DestinationPath)
    {
        $downloadedFile = Get-Item -Path $DestinationPath
        $lastWriteTime = $downloadedFile.LastWriteTimeUtc
        $filesize = $downloadedFile.Length
        $inputObject = @{}
        $inputObject["LastWriteTime"] = $lastWriteTime
        $inputObject["FileSize"] = $filesize
        Update-Cache -DestinationPath $DestinationPath -Uri $Uri -InputObject $inputObject
    }
}

<#
.Synopsis
The Test-TargetResource function is used to validate if the DestinationPath exists on the machine.
#>

function Test-TargetResource
{
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $DestinationPath,

        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Uri,

        [System.String]
        $UserAgent,

        [Microsoft.Management.Infrastructure.CimInstance[]]
        $Headers,

        [System.Management.Automation.PSCredential]
        $Credential,

        [parameter(Mandatory = $false)]
        [System.Boolean]
        $MatchSource = $true,

        [Uint32]
        $TimeoutSec,

        [System.String]
        $Proxy,

        [System.Management.Automation.PSCredential]
        $ProxyCredential
    )

    # Check whether DestinationPath points to existing file or directory
    $fileExists = $false
    $uriFileName = Split-Path $Uri -Leaf
    $pathItemType = Get-PathItemType -Path $DestinationPath
    switch($pathItemType)
    {
        "File"
        {
            Write-Verbose -Message $($LocalizedData.DestinationPathIsExistingFile `
                -f ${DestinationPath})

            if ($MatchSource) {
                $file = Get-Item -Path $DestinationPath
                # Getting cache. It's cleared every time user runs Start-DscConfiguration
                $cache = Get-Cache -DestinationPath $DestinationPath -Uri $Uri

                if ($cache -ne $null `
                    -and ($cache.LastWriteTime -eq $file.LastWriteTimeUtc) `
                    -and ($cache.FileSize -eq $file.Length))
                {
                    Write-Verbose -Message $($LocalizedData.CacheReflectsCurrentState)
                    $fileExists = $true
                }
                else
                {
                    Write-Verbose -Message $($LocalizedData.CacheIsEmptyOrNotMatchCurrentState)
                }
            }
            else
            {
                Write-Verbose -Message $($LocalizedData.MatchSourceFalse)
                $fileExists = $true
            }
        }

        "Directory"
        {
            Write-Verbose -Message $($LocalizedData.DestinationPathIsExistingPath `
                -f ${DestinationPath})

            $expectedDestinationPath = Join-Path -Path $DestinationPath -ChildPath $uriFileName

            if (Test-Path -Path $expectedDestinationPath)
            {
                if ($MatchSource)
                {
                    $file = Get-Item -Path $expectedDestinationPath
                    $cache = Get-Cache -DestinationPath $expectedDestinationPath -Uri $Uri
                    if ($cache -ne $null -and ($cache.LastWriteTime -eq $file.LastWriteTimeUtc))
                    {
                        Write-Verbose -Message $($LocalizedData.CacheReflectsCurrentState)
                        $fileExists = $true
                    }
                    else
                    {
                        Write-Verbose -Message $($LocalizedData.CacheIsEmptyOrNotMatchCurrentState)
                    }
                }
                else
                {
                    Write-Verbose -Message $($LocalizedData.MatchSourceFalse)
                    $fileExists = $true
                }
            }
        }

        "Other"
        {
            Write-Verbose -Message  $($LocalizedData.DestinationPathUnknownType `
                -f ${DestinationPath},${pathItemType})
        }

        "NotExists"
        {
            Write-Verbose -Message  $($LocalizedData.DestinationPathDoesNotExist `
                -f ${DestinationPath})
        }
    }

    $result = $fileExists

    $result
}

<#
.Synopsis
Throws terminating error of category InvalidData with specified errorId and errorMessage
#>

function New-InvalidDataException
{
    param(
        [parameter(Mandatory = $true)]
        [System.String]
        $errorId,

        [parameter(Mandatory = $true)]
        [System.String]
        $errorMessage
    )
    
    $errorCategory = [System.Management.Automation.ErrorCategory]::InvalidData
    $exception = New-Object `
        -TypeName System.InvalidOperationException `
        -ArgumentList $errorMessage 
    $errorRecord = New-Object `
        -TypeName System.Management.Automation.ErrorRecord `
        -ArgumentList $exception, $errorId, $errorCategory, $null
    throw $errorRecord
}

<#
.Synopsis
Checks whether given URI represents specific scheme
.Description
Most common schemes: file, http, https, ftp
We can also specify logical expressions like: [http|https]
#>

function Test-UriScheme
{
    param (
        [parameter(Mandatory = $true)]
        [System.String]
        $uri,

        [parameter(Mandatory = $true)]
        [System.String]
        $scheme
    )
    $newUri = $uri -as [System.URI]
    $newUri.AbsoluteURI -ne $null -and $newUri.Scheme -match $scheme
}

<#
.Synopsis
Gets type of the item which path points to.
.Outputs
File, Directory, Other or NotExists
#>

function Get-PathItemType
{
    param (
        [parameter(Mandatory = $true)]
        [System.String]
        $path
    )

    $type = $null

    # Check whether path exists
    if (Test-Path $path)
    {
        # Check type of the path
        $pathItem = Get-Item -Path $path
        $pathItemType = $pathItem.GetType().Name
        if ($pathItemType -eq "FileInfo")
        {
            $type = "File"
        }
        elseif ($pathItemType -eq "DirectoryInfo")
        {
            $type = "Directory"
        }
        else
        {
            $type = "Other"
        }
    }
    else
    {
        $type = "NotExists"
    }

    return $type
}

<#
.Synopsis
Converts CimInstance array of type KeyValuePair to hashtable
#>

function Convert-KeyValuePairArrayToHashtable
{
    param (
        [parameter(Mandatory = $true)]
        [Microsoft.Management.Infrastructure.CimInstance[]]
        $array
    )

    $hashtable = @{}
    foreach($item in $array)
    {
        $hashtable += @{$item.Key = $item.Value}
    }

    return $hashtable
}

<#
.Synopsis
Gets cache for specific DestinationPath and Uri
#>

function Get-Cache
{
    param (
        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $DestinationPath,

        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Uri
    )

    $cacheContent = $null
    $key = Get-CacheKey -DestinationPath $DestinationPath -Uri $Uri
    $path = Join-Path -Path $script:cacheLocation -ChildPath $key

    Write-Verbose -Message $($LocalizedData.CacheLookingForPath `
        -f ${Path})

    if(-not (Test-Path -Path $path))
    {
        Write-Verbose -Message $($LocalizedData.CacheNotFoundForPath `
            -f ${DestinationPath},${Uri},${Key})

        $cacheContent = $null
    }
    else
    {
        $cacheContent = Import-CliXml -Path $path
        Write-Verbose -Message $($LocalizedData.CacheFoundForPath `
            -f ${DestinationPath},${Uri},${Key})
    }

    return $cacheContent
}

<#
.Synopsis
Creates or updates cache for specific DestinationPath and Uri
#>

function Update-Cache
{
    param (
        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $DestinationPath,

        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Uri,
        
        [parameter(Mandatory = $true)]
        [Object]
        $InputObject
    )

    $key = Get-CacheKey -DestinationPath $DestinationPath -Uri $Uri
    $path = Join-Path -Path $script:cacheLocation -ChildPath $key

    if(-not (Test-Path -Path $script:cacheLocation))
    {
        $null = New-Item -ItemType Directory -Path $script:cacheLocation
    }

    Write-Verbose -Message $($LocalizedData.UpdatingCache `
        -f ${DestinationPath},${Uri},${Key})

    Export-CliXml -Path $path -InputObject $InputObject -Force
}

<#
.Synopsis
Returns cache key for given parameters
#>

function Get-CacheKey
{
    param (
        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $DestinationPath,

        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Uri
    )
    return [string]::Join("", @($DestinationPath, $Uri)).GetHashCode().ToString()
}

Export-ModuleMember -Function *-TargetResource