From 27e76467a8b6d24922533ebe5572d01562c3942e Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 3 Aug 2026 17:23:00 -0400 Subject: [PATCH] manifest engine: resolve installer paths without the PS provider Join-Path routes a drive-qualified path through the PowerShell provider, so it returns null when that drive has gone away mid-cycle, and every consumer then bound the null straight into Test-Path -LiteralPath and crashed the entry with a message that names neither the entry nor the path. Replace the six Join-Path $InstallerRoot sites with Join-InstallerPath, which does plain string math, and have all six callers treat a null resolve as "not found" and log it. Only the PS1 branch had a guard before, and it covered a null Script value, not a null resolved path. The helper deliberately avoids [IO.Path]::Combine: its separator and rooted-path rules follow the host platform, so behaviour cannot be verified off-Windows. It also rejects rooted values ("\x", "D:\x", "\\server\share"), which Combine would have resolved outside the share root. Checked all 36 path values across the share manifests: none are rooted today. Behaviour verified against the real manifest values (forward slashes, backslashes, trailing-slash root, null, whitespace, rooted, UNC). --- .../common/lib/Install-FromManifest.ps1 | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 b/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 index 34a2618..fe7e101 100644 --- a/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 +++ b/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 @@ -241,6 +241,24 @@ function Test-AppInstalled { # --------------------------------------------------------------------------- # Action dispatch # --------------------------------------------------------------------------- +function Join-InstallerPath { + # Plain string math, NOT Join-Path. Join-Path resolves the drive qualifier + # through the PS provider and emits NOTHING (null) if that drive vanished + # mid-cycle - the null then binds into Test-Path -LiteralPath and throws + # "Cannot bind argument to parameter 'LiteralPath' because it is null". + # [IO.Path]::Combine is no good either: its separator and rooted-path rules + # follow the host platform, so it cannot be validated off-Windows. + # Returns $null when the entry is unusable; every caller treats null as + # "not found" and logs, instead of crashing the entry. + param([string]$Root, [string]$Rel) + if ([string]::IsNullOrWhiteSpace($Root) -or [string]::IsNullOrWhiteSpace($Rel)) { return $null } + $clean = ($Rel -replace '/', '\').Trim() + # Reject rooted values ('\x', 'D:\x', '\\server\share'): a manifest entry + # must resolve UNDER the share root, never escape it. + if ($clean -match '^(\\|[A-Za-z]:)') { return $null } + return ($Root.TrimEnd('\') + '\' + $clean) +} + function Invoke-InstallerAction { param($App) @@ -251,8 +269,8 @@ function Invoke-InstallerAction { switch ($App.Type) { 'MSI' { - $installerPath = Join-Path $InstallerRoot $App.Installer - if (-not (Test-Path -LiteralPath $installerPath)) { + $installerPath = Join-InstallerPath $InstallerRoot $App.Installer + if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) { Write-InstallLog " MSI not found: $installerPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } @@ -270,8 +288,8 @@ function Invoke-InstallerAction { return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog } } 'EXE' { - $installerPath = Join-Path $InstallerRoot $App.Installer - if (-not (Test-Path -LiteralPath $installerPath)) { + $installerPath = Join-InstallerPath $InstallerRoot $App.Installer + if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) { Write-InstallLog " EXE not found: $installerPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } @@ -346,8 +364,8 @@ function Invoke-InstallerAction { return [pscustomobject]@{ ExitCode = $exitCode; LogRef = $App.LogFile } } { $_ -eq 'CMD' -or $_ -eq 'BAT' } { - $installerPath = Join-Path $InstallerRoot $App.Installer - if (-not (Test-Path -LiteralPath $installerPath)) { + $installerPath = Join-InstallerPath $InstallerRoot $App.Installer + if ([string]::IsNullOrWhiteSpace($installerPath) -or -not (Test-Path -LiteralPath $installerPath)) { Write-InstallLog " CMD/BAT not found: $installerPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } @@ -369,8 +387,8 @@ function Invoke-InstallerAction { Write-InstallLog (" PS1 entry '{0}' has no Script/Installer value (Script={1}, Installer={2}) - skipping" -f $App.Name, $App.Script, $App.Installer) 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } - $scriptPath = Join-Path $InstallerRoot $rel - if (-not (Test-Path -LiteralPath $scriptPath)) { + $scriptPath = Join-InstallerPath $InstallerRoot $rel + if ([string]::IsNullOrWhiteSpace($scriptPath) -or -not (Test-Path -LiteralPath $scriptPath)) { Write-InstallLog " PS1 not found: $scriptPath (from rel '$rel')" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } @@ -384,8 +402,8 @@ function Invoke-InstallerAction { return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null } } 'INF' { - $infPath = Join-Path $InstallerRoot $App.Installer - if (-not (Test-Path -LiteralPath $infPath)) { + $infPath = Join-InstallerPath $InstallerRoot $App.Installer + if ([string]::IsNullOrWhiteSpace($infPath) -or -not (Test-Path -LiteralPath $infPath)) { Write-InstallLog " INF not found: $infPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } @@ -397,8 +415,8 @@ function Invoke-InstallerAction { } 'File' { # Copy a file from the share (configs/*) to an absolute on-PC path. - $source = Join-Path $InstallerRoot $App.Source - if (-not (Test-Path -LiteralPath $source)) { + $source = Join-InstallerPath $InstallerRoot $App.Source + if ([string]::IsNullOrWhiteSpace($source) -or -not (Test-Path -LiteralPath $source)) { Write-InstallLog " File source not found: $source" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } }