Private/AtomicFile.ps1

function Get-HLFileLockName {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    $pathKey = ConvertTo-HLFullPath -Path $Path
    $isWindows = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
    if ($isWindows) {
        $pathKey = $pathKey.ToUpperInvariant()
    }

    $encoding = New-Object Text.UTF8Encoding($false)
    $hasher = [Security.Cryptography.SHA256]::Create()
    try {
        $digest = $hasher.ComputeHash($encoding.GetBytes($pathKey))
    }
    finally {
        $hasher.Dispose()
    }
    $digestText = -join @($digest | ForEach-Object { $_.ToString('x2') })
    $prefix = if ($isWindows) { 'Global\HardeningLens.File.' } else { 'HardeningLens.File.' }
    return $prefix + $digestText
}

function Enter-HLFileLock {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [ValidateRange(1, 300000)]
        [int]$TimeoutMilliseconds = 15000
    )

    $fullPath = ConvertTo-HLFullPath -Path $Path
    $mutexName = Get-HLFileLockName -Path $fullPath
    $mutex = New-Object System.Threading.Mutex($false, $mutexName)
    $acquired = $false
    try {
        try {
            $acquired = $mutex.WaitOne($TimeoutMilliseconds)
        }
        catch [System.Threading.AbandonedMutexException] {
            # The previous owner terminated without releasing the mutex. Ownership is
            # transferred to this thread, so the protected operation can proceed.
            $acquired = $true
        }

        if (-not $acquired) {
            throw (New-Object System.TimeoutException("Timed out after $TimeoutMilliseconds ms waiting for the file lock for '$fullPath'."))
        }
        return $mutex
    }
    catch {
        if (-not $acquired) {
            $mutex.Dispose()
        }
        throw
    }
}

function Exit-HLFileLock {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Threading.Mutex]$Lock
    )

    try {
        $Lock.ReleaseMutex()
    }
    finally {
        $Lock.Dispose()
    }
}

function Write-HLAtomicUtf8File {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Content,

        [switch]$NoClobber
    )

    $fullPath = ConvertTo-HLFullPath -Path $Path
    $parent = Split-Path -Path $fullPath -Parent
    if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
        throw "Parent directory does not exist: $parent"
    }

    $temporaryPath = Join-Path -Path $parent -ChildPath ('.{0}.{1}.tmp' -f [IO.Path]::GetFileName($fullPath), [guid]::NewGuid().ToString('N'))
    $backupPath = Join-Path -Path $parent -ChildPath ('.{0}.{1}.bak' -f [IO.Path]::GetFileName($fullPath), [guid]::NewGuid().ToString('N'))
    $encoding = New-Object Text.UTF8Encoding($false)
    $bytes = $encoding.GetBytes($Content)
    $stream = [IO.File]::Open($temporaryPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
    try {
        $stream.Write($bytes, 0, $bytes.Length)
        $stream.Flush($true)
    }
    finally {
        $stream.Dispose()
    }

    try {
        if ($NoClobber) {
            # File.Move is atomic on the same volume and fails when the destination
            # appears concurrently, closing the final no-clobber check/write race.
            [IO.File]::Move($temporaryPath, $fullPath)
        }
        elseif (Test-Path -LiteralPath $fullPath) {
            [IO.File]::Replace($temporaryPath, $fullPath, $backupPath)
        }
        else {
            [IO.File]::Move($temporaryPath, $fullPath)
        }
    }
    finally {
        if (Test-Path -LiteralPath $temporaryPath) {
            Remove-Item -LiteralPath $temporaryPath -Force
        }
        if (Test-Path -LiteralPath $backupPath) {
            Remove-Item -LiteralPath $backupPath -Force
        }
    }
}