RestSetAcls.psm1

. $PSScriptRoot/SddlUtils.ps1
. $PSScriptRoot/PrintUtils.ps1

function Write-LiveFilesAndFoldersProcessingStatus {
    [OutputType([int])]
    param (
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
        [Object[]]$FileOrFolder,

        [Parameter(Mandatory=$true)]
        [datetime]$StartTime,

        [Parameter(Mandatory=$false)]
        [int]$RefreshRateHertz = 10
    )

    begin {
        $i = 0
        $failures = 0
        $msBetweenPrints = 1000 / $RefreshRateHertz
        $lastPrint = (Get-Date).AddMilliseconds(-$msBetweenPrints)
        $overwriteLine = -not (Get-IsPowerShellIse)
    }

    process {
        $i++
        $timeSinceLastPrint = (Get-Date) - $lastPrint

        if (-not $_.Success) {
            $failures++
        }
        
        # To avoid overloading gui, only print at most every $msBetweenPrints
        # On a test with 6K files, printing at 60Hz saved ~20% perf compared to printing every update
        if ($timeSinceLastPrint.TotalMilliseconds -gt $msBetweenPrints) {
            $now = Get-Date
            $timeSinceStart = $now - $StartTime
            $itemsPerSec = [math]::Round($i / $timeSinceStart.TotalSeconds, 1)

            if ($overwriteLine) {
                Write-Host "`r" -NoNewline
            }
            Write-Host "Set " -ForegroundColor DarkGray -NoNewline
            Write-Host ($i - $failures) -ForegroundColor Blue -NoNewline
            Write-Host " permissions" -ForegroundColor DarkGray -NoNewline
            if ($failures -gt 0) {
                Write-Host ", " -ForegroundColor DarkGray -NoNewline
                Write-Host $failures -ForegroundColor Red -NoNewline
                Write-Host " failures" -ForegroundColor DarkGray -NoNewline
            }
            Write-Host " (" -ForegroundColor DarkGray -NoNewline
            Write-Host $itemsPerSec -ForegroundColor Blue -NoNewline
            Write-Host " items/s)" -ForegroundColor DarkGray -NoNewline
            if (-not $overwriteLine) {
                Write-Host
            }
            
            $lastPrint = Get-Date
        }

        Write-Output $_
    }
}

function Write-FinalFilesAndFoldersProcessed {
    [OutputType([System.Void])]
    param (
        [Parameter(Mandatory=$true)]
        [int]$ProcessedCount,

        [Parameter(Mandatory=$true)]
        [hashtable]$Errors,

        [Parameter(Mandatory=$true)]
        [timespan]$TotalTime,

        [Parameter(Mandatory=$false)]
        [int]$MaxErrorsToShow = 10
    )

    $successCount = $ProcessedCount - $Errors.Count
    $errorCount = $Errors.Count

    $seconds = [math]::Round($TotalTime.TotalSeconds, 2)

    if ($errorCount -gt 0) {        
        if ($errorCount -eq $processedCount) {
            # Setting ACLs failed for all files; report it.
            Write-FailedHeader
            Write-Host "Failed to set " -NoNewline
            Write-Host $errorCount -ForegroundColor Red -NoNewline
            Write-Host " permissions. Total time " -NoNewline
            Write-Host $seconds -ForegroundColor Blue -NoNewline
            Write-Host " seconds. Errors:"
        }
        else {
            Write-PartialHeader
            Write-Host "Set " -NoNewline
            Write-Host $successCount -ForegroundColor Blue -NoNewline
            Write-Host " permissions, " -NoNewline
            Write-Host $errorCount -ForegroundColor Red -NoNewline
            Write-Host " failures. Total time " -NoNewline
            Write-Host $seconds -ForegroundColor Blue -NoNewline
            Write-Host " seconds. Errors:"
        }
        Write-Host
        
        # Print first $maxErrorsToShow errors
        $Errors.GetEnumerator() | Select-Object -First $MaxErrorsToShow | ForEach-Object {
            Write-Host " $($_.Key): " -NoNewline
            Write-Host $_.Value -ForegroundColor Red
        }

        # Add a note if there are more errors
        if ($errorCount -gt $MaxErrorsToShow) {
            Write-Host " ... and " -NoNewline
            Write-Host ($errorCount - $MaxErrorsToShow) -ForegroundColor Red -NoNewline
            Write-Host " more errors"
            Write-Host
        }
        
        # Save all errors to a JSON file
        ConvertTo-Json $Errors | Out-File "errors.json"
        Write-Host " Full list of errors has been saved in " -NoNewline
        Write-Host "errors.json" -ForegroundColor Blue
        
        Write-Host
    } else {
        $itemsPerSec = [math]::Round($successCount / $TotalTime.TotalSeconds, 1)

        Write-DoneHeader
        Write-Host "Set " -NoNewline
        Write-Host $successCount -ForegroundColor Blue -NoNewline
        Write-Host " permissions in " -NoNewline
        Write-Host $seconds -ForegroundColor Blue -NoNewline
        Write-Host " seconds" -NoNewline
        Write-Host " (" -NoNewline
        Write-Host $itemsPerSec -ForegroundColor Blue -NoNewline
        Write-Host " items/s)"
    }
}

function Write-SddlWarning {
    param (
        [Parameter(Mandatory=$true)]
        [string]$Sddl,

        [Parameter(Mandatory=$true)]
        [string]$NewSddl
    )
    Write-WarningHeader
    Write-Host "The SDDL string has non-standard inheritance rules."
    Write-Host "It is recommended to set OI (Object Inherit) and CI (Container Inherit) on every permission. " -ForegroundColor DarkGray
    Write-Host "This ensures that the permissions are inherited by files and folders created in the future." -ForegroundColor DarkGray
    Write-Host
    Write-Host " Current: "  -NoNewline -ForegroundColor Yellow
    Write-Host $Sddl
    Write-Host " Recommended: " -NoNewline -ForegroundColor Green
    Write-Host $NewSddl
    Write-Host

    Write-Host "Do you want to continue with the " -NoNewline
    Write-Host "current" -ForegroundColor Yellow -NoNewline
    Write-Host " SDDL?" -NoNewline

    return Ask ""
}

function Get-AzureFilesRecursive {
    param (
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
        [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageBase[]]$DirectoryContents,

        [Parameter(Mandatory=$true)]
        [Microsoft.WindowsAzure.Commands.Storage.AzureStorageContext]$Context,

        [Parameter(Mandatory=$false)]
        [string]$DirectoryPath = "",

        [Parameter(Mandatory=$false)]
        [switch]$SkipFiles = $false,

        [Parameter(Mandatory=$false)]
        [switch]$SkipDirectories = $false
    )

    foreach ($file in $DirectoryContents) {
        $fullPath = "${DirectoryPath}$($file.Name)"
        $isDirectory = $file.GetType().Name -eq "AzureStorageFileDirectory"

        $file.Context = $Context

        if ($isDirectory) {
            $fullPath += "/"

            # Get the contents of the directory.
            # Calling Get-AzStorageFile with this parameter set returns an Object[],
            # where items are either AzureStorageFile or AzureStorageFileDirectory.
            # Therefore, when recursing, we can cast Object[] to AzureStorageBase[].
            $subdirectoryContents = Get-AzStorageFile -Directory $file.CloudFileDirectory

            if ($null -ne $subdirectoryContents) {
                Get-AzureFilesRecursive `
                    -Context $Context `
                    -DirectoryContents $subdirectoryContents `
                    -DirectoryPath $fullPath `
                    -SkipFiles:$SkipFiles `
                    -SkipDirectories:$SkipDirectories
            }
        }

        if (($isDirectory -and !$SkipDirectories) -or (!$isDirectory -and !$SkipFiles)) {
            Write-Output @{
                FullPath = $fullPath
                File = $file   
            }
        }
    }
}

function New-AzureFilePermission {
    [OutputType([string])]
    param (
        [Parameter(Mandatory=$true, HelpMessage="Azure storage context")]
        [Microsoft.WindowsAzure.Commands.Storage.AzureStorageContext]$Context,

        [Parameter(Mandatory=$true, HelpMessage="Name of the file share")]
        [string]$FileShareName,
        
        [Parameter(
            Mandatory=$true,
            HelpMessage="File permission in the Security Descriptor Definition Language (SDDL). " +
                        "SDDL must have an owner, group, and discretionary access control list (DACL). " +
                        "The provided SDDL string format of the security descriptor should not have " +
                        "domain relative identifier (like 'DU', 'DA', 'DD' etc) in it.")]
        [string]$Sddl
    )

    $share = Get-AzStorageShare -Name $FileShareName -Context $Context
    $permissionInfo = $share.ShareClient.CreatePermission($Sddl)
    return $permissionInfo.Value.FilePermissionKey
}

function Get-AzureFilePermission {
    [OutputType([string])]
    param (
        [Parameter(Mandatory=$true)]
        [string]$PermissionKey,

        [Parameter(Mandatory=$true)]
        [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageFileShare]$Share
    )

    $Share.ShareClient.GetPermission($PermissionKey).Value
}

function Set-AzureFilePermission {
    param (
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
        [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageBase]$File,

        [Parameter(Mandatory=$true)]
        [string]$SddlPermission
    )
    
    if ($File.GetType().Name -eq "AzureStorageFileDirectory") {
        $directory = [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageFileDirectory]$File
        $directory.ShareDirectoryClient.SetHttpHeaders(
            $null, # SmbProperties
            $SddlPermission # filePermission
        ) | Out-Null
    } else {
        $file = [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageFile]$File
        $file.ShareFileClient.SetHttpHeaders(
            $null, # newSize
            $null, # httpHeaders
            $null, # smbProperties
            $SddlPermission # filePermission
        )  | Out-Null
    }
}

function Set-AzureFilePermissionKey {
    param (
        [Parameter(Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
        [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageBase]$File,

        [Parameter(Mandatory=$true)]
        [string]$FilePermissionKey
    )

    $smbProperties = New-Object Azure.Storage.Files.Shares.Models.FileSmbProperties
    $smbProperties.FilePermissionKey = $FilePermissionKey

    if ($File.GetType().Name -eq "AzureStorageFileDirectory") {
        $directory = [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageFileDirectory]$File
        $directory.ShareDirectoryClient.SetHttpHeaders($smbProperties) | Out-Null
    } else {
        $file = [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageFile]$File
        $file.ShareFileClient.SetHttpHeaders(
            $null, # newSize
            $null, # httpHeaders
            $smbProperties
        ) | Out-Null
    }
}

function Get-AzureFilePermissionKey {
    [OutputType([string])]
    param (
        [Parameter(Mandatory=$true)]
        [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageBase]$FileOrDirectory
    )

    if ($FileOrDirectory.GetType().Name -eq "AzureStorageFileDirectory") {
        $directory = [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageFileDirectory]$FileOrDirectory
        return $directory.ShareDirectoryProperties.SmbProperties.FilePermissionKey
    } else {
        $file = [Microsoft.WindowsAzure.Commands.Common.Storage.ResourceModel.AzureStorageFile]$FileOrDirectory
        return $file.FileProperties.SmbProperties.FilePermissionKey
    }
}

function Set-AzureFilesAclRecursive {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [Microsoft.WindowsAzure.Commands.Storage.AzureStorageContext]$Context,

        [Parameter(Mandatory=$true)]
        [string]$FileShareName,

        [Parameter(Mandatory=$true)]
        [string]$FilePath,

        [Parameter(Mandatory=$true)]
        [string]$SddlPermission,

        [Parameter(Mandatory=$false)]
        [bool]$Parallel = $true,

        [Parameter(Mandatory=$false)]
        [int]$ThrottleLimit = 10,

        [Parameter(Mandatory=$false)]
        [switch]$SkipFiles = $false,

        [Parameter(Mandatory=$false)]
        [switch]$SkipDirectories = $false,

        [Parameter(Mandatory=$false)]
        [switch]$WriteToPipeline = $false
    )

    if ($SkipFiles -and $SkipDirectories) {
        Write-Warning "Both -SkipFiles and -SkipDirectories are set. Nothing to do."
        return
    }

    # Check if parallel mode is supported
    if ($Parallel -and $PSVersionTable.PSVersion.Major -lt 7) {
        Write-Warning "-Parallel is only supported on PowerShell 7+. Falling back to single-threaded mode."
        $Parallel = $false
    }

    # Try to parse SDDL permission, check for common issues
    try {
        $securityDescriptor = ConvertTo-RawSecurityDescriptor -Sddl $SddlPermission
    } catch {
        Write-Failure "SDDL permission is invalid"
        return
    }

    # Check if inheritance flags are okay
    $shouldBeEnabled = "ContainerInherit, ObjectInherit"
    $shouldBeDisabled = "NoPropagateInherit, InheritOnly"

    $matchesTarget = Get-AllAceFlagsMatch `
        -SecurityDescriptor $securityDescriptor `
        -EnabledFlags $shouldBeEnabled `
        -DisabledFlags $shouldBeDisabled

    if (-not ($matchesTarget)) {
        Set-AceFlags `
            -SecurityDescriptor $securityDescriptor `
            -EnableFlags $shouldBeEnabled `
            -DisableFlags $shouldBeDisabled

        $newSddl = ConvertFrom-RawSecurityDescriptor $securityDescriptor

        $continue = Write-SddlWarning -Sddl $SddlPermission -NewSddl $newSddl
        if (-not $continue) {
            return
        }
    }

    # Try to create permission on Azure Files
    # The idea is to create the permission early. If this fails (e.g. due to invalid SDDL), we can fail early.
    # Setting permission key should in theory also be slightly faster than setting SDDL directly (though this may not be noticeable in practice).
    try {
        $filePermissionKey = New-AzureFilePermission -Context $Context -FileShareName $FileShareName -Sddl $SddlPermission
    } catch {
        Write-Failure "Failed to create file permission" -Details $_.Exception.Message
        return
    }

    # Get root directory
    # Calling Get-AzStorageFile with this parameter set returns a AzureStorageFileDirectory
    # (if the path is a directory) or AzureStorageFile (if the path is a file).
    # If it's a directory, Get-AzureFilesRecursive will get its contents.
    try {
        $directory = Get-AzStorageFile -Context $Context -ShareName $FileShareName -Path $FilePath -ErrorAction Stop
    } catch {
        Write-Failure "Failed to read root directory" -Details $_.Exception.Message
        return
    }

    $startTime = Get-Date
    $processedCount = 0
    $errors = @{}
    $ProgressPreference = "SilentlyContinue"

    if ($Parallel) {
        $funcDef = ${function:Set-AzureFilePermissionKey}.ToString()
        Get-AzureFilesRecursive `
            -Context $Context `
            -DirectoryContents @($directory) `
            -SkipFiles:$SkipFiles `
            -SkipDirectories:$SkipDirectories `
        | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
            # Set the ACL
            ${function:Set-AzureFilePermissionKey} = $using:funcDef
            $success = $true
            $errorMessage = ""            
            try {
                Set-AzureFilePermissionKey -File $_.File -FilePermissionKey $using:filePermissionKey
            } catch {
                $success = $false
                $errorMessage = $_.Exception.Message
            }
            
            # Write full output if requested, otherwise write minimal output
            if ($using:WriteToPipeline) {
                Write-Output @{
                    Time = (Get-Date).ToString("o")
                    FullPath = $_.FullPath
                    Permission = $using:SddlPermission
                    Success = $success
                    ErrorMessage = $errorMessage
                }
            } else {
                Write-Output @{
                    FullPath = $_.FullPath
                    Success = $success
                    ErrorMessage = $errorMessage
                }
            }
        } `
        | ForEach-Object {
            # Can't write in the parallel block, so we write here
            if (-not $_.Success) {
                $errors[$_.FullPath] = $_.ErrorMessage
            }
            $processedCount++
            Write-Output $_
        } `
        | Write-LiveFilesAndFoldersProcessingStatus -RefreshRateHertz 10 -StartTime $startTime `
        | ForEach-Object { if ($WriteToPipeline) { Write-Output $_ } }
    } else {       
        Get-AzureFilesRecursive `
            -Context $Context `
            -DirectoryContents @($directory) `
            -SkipFiles:$SkipFiles `
            -SkipDirectories:$SkipDirectories `
        | ForEach-Object {
            $fullPath = $_.FullPath
            $success = $true
            $errorMessage = ""
            
            # Set the ACL
            try {
                Set-AzureFilePermissionKey -File $_.File -FilePermissionKey $filePermissionKey
            } catch {
                $success = $false
                $errorMessage = $_.Exception.Message
                $errors[$fullPath] = $errorMessage
            }

            $processedCount++
            
            # Write full output if requested, otherwise write minimal output
            if ($WriteToPipeline) {
                Write-Output @{
                    Time = (Get-Date).ToString("o")
                    FullPath = $fullPath
                    Permission = $SddlPermission
                    Success =  $success
                    ErrorMessage = $errorMessage
                }
            } else {
                Write-Output @{
                    FullPath = $fullPath
                    Success = $success
                    ErrorMessage = $errorMessage
                }
            }            
        } `
        | Write-LiveFilesAndFoldersProcessingStatus -RefreshRateHertz 10 -StartTime $startTime `
        | ForEach-Object { if ($WriteToPipeline) { Write-Output $_ } }
    }

    $ProgressPreference = "Continue"
    
    $totalTime = (Get-Date) - $startTime
    Write-Host "`r" -NoNewline # Clear the line from the live progress reporting
    Write-FinalFilesAndFoldersProcessed -ProcessedCount $processedCount -Errors $errors -TotalTime $totalTime
}