PowerShell 5.1 设空字符串环境变量 = 静默删除它
在一条 Windows 构建脚本里,我需要告诉构建工具「密钥密码为空」——也就是把一个环境变量设置为空字符串,让子进程感知到「变量存在但值为空」。写法教科书级别:
$env:MY_SIGNING_KEY_PASSWORD = ''
# 或者"更正式"的写法
[Environment]::SetEnvironmentVariable('MY_SIGNING_KEY_PASSWORD', '', 'Process')通过 SSH 跑发布脚本,一切正常——直到某次非交互式 SSH 会话里,构建工具突然打印:
Info Decrypting updater signing key, expect a prompt for password
然后永远挂在那里,等一个永远不会来的用户输入。
现象
SSH 非交互式会话里子进程挂死,SSH 超时断开,构建机侧的进程树变成孤儿继续占用文件锁。本地交互式窗口跑同一段脚本完全正常——因为本地可以手动输入密码,问题被掩盖了。
用诊断命令在 PowerShell 5.1 里验证:
[Environment]::SetEnvironmentVariable('X', '', 'Process')
$x = [Environment]::GetEnvironmentVariable('X', 'Process')
$x -eq $null # → True ← 应该是 False
$x -eq '' # → False ← 应该是 True
& cmd /c 'set X'
# → "环境变量 X 未定义",exit code 1变量设完之后,从 .NET API 查回来是 $null,从 cmd 查是「未定义」。变量根本不存在。
根因
PowerShell 5.1(Windows 内置版本)运行在 .NET Framework 4.x 上。.NET Framework 4.x 的 Environment.SetEnvironmentVariable 实现里有这样一段逻辑:
// .NET Framework 4.x 伪代码
if (string.IsNullOrEmpty(value)) {
SetEnvironmentVariableW(name, IntPtr.Zero); // NULL → Win32 删除变量
} else {
SetEnvironmentVariableW(name, value);
}null 和 "" 被无差别合并,传给 Win32 的是 NULL。而 Win32 SetEnvironmentVariableW 的语义是:
| lpValue | 效果 |
|---|---|
| NULL | 删除该变量 |
| L"" 空字符串指针 | 保留变量,值为空字符串 |
| 其他字符串 | 设值 |
.NET Framework 4.x 把空字符串坍缩成 NULL,变成了删除操作。这个问题在 .NET 6+ 修复,所以 PowerShell 7+(基于 .NET 6+)不受影响。PS 5.1 随 Windows 内置,无法升级运行时,永远停在这个行为上。
$env:X = '' 和 [Environment]::SetEnvironmentVariable('X', '', 'Process') 在 PowerShell 5.1 里效果完全一样——都是删除变量。用 [Environment]::GetEnvironmentVariable 验证也是假安全,它走同一条 .NET 路径,返回 $null 而不是 ''。唯一可信的验证手段是 cmd /c set X。
正解
绕过 .NET Framework,直接 P/Invoke kernel32.dll 的 SetEnvironmentVariableW:
Add-Type @'
using System.Runtime.InteropServices;
public static class W32Env {
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
public static extern bool SetEnvironmentVariable(string name, string value);
}
'@ -ErrorAction SilentlyContinue # 脚本重跑时 Add-Type 会报重复定义,忽略即可
[W32Env]::SetEnvironmentVariable('MY_SIGNING_KEY_PASSWORD', '') | Out-Null传给 Win32 的是真正的空字符串指针(L""),不是 NULL,变量被保留为空值,子进程可以正常感知到它存在。
用 cmd 探针验证(不要用 .NET API 验证):
$probe = & cmd /c 'set MY_SIGNING_KEY_PASSWORD'
if ($LASTEXITCODE -ne 0) {
throw "设空失败:变量未定义,P/Invoke 未生效"
}
Write-Host "验证通过:[$probe]" # 期望:[MY_SIGNING_KEY_PASSWORD=]P/Invoke 绕过 .NET Framework 的 null 合并逻辑,真正把空字符串传到 Win32 层。验证时必须用 cmd /c set X,exit 0 + 输出 X= 才算成功。[Environment]::GetEnvironmentVariable 读回来仍是 $null——它走同一条有 bug 的路径,无法作为验证手段。
踩坑顺序的额外坑
注意脚本里有一次「自以为修了但没修」的升级:从 $env:VAR = '' 改成 [Environment]::SetEnvironmentVariable(...) 这个「更正式」的写法,以为换用 .NET API 会更可靠——结果两者底层完全一样,bug 依然存在。这是一种常见的错误修复模式:换了写法,症状不变,却误以为问题在别处。
一句话外卖
在 PowerShell 5.1 里,"设空字符串"和"删除变量"是同一个操作;区分「变量不存在」与「变量为空」的子进程,一定要用 P/Invoke kernel32 绕过 .NET Framework 4.x 的 null 合并 bug。