private/core-license/Get-OSDeployCoreLicense.ps1
|
function Get-OSDeployCoreLicense { <# .SYNOPSIS Returns a single Recast Core license object. .DESCRIPTION Reads .license2 files from the Recast Software license directory, parses the JSON payload, validates expected fields, removes duplicates, and returns one selected license object. Selection precedence is: 1) PreferredEmail exact match (when provided) 2) Any non-empty email that is not support@recastsoftware.com 3) support@recastsoftware.com fallback 4) Empty or null email Expired licenses are still eligible for selection. .PARAMETER Path The directory path to search for .license2 files. .PARAMETER PreferredEmail Optional preferred email value used to prioritize license selection when multiple license files exist. Comparison is case-insensitive. .OUTPUTS PSCustomObject. Returns one selected license object, or $null when no license files are available. .EXAMPLE Get-OSDeployCoreLicense Returns a selected validated license object from ProgramData\Recast Software\Licenses. .EXAMPLE Get-OSDeployCoreLicense -Path 'D:\Licenses' Returns a selected validated license object from a custom directory. .EXAMPLE Get-OSDeployCoreLicense -PreferredEmail 'david@segura.org' Prefers a license with the specified email when available. .LINK https://github.com/OSDeploy/OSD/tree/master/docs .NOTES Author: David Segura - Recast Software 2026-07-17 - Initial help block created 2026-07-17 - Added content parsing, validation, and de-duplication 2026-07-17 - Added single-object selection with email preference and support fallback #> [CmdletBinding()] param ( [Parameter()] [string]$Path = (Join-Path -Path $env:ProgramData -ChildPath 'Recast Software\Licenses'), [Parameter()] [string]$PreferredEmail ) if (-not (Test-Path -Path $Path -PathType Container)) { try { $null = New-Item -Path $Path -ItemType Directory -Force -ErrorAction Stop } catch { Write-Verbose "Unable to create license path: $Path. Error: $($_.Exception.Message)" return $null } } Write-Verbose "Starting license discovery in path: $Path" if (-not (Test-Path -Path $Path -PathType Container)) { Write-Verbose "License path not found: $Path" return $null } # Gather candidate license files newest-first so tie-breaking remains deterministic. $LicenseFiles = Get-ChildItem -Path $Path -Filter '*.license2' -File -ErrorAction Ignore | Sort-Object -Property LastWriteTime -Descending Write-Verbose "Discovered $(@($LicenseFiles).Count) license file(s)" if (-not $LicenseFiles) { Write-Verbose 'No license files found' return $null } # Parse each file into candidate entries and track seen identities for deduplication. $ParsedResults = New-Object System.Collections.Generic.List[object] $Seen = @{} foreach ($LicenseFile in $LicenseFiles) { $RawContent = $null $Payload = $null try { Write-Verbose "Reading license file: $($LicenseFile.FullName)" $RawContent = Get-Content -Path $LicenseFile.FullName -Raw -ErrorAction Stop $Payload = $RawContent | ConvertFrom-Json -ErrorAction Stop Write-Verbose "Parsed $(@($Payload).Count) payload entr$(if (@($Payload).Count -eq 1) {'y'} else {'ies'}) from $($LicenseFile.Name)" } catch { Write-Verbose "Skipping unreadable license file: $($LicenseFile.FullName). Error: $($_.Exception.Message)" continue } foreach ($Entry in @($Payload)) { # Validate required schema fields and normalize commonly used values. $ValidationErrors = New-Object System.Collections.Generic.List[string] $LicenseGuid = $null $ExpirationDate = $null $ActivationExpirationDate = $null if (-not ($Entry.PSObject.Properties.Name -contains 'Data')) { $ValidationErrors.Add('Missing Data object') } if (-not ($Entry.PSObject.Properties.Name -contains 'Signature') -or [string]::IsNullOrWhiteSpace([string]$Entry.Signature)) { $ValidationErrors.Add('Missing Signature') } if ($Entry.Data) { if (-not ($Entry.Data.PSObject.Properties.Name -contains 'LicenseGuid') -or [string]::IsNullOrWhiteSpace([string]$Entry.Data.LicenseGuid)) { $ValidationErrors.Add('Missing Data.LicenseGuid') } else { try { $LicenseGuid = [guid]([string]$Entry.Data.LicenseGuid) } catch { $ValidationErrors.Add('Invalid Data.LicenseGuid format') } } if (-not ($Entry.Data.PSObject.Properties.Name -contains 'AuthorizedPluginCommands') -or -not @($Entry.Data.AuthorizedPluginCommands)) { $ValidationErrors.Add('Missing or empty Data.AuthorizedPluginCommands') } } if (($Entry.PSObject.Properties.Name -contains 'Expiration') -and -not [string]::IsNullOrWhiteSpace([string]$Entry.Expiration)) { try { $ExpirationDate = [datetime]([string]$Entry.Expiration) } catch { $ValidationErrors.Add('Invalid Expiration date format') } } if (($Entry.PSObject.Properties.Name -contains 'ActivationExpiration') -and -not [string]::IsNullOrWhiteSpace([string]$Entry.ActivationExpiration)) { try { $ActivationExpirationDate = [datetime]([string]$Entry.ActivationExpiration) } catch { $ValidationErrors.Add('Invalid ActivationExpiration date format') } } $SignatureHash = $null if ($Entry.PSObject.Properties.Name -contains 'Signature' -and -not [string]::IsNullOrWhiteSpace([string]$Entry.Signature)) { $HashProvider = [System.Security.Cryptography.SHA256]::Create() try { $Bytes = [System.Text.Encoding]::UTF8.GetBytes([string]$Entry.Signature) $SignatureHash = [System.BitConverter]::ToString($HashProvider.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() } finally { $HashProvider.Dispose() } } $DedupKey = $null if ($LicenseGuid) { $DedupKey = "guid:$($LicenseGuid.ToString().ToLowerInvariant())" } elseif ($SignatureHash) { $DedupKey = "signature:$SignatureHash" } else { $DedupKey = "file:$($LicenseFile.FullName.ToLowerInvariant())" } if ($Seen.ContainsKey($DedupKey)) { Write-Verbose "Skipping duplicate license: $($LicenseFile.FullName)" continue } # Mark identity as seen and materialize a normalized candidate object. $Seen[$DedupKey] = $true $ParsedResults.Add([pscustomobject]@{ FileName = $LicenseFile.Name FullName = $LicenseFile.FullName LastWriteTime = $LicenseFile.LastWriteTime LicenseGuid = if ($LicenseGuid) { $LicenseGuid.ToString() } else { $null } Organization = $Entry.Data.Organization FirstName = $Entry.Data.FirstName LastName = $Entry.Data.LastName Email = $Entry.Data.Email LicenseType = $Entry.Data.LicenseType DeviceCount = $Entry.Data.DeviceCount UserCount = $Entry.Data.UserCount Expiration = $ExpirationDate ActivationExpiration = $ActivationExpirationDate AuthorizedPluginCommandCount = @($Entry.Data.AuthorizedPluginCommands).Count SignatureHash = $SignatureHash IsValid = ($ValidationErrors.Count -eq 0) ValidationErrors = @($ValidationErrors) Data = $Entry.Data }) } } if (-not $ParsedResults) { Write-Verbose 'No valid parseable license candidates were produced' return $null } Write-Verbose "Candidate count after de-duplication: $($ParsedResults.Count)" $PreferredEmailNormalized = $null if (-not [string]::IsNullOrWhiteSpace($PreferredEmail)) { $PreferredEmailNormalized = $PreferredEmail.Trim().ToLowerInvariant() Write-Verbose "PreferredEmail provided: $PreferredEmailNormalized" } else { Write-Verbose 'PreferredEmail not provided; using default selection precedence' } $SupportFallbackEmail = 'support@recastsoftware.com' # Rank candidates by email precedence, then by newest timestamp, then file name. $SelectedLicense = $ParsedResults | Sort-Object -Property @( @{ Expression = { $EmailValue = $null if (-not [string]::IsNullOrWhiteSpace([string]$_.Email)) { $EmailValue = ([string]$_.Email).Trim().ToLowerInvariant() } if ($PreferredEmailNormalized -and $EmailValue -eq $PreferredEmailNormalized) { 0 } elseif ($EmailValue -and $EmailValue -ne $SupportFallbackEmail) { 1 } elseif ($EmailValue -eq $SupportFallbackEmail) { 2 } else { 3 } } Ascending = $true }, @{ Expression = { $_.LastWriteTime } Descending = $true }, @{ Expression = { $_.FileName } Ascending = $true } ) | Select-Object -First 1 if ($SelectedLicense) { Write-Verbose "Selected license file: $($SelectedLicense.FileName) (Email: $($SelectedLicense.Email); LicenseGuid: $($SelectedLicense.LicenseGuid))" } $SelectedLicense } |