Runtime/Invoke-OSDStageFileSystem.ps1

[CmdletBinding()]
param([string]$ConfigPath = (Join-Path $PSScriptRoot 'config.json'))

function Assert-OSDStageFileSystemPath {
    param([Parameter(Mandatory)][string]$Path)
    # Destination syntax is checked in WinPE, but variables are expanded only in Windows.
    $pathText = $Path.Replace('/', '\')
    if ($pathText -notmatch '^(?:[A-Za-z]:\\|%(?:SystemDrive|SystemRoot|windir|ProgramData|ProgramFiles|ProgramFiles\(x86\))%\\).+' -or
        $pathText -match '[*?"<>|\x00-\x1f]' -or $pathText -ne $pathText.Trim()) {
        throw 'Use a literal local absolute path or a supported machine directory variable; roots and wildcards are not allowed.'
    }
    $tail = $pathText -replace '^(?:[A-Za-z]:\\|%[^%]+%\\)', ''
    if ($tail.Contains(':') -or $tail.Contains('%')) { throw 'Alternate data streams and embedded environment variables are not supported.' }
    foreach ($part in $tail.TrimEnd('\').Split('\')) {
        if (-not $part -or $part -match '[. ]$' -or $part -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)') {
            throw 'Empty, relative, trailing-dot/space and reserved device path components are not supported.'
        }
    }
}

function Assert-OSDStageFileSystemOperation {
    param([Parameter(Mandatory)]$Operation)
    if ([string]$Operation.Kind -notin @('Directory','File') -or [string]$Operation.Action -notin @('Create','Remove')) { throw 'Invalid file system operation.' }
    Assert-OSDStageFileSystemPath -Path ([string]$Operation.Path)
    if ($Operation.Force -isnot [bool] -or $Operation.Recurse -isnot [bool]) { throw 'Force and Recurse must be Boolean values.' }
    if ($Operation.Recurse -and ($Operation.Kind -ne 'Directory' -or $Operation.Action -ne 'Remove')) { throw 'Recurse is only valid when removing a directory.' }
    if ($Operation.Kind -eq 'File' -and $Operation.Action -eq 'Create') {
        if ($Operation.Data -isnot [string] -or $Operation.Data.Length -gt 22369624) { throw 'File content is limited to 16 MiB per operation.' }
        $bytes = [Convert]::FromBase64String($Operation.Data)
        if ($bytes.Length -gt 16MB) { throw 'File content is limited to 16 MiB per operation.' }
    }
}

function Assert-OSDStageFileSystemConfiguration {
    param([Parameter(Mandatory)]$Configuration)
    $id = [guid]::Empty
    if ([int]$Configuration.SchemaVersion -ne 2 -or
        -not [guid]::TryParse([string]$Configuration.DeploymentId, [ref]$id) -or
        [string]::IsNullOrWhiteSpace([string]$Configuration.ResultPath) -or
        @($Configuration.Operations).Count -eq 0) { throw 'Invalid file system configuration.' }
    foreach ($operation in @($Configuration.Operations)) { Assert-OSDStageFileSystemOperation -Operation $operation }
}

function Resolve-OSDStageFileSystemPath {
    param([Parameter(Mandatory)][string]$Path)
    Assert-OSDStageFileSystemPath -Path $Path
    $expanded = [Environment]::ExpandEnvironmentVariables($Path.Replace('/', '\'))
    if ($expanded.Contains('%') -or $expanded -notmatch '^[A-Za-z]:\\') { throw 'Destination could not be resolved to a local Windows drive.' }
    $resolved = [IO.Path]::GetFullPath($expanded).TrimEnd('\')
    # Do not allow a file operation to destroy OS roots or this deployment's own data.
    $roots = @($env:windir, $env:ProgramData, $env:ProgramFiles, ${env:ProgramFiles(x86)})
    if ($env:SystemDrive) { $roots += "$env:SystemDrive\Users" }
    foreach ($root in $roots) {
        if ($root -and [string]::Equals($resolved, $root.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { throw 'A protected system directory cannot be targeted.' }
    }
    foreach ($owned in @("$env:windir\Temp\OSDStage", "$env:ProgramData\OSDStage", "$env:windir\Setup\Scripts")) {
        if ($resolved -ieq $owned -or $resolved.StartsWith($owned + '\', [StringComparison]::OrdinalIgnoreCase) -or
            $owned.StartsWith($resolved + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'A deployment directory or its ancestor cannot be targeted.' }
    }
    return $resolved
}

function Assert-OSDStageFileSystemNoLink {
    param([Parameter(Mandatory)][string]$Path)
    $current = $Path
    while ($current) {
        try { $attributes = [IO.File]::GetAttributes($current) }
        catch [IO.FileNotFoundException] { $attributes = $null }
        catch [IO.DirectoryNotFoundException] { $attributes = $null }
        if ($null -ne $attributes -and ($attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Reparse points, junctions and symbolic links are not supported.' }
        $current = [IO.Path]::GetDirectoryName($current)
    }
}

function Get-OSDStageFileSystemTree {
    param([Parameter(Mandatory)][string]$Path)
    # Enumerate one level at a time, refusing links before descending.
    foreach ($child in [IO.Directory]::GetFileSystemEntries($Path)) {
        $attributes = [IO.File]::GetAttributes($child)
        if ($attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'The directory contains a reparse point; nothing was removed.' }
        if ($attributes -band [IO.FileAttributes]::Directory) { Get-OSDStageFileSystemTree -Path $child }
        $child
    }
}

function Invoke-OSDStageFileSystemOperation {
    param([Parameter(Mandatory)]$Operation)
    Assert-OSDStageFileSystemOperation -Operation $Operation
    $path = Resolve-OSDStageFileSystemPath -Path $Operation.Path
    Assert-OSDStageFileSystemNoLink -Path $path
    $isFile = [IO.File]::Exists($path)
    $isDirectory = [IO.Directory]::Exists($path)
    if (($Operation.Kind -eq 'File' -and $isDirectory) -or ($Operation.Kind -eq 'Directory' -and $isFile)) { throw 'The destination exists with a different item type.' }
    $outcome = 'Unchanged'
    if ($Operation.Action -eq 'Remove') {
        if (-not $isFile -and -not $isDirectory) { $outcome = 'AlreadyAbsent' }
        else {
            $children = @()
            if ($isDirectory) {
                if (-not $Operation.Recurse -and [IO.Directory]::GetFileSystemEntries($path).Length -gt 0) { throw 'A nonempty directory requires Recurse.' }
                if ($Operation.Recurse) { $children = @(Get-OSDStageFileSystemTree -Path $path) }
            }
            $targets = @($children) + @($path)
            # Validate the whole tree before changing attributes or removing anything.
            foreach ($item in $targets) {
                if (-not $Operation.Force -and ([IO.File]::GetAttributes($item) -band [IO.FileAttributes]::ReadOnly)) { throw 'A read-only item requires Force.' }
            }
            foreach ($item in $targets) {
                Assert-OSDStageFileSystemNoLink -Path $item
                $attributes = [IO.File]::GetAttributes($item)
                if ($Operation.Force) { [IO.File]::SetAttributes($item, ($attributes -band (-bnot [IO.FileAttributes]::ReadOnly))) }
                if ($attributes -band [IO.FileAttributes]::Directory) { [IO.Directory]::Delete($item, $false) }
                else { [IO.File]::Delete($item) }
            }
            $outcome = 'Removed'
        }
    }
    elseif ($Operation.Kind -eq 'Directory') {
        if (-not $isDirectory) { [IO.Directory]::CreateDirectory($path) | Out-Null; $outcome = 'Created' }
    }
    else {
        $bytes = [Convert]::FromBase64String([string]$Operation.Data)
        $same = $false
        if ($isFile) {
            $sha = [Security.Cryptography.SHA256]::Create()
            try {
                $stream = [IO.File]::OpenRead($path)
                try { $before = [Convert]::ToBase64String($sha.ComputeHash($stream)) } finally { $stream.Dispose() }
                $same = $before -ceq [Convert]::ToBase64String($sha.ComputeHash($bytes))
            } finally { $sha.Dispose() }
            if (-not $same -and -not $Operation.Force) { throw 'Different file content already exists; use Force to overwrite.' }
        }
        if (-not $same) {
            $parent = [IO.Path]::GetDirectoryName($path)
            [IO.Directory]::CreateDirectory($parent) | Out-Null
            $temp = Join-Path $parent ('.osdstage-' + [guid]::NewGuid().ToString('N') + '.tmp')
            try {
                [IO.File]::WriteAllBytes($temp, $bytes)
                Assert-OSDStageFileSystemNoLink -Path $path
                if ($isFile) {
                    $attributes = [IO.File]::GetAttributes($path)
                    [IO.File]::SetAttributes($path, ($attributes -band (-bnot [IO.FileAttributes]::ReadOnly)))
                    try { [IO.File]::Replace($temp, $path, [NullString]::Value) }
                    finally { if ([IO.File]::Exists($path)) { [IO.File]::SetAttributes($path, $attributes) } }
                    $outcome = 'Replaced'
                }
                else { [IO.File]::Move($temp, $path); $outcome = 'Created' }
            }
            finally { if ([IO.File]::Exists($temp)) { [IO.File]::Delete($temp) } }
        }
    }
    [pscustomobject]@{Kind=[string]$Operation.Kind;Action=[string]$Operation.Action;Path=$path;Status='Succeeded';Outcome=$outcome;Error=$null}
}

function Invoke-OSDStageFileSystemConfiguration {
    param([Parameter(Mandatory)]$Configuration)
    Assert-OSDStageFileSystemConfiguration -Configuration $Configuration
    # Reject unresolved/protected destinations before the first operation.
    foreach ($op in @($Configuration.Operations)) { Resolve-OSDStageFileSystemPath -Path $op.Path | Out-Null }
    $items = [System.Collections.Generic.List[object]]::new()
    foreach ($op in @($Configuration.Operations)) {
        try { $items.Add((Invoke-OSDStageFileSystemOperation -Operation $op)) }
        catch {
            $items.Add([pscustomobject]@{Kind=[string]$op.Kind;Action=[string]$op.Action;Path=[string]$op.Path;Status='Failed';Outcome='Failed';Error=$_.Exception.Message})
        }
    }
    $failed = @($items | Where-Object Status -eq 'Failed').Count
    [pscustomobject]@{
        SchemaVersion=2;DeploymentId=[string]$Configuration.DeploymentId;Status=if($failed){'Failed'}else{'Succeeded'}
        OperationCount=$items.Count;SuccessfulOperationCount=($items.Count-$failed);FailedOperationCount=$failed
        RestartNeeded=$false;RebootRequested=$false;Items=@($items);Error=if($failed){"$failed file system operation(s) failed."}else{$null}
    }
}

if ($MyInvocation.InvocationName -eq '.') { return }
$ErrorActionPreference = 'Stop'
try {
    if (Test-Path -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT') { throw 'File system runtime requires full Windows.' }
    $configuration = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
    $record = Invoke-OSDStageFileSystemConfiguration -Configuration $configuration
    $resultPath = [Environment]::ExpandEnvironmentVariables([string]$configuration.ResultPath)
    New-Item -ItemType Directory -Path (Split-Path $resultPath -Parent) -Force | Out-Null
    $record | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $resultPath -Encoding UTF8
    if ($record.Status -eq 'Failed') { exit 1 }
    exit 0
}
catch { Write-Error 'File system configuration failed; no file content is included in this diagnostic.' -ErrorAction Continue; exit 1 }