diff --git a/README.md b/README.md index 9b058eb..7b2456d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ The module exports: | --- | --- | | `ConvertFrom-Yaml` | Parse one or more YAML documents into PowerShell values. | | `ConvertTo-Yaml` | Serialize supported PowerShell values as YAML 1.2-compatible text. | +| `Export-Yaml` | Serialize values and atomically write one YAML file. | +| `Import-Yaml` | Strictly decode and parse YAML files. | | `Test-Yaml` | Test YAML syntax, tags, duplicate keys, and configured resource limits. | ## Parse YAML @@ -84,6 +86,24 @@ $mapping = @' '@ | ConvertFrom-Yaml -AsHashtable ``` +## Import YAML files + +`Import-Yaml` reads complete files with strict Unicode decoding and delegates +parsing to `ConvertFrom-Yaml`. `-Path` expands wildcards and accepts `FileInfo` +pipeline input; `-LiteralPath` preserves wildcard characters in filenames. +Resolved files are deduplicated and read in deterministic path order. + +```powershell +$configs = Import-Yaml -Path '.\config\*.yaml' -AsHashtable +Get-ChildItem -Path '.\services' -Filter '*.yaml' | Import-Yaml +Import-Yaml -LiteralPath '.\config[production].yaml' +``` + +UTF-8 without a byte order mark is the default. UTF-8, UTF-16, and UTF-32 byte +order marks are detected automatically and override `-Encoding`. Malformed +bytes terminate with a path-specific error. `-NoEnumerate` and all parser +resource limits have the same behavior as `ConvertFrom-Yaml`. + ## Serialize PowerShell values `ConvertTo-Yaml` supports `PSCustomObject` and explicit PSObject note-property @@ -123,6 +143,25 @@ Repeated acyclic collection references are emitted with anchors and aliases. Cyclic graphs and unsupported runtime objects fail specifically; values are never silently truncated or converted with `ToString()`. +## Export YAML files + +`Export-Yaml` aggregates pipeline records like `ConvertTo-Yaml`, serializes the +complete value before changing the filesystem, and atomically publishes a +same-directory temporary file. It writes UTF-8 without a byte order mark, LF +line endings, and exactly one final newline by default. + +```powershell +$config | Export-Yaml -Path '.\config.yaml' +'one', 'two' | Export-Yaml -Path '.\items.yaml' -Encoding utf16LE +$config | Export-Yaml -Path '.\generated\config.yaml' -CreateDirectory -PassThru +``` + +Use `-NewLine CRLF` or `-NoFinalNewline` to change presentation. `-NoClobber` +prevents replacement, while `-Force` permits replacing a read-only destination +and preserves its read-only state. The switches are mutually exclusive. +`-WhatIf` creates no directory or temporary file. `-PassThru` is the only mode +that emits the final `FileInfo`. + ## Validate YAML ```powershell diff --git a/examples/General.ps1 b/examples/General.ps1 index 2c428b4..2931f45 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -40,6 +40,13 @@ $outputYaml = [ordered]@{ $outputYaml $outputYaml | ConvertFrom-Yaml +# Atomically export one file, then import it with strict decoding. +$configPath = Join-Path $env:TEMP 'yaml-example.yaml' +$config | Export-Yaml -Path $configPath -PassThru +$importedConfig = Import-Yaml -LiteralPath $configPath +$importedConfig +Remove-Item -LiteralPath $configPath + # Test syntax, duplicate keys, tags, and resource limits without conversion. $isValid = $outputYaml | Test-Yaml $isValid diff --git a/src/functions/private/Get-YamlTextEncoding.ps1 b/src/functions/private/Get-YamlTextEncoding.ps1 new file mode 100644 index 0000000..2792706 --- /dev/null +++ b/src/functions/private/Get-YamlTextEncoding.ps1 @@ -0,0 +1,57 @@ +function Get-YamlTextEncoding { + <# + .SYNOPSIS + Creates a strict text encoding for YAML file input or output. + + .DESCRIPTION + Returns an encoding that throws when bytes or characters cannot be + represented. The encoding preamble matches the public encoding name. + + .PARAMETER Name + The public YAML file encoding name. + + .EXAMPLE + Get-YamlTextEncoding -Name utf8 + + Returns strict UTF-8 without a byte order mark. + + .INPUTS + None. + + .OUTPUTS + System.Text.Encoding + #> + [OutputType( + [System.Text.UTF8Encoding], + [System.Text.UnicodeEncoding], + [System.Text.UTF32Encoding] + )] + [CmdletBinding()] + param ( + # Selects the strict decoder and its output preamble policy. + [Parameter(Mandatory)] + [ValidateSet('utf8', 'utf8BOM', 'utf16LE', 'utf16BE', 'utf32LE', 'utf32BE')] + [string] $Name + ) + + switch ($Name) { + 'utf8' { + [System.Text.UTF8Encoding]::new($false, $true) + } + 'utf8BOM' { + [System.Text.UTF8Encoding]::new($true, $true) + } + 'utf16LE' { + [System.Text.UnicodeEncoding]::new($false, $true, $true) + } + 'utf16BE' { + [System.Text.UnicodeEncoding]::new($true, $true, $true) + } + 'utf32LE' { + [System.Text.UTF32Encoding]::new($false, $true, $true) + } + 'utf32BE' { + [System.Text.UTF32Encoding]::new($true, $true, $true) + } + } +} diff --git a/src/functions/public/Export-Yaml.ps1 b/src/functions/public/Export-Yaml.ps1 new file mode 100644 index 0000000..abb5f6a --- /dev/null +++ b/src/functions/public/Export-Yaml.ps1 @@ -0,0 +1,1169 @@ +function Export-Yaml { + <# + .SYNOPSIS + Exports PowerShell values to a YAML file. + + .DESCRIPTION + Collects pipeline records with the same aggregation semantics as + ConvertTo-Yaml, serializes the complete value before changing the + filesystem, and writes strict encoded bytes through a cryptographically + random same-directory temporary file. + + The completed temporary file is flushed and closed before it atomically + replaces or moves to the destination. Existing files are never + truncated before serialization and writing succeed. + + .PARAMETER InputObject + A value to serialize. Multiple pipeline records become one YAML + sequence. A directly supplied array is one input value. + + .PARAMETER Path + One literal FileSystem destination path. Wildcard characters are not + expanded. + + .PARAMETER Depth + Maximum object-graph nesting depth. The default is 100. + + .PARAMETER MaxNodes + Maximum number of traversed nodes. The default is 100000. + + .PARAMETER MaxScalarLength + Maximum character count for one emitted scalar. The default is + 1048576. + + .PARAMETER Indent + Block indentation from 2 through 9 spaces. The default is 2. + + .PARAMETER ExplicitDocumentStart + Emits an explicit `---` document start marker. + + .PARAMETER EnumsAsStrings + Emits enum names as strings instead of underlying numeric values. + + .PARAMETER Encoding + Strict output encoding. utf8 omits a byte order mark; utf8BOM and the + UTF-16 and UTF-32 encodings include their corresponding byte order mark. + + .PARAMETER NewLine + Line ending used for emitted YAML structure. The default is LF. + + .PARAMETER NoFinalNewline + Omits the final line ending. By default, exactly one is written. + + .PARAMETER NoClobber + Fails when the destination already exists. + + .PARAMETER Force + Permits atomic replacement of a read-only destination and preserves its + read-only attribute. Force cannot be combined with NoClobber. + + .PARAMETER CreateDirectory + Creates missing parent directories after ShouldProcess approval. + + .PARAMETER PassThru + Writes the final FileInfo after a successful export. + + .EXAMPLE + $config | Export-Yaml -Path '.\config.yaml' + + Serializes one pipeline value to config.yaml with UTF-8 and LF. + + .EXAMPLE + 'one', 'two' | Export-Yaml -Path '.\items.yaml' -Encoding utf16LE + + Collects two pipeline records into one sequence and writes UTF-16LE. + + .EXAMPLE + Export-Yaml -InputObject $config -Path '.\new\config.yaml' -CreateDirectory -PassThru + + Creates the parent directory and returns the final FileInfo. + + .INPUTS + System.Object + + .OUTPUTS + System.IO.FileInfo + + .NOTES + Only FileSystem provider destinations are supported. + + .LINK + https://psmodule.io/Yaml/Functions/ConvertTo-Yaml/ + #> + [OutputType([System.IO.FileInfo])] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + param ( + # Collects one value per pipeline record. + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowNull()] + [object] $InputObject, + + # Selects one literal FileSystem destination. + [Parameter(Mandatory, Position = 1)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + # Limits object-graph nesting depth. + [Parameter()] + [ValidateRange(1, 128)] + [int] $Depth = 100, + + # Limits traversed serialization nodes. + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + # Limits emitted characters in one scalar. + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + # Selects block indentation width. + [Parameter()] + [ValidateRange(2, 9)] + [int] $Indent = 2, + + # Emits an explicit YAML document start marker. + [Parameter()] + [switch] $ExplicitDocumentStart, + + # Emits enum names instead of numeric values. + [Parameter()] + [switch] $EnumsAsStrings, + + # Selects strict output encoding and byte order mark policy. + [Parameter()] + [ValidateSet('utf8', 'utf8BOM', 'utf16LE', 'utf16BE', 'utf32LE', 'utf32BE')] + [string] $Encoding = 'utf8', + + # Selects structural line endings. + [Parameter()] + [ValidateSet('LF', 'CRLF')] + [string] $NewLine = 'LF', + + # Omits the one final structural line ending. + [Parameter()] + [switch] $NoFinalNewline, + + # Prevents replacement of an existing destination. + [Parameter()] + [switch] $NoClobber, + + # Permits replacement of a read-only destination. + [Parameter()] + [switch] $Force, + + # Creates missing parent directories after approval. + [Parameter()] + [switch] $CreateDirectory, + + # Emits the final FileInfo after success. + [Parameter()] + [switch] $PassThru + ) + + begin { + $values = [System.Collections.Generic.List[object]]::new() + $inspectionState = [pscustomobject]@{ + MaxScalarLength = $MaxScalarLength + MaxNodes = $MaxNodes + NodeCount = 0 + } + } + process { + try { + $null = Get-YamlSerializationShape -Value $InputObject -State $inspectionState ` + -EnumsAsStrings:$EnumsAsStrings -InspectOnly + if ($values.Count -gt 0 -and ($values.Count + 2) -gt $MaxNodes) { + throw (New-YamlSerializationException -ErrorId 'YamlNodeLimitExceeded' -Message ( + "The object graph exceeds the configured limit of $MaxNodes nodes." + )) + } + } catch [System.NotSupportedException] { + $serializationError = $_ + $exception = [System.InvalidOperationException]::new( + "Cannot serialize YAML for '$Path': $($serializationError.Exception.Message)", + $serializationError.Exception + ) + $errorId = if ($serializationError.Exception.Data.Contains('YamlErrorId')) { + [string] $serializationError.Exception.Data['YamlErrorId'] + } else { + 'YamlUnsupportedType' + } + $record = New-YamlErrorRecord -Exception $exception -DefaultErrorId $errorId ` + -Category InvalidType -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.InvalidOperationException] { + $serializationError = $_ + $exception = [System.InvalidOperationException]::new( + "Cannot serialize YAML for '$Path': $($serializationError.Exception.Message)", + $serializationError.Exception + ) + $errorId = if ($serializationError.Exception.Data.Contains('YamlErrorId')) { + [string] $serializationError.Exception.Data['YamlErrorId'] + } else { + 'YamlExportSerializationFailed' + } + $record = New-YamlErrorRecord -Exception $exception -DefaultErrorId $errorId ` + -Category InvalidOperation -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } + $values.Add($InputObject) + } + end { + if ($values.Count -eq 0) { + return + } + if ($NoClobber -and $Force) { + $exception = [System.ArgumentException]::new( + "Cannot export YAML to '$Path': Force and NoClobber cannot be combined." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportConflictingFileOptions' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } + + try { + $provider = $null + $drive = $null + $providerPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + $Path, + [ref] $provider, + [ref] $drive + ) + $destinationPath = [System.IO.Path]::GetFullPath($providerPath) + } catch [System.Management.Automation.ProviderNotFoundException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot export YAML to '$Path': the provider is not available.", + $pathError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportProviderNotSupported' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.Management.Automation.DriveNotFoundException] { + $pathError = $_ + $exception = [System.IO.DirectoryNotFoundException]::new( + "Cannot export YAML to '$Path': the drive was not found.", + $pathError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDirectoryNotFound' -Category ObjectNotFound ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.ArgumentException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot resolve YAML output path '$Path': $($pathError.Exception.Message)", + $pathError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInvalid' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.NotSupportedException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot resolve YAML output path '$Path': $($pathError.Exception.Message)", + $pathError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInvalid' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.PathTooLongException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot resolve YAML output path '$Path': $($pathError.Exception.Message)", + $pathError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInvalid' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($provider.Name -ne 'FileSystem') { + $exception = [System.NotSupportedException]::new( + "Cannot export YAML to '$Path': only the FileSystem provider is supported." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportProviderNotSupported' -Category InvalidArgument ` + -TargetObject $Path + $PSCmdlet.ThrowTerminatingError($record) + } + $destinationExists = $false + $destinationAttributes = [System.IO.FileAttributes]::Normal + try { + $destinationAttributes = [System.IO.File]::GetAttributes($destinationPath) + $destinationExists = $true + } catch [System.IO.FileNotFoundException] { + $destinationExists = $false + } catch [System.IO.DirectoryNotFoundException] { + $destinationExists = $false + } catch [System.UnauthorizedAccessException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML destination '$destinationPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML destination '$destinationPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInspectionFailed' -Category ReadError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + if ($destinationExists -and + ($destinationAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $exception = [System.IO.IOException]::new( + "Cannot export YAML to '$destinationPath': the destination is a directory." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportNotFile' -Category InvalidType ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + $directoryPath = [System.IO.Path]::GetDirectoryName($destinationPath) + $directoryExists = $false + $directoryAttributes = [System.IO.FileAttributes]::Normal + try { + $directoryAttributes = [System.IO.File]::GetAttributes($directoryPath) + $directoryExists = $true + } catch [System.IO.FileNotFoundException] { + $directoryExists = $false + } catch [System.IO.DirectoryNotFoundException] { + $directoryExists = $false + } catch [System.UnauthorizedAccessException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML output directory '$directoryPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML output directory '$directoryPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInspectionFailed' -Category ReadError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + if ($directoryExists -and + ($directoryAttributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + $exception = [System.IO.IOException]::new( + "Cannot export YAML to '$destinationPath': the parent path is not a directory." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDirectoryInvalid' -Category InvalidType ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + if (-not $directoryExists -and -not $CreateDirectory) { + $exception = [System.IO.DirectoryNotFoundException]::new( + "Cannot export YAML to '$destinationPath': the parent directory does not exist." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDirectoryNotFound' -Category ObjectNotFound ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($destinationExists -and $NoClobber) { + $exception = [System.IO.IOException]::new( + "Cannot export YAML to '$destinationPath': the destination already exists." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDestinationExists' -Category ResourceExists ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + $destinationWasReadOnly = $false + if ($destinationExists) { + $destinationWasReadOnly = ( + $destinationAttributes -band [System.IO.FileAttributes]::ReadOnly + ) -ne 0 + if ($destinationWasReadOnly -and -not $Force) { + $exception = [System.UnauthorizedAccessException]::new( + "Cannot export YAML to '$destinationPath': the destination is read-only. Use Force to replace it." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDestinationReadOnly' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + + if ($values.Count -eq 1) { + $value = [object] $values[0] + } else { + $value = [object[]] $values.ToArray() + } + $serializerParameters = @{ + InputObject = $value + Depth = $Depth + MaxNodes = $MaxNodes + MaxScalarLength = $MaxScalarLength + Indent = $Indent + ExplicitDocumentStart = $ExplicitDocumentStart + EnumsAsStrings = $EnumsAsStrings + } + try { + $yamlText = ConvertTo-Yaml @serializerParameters + } catch { + $serializationError = $_ + $errorId = ($serializationError.FullyQualifiedErrorId -split ',')[0] + if ([string]::IsNullOrWhiteSpace($errorId) -or + $errorId -notmatch '^[A-Za-z][A-Za-z0-9_.-]*$') { + $errorId = 'YamlExportSerializationFailed' + } + $exception = [System.InvalidOperationException]::new( + "Cannot serialize YAML for '$destinationPath': $($serializationError.Exception.Message)", + $serializationError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception -DefaultErrorId $errorId ` + -Category $serializationError.CategoryInfo.Category -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($yamlText.Contains("`r") -or -not $yamlText.EndsWith("`n")) { + $exception = [System.InvalidOperationException]::new( + "Cannot export YAML to '$destinationPath': the serializer did not return LF-normalized text with one final line feed." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPresentationFailed' -Category InvalidData ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + $lineEnding = if ($NewLine -eq 'CRLF') { + "`r`n" + } else { + "`n" + } + $presentationText = $yamlText.Substring(0, $yamlText.Length - 1).Replace( + "`n", + $lineEnding + ) + if (-not $NoFinalNewline) { + $presentationText += $lineEnding + } + + $textEncoding = Get-YamlTextEncoding -Name $Encoding + try { + $contentBytes = $textEncoding.GetBytes($presentationText) + } catch [System.Text.EncoderFallbackException] { + $encodingError = $_ + $exception = [System.Text.EncoderFallbackException]::new( + "Cannot encode YAML for '$destinationPath': the text contains an invalid character sequence.", + $encodingError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportEncodingFailed' -Category InvalidData ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + $preamble = $textEncoding.GetPreamble() + $outputBytes = [byte[]]::new($preamble.Length + $contentBytes.Length) + [System.Buffer]::BlockCopy($preamble, 0, $outputBytes, 0, $preamble.Length) + [System.Buffer]::BlockCopy( + $contentBytes, + 0, + $outputBytes, + $preamble.Length, + $contentBytes.Length + ) + + $approvedDestinationExists = $destinationExists + $action = if ($destinationExists) { + 'Replace YAML file' + } else { + 'Create YAML file' + } + if (-not $PSCmdlet.ShouldProcess($destinationPath, $action)) { + return + } + + if (-not $directoryExists) { + try { + [void] [System.IO.Directory]::CreateDirectory($directoryPath) + } catch [System.UnauthorizedAccessException] { + $directoryError = $_ + $exception = [System.IO.IOException]::new( + "Cannot create YAML output directory '$directoryPath': $($directoryError.Exception.Message)", + $directoryError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDirectoryCreateFailed' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $directoryError = $_ + $exception = [System.IO.IOException]::new( + "Cannot create YAML output directory '$directoryPath': $($directoryError.Exception.Message)", + $directoryError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDirectoryCreateFailed' -Category WriteError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + + $destinationExists = $false + $destinationAttributes = [System.IO.FileAttributes]::Normal + try { + $destinationAttributes = [System.IO.File]::GetAttributes($destinationPath) + $destinationExists = $true + } catch [System.IO.FileNotFoundException] { + $destinationExists = $false + } catch [System.IO.DirectoryNotFoundException] { + $destinationExists = $false + } catch [System.UnauthorizedAccessException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML destination '$destinationPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML destination '$destinationPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPathInspectionFailed' -Category ReadError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($destinationExists -and + ($destinationAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $exception = [System.IO.IOException]::new( + "Cannot export YAML to '$destinationPath': the destination is a directory." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportNotFile' -Category InvalidType ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + if (-not $approvedDestinationExists -and $destinationExists -and + -not $PSCmdlet.ShouldProcess($destinationPath, 'Replace YAML file')) { + return + } + if ($destinationExists -and $NoClobber) { + $exception = [System.IO.IOException]::new( + "Cannot export YAML to '$destinationPath': the destination already exists." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDestinationExists' -Category ResourceExists ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + $destinationWasReadOnly = $false + $destinationUnixMode = $null + if ($destinationExists) { + try { + if (-not $IsWindows) { + $destinationUnixMode = [System.IO.File]::GetUnixFileMode($destinationPath) + } + } catch [System.Security.SecurityException] { + $metadataError = $_ + $exception = [System.IO.IOException]::new( + "Cannot read security metadata for YAML destination '$destinationPath': $($metadataError.Exception.Message)", + $metadataError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.UnauthorizedAccessException] { + $metadataError = $_ + $exception = [System.IO.IOException]::new( + "Cannot read security metadata for YAML destination '$destinationPath': $($metadataError.Exception.Message)", + $metadataError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $metadataError = $_ + $exception = [System.IO.IOException]::new( + "Cannot read security metadata for YAML destination '$destinationPath': $($metadataError.Exception.Message)", + $metadataError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportMetadataReadFailed' -Category ReadError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + $destinationWasReadOnly = ( + $destinationAttributes -band [System.IO.FileAttributes]::ReadOnly + ) -ne 0 + if ($destinationWasReadOnly -and -not $Force) { + $exception = [System.UnauthorizedAccessException]::new( + "Cannot export YAML to '$destinationPath': the destination is read-only. Use Force to replace it." + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportDestinationReadOnly' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + + $temporaryPath = $null + $backupPath = $null + $backupFileHandle = $null + $replacementAttempted = $false + $replacementCompleted = $false + $replacementOriginalAtBackup = $false + $nativeReplacementCompleted = $false + $attributesCleared = $false + $backupCleanupError = $null + $temporaryCleanupError = $null + try { + $randomName = [System.Convert]::ToHexString( + [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(16) + ).ToLowerInvariant() + $temporaryPath = [System.IO.Path]::Combine( + $directoryPath, + ".yaml-$randomName.tmp" + ) + + try { + $streamOptions = [System.IO.FileStreamOptions]::new() + $streamOptions.Mode = [System.IO.FileMode]::CreateNew + $streamOptions.Access = [System.IO.FileAccess]::Write + $streamOptions.Share = [System.IO.FileShare]::None + $streamOptions.BufferSize = 4096 + $streamOptions.Options = [System.IO.FileOptions]::WriteThrough + if (-not $IsWindows) { + $streamOptions.UnixCreateMode = ( + [System.IO.UnixFileMode]::UserRead -bor + [System.IO.UnixFileMode]::UserWrite + ) + } + + $stream = [System.IO.FileStream]::new($temporaryPath, $streamOptions) + try { + $stream.Write($outputBytes, 0, $outputBytes.Length) + $stream.Flush($true) + if ($destinationExists -and -not $IsWindows) { + [System.IO.File]::SetUnixFileMode( + $stream.SafeFileHandle, + $destinationUnixMode + ) + $stream.Flush($true) + } + } finally { + if ($null -ne $stream) { + $stream.Dispose() + } + } + } catch [System.Security.SecurityException] { + $writeError = $_ + $exception = [System.IO.IOException]::new( + "Cannot secure YAML temporary file for '$destinationPath': $($writeError.Exception.Message)", + $writeError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.UnauthorizedAccessException] { + $writeError = $_ + $exception = [System.IO.IOException]::new( + "Cannot write YAML temporary file for '$destinationPath': $($writeError.Exception.Message)", + $writeError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportWriteFailed' -Category WriteError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $writeError = $_ + $exception = [System.IO.IOException]::new( + "Cannot write YAML temporary file for '$destinationPath': $($writeError.Exception.Message)", + $writeError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportWriteFailed' -Category WriteError ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($destinationWasReadOnly -and $IsWindows) { + $prepareError = $null + try { + [System.IO.File]::SetAttributes( + $destinationPath, + $destinationAttributes -band (-bnot [System.IO.FileAttributes]::ReadOnly) + ) + $attributesCleared = $true + } catch [System.UnauthorizedAccessException] { + $prepareError = $_ + } catch [System.IO.IOException] { + $prepareError = $_ + } + if ($null -ne $prepareError) { + if ($attributesCleared) { + try { + [System.IO.File]::SetAttributes( + $destinationPath, + $destinationAttributes + ) + } catch [System.UnauthorizedAccessException] { + $restoreError = $_ + $exception = [System.IO.IOException]::new( + "Cannot restore read-only YAML destination '$destinationPath': $($restoreError.Exception.Message)", + $restoreError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportReadOnlyRestoreFailed' ` + -Category PermissionDenied -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $restoreError = $_ + $exception = [System.IO.IOException]::new( + "Cannot restore read-only YAML destination '$destinationPath': $($restoreError.Exception.Message)", + $restoreError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportReadOnlyRestoreFailed' ` + -Category WriteError -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + $exception = [System.IO.IOException]::new( + "Cannot prepare read-only YAML destination '$destinationPath' for replacement: $($prepareError.Exception.Message)", + $prepareError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportPermissionDenied' -Category PermissionDenied ` + -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + + $moveError = $null + $moveErrorCategory = [System.Management.Automation.ErrorCategory]::NotSpecified + try { + if ($destinationExists -and $IsWindows) { + $backupRandomName = [System.Convert]::ToHexString( + [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(16) + ).ToLowerInvariant() + $backupPath = [System.IO.Path]::Combine( + $directoryPath, + ".yaml-$backupRandomName.old" + ) + $replacementAttempted = $true + [System.IO.File]::Replace( + $temporaryPath, + $destinationPath, + $backupPath, + $false + ) + $nativeReplacementCompleted = $true + $temporaryPath = $null + if ($destinationWasReadOnly) { + [System.IO.File]::SetAttributes( + $destinationPath, + $destinationAttributes + ) + } + $replacementCompleted = $true + } elseif ($NoClobber -and -not $IsWindows) { + $escapedDirectoryPath = ( + [System.Management.Automation.WildcardPattern]::Escape($directoryPath) + ) + $null = New-Item -Path $escapedDirectoryPath ` + -Name ([System.IO.Path]::GetFileName($destinationPath)) ` + -ItemType HardLink -Value $temporaryPath -Confirm:$false ` + -ErrorAction Stop + try { + [System.IO.File]::Delete($temporaryPath) + $temporaryPath = $null + } catch [System.UnauthorizedAccessException] { + $temporaryCleanupError = $_ + } catch [System.IO.IOException] { + $temporaryCleanupError = $_ + } + } else { + [System.IO.File]::Move( + $temporaryPath, + $destinationPath, + -not $NoClobber + ) + } + $temporaryPath = $null + } catch [System.UnauthorizedAccessException] { + $moveError = $_ + $moveErrorCategory = [System.Management.Automation.ErrorCategory]::PermissionDenied + $replacementOriginalAtBackup = $nativeReplacementCompleted + } catch [System.IO.IOException] { + $moveError = $_ + $moveErrorCategory = [System.Management.Automation.ErrorCategory]::WriteError + $nativeErrorCode = $moveError.Exception.HResult -band 0xFFFF + if ($null -ne $moveError.Exception.InnerException) { + $innerErrorCode = ( + $moveError.Exception.InnerException.HResult -band 0xFFFF + ) + if ($innerErrorCode -ge 1175 -and $innerErrorCode -le 1177) { + $nativeErrorCode = $innerErrorCode + } + } + $replacementOriginalAtBackup = ( + $nativeReplacementCompleted -or $nativeErrorCode -eq 1177 + ) + if ($nativeErrorCode -in 5, 32, 33) { + $moveErrorCategory = ( + [System.Management.Automation.ErrorCategory]::PermissionDenied + ) + } + } catch { + $moveError = $_ + $moveErrorCategory = $moveError.CategoryInfo.Category + } + + if ($replacementCompleted -and $null -ne $backupPath) { + try { + if ($destinationWasReadOnly) { + $backupFileHandle = [System.IO.File]::OpenHandle( + $backupPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Write, + ( + [System.IO.FileShare]::ReadWrite -bor + [System.IO.FileShare]::Delete + ), + [System.IO.FileOptions]::None + ) + [System.IO.File]::Delete($backupPath) + [System.IO.File]::SetAttributes( + $backupFileHandle, + $destinationAttributes + ) + $backupFileHandle.Dispose() + $backupFileHandle = $null + } else { + [System.IO.File]::Delete($backupPath) + } + $backupPath = $null + } catch [System.UnauthorizedAccessException] { + $backupCleanupError = $_ + } catch [System.IO.IOException] { + $backupCleanupError = $_ + } finally { + if ($null -ne $backupFileHandle) { + $backupFileHandle.Dispose() + $backupFileHandle = $null + } + } + } + if ($null -ne $moveError) { + $destinationCreatedDuringExport = $false + if ($NoClobber) { + try { + $null = [System.IO.File]::GetAttributes($destinationPath) + $destinationCreatedDuringExport = $true + } catch [System.IO.FileNotFoundException] { + $destinationCreatedDuringExport = $false + } catch [System.IO.DirectoryNotFoundException] { + $destinationCreatedDuringExport = $false + } catch [System.UnauthorizedAccessException] { + $destinationCreatedDuringExport = $false + } catch [System.IO.IOException] { + $destinationCreatedDuringExport = $false + } + } + $errorId = if ($NoClobber -and ( + $destinationCreatedDuringExport -or $moveErrorCategory -eq ( + [System.Management.Automation.ErrorCategory]::ResourceExists + ) + )) { + $moveErrorCategory = [System.Management.Automation.ErrorCategory]::ResourceExists + 'YamlExportDestinationExists' + } else { + 'YamlExportReplaceFailed' + } + $exception = [System.IO.IOException]::new( + "Cannot replace YAML destination '$destinationPath': $($moveError.Exception.Message)", + $moveError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception -DefaultErrorId $errorId ` + -Category $moveErrorCategory -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } finally { + if ($null -ne $backupFileHandle) { + $backupFileHandle.Dispose() + } + if ($null -ne $backupPath) { + if ($replacementCompleted) { + try { + if ($destinationWasReadOnly) { + $backupFileHandle = [System.IO.File]::OpenHandle( + $backupPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Write, + ( + [System.IO.FileShare]::ReadWrite -bor + [System.IO.FileShare]::Delete + ), + [System.IO.FileOptions]::None + ) + [System.IO.File]::Delete($backupPath) + [System.IO.File]::SetAttributes( + $backupFileHandle, + $destinationAttributes + ) + } else { + [System.IO.File]::Delete($backupPath) + } + $backupPath = $null + } catch [System.IO.FileNotFoundException] { + $backupPath = $null + } catch [System.IO.DirectoryNotFoundException] { + $backupPath = $null + } catch [System.UnauthorizedAccessException] { + $cleanupError = $_ + $cleanupHistory = if ($null -ne $backupCleanupError) { + " A previous cleanup attempt failed: $($backupCleanupError.Exception.Message)" + } else { + '' + } + $exception = [System.IO.IOException]::new( + "Cannot clean YAML replacement backup '$backupPath': $($cleanupError.Exception.Message)$cleanupHistory", + $cleanupError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportTempCleanupFailed' ` + -Category PermissionDenied -TargetObject $backupPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $cleanupError = $_ + $cleanupHistory = if ($null -ne $backupCleanupError) { + " A previous cleanup attempt failed: $($backupCleanupError.Exception.Message)" + } else { + '' + } + $exception = [System.IO.IOException]::new( + "Cannot clean YAML replacement backup '$backupPath': $($cleanupError.Exception.Message)$cleanupHistory", + $cleanupError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportTempCleanupFailed' ` + -Category WriteError -TargetObject $backupPath + $PSCmdlet.ThrowTerminatingError($record) + } finally { + if ($null -ne $backupFileHandle) { + $backupFileHandle.Dispose() + $backupFileHandle = $null + } + } + } elseif ($replacementAttempted) { + $backupAvailable = $false + $destinationAvailable = $false + $recoveryCause = $null + $recoveryCategory = [System.Management.Automation.ErrorCategory]::WriteError + try { + $null = [System.IO.File]::GetAttributes($backupPath) + $backupAvailable = $true + } catch [System.IO.FileNotFoundException] { + $backupAvailable = $false + } catch [System.IO.DirectoryNotFoundException] { + $backupAvailable = $false + } catch [System.UnauthorizedAccessException] { + $recoveryCause = $_.Exception + $recoveryCategory = ( + [System.Management.Automation.ErrorCategory]::PermissionDenied + ) + } catch [System.IO.IOException] { + $recoveryCause = $_.Exception + } + + if ($null -eq $recoveryCause) { + try { + $currentDestinationAttributes = ( + [System.IO.File]::GetAttributes($destinationPath) + ) + $destinationAvailable = $true + } catch [System.IO.FileNotFoundException] { + $destinationAvailable = $false + } catch [System.IO.DirectoryNotFoundException] { + $destinationAvailable = $false + } catch [System.UnauthorizedAccessException] { + $recoveryCause = $_.Exception + $recoveryCategory = ( + [System.Management.Automation.ErrorCategory]::PermissionDenied + ) + } catch [System.IO.IOException] { + $recoveryCause = $_.Exception + } + } + + if ($null -eq $recoveryCause -and + $replacementOriginalAtBackup -and $backupAvailable) { + try { + [System.IO.File]::SetAttributes( + $backupPath, + $destinationAttributes + ) + if ($destinationAvailable) { + $destinationIsReadOnly = ( + $currentDestinationAttributes -band + [System.IO.FileAttributes]::ReadOnly + ) -ne 0 + if ($destinationIsReadOnly) { + [System.IO.File]::SetAttributes( + $destinationPath, + $currentDestinationAttributes -band ( + -bnot [System.IO.FileAttributes]::ReadOnly + ) + ) + } + } + [System.IO.File]::Move( + $backupPath, + $destinationPath, + $true + ) + $backupPath = $null + } catch [System.UnauthorizedAccessException] { + $recoveryCause = $_.Exception + $recoveryCategory = ( + [System.Management.Automation.ErrorCategory]::PermissionDenied + ) + } catch [System.IO.IOException] { + $recoveryCause = $_.Exception + } + } elseif ($null -eq $recoveryCause -and + $replacementOriginalAtBackup) { + $recoveryCause = [System.IO.FileNotFoundException]::new( + "The replacement backup containing the original YAML file was not found." + ) + } elseif ($null -eq $recoveryCause -and $destinationAvailable) { + if ($attributesCleared) { + try { + [System.IO.File]::SetAttributes( + $destinationPath, + $destinationAttributes + ) + } catch [System.UnauthorizedAccessException] { + $recoveryCause = $_.Exception + $recoveryCategory = ( + [System.Management.Automation.ErrorCategory]::PermissionDenied + ) + } catch [System.IO.IOException] { + $recoveryCause = $_.Exception + } + } + if ($null -eq $recoveryCause) { + $backupPath = $null + } + } elseif ($null -eq $recoveryCause) { + $recoveryCause = [System.IO.FileNotFoundException]::new( + "Neither the YAML destination nor its replacement backup could be found." + ) + } + + if ($null -ne $recoveryCause) { + $recoveryMessage = ( + "Cannot recover YAML destination '$destinationPath' after replacement failed. " + + "The original is retained at '$backupPath' when that path exists." + ) + $exception = [System.IO.IOException]::new( + $recoveryMessage, + $recoveryCause + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportRecoveryFailed' ` + -Category $recoveryCategory -TargetObject $destinationPath + $PSCmdlet.ThrowTerminatingError($record) + } + } + } + if ($null -ne $temporaryPath) { + $temporaryMissing = $false + try { + $temporaryAttributes = [System.IO.File]::GetAttributes($temporaryPath) + if (($temporaryAttributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0) { + [System.IO.File]::SetAttributes( + $temporaryPath, + $temporaryAttributes -band (-bnot [System.IO.FileAttributes]::ReadOnly) + ) + } + [System.IO.File]::Delete($temporaryPath) + } catch [System.IO.FileNotFoundException] { + $temporaryMissing = $true + } catch [System.IO.DirectoryNotFoundException] { + $temporaryMissing = $true + } catch [System.UnauthorizedAccessException] { + $cleanupError = $_ + $cleanupHistory = if ($null -ne $temporaryCleanupError) { + " A previous cleanup attempt failed: $($temporaryCleanupError.Exception.Message)" + } else { + '' + } + $exception = [System.IO.IOException]::new( + "Cannot clean YAML temporary file '$temporaryPath': $($cleanupError.Exception.Message)$cleanupHistory", + $cleanupError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportTempCleanupFailed' -Category PermissionDenied ` + -TargetObject $temporaryPath + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $cleanupError = $_ + $cleanupHistory = if ($null -ne $temporaryCleanupError) { + " A previous cleanup attempt failed: $($temporaryCleanupError.Exception.Message)" + } else { + '' + } + $exception = [System.IO.IOException]::new( + "Cannot clean YAML temporary file '$temporaryPath': $($cleanupError.Exception.Message)$cleanupHistory", + $cleanupError.Exception + ) + $record = New-YamlErrorRecord -Exception $exception ` + -DefaultErrorId 'YamlExportTempCleanupFailed' -Category WriteError ` + -TargetObject $temporaryPath + $PSCmdlet.ThrowTerminatingError($record) + } + if ($temporaryMissing) { + $temporaryPath = $null + } + } + } + + if ($PassThru) { + $fileInfo = [System.IO.FileInfo]::new($destinationPath) + $fileInfo.Refresh() + $PSCmdlet.WriteObject($fileInfo, $false) + } + } +} diff --git a/src/functions/public/Import-Yaml.ps1 b/src/functions/public/Import-Yaml.ps1 new file mode 100644 index 0000000..40168d8 --- /dev/null +++ b/src/functions/public/Import-Yaml.ps1 @@ -0,0 +1,647 @@ +function Import-Yaml { + <# + .SYNOPSIS + Imports YAML documents from files. + + .DESCRIPTION + Resolves FileSystem files deterministically, decodes each file with a + strict Unicode encoding, and delegates YAML parsing to ConvertFrom-Yaml. + Wildcards are expanded only for Path. Duplicate resolved files are read + once, and each file's documents retain their source order. + + Byte order marks for UTF-8, UTF-16, and UTF-32 are detected + automatically and override Encoding. Files without a byte order mark + default to strict UTF-8. + + .PARAMETER Path + One or more FileSystem paths. Wildcards are expanded. Resolved files + are sorted deterministically and duplicates are suppressed. + + .PARAMETER LiteralPath + One or more literal FileSystem paths. Wildcard characters are not + expanded. + + .PARAMETER Encoding + Encoding for files without a byte order mark. The default is utf8, + which is strict UTF-8 without a byte order mark. + + .PARAMETER AsHashtable + Returns mappings as insertion-ordered dictionaries. + + .PARAMETER NoEnumerate + Writes each top-level YAML sequence as one array pipeline record. + + .PARAMETER Depth + Maximum YAML node nesting depth. The default is 100. + + .PARAMETER MaxNodes + Maximum number of YAML nodes in each file. The default is 100000. + + .PARAMETER MaxAliases + Maximum number of alias nodes in each file. The default is 1000. + + .PARAMETER MaxScalarLength + Maximum decoded character count for one scalar. The default is + 1048576. + + .PARAMETER MaxTagLength + Maximum expanded character count for one tag. The default is 1024. + + .PARAMETER MaxTotalTagLength + Maximum cumulative expanded tag characters. The default is 65536. + + .PARAMETER MaxNumericLength + Maximum digits in an implicitly or explicitly typed number. The + default is 4096. + + .EXAMPLE + Import-Yaml -Path '.\config.yaml' + + Imports the YAML documents from config.yaml. + + .EXAMPLE + Get-ChildItem -Path '.\config' -Filter '*.yaml' | Import-Yaml -AsHashtable + + Imports FileInfo pipeline input and returns mappings as dictionaries. + + .EXAMPLE + Import-Yaml -LiteralPath '.\config[production].data' -NoEnumerate + + Imports a literal wildcard-bearing filename and preserves root arrays. + + .INPUTS + System.String + System.IO.FileInfo + + .OUTPUTS + System.Object + + .NOTES + Only FileSystem provider paths are supported. + + .LINK + https://psmodule.io/Yaml/Functions/ConvertFrom-Yaml/ + #> + [OutputType([object])] + [CmdletBinding(DefaultParameterSetName = 'Path')] + param ( + # Expands wildcard paths and accepts string pipeline values. + [Parameter( + Mandatory, + Position = 0, + ValueFromPipeline, + ParameterSetName = 'Path' + )] + [string[]] $Path, + + # Resolves exact paths from LiteralPath, PSPath, or FullName properties. + [Parameter( + Mandatory, + ValueFromPipelineByPropertyName, + ParameterSetName = 'LiteralPath' + )] + [Alias('PSPath', 'FullName')] + [string[]] $LiteralPath, + + # Selects the fallback decoder when a file has no byte order mark. + [Parameter()] + [ValidateSet('utf8', 'utf8BOM', 'utf16LE', 'utf16BE', 'utf32LE', 'utf32BE')] + [string] $Encoding = 'utf8', + + # Preserves YAML mappings as insertion-ordered dictionaries. + [Parameter()] + [switch] $AsHashtable, + + # Preserves each root YAML sequence as one output record. + [Parameter()] + [switch] $NoEnumerate, + + # Limits YAML node nesting depth. + [Parameter()] + [ValidateRange(1, 128)] + [int] $Depth = 100, + + # Limits the number of YAML nodes in each file. + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + # Limits alias nodes in each file. + [Parameter()] + [ValidateRange(0, 2147483647)] + [int] $MaxAliases = 1000, + + # Limits decoded characters in one scalar. + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + # Limits expanded characters in one tag. + [Parameter()] + [ValidateRange(1, 1048576)] + [int] $MaxTagLength = 1024, + + # Limits cumulative expanded tag characters. + [Parameter()] + [ValidateRange(1, 2147483647)] + [int] $MaxTotalTagLength = 65536, + + # Limits digits in numeric scalars. + [Parameter()] + [ValidateRange(1, 1048576)] + [int] $MaxNumericLength = 4096 + ) + + begin { + $resolvedPaths = [System.Collections.Generic.List[string]]::new() + } + process { + $currentPaths = if ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { + $LiteralPath + } else { + $Path + } + foreach ($requestedPath in $currentPaths) { + if ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { + try { + $provider = $null + $drive = $null + $providerPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + $requestedPath, + [ref] $provider, + [ref] $drive + ) + } catch [System.Management.Automation.ProviderNotFoundException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot import YAML from '$requestedPath': the provider is not available.", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportProviderNotSupported', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.Management.Automation.DriveNotFoundException] { + $pathError = $_ + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found.", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot resolve YAML input path '$requestedPath': $($pathError.Exception.Message)", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathResolutionFailed', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + + if ($provider.Name -ne 'FileSystem') { + $exception = [System.NotSupportedException]::new( + "Cannot import YAML from '$requestedPath': only the FileSystem provider is supported." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportProviderNotSupported', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + try { + $pathAttributes = [System.IO.File]::GetAttributes($providerPath) + } catch [System.IO.FileNotFoundException] { + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.DirectoryNotFoundException] { + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.UnauthorizedAccessException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML input path '$requestedPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPermissionDenied', + [System.Management.Automation.ErrorCategory]::PermissionDenied, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML input path '$requestedPath': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathInspectionFailed', + [System.Management.Automation.ErrorCategory]::ReadError, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + if (($pathAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $exception = [System.IO.IOException]::new( + "Cannot import YAML from '$requestedPath': the path is not a file." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportNotFile', + [System.Management.Automation.ErrorCategory]::InvalidType, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + $resolvedPaths.Add([System.IO.Path]::GetFullPath($providerPath)) + continue + } + + try { + $pathInfos = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath( + $requestedPath + ) + } catch [System.Management.Automation.ItemNotFoundException] { + $pathError = $_ + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found.", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.Management.Automation.ProviderNotFoundException] { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot import YAML from '$requestedPath': the provider is not available.", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportProviderNotSupported', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.Management.Automation.DriveNotFoundException] { + $pathError = $_ + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found.", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch { + $pathError = $_ + $exception = [System.ArgumentException]::new( + "Cannot resolve YAML input path '$requestedPath': $($pathError.Exception.Message)", + $pathError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathResolutionFailed', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + if ($pathInfos.Count -eq 0) { + $exception = [System.IO.FileNotFoundException]::new( + "Cannot import YAML from '$requestedPath': the path was not found." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathNotFound', + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $requestedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + + foreach ($pathInfo in $pathInfos) { + if ($pathInfo.Provider.Name -ne 'FileSystem') { + $exception = [System.NotSupportedException]::new( + "Cannot import YAML from '$($pathInfo.Path)': only the FileSystem provider is supported." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportProviderNotSupported', + [System.Management.Automation.ErrorCategory]::InvalidArgument, + $pathInfo.Path + ) + $PSCmdlet.ThrowTerminatingError($record) + } + try { + $pathAttributes = [System.IO.File]::GetAttributes($pathInfo.ProviderPath) + } catch [System.UnauthorizedAccessException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML input path '$($pathInfo.Path)': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPermissionDenied', + [System.Management.Automation.ErrorCategory]::PermissionDenied, + $pathInfo.Path + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $inspectionError = $_ + $exception = [System.IO.IOException]::new( + "Cannot inspect YAML input path '$($pathInfo.Path)': $($inspectionError.Exception.Message)", + $inspectionError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportPathInspectionFailed', + [System.Management.Automation.ErrorCategory]::ReadError, + $pathInfo.Path + ) + $PSCmdlet.ThrowTerminatingError($record) + } + if (($pathAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $exception = [System.IO.IOException]::new( + "Cannot import YAML from '$($pathInfo.Path)': the path is not a file." + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportNotFile', + [System.Management.Automation.ErrorCategory]::InvalidType, + $pathInfo.Path + ) + $PSCmdlet.ThrowTerminatingError($record) + } + $resolvedPaths.Add([System.IO.Path]::GetFullPath($pathInfo.ProviderPath)) + } + } + } + end { + + $pathsByIdentity = [System.Collections.Generic.Dictionary[string, string]]::new( + [System.StringComparer]::Ordinal + ) + $directoryNameGroups = [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::Ordinal + ) + foreach ($resolvedPath in $resolvedPaths) { + $identityComponents = [System.Collections.Generic.List[string]]::new() + $identityPath = $resolvedPath + $identityFailed = $false + + while ($true) { + $directoryPath = [System.IO.Path]::GetDirectoryName($identityPath) + $leafName = [System.IO.Path]::GetFileName($identityPath) + if ([string]::IsNullOrEmpty($directoryPath) -or + [string]::IsNullOrEmpty($leafName)) { + break + } + + $nameGroups = $null + try { + if (-not $directoryNameGroups.TryGetValue( + $directoryPath, + [ref]$nameGroups + )) { + $nameGroups = ( + [System.Collections.Generic.Dictionary[string, object]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + ) + foreach ($directoryEntry in ( + [System.IO.Directory]::EnumerateFileSystemEntries($directoryPath) + )) { + $entryName = [System.IO.Path]::GetFileName($directoryEntry) + $entryIdentityName = $entryName.Normalize( + [System.Text.NormalizationForm]::FormC + ) + $nameGroup = $null + if ($nameGroups.TryGetValue( + $entryIdentityName, + [ref]$nameGroup + )) { + $nameGroup.Count++ + } else { + $nameGroups.Add( + $entryIdentityName, + [pscustomobject]@{ + Count = 1 + CanonicalName = $entryName + } + ) + } + } + $directoryNameGroups.Add($directoryPath, $nameGroups) + } + + $nameGroup = $null + $leafIdentityName = $leafName.Normalize( + [System.Text.NormalizationForm]::FormC + ) + if (-not $nameGroups.TryGetValue( + $leafIdentityName, + [ref]$nameGroup + )) { + $identityFailed = $true + break + } + if ($nameGroup.Count -gt 1) { + $identityComponents.Add($leafName) + } else { + $identityComponents.Add($nameGroup.CanonicalName) + } + } catch [System.UnauthorizedAccessException] { + $identityFailed = $true + break + } catch [System.IO.IOException] { + $identityFailed = $true + break + } + + $identityPath = $directoryPath + } + + if ($identityFailed) { + $identityKey = $resolvedPath + } else { + $rootPath = [System.IO.Path]::GetPathRoot($resolvedPath) + if ($IsWindows) { + $rootPath = $rootPath.ToUpperInvariant() + } + $identityComponents.Add($rootPath) + $identityComponents.Reverse() + $identityKeyBuilder = [System.Text.StringBuilder]::new() + foreach ($identityComponent in $identityComponents) { + $null = $identityKeyBuilder.Append($identityComponent.Length) + $null = $identityKeyBuilder.Append(':') + $null = $identityKeyBuilder.Append($identityComponent) + } + $identityKey = $identityKeyBuilder.ToString() + } + + $null = $pathsByIdentity.TryAdd($identityKey, $resolvedPath) + } + + [string[]] $orderedIdentityKeys = $pathsByIdentity.Keys + [System.Array]::Sort($orderedIdentityKeys, [System.StringComparer]::Ordinal) + + foreach ($identityKey in $orderedIdentityKeys) { + $resolvedPath = $pathsByIdentity[$identityKey] + try { + $bytes = [System.IO.File]::ReadAllBytes($resolvedPath) + } catch [System.UnauthorizedAccessException] { + $readError = $_ + $exception = [System.IO.IOException]::new( + "Cannot read YAML file '$resolvedPath': $($readError.Exception.Message)", + $readError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportReadFailed', + [System.Management.Automation.ErrorCategory]::ReadError, + $resolvedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } catch [System.IO.IOException] { + $readError = $_ + $exception = [System.IO.IOException]::new( + "Cannot read YAML file '$resolvedPath': $($readError.Exception.Message)", + $readError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportReadFailed', + [System.Management.Automation.ErrorCategory]::ReadError, + $resolvedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + + $textEncoding = Get-YamlTextEncoding -Name $Encoding + $preambleLength = 0 + if ($bytes.Length -ge 4 -and + $bytes[0] -eq 0x00 -and $bytes[1] -eq 0x00 -and + $bytes[2] -eq 0xFE -and $bytes[3] -eq 0xFF) { + $textEncoding = Get-YamlTextEncoding -Name 'utf32BE' + $preambleLength = 4 + } elseif ($bytes.Length -ge 4 -and + $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE -and + $bytes[2] -eq 0x00 -and $bytes[3] -eq 0x00) { + $textEncoding = Get-YamlTextEncoding -Name 'utf32LE' + $preambleLength = 4 + } elseif ($bytes.Length -ge 3 -and + $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and + $bytes[2] -eq 0xBF) { + $textEncoding = Get-YamlTextEncoding -Name 'utf8BOM' + $preambleLength = 3 + } elseif ($bytes.Length -ge 2 -and + $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) { + $textEncoding = Get-YamlTextEncoding -Name 'utf16BE' + $preambleLength = 2 + } elseif ($bytes.Length -ge 2 -and + $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { + $textEncoding = Get-YamlTextEncoding -Name 'utf16LE' + $preambleLength = 2 + } + + try { + $yamlText = $textEncoding.GetString( + $bytes, + $preambleLength, + $bytes.Length - $preambleLength + ) + } catch [System.Text.DecoderFallbackException] { + $decodeError = $_ + $exception = [System.Text.DecoderFallbackException]::new( + "Cannot decode YAML file '$resolvedPath': the byte sequence is invalid.", + $decodeError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + 'YamlImportEncodingFailed', + [System.Management.Automation.ErrorCategory]::InvalidData, + $resolvedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + + $parserParameters = @{ + Yaml = $yamlText + AsHashtable = $AsHashtable + NoEnumerate = $NoEnumerate + Depth = $Depth + MaxNodes = $MaxNodes + MaxAliases = $MaxAliases + MaxScalarLength = $MaxScalarLength + MaxTagLength = $MaxTagLength + MaxTotalTagLength = $MaxTotalTagLength + MaxNumericLength = $MaxNumericLength + } + try { + ConvertFrom-Yaml @parserParameters | ForEach-Object { + $PSCmdlet.WriteObject($_, $false) + } + } catch { + $parseError = $_ + $errorId = ($parseError.FullyQualifiedErrorId -split ',')[0] + if ([string]::IsNullOrWhiteSpace($errorId)) { + $errorId = 'YamlImportParseFailed' + } + $exception = [System.FormatException]::new( + "Cannot parse YAML file '$resolvedPath': $($parseError.Exception.Message)", + $parseError.Exception + ) + $record = [System.Management.Automation.ErrorRecord]::new( + $exception, + $errorId, + $parseError.CategoryInfo.Category, + $resolvedPath + ) + $PSCmdlet.ThrowTerminatingError($record) + } + } + } +} diff --git a/tests/Export-Yaml.Tests.ps1 b/tests/Export-Yaml.Tests.ps1 new file mode 100644 index 0000000..c7d87e2 --- /dev/null +++ b/tests/Export-Yaml.Tests.ps1 @@ -0,0 +1,701 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +BeforeAll { + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') +} + +Describe 'Export-Yaml' { + Context 'Command contract' { + It 'declares pipeline, ShouldProcess, and output metadata' { + $command = Get-Command -Name Export-Yaml + $binding = $command.ScriptBlock.Attributes | + Where-Object { $_ -is [System.Management.Automation.CmdletBindingAttribute] } + $inputParameter = $command.ParameterSets.Parameters | + Where-Object Name -EQ 'InputObject' | + Select-Object -First 1 + $pathParameter = $command.ParameterSets.Parameters | + Where-Object Name -EQ 'Path' | + Select-Object -First 1 + + $binding.SupportsShouldProcess | Should -BeTrue + $binding.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::Medium) + $inputParameter.IsMandatory | Should -BeTrue + $inputParameter.Position | Should -Be 0 + $inputParameter.ValueFromPipeline | Should -BeTrue + $pathParameter.IsMandatory | Should -BeTrue + $pathParameter.Position | Should -Be 1 + $command.OutputType.Type | Should -Contain ([System.IO.FileInfo]) + $command.Parameters['Encoding'].Attributes.ValidValues | + Should -Be @('utf8', 'utf8BOM', 'utf16LE', 'utf16BE', 'utf32LE', 'utf32BE') + $command.Parameters['NewLine'].Attributes.ValidValues | Should -Be @('LF', 'CRLF') + } + + It 'provides complete command help' { + $help = Get-Help -Name Export-Yaml -Full + + $help.Synopsis | Should -Not -BeNullOrEmpty + $help.Description.Text | Should -Not -BeNullOrEmpty + @($help.Examples.Example).Count | Should -BeGreaterOrEqual 3 + @($help.Parameters.Parameter.Name) | Should -Contain 'InputObject' + @($help.Parameters.Parameter.Name) | Should -Contain 'Path' + @($help.Parameters.Parameter.Name) | Should -Contain 'NoClobber' + @($help.Parameters.Parameter.Name) | Should -Contain 'Force' + $help.returnValues.returnValue.Type.Name | Should -Contain 'System.IO.FileInfo' + } + } + + Context 'Pipeline aggregation and serializer controls' { + It 'serializes one direct array as one input value' { + $path = Join-Path $TestDrive 'direct-array.yaml' + $items = @('one', 'two') + + Export-Yaml -InputObject $items -Path $path + $result = Import-Yaml -Path $path -NoEnumerate + + , $result | Should -BeOfType [object[]] + $result | Should -Be @('one', 'two') + } + + It 'preserves an explicit one-element array as a sequence' { + $path = Join-Path $TestDrive 'one-element-array.yaml' + + Export-Yaml -InputObject @(42) -Path $path + + [System.IO.File]::ReadAllText($path) | Should -Be "- 42`n" + } + + It 'preserves an explicit empty array as an empty sequence' { + $path = Join-Path $TestDrive 'empty-array.yaml' + + Export-Yaml -InputObject @() -Path $path + + [System.IO.File]::ReadAllText($path) | Should -Be "[]`n" + } + + It 'collects multiple pipeline records into one sequence' { + $path = Join-Path $TestDrive 'pipeline.yaml' + + 'one', 'two', 'three' | Export-Yaml -Path $path + $result = Import-Yaml -Path $path -NoEnumerate + + $result | Should -Be @('one', 'two', 'three') + } + + It 'distinguishes one nested pipeline record from multiple records' { + $singlePath = Join-Path $TestDrive 'single-nested-record.yaml' + $multiplePath = Join-Path $TestDrive 'multiple-nested-records.yaml' + $firstRecord = [object[]]::new(1) + $firstRecord[0] = [object[]] @(1, 2) + $secondRecord = [object[]]::new(1) + $secondRecord[0] = [object[]] @(3, 4) + + Write-Output -InputObject $firstRecord -NoEnumerate | + Export-Yaml -Path $singlePath + & { + Write-Output -InputObject $firstRecord -NoEnumerate + Write-Output -InputObject $secondRecord -NoEnumerate + } | Export-Yaml -Path $multiplePath + + $singleExpected = ConvertTo-Yaml -InputObject $firstRecord + $multipleExpected = & { + Write-Output -InputObject $firstRecord -NoEnumerate + Write-Output -InputObject $secondRecord -NoEnumerate + } | ConvertTo-Yaml + [System.IO.File]::ReadAllText($singlePath) | Should -Be $singleExpected + [System.IO.File]::ReadAllText($multiplePath) | Should -Be $multipleExpected + } + + It 'serializes an explicit null input' { + $path = Join-Path $TestDrive 'null.yaml' + + Export-Yaml -InputObject $null -Path $path + + [System.IO.File]::ReadAllText($path) | Should -Be "null`n" + } + + It 'passes serializer formatting controls through' { + $path = Join-Path $TestDrive 'format.yaml' + $inputObject = [ordered]@{ + nested = [ordered]@{ day = [DayOfWeek]::Monday } + } + + Export-Yaml -InputObject $inputObject -Path $path -Depth 3 -MaxNodes 5 ` + -MaxScalarLength 6 -Indent 4 -ExplicitDocumentStart -EnumsAsStrings + $text = [System.IO.File]::ReadAllText($path) + $result = Import-Yaml -Path $path + + $text | Should -Match '^---\n' + $text | Should -Match '(?m)^ {4}"day": "Monday"$' + $result.nested.day | Should -Be 'Monday' + } + + It 'preserves an existing destination when serialization fails' { + $path = Join-Path $TestDrive 'serialization-failure.yaml' + [System.IO.File]::WriteAllText($path, "original`n") + + try { + Export-Yaml -InputObject ([uri] 'https://example.com') -Path $path + throw 'Expected Export-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlUnsupportedType,Export-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidType' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + [System.IO.File]::ReadAllText($path) | Should -Be "original`n" + @(Get-ChildItem -LiteralPath $TestDrive -Filter '.yaml-*.tmp').Count | Should -Be 0 + } + + It 'classifies malformed text serialization and preserves the destination' { + $path = Join-Path $TestDrive 'malformed-text.yaml' + [System.IO.File]::WriteAllText($path, "original`n") + + try { + Export-Yaml -InputObject ([string] [char] 0xD800) -Path $path + throw 'Expected Export-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | + Should -Be 'YamlExportSerializationFailed,Export-Yaml' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + + [System.IO.File]::ReadAllText($path) | Should -Be "original`n" + @(Get-ChildItem -LiteralPath $TestDrive -Filter '.yaml-*.tmp').Count | Should -Be 0 + } + + It 'preserves serializer resource-limit classifications and the destination' { + $path = Join-Path $TestDrive 'limit-failure.yaml' + [System.IO.File]::WriteAllText($path, "original`n") + + try { + @('one', 'two') | Export-Yaml -Path $path -MaxNodes 2 + throw 'Expected Export-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlNodeLimitExceeded,Export-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidOperation' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + [System.IO.File]::ReadAllText($path) | Should -Be "original`n" + } + + It 'enforces serializer limits while pipeline records arrive' { + $path = Join-Path $TestDrive 'bounded-pipeline.yaml' + $script:producedRecordCount = 0 + + try { + 1..25 | ForEach-Object { + $script:producedRecordCount++ + $_ + } | Export-Yaml -Path $path -MaxNodes 1 + throw 'Expected Export-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlNodeLimitExceeded,Export-Yaml' + } + + $script:producedRecordCount | Should -Be 2 + Test-Path -LiteralPath $path | Should -BeFalse + } + } + + Context 'Strict output encoding' { + It 'writes exact preamble bytes and strict encoded text' -ForEach @( + @{ + Name = 'utf8' + ExpectedPreamble = [byte[]] @() + Decoder = [System.Text.UTF8Encoding]::new($false, $true) + } + @{ + Name = 'utf8BOM' + ExpectedPreamble = [byte[]] @(0xEF, 0xBB, 0xBF) + Decoder = [System.Text.UTF8Encoding]::new($false, $true) + } + @{ + Name = 'utf16LE' + ExpectedPreamble = [byte[]] @(0xFF, 0xFE) + Decoder = [System.Text.UnicodeEncoding]::new($false, $false, $true) + } + @{ + Name = 'utf16BE' + ExpectedPreamble = [byte[]] @(0xFE, 0xFF) + Decoder = [System.Text.UnicodeEncoding]::new($true, $false, $true) + } + @{ + Name = 'utf32LE' + ExpectedPreamble = [byte[]] @(0xFF, 0xFE, 0x00, 0x00) + Decoder = [System.Text.UTF32Encoding]::new($false, $false, $true) + } + @{ + Name = 'utf32BE' + ExpectedPreamble = [byte[]] @(0x00, 0x00, 0xFE, 0xFF) + Decoder = [System.Text.UTF32Encoding]::new($true, $false, $true) + } + ) { + $path = Join-Path $TestDrive "$Name-output.yaml" + $value = "caf$([char] 0xE9)" + $inputObject = [ordered]@{ value = $value } + + Export-Yaml -InputObject $inputObject -Path $path -Encoding $Name + $bytes = [System.IO.File]::ReadAllBytes($path) + if ($ExpectedPreamble.Count -eq 0) { + @($bytes[0..2]) | Should -Not -Be @([byte] 0xEF, [byte] 0xBB, [byte] 0xBF) + } else { + @($bytes[0..($ExpectedPreamble.Count - 1)]) | Should -Be $ExpectedPreamble + } + $text = $Decoder.GetString( + $bytes, + $ExpectedPreamble.Count, + $bytes.Length - $ExpectedPreamble.Count + ) + + $text | Should -Be (ConvertTo-Yaml -InputObject $inputObject) + (Import-Yaml -Path $path).value | Should -Be $value + } + } + + Context 'Line ending policy' { + It 'writes LF with exactly one final newline by default' { + $path = Join-Path $TestDrive 'lf.yaml' + + Export-Yaml -InputObject ([ordered]@{ value = 'text' }) -Path $path + $text = [System.Text.UTF8Encoding]::new($false, $true).GetString( + [System.IO.File]::ReadAllBytes($path) + ) + + $text | Should -Be ( + ConvertTo-Yaml -InputObject ([ordered]@{ value = 'text' }) + ) + $text.EndsWith("`n") | Should -BeTrue + $text.EndsWith("`n`n") | Should -BeFalse + $text.Contains("`r") | Should -BeFalse + } + + It 'writes CRLF without changing escaped multiline scalar content' { + $path = Join-Path $TestDrive 'crlf.yaml' + $inputObject = [ordered]@{ + multiline = "one`ntwo" + value = 'text' + } + + Export-Yaml -InputObject $inputObject -Path $path -NewLine CRLF + $text = [System.Text.UTF8Encoding]::new($false, $true).GetString( + [System.IO.File]::ReadAllBytes($path) + ) + $result = Import-Yaml -Path $path + + $text | Should -Not -Match '(?&1 + if ($LASTEXITCODE -ne 0) { + Set-ItResult -Skipped -Because 'per-directory case sensitivity is unavailable' + return + } + + $upperPath = Join-Path $caseDirectory 'Case.yaml' + $lowerPath = Join-Path $caseDirectory 'case.yaml' + $variantUpperPath = Join-Path ( + Split-Path -Parent $caseDirectory + ) 'sensitive\Case.yaml' + [System.IO.File]::WriteAllText($upperPath, 'value: upper') + [System.IO.File]::WriteAllText($lowerPath, 'value: lower') + + $result = @(Import-Yaml -Path @($upperPath, $variantUpperPath, $lowerPath)) + + @($result.value) | Should -Be @('upper', 'lower') + } + + It 'resolves each relative pipeline path at the location where it arrives' { + $firstDirectory = Join-Path $TestDrive 'pipeline-a' + $secondDirectory = Join-Path $TestDrive 'pipeline-b' + $null = [System.IO.Directory]::CreateDirectory($firstDirectory) + $null = [System.IO.Directory]::CreateDirectory($secondDirectory) + [System.IO.File]::WriteAllText( + (Join-Path $firstDirectory 'config.yaml'), + 'value: first' + ) + [System.IO.File]::WriteAllText( + (Join-Path $secondDirectory 'config.yaml'), + 'value: second' + ) + + $result = @( + & { + Push-Location $firstDirectory + try { + Write-Output 'config.yaml' + Set-Location $secondDirectory + Write-Output 'config.yaml' + } finally { + Pop-Location + } + } | Import-Yaml + ) + + @($result.value) | Should -Be @('first', 'second') + } + + It 'preserves deterministic file and document order together' { + $firstPath = Join-Path $TestDrive '01.yaml' + $secondPath = Join-Path $TestDrive '02.yaml' + [System.IO.File]::WriteAllText( + $secondPath, + "---`nvalue: three`n---`nvalue: four" + ) + [System.IO.File]::WriteAllText( + $firstPath, + "---`nvalue: one`n---`nvalue: two" + ) + + $result = @(Import-Yaml -Path (Join-Path $TestDrive '0?.yaml')) + + @($result.value) | Should -Be @('one', 'two', 'three', 'four') + } + + It 'treats wildcard characters literally with LiteralPath' { + $path = Join-Path $TestDrive 'settings[1].data' + [System.IO.File]::WriteAllText($path, 'value: literal') + [System.IO.File]::WriteAllText( + (Join-Path $TestDrive 'settings1.data'), + 'value: wildcard' + ) + + (Import-Yaml -LiteralPath $path).value | Should -Be 'literal' + } + + It 'resolves FileInfo pipeline input literally' { + $path = Join-Path $TestDrive 'config[production].yaml' + [System.IO.File]::WriteAllText($path, 'value: pipeline') + + $result = Get-Item -LiteralPath $path | Import-Yaml + + $result.value | Should -Be 'pipeline' + } + + It 'resolves FullName pipeline properties literally' { + $path = Join-Path $TestDrive 'full[name].yaml' + [System.IO.File]::WriteAllText($path, 'value: fullname') + + $result = [pscustomobject]@{ FullName = $path } | Import-Yaml + + $result.value | Should -Be 'fullname' + } + + It 'accepts PSPath pipeline properties through LiteralPath' { + $path = Join-Path $TestDrive 'literal[2].yaml' + [System.IO.File]::WriteAllText($path, 'value: property') + $item = Get-Item -LiteralPath $path + + $result = [pscustomobject]@{ PSPath = $item.PSPath } | Import-Yaml + + $result.value | Should -Be 'property' + } + + It 'preserves each root sequence as one record with NoEnumerate' { + $path = Join-Path $TestDrive 'sequence.yaml' + [System.IO.File]::WriteAllText($path, "- one`n- two") + + $result = Import-Yaml -Path $path -NoEnumerate + + , $result | Should -BeOfType [object[]] + $result | Should -Be @('one', 'two') + } + + It 'emits no documents for an empty file' { + $path = Join-Path $TestDrive 'empty.yaml' + [System.IO.File]::WriteAllBytes($path, [byte[]]::new(0)) + + @(Import-Yaml -Path $path).Count | Should -Be 0 + } + } + + Context 'Strict text decoding' { + It 'imports BOM-less text with the selected encoding' -ForEach @( + @{ + Name = 'utf8' + EncodingName = 'utf8' + Encoding = [System.Text.UTF8Encoding]::new($false, $true) + } + @{ + Name = 'utf8BOM' + EncodingName = 'utf8BOM' + Encoding = [System.Text.UTF8Encoding]::new($false, $true) + } + @{ + Name = 'utf16LE' + EncodingName = 'utf16LE' + Encoding = [System.Text.UnicodeEncoding]::new($false, $false, $true) + } + @{ + Name = 'utf16BE' + EncodingName = 'utf16BE' + Encoding = [System.Text.UnicodeEncoding]::new($true, $false, $true) + } + @{ + Name = 'utf32LE' + EncodingName = 'utf32LE' + Encoding = [System.Text.UTF32Encoding]::new($false, $false, $true) + } + @{ + Name = 'utf32BE' + EncodingName = 'utf32BE' + Encoding = [System.Text.UTF32Encoding]::new($true, $false, $true) + } + ) { + $path = Join-Path $TestDrive "$Name.data" + $text = "value: caf$([char] 0xE9)" + [System.IO.File]::WriteAllBytes($path, $Encoding.GetBytes($text)) + + (Import-Yaml -Path $path -Encoding $EncodingName).value | Should -Be "caf$([char] 0xE9)" + } + + It 'detects a BOM and lets it override the selected encoding' -ForEach @( + @{ + Name = 'utf8' + EncodingName = 'utf16BE' + Encoding = [System.Text.UTF8Encoding]::new($true, $true) + } + @{ + Name = 'utf16LE' + EncodingName = 'utf8' + Encoding = [System.Text.UnicodeEncoding]::new($false, $true, $true) + } + @{ + Name = 'utf16BE' + EncodingName = 'utf8' + Encoding = [System.Text.UnicodeEncoding]::new($true, $true, $true) + } + @{ + Name = 'utf32LE' + EncodingName = 'utf8' + Encoding = [System.Text.UTF32Encoding]::new($false, $true, $true) + } + @{ + Name = 'utf32BE' + EncodingName = 'utf8' + Encoding = [System.Text.UTF32Encoding]::new($true, $true, $true) + } + ) { + $path = Join-Path $TestDrive "$Name-bom.data" + $payload = [byte[]] ( + $Encoding.GetPreamble() + + $Encoding.GetBytes("value: caf$([char] 0xE9)") + ) + [System.IO.File]::WriteAllBytes($path, $payload) + + (Import-Yaml -Path $path -Encoding $EncodingName).value | Should -Be "caf$([char] 0xE9)" + } + + It 'defaults to strict BOM-less UTF-8' { + $path = Join-Path $TestDrive 'default-utf8.yaml' + $encoding = [System.Text.UTF8Encoding]::new($false, $true) + [System.IO.File]::WriteAllBytes( + $path, + $encoding.GetBytes("value: caf$([char] 0xE9)") + ) + + (Import-Yaml -Path $path).value | Should -Be "caf$([char] 0xE9)" + } + + It 'rejects malformed bytes with a path-specific encoding error' { + $path = Join-Path $TestDrive 'malformed.yaml' + [System.IO.File]::WriteAllBytes($path, [byte[]] @(0xC3, 0x28)) + + try { + Import-Yaml -Path $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportEncodingFailed,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidData' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + } + + Context 'Parser controls' { + It 'passes AsHashtable and every parser limit through' { + $path = Join-Path $TestDrive 'mapping.yaml' + [System.IO.File]::WriteAllText($path, "outer:`n value: 12") + + $result = Import-Yaml -Path $path -AsHashtable -Depth 3 -MaxNodes 5 ` + -MaxAliases 0 -MaxScalarLength 5 -MaxTagLength 3 ` + -MaxTotalTagLength 3 -MaxNumericLength 2 + + $result | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result['outer']['value'] | Should -Be 12 + } + + It 'preserves parser limit classifications and identifies the path' { + $path = Join-Path $TestDrive 'limited.yaml' + [System.IO.File]::WriteAllText($path, "[one, two]") + + try { + Import-Yaml -Path $path -MaxNodes 2 + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlNodeLimitExceeded,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidData' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + } + + Context 'Classified failures' { + It 'classifies a missing literal path' { + $path = Join-Path $TestDrive 'missing.yaml' + + try { + Import-Yaml -LiteralPath $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportPathNotFound,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'ObjectNotFound' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + + It 'classifies an unresolved wildcard path' { + $path = Join-Path $TestDrive '*.missing' + + try { + Import-Yaml -Path $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportPathNotFound,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'ObjectNotFound' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + + It 'rejects non-FileSystem providers' { + try { + Import-Yaml -LiteralPath 'Env:PATH' + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportProviderNotSupported,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidArgument' + $_.Exception.Message | Should -Match 'Env:PATH' + } + } + + It 'rejects directories' { + $path = Join-Path $TestDrive 'directory' + $null = [System.IO.Directory]::CreateDirectory($path) + + try { + Import-Yaml -LiteralPath $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportNotFile,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'InvalidType' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + + It 'classifies file read failures' { + $path = Join-Path $TestDrive 'locked.yaml' + [System.IO.File]::WriteAllText($path, 'value: locked') + $stream = [System.IO.File]::Open( + $path, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None + ) + try { + try { + Import-Yaml -LiteralPath $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Be 'YamlImportReadFailed,Import-Yaml' + $_.CategoryInfo.Category | Should -Be 'ReadError' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } finally { + $stream.Dispose() + } + } + + It 'preserves parser classifications and identifies the path' { + $path = Join-Path $TestDrive 'invalid.yaml' + [System.IO.File]::WriteAllText($path, '[unterminated') + + try { + Import-Yaml -Path $path + throw 'Expected Import-Yaml to terminate.' + } catch { + $_.FullyQualifiedErrorId | Should -Match '^Yaml.+,Import-Yaml$' + $_.CategoryInfo.Category | Should -Be 'InvalidData' + $_.Exception.Message | Should -Match ([regex]::Escape($path)) + } + } + } +} diff --git a/tests/Packaging.Tests.ps1 b/tests/Packaging.Tests.ps1 index dbd1e53..411e5fd 100644 --- a/tests/Packaging.Tests.ps1 +++ b/tests/Packaging.Tests.ps1 @@ -127,7 +127,13 @@ Describe 'Generated artifact package' { $manifest.ContainsKey('RequiredAssemblies') | Should -BeFalse $manifest.ContainsKey('DotNetFrameworkVersion') | Should -BeFalse @($manifest.FunctionsToExport | Sort-Object) | - Should -Be @('ConvertFrom-Yaml', 'ConvertTo-Yaml', 'Test-Yaml') + Should -Be @( + 'ConvertFrom-Yaml', + 'ConvertTo-Yaml', + 'Export-Yaml', + 'Import-Yaml', + 'Test-Yaml' + ) @($manifest.FileList) | Should -Contain 'Yaml.psm1' $packagedFiles = @( Get-ChildItem -Path $moduleBase -Recurse -File |