ValidateScript and order of operations - powershell

I am not understanding why ValidateScript is not erroring out until the end..
param (
[Parameter(
Mandatory = $true,
HelpMessage = "Specifies the email address that will manage the share drive to be created.")]
[ValidateScript({
if ($_.host -eq "domain.com") {
return $true
}
else {
Throw [System.Management.Automation.ValidationMetadataException] "Enter an email address ending with #domain.com"
}
})][Net.Mail.MailAddress]$EmailAddress,
[Parameter(
Mandatory = $true,
HelpMessage = "Specifies the name of the share drive name to be created. Enter a shared drive name like prgm- and at least 2 letters. Example prgm-hi")]
[ValidateScript({
if ($_ -like "prgm-??*") {
return $true
}
else {
Throw [System.Management.Automation.ValidationMetadataException] "Enter a shared drive name like prgm- and at least 2 letters. Example prgm-hi"
}
})][string]$SharedDriveName
)
In this code snip sample above if the script is run the person will get past the emailaddress variable if it is wrong and than get to the error after all the variables are answered.
I am not understanding how to have the script stop right as someone makes a bad entry.
I assume, I will have to use something other than validate script but I am not sure what?

Perhaps surprisingly, when PowerShell automatically prompts for values for missing mandatory parameters (written as of PowerShell 7.3.2):
it enforces the parameter data type after each prompt.
but performs validation - based on attributes such as ValidateScript() - only after ALL prompts have been answered.
This makes for an awkward interactive experience, given that as a user you won't learn whether any of your inputs are valid until all of them have been provided.
I can't speak to the design intent - a conceivable reason for this evaluation order is to allow validation script blocks to also consider other parameter values; however, in practice you can not refer to other parameters inside a [ValidateScript()] script block.
On a general note, the automatic prompting mechanism has fundamental limitations - see GitHub issue #7093.
Workaround:
You can move prompting and validating into the body of your function (as well), where you get to control the execution flow - obviously, this requires extra work:
param (
[Parameter(
# Do NOT make the parameter mandatory, so that it can be prompted for
# in the function body, but DO define a HelpMessage.
HelpMessage = "Specify the email address that will manage the share drive to be created (must end in #sonos.com)"
)]
[ValidateScript({
if ($_.host -eq 'sonos.com') {
return $true
}
else {
Throw "Enter an email address ending with #sonos.com"
}
})]
[Net.Mail.MailAddress] $EmailAddress,
[Parameter(
# Do NOT make the parameter mandatory, so that it can be prompted for
# in the function body, but DO define a HelpMessage.
HelpMessage = "Specify the name of the share drive name to be created. Enter a shared drive name like prgm- and at least 2 letters. Example prgm-hi"
)]
[ValidateScript({
if ($_ -like "prgm-??*") {
return $true
}
else {
Throw "Enter a shared drive name like prgm- and at least 2 letters. Example prgm-hi"
}
})]
[string]$SharedDriveName
)
# If no values were provided any of the conceptually mandatory parameters,
# prompt for and validate them now.
# Infer which parameters are conceptually mandatory from the presence of a
# .HelpMessage property.
$MyInvocation.MyCommand.Parameters.GetEnumerator() | ForEach-Object {
# See if there's a .HelpMessage property value and, if so,
# check if the parameter hasn't already been bound.
$helpMessage = $_.Value.Attributes.Where({ $_ -is [Parameter] }).HelpMessage
if ($helpMessage -and -not $PSBoundParameters.ContainsKey($_.Key)) {
# Prompt now and validate right away, which happens implicitly when
# the parameter variable is assigned to.
while ($true) {
try {
Set-Variable -Name $_.Key -Value (Read-Host $helpMessage)
break # Getting here means that validation succeeded -> exit loop.
}
catch {
# Validation failed: write the error message as a warning, and keep prompting.
Write-Warning "$_"
}
}
}
}
# ... all required arguments are now present and validated.
Note:
The upside of this approach is that you can keep prompting until valid input is received (or the user aborts with Ctrl-C).

Related

What is the proper way to define a dynamic ValidateSet in a PowerShell script?

I have a PowerShell 7.1 helper script that I use to copy projects from subversion to my local device. I'd like to make this script easier for me to use by enabling PowerShell to auto-complete parameters into this script. After some research, it looks like I can implement an interface to provide valid parameters via a ValidateSet.
Based on Microsoft's documentation, I attempted to do this like so:
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[ValidateSet([ProjectNames])]
[String]
$ProjectName,
#Other params
)
Class ProjectNames : System.Management.Automation.IValidateSetValuesGenerator {
[string[]] GetValidValues() {
# logic to return projects here.
}
}
When I run this, it does not auto-complete and I get the following error:
❯ Copy-ProjectFromSubversion.ps1 my-project
InvalidOperation: C:\OneDrive\Powershell-Scripts\Copy-ProjectFromSubversion.ps1:4
Line |
4 | [ValidateSet([ProjectNames])]
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| Unable to find type [ProjectNames].
This makes sense since the class isn't defined until after the parameters. So I moved the class above the parameters. Obviously this is a syntax error. So how do I do this? Is it not possible in a simple PowerShell script?
Indeed, you've hit a catch-22: for the parameter declaration to work during the script-parsing phase, class [ProjectNames] must already be defined, yet you're not allowed to place the class definition before the parameter declaration.
The closest approximation of your intent using a stand-alone script file (.ps1) is to use the ValidateScript attribute instead:
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[ValidateScript(
{ $_ -in (Get-ChildItem -Directory).Name },
ErrorMessage = 'Please specify the name of a subdirectory in the current directory.'
)]
[String] $ProjectName # ...
)
Limitations:
[ValidateScript] does not and cannot provide tab-completion: the script block, { ... }, providing the validation is only expected to return a Boolean, and there's no guarantee that a discrete set of values is even involved.
Similarly, you can't reference the dynamically generated set of valid values (as generated inside the script block) in the ErrorMessage property value.
The only way around these limitations would be to duplicate that part of the script block that calculates the valid values, but that can become a maintenance headache.
To get tab-completion you'll have to duplicate the relevant part of the code in an [ArgumentCompleter] attribute:
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[ValidateScript(
{ $_ -in (Get-ChildItem -Directory).Name },
ErrorMessage = 'Please specify the name of a subdirectory in the current directory.'
)]
[ArgumentCompleter(
{
param($cmd, $param, $wordToComplete)
# This is the duplicated part of the code in the [ValidateScipt] attribute.
[array] $validValues = (Get-ChildItem -Directory).Name
$validValues -like "$wordToComplete*"
}
)]
[String] $ProjectName # ...
)

Using ValidateSet() and ValidatePattern() to allow new values?

I would like to write a script with a parameter that has an existing set of values, but also allows the user to enter an unknown value. I am hoping that it will allow tab-completion from the known set, but not reject a value not yet in the known set.
In this case, there is a list of known servers. A new server might be added, so I want to allow the new server name to be entered. However, the ValidateSet() will reject anything it does not know.
This code does not work.
[cmdletbinding()]
Param (
[Parameter(Mandatory=$true)]
[Validatepattern('.*')]
[ValidateSet('server1', 'server2', 'bazooka')]
[string]$dbhost
)
Write-Host $dbhost
Running this for a known host works well. Automatic tab-completion works with the known list of hosts. But, a new hostname will be rejected.
>.\qd.ps1 -dbname server2
server2
>.\qd.ps1 -dbname spock
C:\src\t\qd.ps1 : Cannot validate argument on parameter 'dbname'. The argument "spock" does not belong to the set "server1,server2,bazooka" specified by the
ValidateSet attribute. Supply an argument that is in the set and then try the command again.
You can use a ArgumentCompleter script block for this purpose. See https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_functions_advanced_parameters
Example:
function Test-ArgumentCompleter {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ArgumentCompleter({
$possibleValues = #('server1', 'server2', 'bazooka')
return $possibleValues | ForEach-Object { $_ }
})]
[String] $DbHost
)
}

ValidatePattern in PowerShell: While value doesn't match

I want to perform a pattern validation of a string value I'm entering and then only continue with the script if it matches but feel like I'm missing something here:
Function Test-ValidatePattern {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[ValidatePattern(".co.uk|.com")]
[System.String]$DomainName
)
$DomainValid = "1"
Write-Output "$DomainName is a valid domain name format"
}
#Sets forwarding email address, checks what it's set to and outputs this to logfile.
If ($EmailForwarding) {
$ForwardDomain = Read-Host "What domain do you want to forward to? e.g. contoso.com`n"
Test-ValidatePattern -DomainName $ForwardDomain
While (!($DomainValid -eq "1")) {
Write-Host "$ForwardDomain isn't a valid domain. Try again"
$ForwardDomain = Read-Host "What domain do you want to forward to? e.g. contoso.com`n"
}
}
It returns an error as expected when entering a string that doesn't have a .co.uk or .com extension and no error when entering one that does but rather than presenting the message that the domain name is valid it loops back to the while block messages instead of continuing on with the rest of the script.
I've tried -eq, -notmatch, -notlike but still not working as intended.
Any ideas?
I don't understand why you think you need to write a validation script when that is the purpose of the ValidatePattern attribute. Here is an example function:
function sample {
param(
[Parameter(Mandatory=$true)]
[ValidatePattern('(\.co\.uk|\.com)$')]
[String] $DomainName
)
"Your domain name is $DomainName"
}
If you run
sample foo.bar
PowerShell will throw a ParameterBindingValidationException exception because the string you passed as the $DomainName parameter doesn't match the pattern. However, if you run
sample foo.com
You will see the expected output:
Your domain name is foo.com
You're not setting $DomainValid to any value. The scope of the function clears out $DomainValid when it finishes. You'll need to return a value from the Test-ValidatePattern function. For Example:
Function Test-ValidatePattern {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[ValidatePattern(".co.uk|.com")]
[System.String]$DomainName
)
#$DomainValid = "1"
Write-Output "$DomainName is a valid domain name format"
return 1
}
#Sets forwarding email address, checks what it's set to and outputs this to logfile.
If ($EmailForwarding) {
$ForwardDomain = Read-Host "What domain do you want to forward to? e.g. contoso.com`n"
$DomainValid = Test-ValidatePattern -DomainName $ForwardDomain
While (!($DomainValid -eq "1")) {
Write-Host "$ForwardDomain isn't a valid domain. Try again"
$ForwardDomain = Read-Host "What domain do you want to forward to? e.g. contoso.com`n"
}
}
I feel I've missed off a few things when explaining what I was after. I simply wanted to make sure the input had a dot present so single words couldn't be entered e.g. "foo.bar" is allowed but "foo" isn't. If not present then the user should be prompted again. Nothing too strict and doesn't stop invalid entries but more so to catch a lazy input.
After tinkering around a bit more the below appears to do what I require.
If ($EmailForwarding) {
$ForwardDomain = Read-Host "What domain do you want to forward to? e.g. contoso.com`n"
While (!($ForwardDomain -like "*.*"))
{Write-Host "$ForwardDomain isn't a valid domain. Try again"
$ForwardDomain = Read-Host "What domain do you want to forward to? e.g. contoso.com`n"}}
"rest of script"

Dynamic parameter accessing default value

I have a PowerShell function that takes an optional parameter, validated using a ValidateSetAttribute, and based on that value it adds another dynamic parameter. However, in strict mode, when trying to access the parameter inside of the DynamicParam block, and I didn’t explicitely set said parameter, then I get an error that the variable was not defined.
Param(
[Parameter()]
[ValidateSet('A', 'B')]
[string] $Target = 'A'
)
DynamicParam {
if ($Target -eq 'B') { # <- Here it fails
# Add new parameter here...
}
}
end {
Write-Host $Target
}
The script works when called with A or B as the first parameter, but fails when the parameter is omitted. Interestingly, if I remove either the ParameterAttribute or the ValidateSetAttribute from the parameter definition it works.
My current workaround is to access the variable using $PSBoundParameters and check if the parameter was set, like this:
if ($PSBoundParameters.ContainsKey('Target') -and $PSBoundParameters.Target -eq 'B') {
# Add new parameter here...
}
While this works fine, it has one downside if I want to check for the value A instead: As A is the parameter’s default value it won’t be added to $PSBoundParameters when the parameter is omitted and the default value is applied. So I need to modify my check to explicitely check that:
if (-not $PSBoundParameters.ContainsKey('Target') -or $PSBoundParameters.Target -eq 'A')) {
# Add new parameter here...
}
I don’t really like this solution as it will unnecessarily tie the dynamic parameter addition with the default values. Ideally, I would want to be able to change the default value without having to touch anything else. Is there any way to access the actual parameter value from within the DynamicParam block? Or is there at least a possibility to access the parameter definition and access the default value?
If you need run correctly in case PSDebug is running in strict mode ( set-psdebug -strict ), you can do something like this:
Param(
[Parameter()]
[ValidateSet('A', 'B')]
[string] $Target = 'A'
)
DynamicParam {
# Ensure $Target is defined
try { [void]$Target }
catch { $Target = [string]::Empty }
if ($Target -eq 'B') {
write-host "si si"
}
}
end {
Write-Host $Target
}

Alternative to Throwing Param Exceptions in PowerShell?

Bottom Line Up Front
I'm looking for a method to validate powershell (v1) command line parameters without propagating exceptions back to the command line.
Details
I have a powershell script that currently uses param in conjunction with [ValidateNotNullOrEmpty] to validate command line paramaters:
param(
[string]
[ValidateNotNullOrEmpty()]$domain = $(throw "Domain (-d) param required.")
)
We're changing the paradigm of error handling where we no longer want to pass exceptions back to the command line, but rather provide custom error messages. Since the param block can not be wrapped in a try catch block, i've resorted to something like the following:
param(
[string]$domain = $("")
)
Try{
if($domain -like $("")){
throw "Domain (-d) param required."
}
...
}Catch{
#output error message
}
My concern is that we're bypassing all of the built-in validation that is available with using param. Is my new technique a reasonable solution? Is there a better way to validate command line params while encapsulating exceptions within the script? I'm very much interested in see how PowerShell professionals would handle this situation.
Any advice would be appreciated.
You can write a custom validation script. Give this parameter a try.
Param(
[ValidateScript({
If ($_ -eq $Null -or $_ -eq "") {
Throw "Domain (-d) param required."
}
Else {
$True
}
})][string]$Domain
)
As I mentioned in a comment: more I read your description, more I come to the conclusion that you should not worry about "bypassing all built-in validation". Why? Because that's exactly your target. You want to bypass it's default behavior, so if that's what you need and have to do - than just do it. ;)
One way is to use default parameters like this [from msdn] -
Function CheckIfKeyExists
{
Param(
[Parameter(Mandatory=$false,ValueFromPipeline=$true)]
[String]
$Key = 'HKLM:\Software\DoesNotExist'
)
Process
{
Try
{
Get-ItemProperty -Path $Key -EA 'Stop'
}
Catch
{
write-warning "Error accessing $Key $($_.Exception.Message)"
}
}
}
So, here, if you try calling the function without passing any parameters, you will get warning what you have defined in your try/catch block. And, you are not using any default validation attributes for that. You should always assume that you will encounter an error, and write code that can survive the error. But the lesson here is if you implement a default value, remember that it is not being validated.
Read more here