Public/New-HDTDiskService.ps1

function New-HDTDiskService {
    <#
        .SYNOPSIS
            Creates the real IDiskService adapter over the Storage module.
 
        .DESCRIPTION
            The one place in HDT that names Get-Disk, Clear-Disk,
            Initialize-Disk, New-Partition, Set-Partition, Format-Volume or
            Remove-PartitionAccessPath. PROJECT constraint 4 forbids a step from
            touching hardware directly, so DiskPartition and everything after it
            receives this object and can be swapped for New-HDTFakeDiskService
            in a test with no disk attached - which matters more here than
            anywhere else in HDT, because the developer machine is the disk most
            likely to be in front of this code.
 
            The Storage module is present in WinPE (proven by
            SPIKES.md S1's WinPE-StorageWMI image).
 
            NINE METHODS, THREE OF THEM READ-ONLY:
 
              GetDisk() -> object[] every disk on the machine
              GetPartition() -> object[] every partition on every disk
              GetVolume() -> object[] every volume with a drive letter
 
              ClearDisk(diskNumber)
              InitializeDisk(diskNumber, partitionStyle)
              NewPartition(diskNumber, sizeByte, useMaximumSize, gptType, isActive)
              SetPartitionDriveLetter(diskNumber, partitionNumber, driveLetter)
              SetPartitionType(diskNumber, partitionNumber, gptType)
              FormatVolume(driveLetter, fileSystem, label)
 
            THE THREE LISTINGS ARE FLAT - NO FILTERS AND NO JOINS - the same
            decision ICimProvider made when it refused a -Filter. A partition row
            carries its DiskNumber and a volume row carries its DriveLetter, so
            the pure logic that selects a disk does the joining and this file
            stays a projection of three cmdlets. An adapter that filtered would
            be an adapter with a branch in it.
 
            THIS FILE MUST NEVER CREATE A MICROSOFT RESERVED PARTITION.
            Initialize-Disk -PartitionStyle GPT silently creates a 16 MB MSR of
            its own. PSD's Scripts/PSDPartition.ps1 initialises GPT on line 97
            and then creates an MSR by hand on line 116, which is exactly the
            duplicate 16 MB partition SPIKES.md S6 recorded. The working recipe
            the spike proved is Clear-Disk -RemoveData -RemoveOEM, then
            Initialize-Disk, and then ESP / Windows / Recovery - with no MSR
            created by us. Mine PSD for its GUIDs, not for its sequence.
 
            THIS IS AN UNTESTED ADAPTER, and deliberately so:
            there is no way to unit test the destructive half that does not
            write to a physical disk. Its contract row is opt-in - elevated AND
            $env:HDT_ALLOW_DISK_TEST -eq '1' - and even then read-only; the
            destructive half is proven in tests/integration against a mounted
            scratch VHDX. The price of not testing it is that it must stay dumb.
            THE ONLY BRANCHES BELOW ARE EXISTENCE GUARDS AND ARGUMENT
            CONSTRUCTION, each commented as such. Every decision about WHICH
            disk to wipe lives in the DiskPartition step, which is tested against
            the fake. Do not add logic here.
 
            The existence guards are not decoration. They give the two
            implementations of IDiskService the same failure for the same
            mistake - ArgumentOutOfRangeException for a disk or partition number
            that is not there - and they mean a call naming a disk that does not
            exist never reaches Clear-Disk at all.
 
            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).
 
        .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
            IDiskService ScriptMethods. Note that Get-Member -MemberType Method
            does NOT list a ScriptMethod - use -MemberType Method, ScriptMethod.
 
        .EXAMPLE
            $disk = New-HDTDiskService
            @($disk.GetDisk() | Where-Object { -not $_.IsBoot -and -not $_.IsSystem })
 
            Every disk that is not the one this machine booted from. HDT
            requires exactly one candidate before anything is wiped.
 
        .EXAMPLE
            $disk = New-HDTDiskService
            $disk.GetPartition() | Format-Table DiskNumber, PartitionNumber, Type, GptType
 
            The partition table, flat, with each row naming its own disk.
 
        .NOTES
            The GPT partition type GUIDs this service is called with come from
            PSD's Scripts/PSDPartition.ps1 (MIT, see NOTICE.md), cross-checked
            against this machine's own captured tests/fixtures/disk/host-partition.json:
 
              {c12a7328-f81f-11d2-ba4b-00a0c93ec93b} EFI System
              {e3c9e316-0b5c-4db8-817d-f92df00215ae} Microsoft Reserved
              {ebd0a0a2-b9e5-4433-87c0-68b6b72699c7} Basic data
              {de94bba4-06d1-4d40-a16a-bfd50179d6ac} Windows Recovery
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds a stateless service adapter object; it changes no state. The destructive methods it exposes are called by DiskPartition, which carries SupportsShouldProcess.')]
    [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 = 'DiskService'
    }

    $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 }))
    }

    # Get-Partition and Get-Volume report an absent drive letter as [char] 0,
    # which is not an empty string and not whitespace. This is the null guard
    # that turns it into one.
    $service | Add-Member -MemberType ScriptMethod -Name ToLetter -Value {
        param([object] $Value)

        if ($Value) {
            return [string] $Value
        }

        return ''
    }

    $service | Add-Member -MemberType ScriptMethod -Name ToDiskRow -Value {
        param([object] $Disk)

        return [pscustomobject] @{
            Number            = [int] $Disk.Number
            FriendlyName      = [string] $Disk.FriendlyName
            SerialNumber      = [string] $Disk.SerialNumber
            SizeBytes         = [long] $Disk.Size
            BusType           = [string] $Disk.BusType
            PartitionStyle    = [string] $Disk.PartitionStyle
            IsBoot            = [bool] $Disk.IsBoot
            IsSystem          = [bool] $Disk.IsSystem
            IsReadOnly        = [bool] $Disk.IsReadOnly
            IsOffline         = [bool] $Disk.IsOffline
            OperationalStatus = [string] $Disk.OperationalStatus
        }
    }

    $service | Add-Member -MemberType ScriptMethod -Name ToPartitionRow -Value {
        param([object] $Partition)

        return [pscustomobject] @{
            DiskNumber      = [int] $Partition.DiskNumber
            PartitionNumber = [int] $Partition.PartitionNumber
            DriveLetter     = [string] $this.ToLetter($Partition.DriveLetter)
            SizeBytes       = [long] $Partition.Size
            OffsetBytes     = [long] $Partition.Offset
            Type            = [string] $Partition.Type
            GptType         = [string] $Partition.GptType
            IsActive        = [bool] $Partition.IsActive
            IsHidden        = [bool] $Partition.IsHidden
            IsBoot          = [bool] $Partition.IsBoot
            IsSystem        = [bool] $Partition.IsSystem
        }
    }

    $service | Add-Member -MemberType ScriptMethod -Name ToVolumeRow -Value {
        param([object] $Volume)

        return [pscustomobject] @{
            DriveLetter        = [string] $this.ToLetter($Volume.DriveLetter)
            FileSystem         = [string] $Volume.FileSystem
            FileSystemLabel    = [string] $Volume.FileSystemLabel
            SizeBytes          = [long] $Volume.Size
            SizeRemainingBytes = [long] $Volume.SizeRemaining
        }
    }

    # Existence guard, not logic. It gives this adapter and the fake the same
    # failure for the same mistake, and it means a call naming a disk that is
    # not there never reaches Clear-Disk.
    $service | Add-Member -MemberType ScriptMethod -Name AssertDisk -Value {
        param([int] $DiskNumber)

        # Filtered client-side rather than with -Number, which is a [uint32]:
        # Get-Disk -Number -1 throws System.OverflowException before it queries
        # anything, and the contract requires the same ArgumentOutOfRangeException
        # from both implementations for the same mistake.
        $found = @(Get-Disk | Where-Object { $_.Number -eq $DiskNumber })
        if ($found.Count -eq 0) {
            throw [System.ArgumentOutOfRangeException]::new(
                'DiskNumber', $DiskNumber, "No disk numbered $DiskNumber exists on this machine.")
        }
    }

    $service | Add-Member -MemberType ScriptMethod -Name AssertPartition -Value {
        param([int] $DiskNumber, [int] $PartitionNumber)

        # Filtered client-side for the same reason AssertDisk is: -DiskNumber and
        # -PartitionNumber are both [uint32].
        $found = @(Get-Partition | Where-Object { $_.DiskNumber -eq $DiskNumber -and $_.PartitionNumber -eq $PartitionNumber })
        if ($found.Count -eq 0) {
            throw [System.ArgumentOutOfRangeException]::new(
                'PartitionNumber', $PartitionNumber,
                "No partition $PartitionNumber on disk $DiskNumber exists on this machine.")
        }

        return $found[0]
    }

    # -- the read-only three -----------------------------------------------
    #
    # They record too: query order is evidence about what the code under test
    # tried, and DESIGN 9.1's refusal to guess is judged on what it looked at.
    # The unary comma is mandatory - without it a ScriptMethod collapses a
    # one-element array to a scalar, and a single-disk machine is the common
    # case (tests/helpers/README.md F3).

    $service | Add-Member -MemberType ScriptMethod -Name GetDisk -Value {
        $this.Record('GetDisk', @())

        return , ([object[]] @(Get-Disk | ForEach-Object { $this.ToDiskRow($_) }))
    }

    $service | Add-Member -MemberType ScriptMethod -Name GetPartition -Value {
        $this.Record('GetPartition', @())

        return , ([object[]] @(Get-Partition | ForEach-Object { $this.ToPartitionRow($_) }))
    }

    $service | Add-Member -MemberType ScriptMethod -Name GetVolume -Value {
        $this.Record('GetVolume', @())

        # A volume with no access path is not one IDiskService reports, and
        # Get-Volume returns [char] 0 for it. This is the interface's own
        # definition of the listing rather than a decision taken here.
        return , ([object[]] @(Get-Volume | Where-Object { $_.DriveLetter } | ForEach-Object { $this.ToVolumeRow($_) }))
    }

    # -- the six that change something --------------------------------------

    $service | Add-Member -MemberType ScriptMethod -Name ClearDisk -Value {
        param([int] $DiskNumber)

        $this.Record('ClearDisk', @($DiskNumber))
        $this.AssertDisk($DiskNumber)

        # SPIKES.md S6: -RemoveData -RemoveOEM is the form that leaves a disk
        # Initialize-Disk will accept.
        Clear-Disk -Number $DiskNumber -RemoveData -RemoveOEM -Confirm:$false -ErrorAction Stop
    }

    $service | Add-Member -MemberType ScriptMethod -Name InitializeDisk -Value {
        param([int] $DiskNumber, [string] $PartitionStyle)

        $this.Record('InitializeDisk', @($DiskNumber, $PartitionStyle))
        $this.AssertDisk($DiskNumber)

        # This creates a 16 MB MSR of its own. Do not create a second one.
        Initialize-Disk -Number $DiskNumber -PartitionStyle $PartitionStyle -ErrorAction Stop
    }

    $service | Add-Member -MemberType ScriptMethod -Name NewPartition -Value {
        param([int] $DiskNumber, [long] $SizeByte, [bool] $UseMaximumSize, [string] $GptType, [bool] $IsActive)

        $this.Record('NewPartition', @($DiskNumber, $SizeByte, $UseMaximumSize, $GptType, $IsActive))
        $this.AssertDisk($DiskNumber)

        # Argument construction, not logic: -Size and -UseMaximumSize are
        # mutually exclusive on New-Partition, an empty GptType means "do not
        # pass -GptType", and -IsActive is a switch that must be absent rather
        # than false on a GPT disk, which rejects it outright.
        # ErrorAction travels in the splat because there is nowhere else to put
        # it: this is the one destructive call here that is splatted. Same rule
        # as its six siblings - a disk operation must not depend on the caller's
        # preference to report that it failed (05-06).
        $argument = @{ DiskNumber = $DiskNumber; ErrorAction = 'Stop' }
        if ($UseMaximumSize) {
            $argument['UseMaximumSize'] = $true
        } else {
            $argument['Size'] = $SizeByte
        }
        if (-not [string]::IsNullOrEmpty($GptType)) {
            $argument['GptType'] = $GptType
        }
        if ($IsActive) {
            $argument['IsActive'] = $true
        }

        return $this.ToPartitionRow((New-Partition @argument))
    }

    $service | Add-Member -MemberType ScriptMethod -Name SetPartitionDriveLetter -Value {
        param([int] $DiskNumber, [int] $PartitionNumber, [string] $DriveLetter)

        $this.Record('SetPartitionDriveLetter', @($DiskNumber, $PartitionNumber, $DriveLetter))
        $existing = $this.AssertPartition($DiskNumber, $PartitionNumber)

        # Argument construction, not logic: an empty letter means "remove the
        # access path", which Set-Partition cannot express - there is no
        # -NewDriveLetter value that clears one.
        if ([string]::IsNullOrEmpty($DriveLetter)) {
            Remove-PartitionAccessPath -DiskNumber $DiskNumber -PartitionNumber $PartitionNumber `
                -AccessPath ('{0}:\' -f $this.ToLetter($existing.DriveLetter)) -ErrorAction Stop
            return
        }

        Set-Partition -DiskNumber $DiskNumber -PartitionNumber $PartitionNumber -NewDriveLetter $DriveLetter -ErrorAction Stop
    }

    $service | Add-Member -MemberType ScriptMethod -Name SetPartitionType -Value {
        param([int] $DiskNumber, [int] $PartitionNumber, [string] $GptType)

        $this.Record('SetPartitionType', @($DiskNumber, $PartitionNumber, $GptType))
        [void] $this.AssertPartition($DiskNumber, $PartitionNumber)

        Set-Partition -DiskNumber $DiskNumber -PartitionNumber $PartitionNumber -GptType $GptType -ErrorAction Stop
    }

    $service | Add-Member -MemberType ScriptMethod -Name FormatVolume -Value {
        param([string] $DriveLetter, [string] $FileSystem, [string] $Label)

        $this.Record('FormatVolume', @($DriveLetter, $FileSystem, $Label))

        Format-Volume -DriveLetter $DriveLetter -FileSystem $FileSystem `
            -NewFileSystemLabel $Label -Force -Confirm:$false -ErrorAction Stop | Out-Null
    }

    return $service
}