From 76a3057abc8464ebec2ccef5f9075c5b66987a76 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:26:42 -0500 Subject: [PATCH 1/2] Correct the wording about quoted boolean settings - Four places said quoted text in a boolean setting reads as off, but the text "true" in any letter case compares equal to $true, so it reads as on: the rule in AGENTS.md, the From source paragraph in Research_Notes/PowerShell-Language-Behavior.md section 10, the IncludeBCCInGroupChat comment in Send-ScriptMessage, and the comment above the quoted-"false" tests. The code already behaved this way; only the wording changes. - Changelog: the Minor entry about validating configuration file settings overstated it. Nothing is validated, and only IncludeBCCInGroupChat and MgDisconnectWhenDone read differently from 1.1.1; the MgDelegatedPermission_* settings already used -eq $true. Reword it as reading every true/false setting the same way. - Fix the installed-module scratch script, which imported Script-Message instead of ScriptMessage. - Delete Tests/MgGraphDynamicParams.ps1, an unused DynamicParam snippet that nothing in the module or tests references. --- AGENTS.md | 4 +- CHANGELOG.md | 2 +- .../PowerShell-Language-Behavior.md | 5 +- ScriptMessage/Public/Send-ScriptMessage.ps1 | 5 +- Tests/MgGraphDynamicParams.ps1 | 52 ------------------- ...riptMessage_Testing (Installed Module).ps1 | 2 +- Tests/Send-ScriptMessage.Tests.ps1 | 5 +- 7 files changed, 13 insertions(+), 62 deletions(-) delete mode 100644 Tests/MgGraphDynamicParams.ps1 diff --git a/AGENTS.md b/AGENTS.md index e5e66a0..61f030d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,8 +206,8 @@ $Text = [System.IO.File]::ReadAllText($Path) - **Comments describe the code as it is now**, never what it used to do or what a fix changed. Change history belongs in the commit message and the changelog. - **A boolean configuration setting is an opt-in flag: on only when it equals `$true`.** Read it as - `$ServiceConfig. -eq $true`, with the setting on the left, so missing, `false`, quoted text, and typos - all mean off. Never read one with a `[bool]` cast, a truthiness test, or `$true -eq`: each of those reads the + `$ServiceConfig. -eq $true`, with the setting on the left, so missing, `false`, typos, and any quoted + text except `"true"` (in any letter case, which counts as on) all mean off. Never read one with a `[bool]` cast, a truthiness test, or `$true -eq`: each of those reads the text `"false"` as true. Name a new boolean setting so that `true` opts in and off is the safe state. - **A service that cannot honor a setting or parameter it receives says so in its result and sends what it can.** Add a `Warning` entry to that message's `Error` property, as the Microsoft Graph service does for a diff --git a/CHANGELOG.md b/CHANGELOG.md index bee4a67..46f6f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ - `Send-ScriptMessage` now honors an explicit `-IncludeBCCInGroupChat $false`. The configuration file's `IncludeBCCInGroupChat` setting was used in its place, so a call asking to keep BCC recipients out of a group chat added them anyway when that setting was `true`, making those addresses visible to everyone in the chat. An explicit `-IncludeBCCInGroupChat $true` was unaffected. - `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. - `Get-ScriptMessageContext -ReturnCachedContext` no longer reports a connection that `Disconnect-ScriptMessage` has already ended. Disconnecting left the cached context in place, so a cached read kept returning the account and scopes of the finished session, and only a read without `-ReturnCachedContext` showed the truth. Disconnecting now clears that service's cached context, so the next cached read asks the service again. -- Minor: Improved how `Send-ScriptMessage` validates configuration file settings, so a value in a form a setting does not support is no longer misread. +- Minor: `Send-ScriptMessage` now reads every true/false setting in the configuration file the same way, so a value other than `true` no longer turns a setting on. - Minor: The help for `Get-ScriptMessageConfig` and `Set-ScriptMessageConfigFilePath` now describes the `-Path` parameter, which it left blank. - Minor: The fourth `Send-ScriptMessage` help example, which sends attachments held in variables, now runs as written. It failed with "Missing closing ')' in subexpression" when copied, and it ended without calling `Send-ScriptMessage`. diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index 92891b7..ae80169 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -307,8 +307,9 @@ three states it needs (unset, true, false) and makes the binder reject a string. setting is read as an opt-in flag, ` -eq $true` with the setting on the left: `IncludeBCCInGroupChat` in `Send-ScriptMessage`, `MgDisconnectWhenDone` in `Send-ScriptMessage_MicrosoftGraph`, and `MgDelegatedPermission_RequestChatReadPermission` and `MgDelegatedPermission_RequestFilesReadWritePermission` in -`Connect-ScriptMessage_MicrosoftGraph`. A missing setting, `false`, quoted text, and a typo all read as off, which -is the safe state of all four. Unquoted JSON `true` and `false` arrive as real booleans. +`Connect-ScriptMessage_MicrosoftGraph`. A missing setting, `false`, a typo, and quoted text all read as off, which +is the safe state of all four settings, except that quoted `"true"` in any letter case reads as on, as the table +above shows. Unquoted JSON `true` and `false` arrive as real booleans. ### What this does not establish diff --git a/ScriptMessage/Public/Send-ScriptMessage.ps1 b/ScriptMessage/Public/Send-ScriptMessage.ps1 index c48096f..4b11f7b 100644 --- a/ScriptMessage/Public/Send-ScriptMessage.ps1 +++ b/ScriptMessage/Public/Send-ScriptMessage.ps1 @@ -321,8 +321,9 @@ function Send-ScriptMessage # 'IncludeBCCInGroupChat' accepts $null to mean "use the configured value", which is also what an # unsupplied [Nullable[bool]] holds. The configured value is an opt-in flag, on only when it equals - # $true, so anything else, including quoted text, leaves BCC recipients out. Keep the setting on the - # left: a [bool] cast, or $true on the left, reads the text "false" as $true. + # $true. Text equals $true only when it reads "true" in any letter case, so anything else, including + # the text "false", leaves BCC recipients out. Keep the setting on the left: a [bool] cast, or $true + # on the left, reads the text "false" as $true. if ($null -ne $IncludeBCCInGroupChat) { [bool]$ResolvedIncludeBCCInGroupChat = $IncludeBCCInGroupChat diff --git a/Tests/MgGraphDynamicParams.ps1 b/Tests/MgGraphDynamicParams.ps1 deleted file mode 100644 index 2b634ad..0000000 --- a/Tests/MgGraphDynamicParams.ps1 +++ /dev/null @@ -1,52 +0,0 @@ -DynamicParam -{ - # Initialize Parameter Dictionary - $ParameterDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new() - - # Make Mg* parameters appear only if messaging 'Service' is 'MicrosoftGraph'. - if ($Service -eq 'MicrosoftGraph') - { - $ParameterAttributes = [System.Management.Automation.ParameterAttribute]@{ - ParameterSetName = "MicrosoftGraph" - Mandatory = $true - ValueFromPipeline = $true - ValueFromPipelineByPropertyName = $true - } - - $AttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() - $AttributeCollection.Add($ParameterAttributes) - - $DynamicParameter1 = [System.Management.Automation.RuntimeDefinedParameter]::new( - 'MgPermissionType', [string], $AttributeCollection) # Delegated or Application. See: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#permission-types and https://docs.microsoft.com/en-us/graph/auth/auth-concepts#delegated-and-application-permissions. - - $DynamicParameter2 = [System.Management.Automation.RuntimeDefinedParameter]::new( - 'MgDisconnectWhenDone', [bool], $AttributeCollection) - - $DynamicParameter3 = [System.Management.Automation.RuntimeDefinedParameter]::new( - 'MgTenantID', [string], $AttributeCollection) - - $DynamicParameter4 = [System.Management.Automation.RuntimeDefinedParameter]::new( - 'MgClientID', [string], $AttributeCollection) - - $ParameterDictionary.Add('MgPermissionType', $DynamicParameter1) - $ParameterDictionary.Add('MgDisconnectWhenDone', $DynamicParameter2) - $ParameterDictionary.Add('MgTenantID', $DynamicParameter3) - $ParameterDictionary.Add('MgClientID', $DynamicParameter4) - } - - return $ParameterDictionary -} - -begin -{ - # Set Variables From Dynamic Parameters - switch ($Service) - { - 'MicrosoftGraph' { - $MgPermissionType = $PSBoundParameters['MgPermissionType'] - $MgDisconnectWhenDone = $PSBoundParameters['MgDisconnectWhenDone'] - $MgTenantID = $PSBoundParameters['MgTenantID'] - $MgClientID = $PSBoundParameters['MgClientID'] - } - } -} diff --git a/Tests/ScriptMessage_Testing (Installed Module).ps1 b/Tests/ScriptMessage_Testing (Installed Module).ps1 index da82efd..2f65614 100644 --- a/Tests/ScriptMessage_Testing (Installed Module).ps1 +++ b/Tests/ScriptMessage_Testing (Installed Module).ps1 @@ -19,7 +19,7 @@ $ErrorActionPreference = 'Stop' ############################## # Import ScriptMessage Module -Import-Module Script-Message +Import-Module ScriptMessage # Set ScriptMessage MessagingConfiguration Path Set-ScriptMessageConfigFilePath -Path "$PSScriptRoot\Config\config_scriptmessage.json" diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index bc94774..7a8b0bb 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -747,8 +747,9 @@ Describe 'Send-ScriptMessage configuration defaults' { } } - # A boolean setting is an opt-in flag, on only when it equals $true. Quoted text is not $true, so a - # quoted "false" must read as off; a [bool] cast would read it as on. + # A boolean setting is an opt-in flag, on only when it equals $true. Text equals $true only when it + # reads "true" in any letter case, so a quoted "false" must read as off; a [bool] cast would read it + # as on. Context 'A configuration file that quotes IncludeBCCInGroupChat as "false"' { BeforeAll { Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { From 25ab5b4e9099bcc0f236aa93537fcc512f999210 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:48:11 -0500 Subject: [PATCH 2/2] Report why the configuration file could not be used Get-ScriptMessageConfig reported every failure as "Can't find the JSON configuration file" and told the caller to create one with Set-ScriptMessageConfigFilePath, which only stores the path. A missing file, a file that cannot be read, and a file that is not valid JSON now get separate messages that include the reason. Windows PowerShell 5.1's ConvertFrom-Json ends many of its messages with the entire input, which here is the configuration file and its secrets, so the parse error keeps only the reason in front of it. Measured in Research_Notes/PowerShell-Language-Behavior.md section 11. --- CHANGELOG.md | 1 + .../PowerShell-Language-Behavior.md | 50 +++++++++++++++++++ .../Public/Get-ScriptMessageConfig.ps1 | 39 +++++++++++---- Tests/Get-ScriptMessageConfig.Tests.ps1 | 20 ++++++++ 4 files changed, 99 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f6f20..dc5ba4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Minor: `Send-ScriptMessage` now reads every true/false setting in the configuration file the same way, so a value other than `true` no longer turns a setting on. - Minor: The help for `Get-ScriptMessageConfig` and `Set-ScriptMessageConfigFilePath` now describes the `-Path` parameter, which it left blank. - Minor: The fourth `Send-ScriptMessage` help example, which sends attachments held in variables, now runs as written. It failed with "Missing closing ')' in subexpression" when copied, and it ended without calling `Send-ScriptMessage`. +- Minor: `Get-ScriptMessageConfig`, `Connect-ScriptMessage`, and `Send-ScriptMessage` now say why a configuration file could not be used. --- ## [1.1.1](https://github.com/Sekers/ScriptMessage/tree/1.1.1) - 2026-09-16 diff --git a/Research_Notes/PowerShell-Language-Behavior.md b/Research_Notes/PowerShell-Language-Behavior.md index ae80169..c11ed19 100644 --- a/Research_Notes/PowerShell-Language-Behavior.md +++ b/Research_Notes/PowerShell-Language-Behavior.md @@ -322,3 +322,53 @@ above shows. Unquoted JSON `true` and `false` arrive as real booleans. - Numbers were measured only with the value on the left, the same day: `1 -eq $true` is `True` and `0 -eq $true` is `False`. A JSON array or object in a boolean setting was not tested; an array on the left of `-eq` filters rather than compares. + +## 11. Measured: catching file errors, and `ConvertFrom-Json` messages that repeat their input + +Measured on **2026-09-21**, same machine and editions as the header, in a standalone script with the module not +imported. The editions differ on `ConvertFrom-Json`, as the second table shows. + +A .NET method called from PowerShell throws its exception wrapped in a `MethodInvocationException`. A typed +`catch []` still matches the exception inside, and there `$_.Exception` is that inner exception. A plain +`catch` receives the wrapper, and `$_.Exception.GetBaseException()` returns the inner one. Each path below went +through `$PSCmdlet.GetUnresolvedProviderPathFromPSPath()` and then `[System.IO.StreamReader]::new()`: + +| Path | Exception | Matched by a typed `catch` for it | +| --- | --- | --- | +| a file that does not exist | `System.IO.FileNotFoundException`: "Could not find file '...'" with the full path | yes | +| a file in a folder that does not exist | `System.IO.DirectoryNotFoundException`: "Could not find a part of the path '...'" | yes | +| a drive that does not exist, `Q:\missing.json` | `System.Management.Automation.DriveNotFoundException`, from the path resolution | yes | +| a folder | `System.UnauthorizedAccessException`: "Access to the path '...' is denied." | not tried; a plain `catch` got it | + +`ConvertFrom-Json -ErrorAction Stop` throws `System.ArgumentException` in both editions. Windows PowerShell 5.1 +ends many of its messages with a position in parentheses, such as `(45):`, followed by the entire text it was +given: + +| Input | Windows PowerShell 5.1 | PowerShell 7 | +| --- | --- | --- | +| a missing colon, `"MgClientID" "client-id"` | `Invalid object passed in, ':' or '}' expected. (45):` then the input | `Invalid character after parsing property name. Expected ':' but got: ". Path 'MgTenantID', line 1, position 44.` | +| a missing closing `}` | `Invalid object passed in, ':' or '}' expected. (83):` then the input | `Unexpected end when deserializing object. Path 'Secret', line 1, position 83.` | +| a single backslash, `"C:\Certs\a.pfx"` | `Unrecognized escape sequence. (44):` then the input | `Bad JSON escape sequence: \C. Path 'Path', line 1, position 44.` | +| a missing closing `]` | `Invalid array passed in, ',' expected. (32):` then the input | no error | +| `not json` | `Invalid JSON primitive: not.` | `Unexpected character encountered while parsing value: n. Path '', line 0, position 0.` | +| a missing value, `"Bad": ,`, or a trailing comma | `Invalid JSON primitive: .` | no error | +| an empty string | no error; the result is `$null` | the same | + +Every PowerShell 7 message starts with `Conversion from JSON failed with error:`, omitted above. A 5,076-character +input with a missing colon near its start gave a 5,129-character message on 5.1 ending with the input's last +line, so the input is not truncated. + +**From source:** `Get-ScriptMessageConfig` catches the three not-found exceptions above by type to report a +missing file, and reports anything else from reading the file as unreadable, both with +`GetBaseException().Message`. It removes the `():` position and everything after it from a `ConvertFrom-Json` message, so +the configuration file, which holds the tenant ID, client ID, and encrypted secrets, never reaches the error. An +empty configuration file reads as `$null` without an error in both editions. + +### What this does not establish + +- Only the inputs above were tried. That every 5.1 message repeating the input uses the `():` form is + inferred from them, not read from the source of the serializer 5.1 uses. +- A message without that form can still quote one token: `Invalid JSON primitive` names the unquoted text it + stopped at, so an unquoted secret would appear in it. +- What PowerShell 7 builds from the input it accepts but 5.1 rejects was not examined. +- A file the account has no permission to read was not tested, only a folder given as the path. diff --git a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 index dc85ee2..752ee16 100644 --- a/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 +++ b/ScriptMessage/Public/Get-ScriptMessageConfig.ps1 @@ -45,7 +45,7 @@ function Get-ScriptMessageConfig throw "`'`$ScriptMessage_Global_ConfigFilePath`' is not specified. Don't forget to first use the `'Set-ScriptMessageConfigFilePath`' cmdlet!" } - # Get Config and Secrets + # Read the configuration file. try { # .NET resolves a relative path against the process directory, not PowerShell's current location. @@ -67,19 +67,36 @@ function Get-ScriptMessageConfig { $Reader.Dispose() } + } + catch [System.IO.FileNotFoundException], [System.IO.DirectoryNotFoundException], [System.Management.Automation.DriveNotFoundException] + { + throw "Can't find the JSON configuration file. $($_.Exception.GetBaseException().Message)" + } + catch + { + throw "Can't read the JSON configuration file. $($_.Exception.GetBaseException().Message)" + } - $ScriptMessageConfig = $ConfigText | ConvertFrom-Json - if (-not ($null -eq $Service)) - { - return $ScriptMessageConfig.$Service - } - else - { - return $ScriptMessageConfig - } + # Parse it. + try + { + $ScriptMessageConfig = $ConfigText | ConvertFrom-Json -ErrorAction Stop } catch { - throw "Can't find the JSON configuration file. Use 'Set-ScriptMessageConfigFilePath' to create one." + # Windows PowerShell 5.1 ends many of these messages with the entire text it was parsing, which here is the + # configuration file and its secrets, so keep only the reason in front of it. See section 11 of + # Research_Notes/PowerShell-Language-Behavior.md. + $Reason = $_.Exception.Message -replace '(?s) \(\d+\): .*$', '' + throw "The configuration file '$FullPath' is not valid JSON. $Reason" + } + + if (-not ($null -eq $Service)) + { + return $ScriptMessageConfig.$Service + } + else + { + return $ScriptMessageConfig } } \ No newline at end of file diff --git a/Tests/Get-ScriptMessageConfig.Tests.ps1 b/Tests/Get-ScriptMessageConfig.Tests.ps1 index 44da781..e6cea73 100644 --- a/Tests/Get-ScriptMessageConfig.Tests.ps1 +++ b/Tests/Get-ScriptMessageConfig.Tests.ps1 @@ -104,3 +104,23 @@ Describe 'Get-ScriptMessageConfig paths' { { Get-ScriptMessageConfig -Path (Join-Path $TestDrive 'missing.json') } | Should -Throw -ExpectedMessage "Can't find the JSON configuration file*" } } + +Describe 'Get-ScriptMessageConfig errors' { + It 'reports a folder that does not exist' { + { Get-ScriptMessageConfig -Path (Join-Path $TestDrive 'No Such Folder\config.json') } | + Should -Throw -ExpectedMessage "Can't find the JSON configuration file*No Such Folder*" + } + + It 'reports a path it cannot read as a file' { + { Get-ScriptMessageConfig -Path $TestDrive } | Should -Throw -ExpectedMessage "Can't read the JSON configuration file*" + } + + # Windows PowerShell 5.1 ends its message for this mistake with the whole file, secrets included. + It 'reports a file that is not valid JSON, without repeating its contents' { + $Path = Join-Path $TestDrive 'invalid.json' + [System.IO.File]::WriteAllText($Path, '{ "MicrosoftGraph": { "MgClientID" "not-a-real-secret" } }') + + $ErrorRecord = { Get-ScriptMessageConfig -Path $Path } | Should -Throw -ExpectedMessage "The configuration file '*invalid.json' is not valid JSON.*" -PassThru + $ErrorRecord.Exception.Message | Should -Not -BeLike '*not-a-real-secret*' + } +}