From 3ddda6678859a79547e3e89b21d182588a2cc889 Mon Sep 17 00:00:00 2001 From: SingularVector Date: Sat, 24 Oct 2026 00:14:26 +0200 Subject: [PATCH] Align standalone conversion, validation and elapsed time with IT-ToolBox --- .github/workflows/toolbox-alignment.yml | 38 +++ Get-ElapsedTime.ps1 | 69 ++--- New-StringConversion.ps1 | 393 +++++------------------- Private/Get-ITToolBoxCharacterMap.ps1 | 149 +++++++++ Private/Test-ITToolBoxDnSyntax.ps1 | 91 ++++++ Private/Test-ITToolBoxDnsName.ps1 | 44 +++ README.md | 4 + Test-IsEmail.ps1 | 76 +++-- Test-IsValidDn.ps1 | 65 ++-- Tests/ToolboxAlignment.Tests.ps1 | 189 ++++++++++++ docs/ToolboxAlignment.md | 38 +++ 11 files changed, 730 insertions(+), 426 deletions(-) create mode 100644 .github/workflows/toolbox-alignment.yml create mode 100644 Private/Get-ITToolBoxCharacterMap.ps1 create mode 100644 Private/Test-ITToolBoxDnSyntax.ps1 create mode 100644 Private/Test-ITToolBoxDnsName.ps1 create mode 100644 Tests/ToolboxAlignment.Tests.ps1 create mode 100644 docs/ToolboxAlignment.md diff --git a/.github/workflows/toolbox-alignment.yml b/.github/workflows/toolbox-alignment.yml new file mode 100644 index 0000000..aa886d7 --- /dev/null +++ b/.github/workflows/toolbox-alignment.yml @@ -0,0 +1,38 @@ +name: Standalone toolbox alignment + +on: + push: + branches: [master] + paths: ['New-StringConversion.ps1', 'Test-IsEmail.ps1', 'Test-IsValidDn.ps1', 'Get-ElapsedTime.ps1', 'Private/**', 'Tests/ToolboxAlignment.Tests.ps1', '.github/workflows/toolbox-alignment.yml'] + pull_request: + paths: ['New-StringConversion.ps1', 'Test-IsEmail.ps1', 'Test-IsValidDn.ps1', 'Get-ElapsedTime.ps1', 'Private/**', 'Tests/ToolboxAlignment.Tests.ps1', '.github/workflows/toolbox-alignment.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install and run Pester + run: | + $ErrorActionPreference = 'Stop' + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force -Repository PSGallery + Import-Module Pester -RequiredVersion 5.7.1 -Force + $configuration = New-PesterConfiguration + $configuration.Run.Path = @('./Tests/New-ApiRequest.Tests.ps1', './Tests/ToolboxAlignment.Tests.ps1') + $configuration.Run.Exit = $true + $configuration.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $configuration diff --git a/Get-ElapsedTime.ps1 b/Get-ElapsedTime.ps1 index 148d596..44de91d 100644 --- a/Get-ElapsedTime.ps1 +++ b/Get-ElapsedTime.ps1 @@ -1,3 +1,5 @@ +#Requires -Version 7.4 + function Get-ElapsedTime { <# @@ -48,10 +50,12 @@ function Get-ElapsedTime System.TimeSpan, System.Double, System.Int32 #> - [CmdletBinding(DefaultParameterSetName = 'FullOutput', - ConfirmImpact = 'High', - SupportsPaging = $false, - SupportsShouldProcess = $false)] + [CmdletBinding( + DefaultParameterSetName = 'FullOutput', + ConfirmImpact = 'High', + SupportsPaging = $false, + SupportsShouldProcess = $false + )] [OutputType([timespan], ParameterSetName = 'FullOutput')] [OutputType([int], ParameterSetName = 'Days')] [OutputType([int], ParameterSetName = 'Hours')] @@ -63,48 +67,19 @@ function Get-ElapsedTime [OutputType([double], ParameterSetName = 'TotalSeconds')] [OutputType([double], ParameterSetName = 'TotalMilliseconds')] [OutputType([timespan])] - param - ( - [Parameter(ParameterSetName = 'FullOutput', - Mandatory = $true)] - [Parameter(ParameterSetName = 'Days')] - [Parameter(ParameterSetName = 'Hours')] - [Parameter(ParameterSetName = 'Minutes')] - [Parameter(ParameterSetName = 'Seconds')] - [Parameter(ParameterSetName = 'TotalDays')] - [Parameter(ParameterSetName = 'TotalHours')] - [Parameter(ParameterSetName = 'TotalMilliseconds')] - [Parameter(ParameterSetName = 'TotalMinutes')] - [Parameter(ParameterSetName = 'TotalSeconds')] - [System.Diagnostics.Stopwatch] - $ElapsedTime, - [Parameter(ParameterSetName = 'Days')] - [switch] - $Days, - [Parameter(ParameterSetName = 'Hours')] - [switch] - $Hours, - [Parameter(ParameterSetName = 'Minutes')] - [switch] - $Minutes, - [Parameter(ParameterSetName = 'Seconds')] - [switch] - $Seconds, - [Parameter(ParameterSetName = 'TotalDays')] - [switch] - $TotalDays, - [Parameter(ParameterSetName = 'TotalHours')] - [switch] - $TotalHours, - [Parameter(ParameterSetName = 'TotalMinutes')] - [switch] - $TotalMinutes, - [Parameter(ParameterSetName = 'TotalSeconds')] - [switch] - $TotalSeconds, - [Parameter(ParameterSetName = 'TotalMilliseconds')] - [switch] - $TotalMilliseconds + param( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [System.Diagnostics.Stopwatch]$ElapsedTime, + [Parameter(ParameterSetName = 'Days')][switch]$Days, + [Parameter(ParameterSetName = 'Hours')][switch]$Hours, + [Parameter(ParameterSetName = 'Minutes')][switch]$Minutes, + [Parameter(ParameterSetName = 'Seconds')][switch]$Seconds, + [Parameter(ParameterSetName = 'TotalDays')][switch]$TotalDays, + [Parameter(ParameterSetName = 'TotalHours')][switch]$TotalHours, + [Parameter(ParameterSetName = 'TotalMinutes')][switch]$TotalMinutes, + [Parameter(ParameterSetName = 'TotalSeconds')][switch]$TotalSeconds, + [Parameter(ParameterSetName = 'TotalMilliseconds')][switch]$TotalMilliseconds ) switch ($PsCmdlet.ParameterSetName) @@ -180,4 +155,4 @@ function Get-ElapsedTime break } } -} +} \ No newline at end of file diff --git a/New-StringConversion.ps1 b/New-StringConversion.ps1 index cd15e90..b1e8db5 100644 --- a/New-StringConversion.ps1 +++ b/New-StringConversion.ps1 @@ -1,338 +1,101 @@ -function New-StringConversion -{ - <# - .SYNOPSIS - Function to remove any non-unicode character from a string. - - .DESCRIPTION - Function is used to sanitize non-unicode characters from a string. - - Function supports custom characters map via the -UnicodeHashTable parameter accepting an hashtable of characters to replace. - - If characters not specified in the default character map are found they are replace with a question mark '?' unless a custom - unknown character is specified via -UnknownCharacter parameter. - - .PARAMETER StringToConvert - A string containing characters that need to be sanitized/converted. - - .PARAMETER UnicodeHashTable - An hashtable containing characters that should be replaced if parameter is not specified default values will be used. - - .PARAMETER IgnoreSpaces - By default spaces will be replaced with a dash '-' sign if paramter is specified function will not convert/take into consideraiotn - spaces in the string. - - .PARAMETER RemoveSpaces - If parameter is specified spaces will be removed from input string. +#Requires -Version 7.4 - .PARAMETER ReplaceSpaces - By default spaces will be replaced with a dash '-' sign if parameter is specified it is possible to specify character to use when - a space is encountered in the string. +. (Join-Path $PSScriptRoot 'Private/Get-ITToolBoxCharacterMap.ps1') - .PARAMETER UnknownCharacter - By default any special character not found in the UnicodeHashTable will be replaced with a question mark when parameter is used - it is possible to specify which character will be used for unknown entries. +function New-StringConversion +{ + <# + .SYNOPSIS + Converts mapped characters and replaces unsupported text elements. - .EXAMPLE - PS C:\> New-StringConversion + .DESCRIPTION - .NOTES - Additional information about the function. + Retains the historical character map and permitted ASCII punctuation. + Default spaces become hyphens. Use IgnoreSpaces, RemoveSpaces or ReplaceSpaces + to select another policy. Custom maps replace the default map and are not mutated. + Input is normalized to Unicode Form C; each remaining text element is processed + once. This is a domain-specific conversion, not general Unicode transliteration. + Returns exactly one string, without internal collection indices. #> - [CmdletBinding(DefaultParameterSetName = 'ReplaceSpaces', - ConfirmImpact = 'High', - HelpUri = 'https://singularvector.github.io')] - param - ( - [Parameter(ParameterSetName = 'IgnoreSpaces', - Mandatory = $true)] - [Parameter(ParameterSetName = 'RemoveSpaces')] - [Parameter(ParameterSetName = 'ReplaceSpaces')] - [ValidateNotNullOrEmpty()] - [string] - $StringToConvert, - [Parameter(ParameterSetName = 'IgnoreSpaces')] - [Parameter(ParameterSetName = 'RemoveSpaces')] - [Parameter(ParameterSetName = 'ReplaceSpaces')] - [hashtable] - $UnicodeHashTable, - [Parameter(ParameterSetName = 'IgnoreSpaces')] - [switch] - $IgnoreSpaces, - [Parameter(ParameterSetName = 'RemoveSpaces')] - [switch] - $RemoveSpaces, - [Parameter(ParameterSetName = 'ReplaceSpaces')] - [ValidateNotNullOrEmpty()] - [string] - $ReplaceSpaces, - [Parameter(ParameterSetName = 'IgnoreSpaces')] - [Parameter(ParameterSetName = 'RemoveSpaces')] - [Parameter(ParameterSetName = 'ReplaceSpaces')] - [ValidateNotNullOrEmpty()] - [string] - $UnknownCharacter = '?' + + [CmdletBinding(DefaultParameterSetName = 'ReplaceSpaces')] + [OutputType([string])] + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$StringToConvert, + [ValidateNotNull()][hashtable]$UnicodeHashTable, + [Parameter(ParameterSetName = 'IgnoreSpaces')][switch]$IgnoreSpaces, + [Parameter(ParameterSetName = 'RemoveSpaces')][switch]$RemoveSpaces, + [Parameter(ParameterSetName = 'ReplaceSpaces')][AllowEmptyString()][ValidateNotNull()][string]$ReplaceSpaces = '-', + [ValidateNotNullOrEmpty()][string]$UnknownCharacter = '?' ) - begin + if ($PSBoundParameters.ContainsKey('UnicodeHashTable')) { - # Declare control variable - [bool]$isUpperCase = $false - - # Check if we should use custom array hash - if (-not ($PSBoundParameters.ContainsKey('UnicodeHashTable'))) + # Normalize a private copy so the caller's map remains unchanged. + $map = @{} + foreach ($key in $UnicodeHashTable.Keys) { - # Hashtable contaning special characters to replace - [hashtable]$unicodeHashTable = @{ - - # a - 'æ' = 'a' - 'à' = 'a' - 'â' = 'a' - 'ã' = 'a' - 'å' = 'a' - 'ā' = 'a' - 'ă' = 'a' - 'ą' = 'a' - 'ä' = 'a' - 'á' = 'a' - - # b - 'ƀ' = 'b' - 'ƃ' = 'b' - - # Tone six - 'ƅ' = 'b' - - # c - 'ç' = 'c' - 'ć' = 'c' - 'ĉ' = 'c' - 'ċ' = 'c' - 'č' = 'c' - 'ƈ' = 'c' - - # d - 'ď' = 'd' - 'đ' = 'd' - 'ƌ' = 'd' - - # e - 'è' = 'e' - 'é' = 'e' - 'ê' = 'e' - 'ë' = 'e' - 'ē' = 'e' - 'ĕ' = 'e' - 'ė' = 'e' - 'ę' = 'e' - 'ě' = 'e' - '&' = 'e' - - # g - 'ĝ' = 'e' - 'ğ' = 'e' - 'ġ' = 'e' - 'ģ' = 'e' - - # h - 'ĥ' = 'h' - 'ħ' = 'h' - - # i - 'ì' = 'i' - 'í' = 'i' - 'î' = 'i' - 'ï' = 'i' - 'ĩ' = 'i' - 'ī' = 'i' - 'ĭ' = 'i' - 'į' = 'i' - 'ı' = 'i' - - # j - 'ij' = 'j' - 'ĵ' = 'j' - - # k - 'ķ' = 'k' - 'ĸ' = 'k' - - # l - 'ĺ' = 'l' - 'ļ' = 'l' - 'ľ' = 'l' - 'ŀ' = 'l' - 'ł' = 'l' - - # n - 'ñ' = 'n' - 'ń' = 'n' - 'ņ' = 'n' - 'ň' = 'n' - 'ʼn' = 'n' - 'ŋ' = 'n' - - # o - 'ð' = 'o' - 'ó' = 'o' - 'õ' = 'o' - 'ô' = 'o' - 'ö' = 'o' - 'ø' = 'o' - 'ō' = 'o' - 'ŏ' = 'o' - 'ő' = 'o' - 'œ' = 'o' - - # r - 'ŕ' = 'r' - 'ŗ' = 'r' - 'ř' = 'r' - - # s - 'ś' = 's' - 'ŝ' = 's' - 'ş' = 's' - 'š' = 's' - 'ß' = 'ss' - 'ſ' = 's' - - # t - 'ţ' = 't' - 'ť' = 't' - 'ŧ' = 't' - - # u - 'ù' = 'u' - 'ú' = 'u' - 'û' = 'u' - 'ü' = 'u' - 'ũ' = 'u' - 'ū' = 'u' - 'ŭ' = 'u' - 'ů' = 'u' - 'ű' = 'u' - 'ų' = 'u' - - # w - 'ŵ' = 'w' - - # y - 'ý' = 'y' - 'ÿ' = 'y' - 'ŷ' = 'y' - - # z - 'ź' = 'z' - 'ż' = 'z' - 'ž' = 'z' - } - } - - switch ($PSBoundParameters.Keys) - { - 'IgnoreSpaces' + $normalizedKey = ([string]$key).Normalize( + [System.Text.NormalizationForm]::FormC + ).ToLowerInvariant() + if ([string]::IsNullOrEmpty($normalizedKey)) { - $UnicodeHashTable.Add(' ', ' ') - - break - } - 'ReplaceSpaces' - { - # Replace spaces with specified character - $UnicodeHashTable.Add(' ', $ReplaceSpaces) - - break - } - 'RemoveSpaces' - { - # Replace spaces with specified character - $UnicodeHashTable.Add(' ', '') - - break + throw [ArgumentException]::new('Character map keys cannot be empty.') } + $map[$normalizedKey] = [string]$UnicodeHashTable[$key] } - - # Create new chararray - [System.Collections.ArrayList]$resultStringArray = @() - - # Set a regex for additional special characters - [string]$unicodeRegExString = "^([0-9a-zA-Z!#$@.'^_`~-])*$" } - process + else { - # Convert string to array - [array]$stringCharArray = $StringToConvert.ToCharArray() - - foreach ($character in $stringCharArray) - { - # Reset control variables - $isUpperCase = $false + $map = Get-ITToolBoxCharacterMap + } - # Set Char ref with current value - [string]$currentChar = $character.ToString() - [string]$currentCharLower = $character.ToString().ToLower() + $map[' '] = if ($IgnoreSpaces) + { + ' ' + } + elseif ($RemoveSpaces) + { + '' + } + else + { + $ReplaceSpaces + } - # Get character case - if ($currentChar.CompareTo($currentCharLower) -eq 1) - { - $isUpperCase = $true - } + # Canonicalize equivalent Unicode sequences so character-map lookups are consistent. + $normalized = $StringToConvert.Normalize([System.Text.NormalizationForm]::FormC) - # Check if character should be translated - if ($UnicodeHashTable.ContainsKey($currentCharLower) -eq $true) - { - # Get unicode equivalent - [string]$tmpChar = $UnicodeHashTable[$currentChar] + # Enumerate text elements, such as surrogate pairs and combining sequences, as units. + $elements = [System.Globalization.StringInfo]::GetTextElementEnumerator($normalized) - # Set character case - switch ($isUpperCase) - { - $true - { - $resultStringArray.Add($tmpChar.ToUpper()) + # Accumulate converted text without repeatedly creating longer strings. + $result = [System.Text.StringBuilder]::new() - break - } - default - { - $resultStringArray.Add($tmpChar) - } - } - } - else + # Process text elements so surrogate pairs and combining marks stay together. + while ($elements.MoveNext()) + { + [string]$element = $elements.GetTextElement() + [string]$lower = $element.ToLowerInvariant() + if ($map.ContainsKey($lower)) + { + [string]$replacement = $map[$lower] + if ($element -cne $lower) { - # Check if character should be translated - if (($currentCharLower -match $unicodeRegExString) -eq $false) - { - # Handle characters not in hash - $currentChar = $UnknownCharacter - - # Append to result array - $resultStringArray.Add($currentChar).ToString() - } - else - { - # Set character case - switch ($isUpperCase) - { - $true - { - $resultStringArray.Add($currentChar).ToString().ToUpper() - } - default - { - $resultStringArray.Add($currentChar).ToString() - } - } - } + # Preserve the input element's capitalization in mapped output. + $replacement = $replacement.ToUpperInvariant() } + [void]$result.Append($replacement) + } + # Pass through ASCII letters, digits, and the explicitly allowed punctuation. + elseif ($element -cmatch '^[0-9a-zA-Z!#$@.''^_~-]$') + { + [void]$result.Append($element) + } + else + { + [void]$result.Append($UnknownCharacter) } } - end - { - # Return string - return (-join $resultStringArray) - } -} \ No newline at end of file + return $result.ToString() +} diff --git a/Private/Get-ITToolBoxCharacterMap.ps1 b/Private/Get-ITToolBoxCharacterMap.ps1 new file mode 100644 index 0000000..d15057e --- /dev/null +++ b/Private/Get-ITToolBoxCharacterMap.ps1 @@ -0,0 +1,149 @@ +function Get-ITToolBoxCharacterMap +{ + # Retain the original domain-specific mapping; this is not universal transliteration. + return @{ + + # a + 'æ' = 'a' + 'à' = 'a' + 'â' = 'a' + 'ã' = 'a' + 'å' = 'a' + 'ā' = 'a' + 'ă' = 'a' + 'ą' = 'a' + 'ä' = 'a' + 'á' = 'a' + + # b + 'ƀ' = 'b' + 'ƃ' = 'b' + + # Tone six + 'ƅ' = 'b' + + # c + 'ç' = 'c' + 'ć' = 'c' + 'ĉ' = 'c' + 'ċ' = 'c' + 'č' = 'c' + 'ƈ' = 'c' + + # d + 'ď' = 'd' + 'đ' = 'd' + 'ƌ' = 'd' + + # e + 'è' = 'e' + 'é' = 'e' + 'ê' = 'e' + 'ë' = 'e' + 'ē' = 'e' + 'ĕ' = 'e' + 'ė' = 'e' + 'ę' = 'e' + 'ě' = 'e' + '&' = 'e' + + # g + 'ĝ' = 'e' + 'ğ' = 'e' + 'ġ' = 'e' + 'ģ' = 'e' + + # h + 'ĥ' = 'h' + 'ħ' = 'h' + + # i + 'ì' = 'i' + 'í' = 'i' + 'î' = 'i' + 'ï' = 'i' + 'ĩ' = 'i' + 'ī' = 'i' + 'ĭ' = 'i' + 'į' = 'i' + 'ı' = 'i' + + # j + 'ij' = 'j' + 'ĵ' = 'j' + + # k + 'ķ' = 'k' + 'ĸ' = 'k' + + # l + 'ĺ' = 'l' + 'ļ' = 'l' + 'ľ' = 'l' + 'ŀ' = 'l' + 'ł' = 'l' + + # n + 'ñ' = 'n' + 'ń' = 'n' + 'ņ' = 'n' + 'ň' = 'n' + 'ʼn' = 'n' + 'ŋ' = 'n' + + # o + 'ð' = 'o' + 'ó' = 'o' + 'õ' = 'o' + 'ô' = 'o' + 'ö' = 'o' + 'ø' = 'o' + 'ō' = 'o' + 'ŏ' = 'o' + 'ő' = 'o' + 'œ' = 'o' + + # r + 'ŕ' = 'r' + 'ŗ' = 'r' + 'ř' = 'r' + + # s + 'ś' = 's' + 'ŝ' = 's' + 'ş' = 's' + 'š' = 's' + 'ß' = 'ss' + 'ſ' = 's' + + # t + 'ţ' = 't' + 'ť' = 't' + 'ŧ' = 't' + + # u + 'ù' = 'u' + 'ú' = 'u' + 'û' = 'u' + 'ü' = 'u' + 'ũ' = 'u' + 'ū' = 'u' + 'ŭ' = 'u' + 'ů' = 'u' + 'ű' = 'u' + 'ų' = 'u' + + # w + 'ŵ' = 'w' + + # y + 'ý' = 'y' + 'ÿ' = 'y' + 'ŷ' = 'y' + + # z + 'ź' = 'z' + 'ż' = 'z' + 'ž' = 'z' + } +} diff --git a/Private/Test-ITToolBoxDnSyntax.ps1 b/Private/Test-ITToolBoxDnSyntax.ps1 new file mode 100644 index 0000000..74711a8 --- /dev/null +++ b/Private/Test-ITToolBoxDnSyntax.ps1 @@ -0,0 +1,91 @@ +function Test-ITToolBoxDnSyntax +{ + param + ( + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value) -or $Value -match '\p{Cc}') + { + return $false + } + + $parts = [System.Collections.Generic.List[string]]::new() + $start = 0 + + for ($i = 0; $i -lt $Value.Length; $i++) + { + if ($Value[$i] -eq '\') + { + $i++ + continue + } + + if ($Value[$i] -in @(',', '+')) + { + $parts.Add($Value.Substring($start, $i - $start)) + $start = $i + 1 + } + } + + $parts.Add($Value.Substring($start)) + + foreach ($part in $parts) + { + if ($part -cnotmatch '^(?:[a-zA-Z][a-zA-Z0-9-]*|[0-9]+(?:\.[0-9]+)+)=(.*)$') + { + return $false + } + + $text = $Matches[1] + + if ($text.Length -eq 0) + { + return $false + } + + if ($text.StartsWith('#')) + { + # Validate hex-string notation only; do not claim to validate ASN.1/BER contents. + if ($text -cnotmatch '^#(?:[a-fA-F0-9]{2})+$') + { + return $false + } + + continue + } + + for ($j = 0; $j -lt $text.Length; $j++) + { + $ch = $text[$j] + + if ($ch -eq '\') + { + if ($j + 1 -ge $text.Length) + { + return $false + } + + if ($j + 2 -lt $text.Length -and $text.Substring($j + 1, 2) -cmatch '^[a-fA-F0-9]{2}$') + { + $j += 2 + } + elseif ($text[$j + 1] -in @(' ', '"', '#', '+', ',', ';', '<', '=', '>', '\')) + { + $j++ + } + else + { + return $false + } + } + elseif ($ch -in @('"', '+', ',', ';', '<', '>') -or + ($ch -eq ' ' -and ($j -eq 0 -or $j -eq $text.Length - 1))) + { + return $false + } + } + } + + return $true +} diff --git a/Private/Test-ITToolBoxDnsName.ps1 b/Private/Test-ITToolBoxDnsName.ps1 new file mode 100644 index 0000000..f5e49c4 --- /dev/null +++ b/Private/Test-ITToolBoxDnsName.ps1 @@ -0,0 +1,44 @@ +function Test-ITToolBoxDnsName +{ + param + ( + [string]$Name, + [switch]$AllowRootDot + ) + + if ([string]::IsNullOrWhiteSpace($Name)) + { + return $false + } + + if ($AllowRootDot -and $Name.EndsWith('.')) + { + $Name = $Name.Substring(0, $Name.Length - 1) + } + + try + { + $idn = [System.Globalization.IdnMapping]::new() + $idn.UseStd3AsciiRules = $true + $ascii = $idn.GetAscii($Name) + } + catch [System.ArgumentException] + { + return $false + } + + if ($ascii.Length -gt 253) + { + return $false + } + + foreach ($label in $ascii.Split('.')) + { + if ($label.Length -gt 63 -or $label -cnotmatch '^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$') + { + return $false + } + } + + return $true +} diff --git a/README.md b/README.md index ad4af23..de6c587 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ See [`New-LogEntry/`](./New-LogEntry/) for documentation, source, tests, and cha The standalone OAuth-style request helper now follows the IT-ToolBox implementation. It requires PowerShell 7.4+, defaults to a form POST, supports dictionary headers and prevents client secrets from entering GET URLs. See [usage and migration notes](./docs/New-ApiRequest.md) and [tests](./Tests/New-ApiRequest.Tests.ps1). No module installation is required. +### String conversion, email/DN validation and elapsed time + +`New-StringConversion`, `Test-IsEmail`, `Test-IsValidDn` and `Get-ElapsedTime` now use the IT-ToolBox implementations with a PowerShell 7.4 baseline. Dot-source the existing root files as before, keeping `Private/` alongside them. No IT-ToolBox installation is required. See [alignment and migration notes](./docs/ToolboxAlignment.md) and [tests](./Tests/ToolboxAlignment.Tests.ps1). + ## Standalone functions The repository also contains standalone utilities including: diff --git a/Test-IsEmail.ps1 b/Test-IsEmail.ps1 index 2dc7295..c589f34 100644 --- a/Test-IsEmail.ps1 +++ b/Test-IsEmail.ps1 @@ -1,49 +1,73 @@ +#Requires -Version 7.4 + +. (Join-Path $PSScriptRoot 'Private/Test-ITToolBoxDnsName.ps1') + function Test-IsEmail { <# .SYNOPSIS - Function to check if a string is an RFC email address. + Tests common bare email-address syntax without contacting a mail server. .DESCRIPTION - Function will check if an input string is an RFC complient email address. + Accepts an ASCII dot-atom local part and a DNS domain, including an IDN domain + and single-label names. The local part is limited to 64 ASCII characters and + the complete address to 254 characters after IDN conversion. + Rejects display names, comments, quoted local parts, address literals, Unicode + local parts, surrounding whitespace and controls. This is a practical subset, + not a complete RFC parser or proof of mailbox existence or deliverability. .PARAMETER EmailAddress - A string representing the email address to be checked + A bare email address. Null, empty and malformed values return false. .EXAMPLE - PS C:\> Test-IsEmail -EmailAddress 'value1' - - .OUTPUTS - System.Boolean + Test-IsEmail -EmailAddress 'person+tag@example.com' - .LINK - Restrictions on email addresses - https://tools.ietf.org/html/rfc3696#section-3 + .EXAMPLE + 'person@example.com', 'invalid' | Test-IsEmail #> + [CmdletBinding()] [OutputType([bool])] - param - ( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] + param ( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] + [AllowNull()] + [AllowEmptyString()] [Alias('Email', 'Mail', 'Address')] - [string] - $EmailAddress + [string]$EmailAddress ) - try + process { - # Check if address is RFC compliant - [void]([mailaddress]$EmailAddress) + # Reject blank values and any embedded whitespace or control characters. + if ([string]::IsNullOrWhiteSpace($EmailAddress) -or $EmailAddress -match '[\s\p{Cc}]') + { + return $false + } - Write-Verbose -Message "Address $EmailAddress is an RFC compliant address" + [System.Net.Mail.MailAddress]$parsed = $null - return $true - } - catch - { - Write-Verbose -Message "Address $EmailAddress is not an RFC compliant address" + # Use the framework parser to reject malformed addresses and non-bare syntaxes. + if (-not [System.Net.Mail.MailAddress]::TryCreate($EmailAddress, [ref]$parsed) -or + $parsed.Address -cne $EmailAddress -or $parsed.DisplayName.Length -gt 0) + { + return $false + } + + # ASCII dot-atom policy deliberately excludes quoted and internationalized local parts. + if ($parsed.User.Length -gt 64 -or + $parsed.User -cnotmatch '^[a-zA-Z0-9!#$%&''*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&''*+/=?^_`{|}~-]+)*$') + { + return $false + } + + if (-not (Test-ITToolBoxDnsName -Name $parsed.Host)) + { + return $false + } + + # Convert the domain to its ASCII form for the final total-length check. + $asciiDomain = [System.Globalization.IdnMapping]::new().GetAscii($parsed.Host) - return $false + return ($parsed.User.Length + 1 + $asciiDomain.Length -le 254) } } diff --git a/Test-IsValidDn.ps1 b/Test-IsValidDn.ps1 index 1d072b1..a8c9bd9 100644 --- a/Test-IsValidDn.ps1 +++ b/Test-IsValidDn.ps1 @@ -1,46 +1,35 @@ -function Test-IsValidDN -{ - <# - .SYNOPSIS - Cmdlet will check if the input string is a valid distinguishedname. - - .DESCRIPTION - Cmdlet will check if the input string is a valid distinguishedname. - - Cmdlet is intended as a dignostic tool for input validation +#Requires -Version 7.4 - .PARAMETER ObjectDN - A string representing the object distinguishedname. +. (Join-Path $PSScriptRoot 'Private/Test-ITToolBoxDnSyntax.ps1') - .EXAMPLE - PS C:\> Test-IsValidDN -ObjectDN 'Value1' +function Test-IsValidDn +{ + <# + .SYNOPSIS + Tests a practical nonempty distinguished-name string syntax. + + .DESCRIPTION + Supports attribute descriptors/OIDs, escaped separators, hex escapes and + multi-valued RDNs. Does not require CN/OU/DC attributes or a DC suffix. Rejects + empty attribute values, literal controls, dangling/invalid escapes, unescaped + edge spaces and legacy quoted values. Hex-string notation is checked without + validating BER contents. No schema, directory existence, canonical equality + or decoded UTF-8 validation is performed; this is not a complete RFC parser. #> + [CmdletBinding()] [OutputType([bool])] - param - ( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] + param ( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] + [AllowNull()] + [AllowEmptyString()] [Alias('DN', 'DistinguishedName')] - [string] - $ObjectDN + [string]$ObjectDN ) - # Create new string builder - [System.Text.StringBuilder]$regexStringBuilder = [System.Text.StringBuilder]::New() - [void]($regexStringBuilder.Append('^(?:[A-Za-z][\w-]*|\d+(?:\.\d+)*)=(?:#(?:[\dA-Fa-f]{2})+|')) - [void]($regexStringBuilder.Append('(?:[^,=\+<>#;\\"]|\\[,=\+<>#;\\"]|\\[\dA-Fa-f]{2})*|"(?:')) - [void]($regexStringBuilder.Append('[^\\"]|\\[,=\+<>#;\\"]|\\[\dA-Fa-f]{2})*")(?:\+(?:[A-Za-z]')) - [void]($regexStringBuilder.Append('[\w-]*|\d+(?:\.\d+)*)=(?:#(?:[\dA-Fa-f]{2})+|(?:[^,=\+<>#;\\"]')) - [void]($regexStringBuilder.Append('|\\[,=\+<>#;\\"]|\\[\dA-Fa-f]{2})*|"(?:[^\\"]|\\[,=\+<>#;\\"]|')) - [void]($regexStringBuilder.Append('\\[\dA-Fa-f]{2})*"))*(?:,(?:[A-Za-z][\w-]*|\d+(?:\.\d+)*)=(?:#')) - [void]($regexStringBuilder.Append('(?:[\dA-Fa-f]{2})+|(?:[^,=\+<>#;\\"]|\\[,=\+<>#;\\"]|\\[\dA-Fa-f]')) - [void]($regexStringBuilder.Append('{2})*|"(?:[^\\"]|\\[,=\+<>#;\\"]|\\[\dA-Fa-f]{2})*")(?:\+(?:[A-Za-z]')) - [void]($regexStringBuilder.Append('[\w-]*|\d+(?:\.\d+)*)=(?:#(?:[\dA-Fa-f]{2})+|(?:[^,=\+<>#;\\"]|\\[,=\')) - [void]($regexStringBuilder.Append('+<>#;\\"]|\\[\dA-Fa-f]{2})*|"(?:[^\\"]|\\[,=\+<>#;\\"]|\\[\dA-Fa-f]{2})*"))*)*$')) - - # Define DN Regex - [string]$distinguishedNameRegex = $regexStringBuilder.ToString() - - return $ObjectDN -match $distinguishedNameRegex -} \ No newline at end of file + process + { + # Delegate the actual syntax validation to the DN-specific helper so the public wrapper stays simple. + return (Test-ITToolBoxDnSyntax -Value $ObjectDN) + } +} diff --git a/Tests/ToolboxAlignment.Tests.ps1 b/Tests/ToolboxAlignment.Tests.ps1 new file mode 100644 index 0000000..3d58c50 --- /dev/null +++ b/Tests/ToolboxAlignment.Tests.ps1 @@ -0,0 +1,189 @@ +BeforeAll { + foreach ($name in @('New-StringConversion', 'Test-IsEmail', 'Test-IsValidDn', 'Get-ElapsedTime')) { + . (Join-Path $PSScriptRoot ("../{0}.ps1" -f $name)) + } +} + +Describe 'String conversion' { + It 'returns one string without internal array indices' { + $result = @(New-StringConversion -StringToConvert 'café') + $result.Count | Should -Be 1 + $result[0] | Should -BeExactly 'cafe' + } + It 'preserves mapped uppercase and multi-character replacements' { + New-StringConversion -StringToConvert 'CAFÉ Straße' | Should -BeExactly 'CAFE-Strasse' + New-StringConversion -StringToConvert 'ÀÉÖ' | Should -BeExactly 'AEO' + } + It 'supports default, preserved, removed and custom spaces' { + New-StringConversion -StringToConvert 'a b' | Should -BeExactly 'a-b' + New-StringConversion -StringToConvert 'a b' -IgnoreSpaces | Should -BeExactly 'a b' + New-StringConversion -StringToConvert 'a b' -RemoveSpaces | Should -BeExactly 'ab' + New-StringConversion -StringToConvert 'a b' -ReplaceSpaces '_' | Should -BeExactly 'a_b' + New-StringConversion -StringToConvert 'a b' -ReplaceSpaces '' | Should -BeExactly 'ab' + } + It 'does not mutate custom maps even if a space key exists' { + $map = @{ 'é' = 'ee'; ' ' = 'original' } + New-StringConversion -StringToConvert 'É é' -UnicodeHashTable $map -IgnoreSpaces | Should -BeExactly 'EE ee' + $map.Count | Should -Be 2 + $map[' '] | Should -BeExactly 'original' + } + It 'uses the custom map instead of silently merging defaults' { + New-StringConversion -StringToConvert 'éö' -UnicodeHashTable @{ 'é' = 'e' } | Should -BeExactly 'e?' + } + It 'normalizes decomposed accents and handles supplementary text elements once' { + New-StringConversion -StringToConvert "cafe$([char]0x301)" | Should -BeExactly 'cafe' + New-StringConversion -StringToConvert 'a🔐b' -UnknownCharacter '' | Should -BeExactly 'ab' + } + It 'preserves the historical permitted punctuation and special mappings' { + New-StringConversion -StringToConvert 'A1!#$@.''^_~-' | Should -BeExactly 'A1!#$@.''^_~-' + New-StringConversion -StringToConvert '&' | Should -BeExactly 'e' + New-StringConversion -StringToConvert ':' | Should -BeExactly '?' + } + It 'rejects conflicting space policies and empty map keys' { + { New-StringConversion -StringToConvert 'a b' -IgnoreSpaces -RemoveSpaces } | Should -Throw + { New-StringConversion -StringToConvert 'a' -UnicodeHashTable @{ '' = 'x' } } | Should -Throw '*keys cannot be empty*' + } +} + +Describe 'Bare email-address validation' { + It 'accepts a common bare address: ' -ForEach @( + @{ Value = 'person@example.com' } + @{ Value = 'Person.Name+tag@EXAMPLE.COM' } + @{ Value = "a!#$%&'*+-/=?^_``{|}~@example.com" } + @{ Value = 'person@localhost' } + @{ Value = 'person@bücher.example' } + @{ Value = 'person@xn--bcher-kva.example' } + @{ Value = 'person@sub-domain.example' } + ) { + Test-IsEmail -EmailAddress $Value | Should -BeTrue + } + + It 'rejects a malformed or unsupported address: ' -ForEach @( + @{ Value = $null }; @{ Value = '' }; @{ Value = ' ' } + @{ Value = 'not-an-email' }; @{ Value = 'person@' }; @{ Value = '@example.com' } + @{ Value = ' person@example.com' }; @{ Value = 'person@example.com ' } + @{ Value = "person@example.com`r`nBcc: other@example.com" } + @{ Value = "per$([char]0)son@example.com" } + @{ Value = 'Person ' }; @{ Value = '' } + @{ Value = 'person(comment)@example.com' }; @{ Value = 'person@example.com(comment)' } + @{ Value = 'a@example.com,b@example.com' }; @{ Value = 'a@example.com;b@example.com' } + @{ Value = 'a..b@example.com' }; @{ Value = '.person@example.com' }; @{ Value = 'person.@example.com' } + @{ Value = '"person name"@example.com' }; @{ Value = 'pérson@example.com' } + @{ Value = 'person@[127.0.0.1]' }; @{ Value = 'person@[IPv6:::1]' } + @{ Value = 'person@-bad.example' }; @{ Value = 'person@bad-.example' } + @{ Value = 'person@bad_domain.example' }; @{ Value = 'person@example..com' } + @{ Value = 'person@example.com.' }; @{ Value = 'person@bad/example.com' } + ) { + Test-IsEmail -EmailAddress $Value | Should -BeFalse + } + + It 'preserves the Email, Mail and Address parameter aliases' { + Test-IsEmail -Email 'person@example.com' | Should -BeTrue + Test-IsEmail -Mail 'person@example.com' | Should -BeTrue + Test-IsEmail -Address 'person@example.com' | Should -BeTrue + } + + It 'returns one boolean per pipeline record, including blank input' { + $results = @('person@example.com', '', 'bad', 'person@localhost') | Test-IsEmail + $results.Count | Should -Be 4 + ($results -join ',') | Should -Be 'True,False,False,True' + foreach ($result in $results) { $result | Should -BeOfType ([bool]) } + } + + It 'enforces local-part, DNS-label and complete-address length boundaries' { + Test-IsEmail (('a' * 64) + '@example.com') | Should -BeTrue + Test-IsEmail (('a' * 65) + '@example.com') | Should -BeFalse + Test-IsEmail ('a@' + ('b' * 63) + '.example') | Should -BeTrue + Test-IsEmail ('a@' + ('b' * 64) + '.example') | Should -BeFalse + $domain = ('b' * 63) + '.' + ('c' * 63) + '.' + ('d' * 61) + Test-IsEmail (('a' * 64) + '@' + $domain) | Should -BeTrue + Test-IsEmail (('a' * 64) + '@' + $domain + 'e') | Should -BeFalse + # IDN length is measured after conversion, not from the Unicode spelling. + Test-IsEmail ('a@' + ('é' * 60) + '.example') | Should -BeFalse + } +} + + +Describe 'Distinguished-name syntax policy' { + It 'accepts supported syntax: ' -ForEach @( + @{ Value = 'CN=Person,OU=People,DC=example,DC=com' } + @{ Value = 'cn=Person,dc=example,dc=technology' } + @{ Value = 'CN=Last\, First,OU=People,DC=example' } + @{ Value = 'CN=Last\2C First,DC=example' } + @{ Value = 'CN=A\+B+UID=123,OU=People,DC=example' } + @{ Value = 'CN=\ Leading\ ,DC=example' } + @{ Value = 'CN=\#literal,DC=example' } + @{ Value = 'CN=A=B,DC=example' } + @{ Value = 'CN=Jörg,DC=example' } + @{ Value = '2.5.4.3=#04024869,DC=example' } + @{ Value = 'CN=Standalone' } + @{ Value = 'CN=Back\\Slash,DC=example' } + ) { Test-IsValidDn $Value | Should -BeTrue } + It 'rejects malformed or unsupported syntax: ' -ForEach @( + @{ Value = $null }; @{ Value = '' }; @{ Value = ' ' } + @{ Value = 'Person' }; @{ Value = 'CN=' }; @{ Value = 'CN=Person,' } + @{ Value = ',CN=Person' }; @{ Value = 'CN=Person,,DC=example' } + @{ Value = 'CN=Person+,DC=example' }; @{ Value = 'CN=Person,DC=' } + @{ Value = ' CN=Person' }; @{ Value = 'CN= Person' }; @{ Value = 'CN=Person ' } + @{ Value = 'CN=Person\' }; @{ Value = 'CN=A\z,DC=example' } + @{ Value = 'CN=A\2Z,DC=example' }; @{ Value = 'CN="Last, First",DC=example' } + @{ Value = 'CN=#odd' }; @{ Value = 'CN=#123' }; @{ Value = 'CN=A' -ForEach @( + @{ Component = 'Days' }; @{ Component = 'Hours' }; @{ Component = 'Minutes' } + @{ Component = 'Seconds' }; @{ Component = 'TotalDays' }; @{ Component = 'TotalHours' } + @{ Component = 'TotalMinutes' }; @{ Component = 'TotalSeconds' }; @{ Component = 'TotalMilliseconds' } + ) { + $timer = [System.Diagnostics.Stopwatch]::new() + $selector = @{ $Component = $true } + Get-ElapsedTime -ElapsedTime $timer @selector | Should -Be $timer.Elapsed.$Component + } +} + +Describe 'Independent standalone loading' { + It 'loads without IT-ToolBox or another function' -ForEach @( + @{ Function = 'New-StringConversion'; Parameter = 'StringToConvert'; Argument = 'café'; Expected = 'cafe' } + @{ Function = 'Test-IsEmail'; Parameter = 'EmailAddress'; Argument = 'person@example.com'; Expected = 'True' } + @{ Function = 'Test-IsValidDn'; Parameter = 'ObjectDN'; Argument = 'CN=Person'; Expected = 'True' } + ) { + $path = (Resolve-Path (Join-Path $PSScriptRoot ("../{0}.ps1" -f $Function))).Path + $result = & (Join-Path $PSHOME 'pwsh') -NoProfile -Command { + param($Path, $Function, $Parameter, $Argument) + . $Path + $arguments = @{ $Parameter = $Argument } + & $Function @arguments + } -args $path, $Function, $Parameter, $Argument + $LASTEXITCODE | Should -Be 0 + [string]$result | Should -BeExactly $Expected + } + It 'returns a TimeSpan when loaded independently' { + $path = (Resolve-Path (Join-Path $PSScriptRoot '../Get-ElapsedTime.ps1')).Path + $result = & (Join-Path $PSHOME 'pwsh') -NoProfile -Command { + param($Path) + . $Path + $timer = [System.Diagnostics.Stopwatch]::new() + (Get-ElapsedTime -ElapsedTime $timer).GetType().FullName + } -args $path + $LASTEXITCODE | Should -Be 0 + $result | Should -BeExactly 'System.TimeSpan' + } +} diff --git a/docs/ToolboxAlignment.md b/docs/ToolboxAlignment.md new file mode 100644 index 0000000..bc99778 --- /dev/null +++ b/docs/ToolboxAlignment.md @@ -0,0 +1,38 @@ +# Standalone alignment: conversion, validation and elapsed time + +These four implementations are backported from IT-ToolBox master a73f0af. The public function bodies and required helpers are unchanged. Root scripts add PowerShell 7.4 requirements and explicit helper loading. No IT-ToolBox module installation is required. + +## Loading + +```powershell +. ./New-StringConversion.ps1 +. ./Test-IsEmail.ps1 +. ./Test-IsValidDn.ps1 +. ./Get-ElapsedTime.ps1 +``` + +Each file loads independently. Preserve the repository's Private/ directory when copying helper-dependent functions; copying the root file alone is no longer sufficient. Helper names retain the ITToolBox prefix to match the source implementation. Private/ is a maintenance convention: dot-sourcing makes those helper functions visible in the caller's scope, so this is not an enforced module-private boundary. Installing these scripts does not import or alter an existing IT-ToolBox module. + +## Intentional contract changes + +### New-StringConversion + +Returns one string, without internal collection indices. Default spaces become hyphens; IgnoreSpaces, RemoveSpaces and ReplaceSpaces choose alternatives. An empty replacement is allowed. Custom maps replace the default map and are copied rather than modified. Input is normalized to Unicode Form C and processed as text elements, with invariant case handling. Unsupported text elements use UnknownCharacter. This is a specific character-conversion policy, not general transliteration or removal of non-Unicode characters. Helper: Get-ITToolBoxCharacterMap.ps1. + +### Test-IsEmail + +Returns a Boolean per input, including false for null/empty values. Existing Email/Mail/Address aliases remain. Accepts a practical bare-address subset: ASCII dot-atom local parts and DNS/IDN domains, including single-label domains. Rejects display-name wrappers, comments, quoted local parts, address literals and Unicode local parts. Syntax validation does not establish mailbox existence or deliverability. This narrows the historical MailAddress-only acceptance policy. Helper: Test-ITToolBoxDnsName.ps1. + +### Test-IsValidDn + +Returns a Boolean per input, preserving DN/DistinguishedName aliases. Supports attribute names/OIDs, escaped separators, hex escapes and multi-valued RDNs. Rejects empty values, unescaped edge spaces, controls, invalid escapes and legacy quoted values. Does not require CN/OU/DC or a DC suffix. It checks a documented practical syntax subset, not schema, existence, decoded UTF-8 validity or full RFC conformance. Helper: Test-ITToolBoxDnSyntax.ps1. + +### Get-ElapsedTime + +The non-null Stopwatch input is mandatory in every parameter set. Parameter name ElapsedTime and all existing selectors remain. Default output is TimeSpan. Seconds/Minutes/Hours return components; TotalSeconds/TotalMinutes/TotalHours return totals. The distinction is unchanged. + +## Tests and source alignment + +The standalone test suite selects the existing IT-ToolBox StringUtilities, EmailUrlValidation, AdHelpers and Module regression cases for these four functions. It adapts loading and command discovery only, plus independent-process loading checks. The standalone API suite from the prior batch remains unchanged. No live directory, mail or network calls are used. The added workflow runs both standalone suites on Windows, Linux and macOS with Pester 5.7.1 and each runner's installed PowerShell. + +Future changes should be reconciled with IT-ToolBox public implementations and helpers rather than developed as independent fixes. The existing New-LogEntry workflow and implementation remain untouched.