Public/New-HDTFileSystem.ps1
|
function New-HDTFileSystem { <# .SYNOPSIS Creates the real IFileSystem adapter over System.IO. .DESCRIPTION The one place in HDT that touches the filesystem. PROJECT constraint 4 forbids engine logic from doing it directly, so the log writer, the state document and every step receive this object and can be swapped for New-HDTFakeFileSystem in a test. It implements the eleven IFileSystem methods - TestPath, ReadAllText, WriteAllText, AppendAllText, CreateDirectory, RemoveItem, CopyItem, GetChildItem, GetLength, GetHash, GetVersion - and lets System.IO throw its own exception types, because those types are what the contract asserts and what the fake reproduces. IT WRITES UTF-8 WITHOUT A BYTE ORDER MARK, THROUGH System.IO.File. Set-Content -Encoding UTF8 emits 239 187 191 under Windows PowerShell 5.1 and nothing under pwsh 7, and the engine writes its logs under 5.1 in WinPE and its tests under 7 on a desk - so a BOM would appear in exactly the files a parser reads. [System.IO.File]::WriteAllText and ::AppendAllText with UTF8Encoding($false) are BOM-free on both. This is SPIKES.md S6's UTF-16 Tee-Object trap in a different disguise. Set-Content and Add-Content are banned in this file. WriteAllText and AppendAllText create the parent directory first: ::AppendAllText creates a missing file but throws for a missing directory, and a log writer that has to know whether today is the first write is a log writer with a bug in it. Paths are normalised with [System.IO.Path]::GetFullPath and stripped of a trailing separator, matching the fake. Every call is recorded in $Operations, before it can throw, exactly as the fakes record (tests/helpers/README.md section 4). It is a [pscustomobject] carrying ScriptMethod members rather than a PowerShell class: classes dot-sourced into the module are the known flaky path across -Force re-imports (see 01-03). An array-returning ScriptMethod returns with the unary comma, or a single-element result collapses to a scalar. .PARAMETER Journal The shared cross-service operation journal. When supplied, every recorded call is appended to it in addition to $Operations, numbered globally across services. .OUTPUTS System.Management.Automation.PSCustomObject with the nine IFileSystem ScriptMethods. Note that Get-Member -MemberType Method does NOT list a ScriptMethod - use -MemberType Method, ScriptMethod. .EXAMPLE $fs = New-HDTFileSystem $fs.AppendAllText('C:\HDT\Logs\HDT.jsonl', $record + "`n") One JSONL record appended, UTF-8, no BOM, directory created if absent. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds a stateless service adapter object; it changes no state.')] [CmdletBinding()] [OutputType([object])] param( [Parameter()] [AllowNull()] [System.Collections.ArrayList] $Journal ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $service = [pscustomobject] @{ Operations = [System.Collections.ArrayList]::new() Journal = $Journal ServiceName = 'FileSystem' Encoding = (New-Object -TypeName System.Text.UTF8Encoding -ArgumentList $false) } $service | Add-Member -MemberType ScriptMethod -Name Record -Value { param([string] $Operation, [object[]] $Argument) [void] $this.Operations.Add([pscustomobject] @{ Sequence = $this.Operations.Count + 1 Operation = $Operation Arguments = $Argument }) if ($null -ne $this.Journal) { [void] $this.Journal.Add([pscustomobject] @{ Sequence = $this.Journal.Count + 1 Service = $this.ServiceName Operation = $Operation Arguments = $Argument }) } } $service | Add-Member -MemberType ScriptMethod -Name GetOperationName -Value { return , ([string[]] @($this.Operations | ForEach-Object { $_.Operation })) } $service | Add-Member -MemberType ScriptMethod -Name NormalizePath -Value { param([string] $Path) $full = [System.IO.Path]::GetFullPath($Path) # 'C:\' is three characters and its separator is part of the root. if ($full.Length -gt 3) { $full = $full.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) } return $full } $service | Add-Member -MemberType ScriptMethod -Name EnsureParent -Value { param([string] $NormalizedPath) $parent = [System.IO.Path]::GetDirectoryName($NormalizedPath) if ($parent) { [void] [System.IO.Directory]::CreateDirectory($parent) } } $service | Add-Member -MemberType ScriptMethod -Name TestPath -Value { param([string] $Path) $this.Record('TestPath', @($Path)) $full = $this.NormalizePath($Path) return ([System.IO.File]::Exists($full) -or [System.IO.Directory]::Exists($full)) } $service | Add-Member -MemberType ScriptMethod -Name ReadAllText -Value { param([string] $Path) $this.Record('ReadAllText', @($Path)) return [System.IO.File]::ReadAllText($this.NormalizePath($Path)) } $service | Add-Member -MemberType ScriptMethod -Name WriteAllText -Value { param([string] $Path, [string] $Content) $this.Record('WriteAllText', @($Path, $Content)) $full = $this.NormalizePath($Path) $this.EnsureParent($full) [System.IO.File]::WriteAllText($full, $Content, $this.Encoding) } $service | Add-Member -MemberType ScriptMethod -Name AppendAllText -Value { param([string] $Path, [string] $Content) $this.Record('AppendAllText', @($Path, $Content)) $full = $this.NormalizePath($Path) $this.EnsureParent($full) [System.IO.File]::AppendAllText($full, $Content, $this.Encoding) } $service | Add-Member -MemberType ScriptMethod -Name CreateDirectory -Value { param([string] $Path) $this.Record('CreateDirectory', @($Path)) [void] [System.IO.Directory]::CreateDirectory($this.NormalizePath($Path)) } $service | Add-Member -MemberType ScriptMethod -Name RemoveItem -Value { param([string] $Path, [bool] $Recurse) $this.Record('RemoveItem', @($Path, $Recurse)) $full = $this.NormalizePath($Path) if ([System.IO.File]::Exists($full)) { [System.IO.File]::Delete($full) return } if (-not [System.IO.Directory]::Exists($full)) { return } # Directory.Delete throws IOException for a populated directory when # recursion was not asked for, which is the contract. [System.IO.Directory]::Delete($full, $Recurse) } $service | Add-Member -MemberType ScriptMethod -Name CopyItem -Value { param([string] $Source, [string] $Destination) $this.Record('CopyItem', @($Source, $Destination)) $destinationPath = $this.NormalizePath($Destination) $this.EnsureParent($destinationPath) [System.IO.File]::Copy($this.NormalizePath($Source), $destinationPath, $true) } # RENAMING IS HOW AN ARTIFACT IS PUBLISHED. A boot image build writes its # .wim and .iso beside their final names and moves both into place only once # both exist - same directory, so same volume, so this is a rename rather # than half a gigabyte of copying, and a build that dies half way leaves the # previous pair intact instead of a new .wim beside a stale .iso. $service | Add-Member -MemberType ScriptMethod -Name MoveItem -Value { param([string] $Source, [string] $Destination) $this.Record('MoveItem', @($Source, $Destination)) $this.EnsureParent($Destination) # -Force overwrites the destination, which is the whole point: the file # being replaced is the previous build's. Move-Item -LiteralPath $this.NormalizePath($Source) ` -Destination $this.NormalizePath($Destination) -Force -ErrorAction Stop } $service | Add-Member -MemberType ScriptMethod -Name GetChildItem -Value { param([string] $Path) $this.Record('GetChildItem', @($Path)) $child = [string[]] @([System.IO.Directory]::GetFileSystemEntries($this.NormalizePath($Path))) [array]::Sort($child, [System.StringComparer]::Ordinal) # The unary comma is mandatory: a ScriptMethod collapses a single-element # array to a scalar without it. return , ([string[]] $child) } # OWNERSHIP AND FULL CONTROL FOR ADMINISTRATORS, WHICH IS THE ONLY WAY TO # REPLACE A FILE INSIDE A MOUNTED IMAGE. \Windows\System32\winpe.jpg - the # WinPE background - is owned by TrustedInstaller and denies write even to # an elevated build: a straight copy over it fails with "Access to the path # is denied", which is exactly what a real build reported. Microsoft's own # instructions for changing that background are take ownership, grant # Administrators full control, then replace, and this is that pair. # # IT NEEDS ELEVATION, and every build that mounts an image already has it - # DISM will not mount without it. A caller that is not elevated gets the # framework's own exception, which says so. $service | Add-Member -MemberType ScriptMethod -Name TakeOwnership -Value { param([string] $Path) $this.Record('TakeOwnership', @($Path)) $full = $this.NormalizePath($Path) # A FILE THAT IS NOT THERE STAYS AN EXCEPTION, and it is stated here # rather than left to the tools: takeown reports a missing file on # stderr with an exit code, and "ERROR: The system cannot find the file" # is a worse answer than the type every other method on this service # throws for the same mistake. if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { throw [System.IO.FileNotFoundException]::new( "Could not find '$full' to take ownership of.", $full) } # takeown.exe AND icacls.exe, NOT .NET's SetOwner - AND ELEVATION IS NOT # WHAT DECIDES IT. Taking ownership of somebody else's file needs # SeTakeOwnershipPrivilege, and that privilege is present but DISABLED # in an elevated token until a process enables it. SetAccessControl # never does, so it failed with "Attempted to perform an unauthorized # operation" against \Windows\System32\winpe.jpg in a mounted image on a # fully elevated build - an error that reads exactly like "run as # administrator", which is what it had already been. takeown.exe enables # the privilege itself; that is the whole reason the tool exists. # # OWNERSHIP GOES TO THE CALLER, NOT TO Administrators. Assigning it to a # group is takeown /A, which needs SeRestorePrivilege as well - a second # privilege to be defeated by. Owning it is enough to grant the rest. # # 5.1 TRAP, NOT TIDINESS. Under Windows PowerShell 5.1 the 2>&1 below # wraps every stderr line in an ErrorRecord, and the ErrorActionPreference # Stop that engine code sets makes the FIRST one terminating - so a tool # that merely printed a warning kills the call before its exit code is # ever consulted (SPIKES S13.5). Local to this method scope. $ErrorActionPreference = 'Continue' $takeOutput = @(& "$env:SystemRoot\System32\takeown.exe" '/F' $full 2>&1) # Exit-code check, with the tool's own sentence attached. The only # branches in this method are this and the existence guard above. if ($LASTEXITCODE -ne 0) { throw [System.InvalidOperationException]::new( ("takeown.exe exited {0} for '{1}'{2}{3}" -f $LASTEXITCODE, $full, [System.Environment]::NewLine, (@($takeOutput) -join [System.Environment]::NewLine))) } # S-1-5-32-544 IS Administrators IN EVERY LANGUAGE. 'BUILTIN\Administrators' # is not: icacls resolves names against the local system's locale, and a # German build host has 'VORDEFINIERT\Administratoren'. $grantOutput = @(& "$env:SystemRoot\System32\icacls.exe" $full '/grant' '*S-1-5-32-544:(F)' 2>&1) if ($LASTEXITCODE -ne 0) { throw [System.InvalidOperationException]::new( ("icacls.exe exited {0} for '{1}'{2}{3}" -f $LASTEXITCODE, $full, [System.Environment]::NewLine, (@($grantOutput) -join [System.Environment]::NewLine))) } } # THE FOLDERS ONLY, because a caller frequently means folders. A driver # group is a FOLDER under Drivers\, and a flat list of paths cannot be # filtered back down to folders without guessing from the name - which fails # on 'Dell Latitude 7450 v2.1' the first time somebody names one after a # driver version. $service | Add-Member -MemberType ScriptMethod -Name GetDirectory -Value { param([string] $Path) $this.Record('GetDirectory', @($Path)) $child = [string[]] @([System.IO.Directory]::GetDirectories($this.NormalizePath($Path))) [array]::Sort($child, [System.StringComparer]::Ordinal) return , ([string[]] $child) } $service | Add-Member -MemberType ScriptMethod -Name GetLength -Value { param([string] $Path) $this.Record('GetLength', @($Path)) $full = $this.NormalizePath($Path) if (-not [System.IO.File]::Exists($full)) { throw (New-Object -TypeName System.IO.FileNotFoundException -ArgumentList "Could not find file '$full'.", $full) } return [long] (New-Object -TypeName System.IO.FileInfo -ArgumentList $full).Length } # THE TENTH METHOD, ADDED IN 05-04. DESIGN 6.1.1's claim - "the WIM inside # the ISO and the standalone WIM have identical hashes" - has to be written # into the boot image manifest so an operator can check it without the test # suite. Hashing a 500 MB ISO through ReadAllText would be wrong twice over # (it is not text, and it would be held in memory), so the interface grew a # method rather than Update-HDTBootImage growing a Get-FileHash call that no # fake could answer. # # The existence guard is the same one GetLength carries, for the same reason: # Get-FileHash reports a path error that does not plainly say "that file is # not there", and the fake throws FileNotFoundException. $service | Add-Member -MemberType ScriptMethod -Name GetHash -Value { param([string] $Path) $this.Record('GetHash', @($Path)) $full = $this.NormalizePath($Path) if (-not [System.IO.File]::Exists($full)) { throw (New-Object -TypeName System.IO.FileNotFoundException -ArgumentList "Could not find file '$full'.", $full) } return [string] (Get-FileHash -LiteralPath $full -Algorithm SHA256).Hash } $service | Add-Member -MemberType ScriptMethod -Name GetVersion -Value { param([string] $Path) $this.Record('GetVersion', @($Path)) $full = $this.NormalizePath($Path) if (-not [System.IO.File]::Exists($full)) { throw (New-Object -TypeName System.IO.FileNotFoundException -ArgumentList "Could not find file '$full'.", $full) } # THE FOUR PARTS, NOT THE FileVersion STRING. FileVersion is free text a # vendor fills in and regularly holds things like '4.2.0.0 (release)', # which no version comparison can parse. The four integer parts are the # numbers Windows itself compares, and a file with no version resource # reports 0.0.0.0 through them rather than $null - so the caller casts to # [version] with no special case. $info = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($full) return [string] ('{0}.{1}.{2}.{3}' -f $info.FileMajorPart, $info.FileMinorPart, $info.FileBuildPart, $info.FilePrivatePart) } return $service } |