Public/New-HDTLocalContentProvider.ps1
|
function New-HDTLocalContentProvider { <# .SYNOPSIS Creates the Local IContentProvider - content on the media the machine booted from. .DESCRIPTION The provider interface, over a path on this machine. It is what standalone media runs on, and it is what the lab runs on: A lab test records that a VM on the isolated 'HDT Lab' switch cannot reach a share on the host, so every end-to-end run in this repository reaches its content locally. FIVE MEMBERS, AND Connect / Disconnect ARE TWO OF THEM EVEN THOUGH THEY DO ALMOST NOTHING HERE: ResolveContent(relativePath) -> an absolute path a step can use TestContent(relativePath) -> [bool] CopyContent(relativePath, destination) -> the destination Connect() -> the root that is now reachable Disconnect() Media generation is "a content projection plus a provider swap, not a parallel code path". A provider where one implementation carries two extra methods is a provider a step has to branch on, and a step that branches on its transport is the parallel code path that sentence exists to prevent. So Connect is here, it records like everything else, and it does one useful thing: it verifies the root is there. A USB stick that was never inserted must fail at Connect, naming the root, rather than three steps later in the middle of an apply. THE RESOLUTION RULES, IDENTICAL IN EVERY IMPLEMENTATION: - a relative path is combined with Root; - a ROOTED path is returned unchanged - the media case too large to bring into the share, registered where it stands; - a '..' that escapes Root is refused; - an empty path is refused. Segments are collapsed here rather than by [IO.Path]::GetFullPath, which consults the current directory for a volume-relative root and silently clamps '..' at the root of a UNC share instead of reporting the escape. THE ERROR ID TRAVELS IN THE MESSAGE. A refusal raised inside a ScriptMethod reaches its caller as ScriptMethodRuntimeException and loses an ErrorRecord's FullyQualifiedErrorId (verified on pwsh 7.5.8 and Windows PowerShell 5.1.26100.8655), so 'HDTConfigurationError' is written into the sentence, where the fake's class method and this object's ScriptMethod can both carry it. ResolveContent DOES NOT CHECK EXISTENCE - TestContent is that question and a step asks it when it wants the answer. Everything that does touch the disk goes through the injected IFileSystem, which is what makes this whole file provable with no media attached. .PARAMETER Root The content root - the media root, or a workspace on this machine. .PARAMETER FileSystem An IFileSystem. Defaults to the real adapter. .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 five IContentProvider ScriptMethods, plus Root, ServiceName, Operations and GetOperationName(). Note that Get-Member -MemberType Method does NOT list a ScriptMethod - use -MemberType Method, ScriptMethod. .EXAMPLE $content = New-HDTLocalContentProvider -Root 'E:\HDTMedia' $content.Connect() $content.ResolveContent('OperatingSystems\Win11-LTSC-2024\sources\install.wim') .EXAMPLE $content = New-HDTLocalContentProvider -Root $mediaRoot -FileSystem $fs $catalog = New-HDTServiceCatalog -FileSystem $fs -Clock $clock -Content $content The provider as the catalog's twelfth service, which is how a step reaches it. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds a stateless service adapter object; it changes no state.')] [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Root, [Parameter()] [AllowNull()] [object] $FileSystem, [Parameter()] [AllowNull()] [System.Collections.ArrayList] $Journal ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($null -eq $FileSystem) { $FileSystem = New-HDTFileSystem } $service = [pscustomobject] @{ Root = $Root FileSystem = $FileSystem Operations = [System.Collections.ArrayList]::new() Journal = $Journal ServiceName = 'ContentProvider' IsConnected = $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 AssertUsablePath -Value { param([string] $Path) if ([string]::IsNullOrWhiteSpace($Path)) { throw (New-Object System.ArgumentException ( "HDTConfigurationError: a content path must not be empty. The provider was asked to resolve nothing against the content root '$($this.Root)'.")) } } $service | Add-Member -MemberType ScriptMethod -Name Combine -Value { param([string] $RelativePath) $segment = [System.Collections.ArrayList]::new() foreach ($part in ($RelativePath -split '[\\/]+')) { if (($part -eq '') -or ($part -eq '.')) { continue } if ($part -eq '..') { if ($segment.Count -eq 0) { throw (New-Object System.ArgumentException ( "HDTConfigurationError: the content path '$RelativePath' escapes the content root '$($this.Root)'. A step asking for content outside the workspace is a defect, not a path to follow.")) } $segment.RemoveAt($segment.Count - 1) continue } [void] $segment.Add($part) } if ($segment.Count -eq 0) { return $this.Root } return ($this.Root.TrimEnd('\', '/') + '\' + ($segment -join '\')) } $service | Add-Member -MemberType ScriptMethod -Name ResolveContent -Value { param([string] $RelativePath) $this.Record('ResolveContent', @($RelativePath)) $this.AssertUsablePath($RelativePath) if ([System.IO.Path]::IsPathRooted($RelativePath)) { return $RelativePath } return $this.Combine($RelativePath) } $service | Add-Member -MemberType ScriptMethod -Name TestContent -Value { param([string] $RelativePath) $this.Record('TestContent', @($RelativePath)) $this.AssertUsablePath($RelativePath) $path = $RelativePath if (-not [System.IO.Path]::IsPathRooted($RelativePath)) { $path = $this.Combine($RelativePath) } return [bool] $this.FileSystem.TestPath($path) } $service | Add-Member -MemberType ScriptMethod -Name CopyContent -Value { param([string] $RelativePath, [string] $Destination) $this.Record('CopyContent', @($RelativePath, $Destination)) $this.AssertUsablePath($RelativePath) if ([string]::IsNullOrWhiteSpace($Destination)) { throw (New-Object System.ArgumentException ( "HDTConfigurationError: CopyContent was given no destination for '$RelativePath'.")) } $source = $RelativePath if (-not [System.IO.Path]::IsPathRooted($RelativePath)) { $source = $this.Combine($RelativePath) } if (-not $this.FileSystem.TestPath($source)) { throw (New-Object System.IO.FileNotFoundException ("Could not find content '$source'.", $source)) } $parent = [System.IO.Path]::GetDirectoryName($Destination) if (-not [string]::IsNullOrWhiteSpace($parent)) { $this.FileSystem.CreateDirectory($parent) } $this.FileSystem.CopyItem($source, $Destination) return $Destination } $service | Add-Member -MemberType ScriptMethod -Name Connect -Value { $this.Record('Connect', @()) if (-not $this.FileSystem.TestPath($this.Root)) { throw (New-Object System.IO.DirectoryNotFoundException ( "HDTEnvironmentError: the content root '$($this.Root)' is not there. Local content comes from the media this machine booted from, so a stick that was never inserted fails here rather than in the middle of an apply.")) } $this.IsConnected = $true return $this.Root } $service | Add-Member -MemberType ScriptMethod -Name Disconnect -Value { # A no-op that records, because that is what makes the recorded ceremony # identical to the Smb provider's (DESIGN 6.2). It also never throws: # every caller runs it from a finally. $this.Record('Disconnect', @()) $this.IsConnected = $false } return $service } |