Skip to content

Commit 879529c

Browse files
committed
fix: preserve Windows installer paths across shell commands
Fixes version-fox/vfox#685
1 parent 7c0b2fe commit 879529c

4 files changed

Lines changed: 176 additions & 12 deletions

File tree

‎.github/workflows/hooks-test.yml‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
name: Test Plugin
22

33
on:
4+
workflow_dispatch:
45
pull_request:
56
push:
67
branches: [main]
@@ -21,3 +22,52 @@ jobs:
2122
cache: false
2223
- name: Test plugin hooks with vfox's Lua interpreter
2324
run: go run github.com/yuin/gopher-lua/cmd/glua@v1.1.1 tests/hooks_test.lua
25+
- name: Test Windows installer command generation
26+
run: go run github.com/yuin/gopher-lua/cmd/glua@v1.1.1 tests/windows_install_test.lua
27+
28+
windows-install:
29+
name: Windows installer paths
30+
runs-on: windows-latest
31+
timeout-minutes: 20
32+
defaults:
33+
run:
34+
shell: pwsh
35+
steps:
36+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
37+
with:
38+
persist-credentials: false
39+
- name: Install released vfox
40+
env:
41+
GH_TOKEN: ${{ github.token }}
42+
run: |
43+
$ErrorActionPreference = 'Stop'
44+
$PSNativeCommandUseErrorActionPreference = $true
45+
$directory = Join-Path $env:RUNNER_TEMP 'vfox-cli'
46+
gh release download v1.0.12 --repo version-fox/vfox --pattern vfox_1.0.12_windows_x86_64.zip --dir $directory
47+
Expand-Archive -LiteralPath (Join-Path $directory 'vfox_1.0.12_windows_x86_64.zip') -DestinationPath $directory
48+
$vfox = Get-ChildItem -LiteralPath $directory -Recurse -Filter vfox.exe | Select-Object -First 1
49+
if (-not $vfox) { throw 'vfox.exe was not found in the release archive' }
50+
"VFOX_TEST_CLI=$($vfox.FullName)" >> $env:GITHUB_ENV
51+
- name: Install Python through WiX and embedded MSI packages
52+
run: |
53+
$ErrorActionPreference = 'Stop'
54+
$PSNativeCommandUseErrorActionPreference = $true
55+
# Literal percent/exclamation marks also catch cmd expansion bugs.
56+
$env:VFOX_HOME = Join-Path $env:RUNNER_TEMP 'Mechael Jackson''s & %USERNAME% ! SDKs'
57+
$env:VFOX_PYTHON_USE_UV_BUILD = '0'
58+
$archive = Join-Path $env:RUNNER_TEMP 'python-plugin.zip'
59+
Compress-Archive -Path metadata.lua,hooks,lib,bin -DestinationPath $archive
60+
& $env:VFOX_TEST_CLI add --source $archive
61+
& $env:VFOX_TEST_CLI install python@3.14.5
62+
63+
$root = Join-Path $env:VFOX_HOME 'cache\python\v-3.14.5\python-3.14.5'
64+
$python = Join-Path $root 'python.exe'
65+
if (-not (Test-Path -LiteralPath $python)) { throw "Python missing: $python" }
66+
& $python -c 'import sys; assert sys.version_info[:3] == (3, 14, 5), sys.version; print(sys.executable)'
67+
& $python -m pip --version
68+
foreach ($name in @('python3.exe', 'python314.exe', 'python3.14.exe')) {
69+
& (Join-Path $root $name) -c 'import sys; assert sys.version_info[:3] == (3, 14, 5)'
70+
}
71+
$venv = Join-Path $env:RUNNER_TEMP 'venv with spaces & symbols'
72+
& $python -m venv $venv
73+
& (Join-Path $venv 'Scripts\python.exe') -m pip --version

‎lib/util.lua‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
local http = require("http")
22
local html = require("html")
33
local json = require("json")
4+
local windowsCommand = require("windows_command")
45

56
-- get mirror
67
local PYTHON_URL = "https://www.python.org/ftp/python/"
@@ -91,7 +92,7 @@ function windowsInstallMsi(path, url, version, filename)
9192

9293
-- Install msi
9394
print("Installing python...")
94-
local command = 'msiexec /quiet /a ' .. qInstallFile .. ' TargetDir=' .. qInstallPath
95+
local command = windowsCommand.msi(qInstallFile, qInstallPath)
9596
local exitCode = os.execute(command)
9697
os.remove(qInstallFile)
9798
if exitCode ~= 0 then
@@ -104,7 +105,7 @@ function windowsInstallMsi(path, url, version, filename)
104105
if file then
105106
io.close(file)
106107
print("Installing pip...")
107-
local command = qInstallPath .. '\\python -E -s -m ensurepip -U --default-pip > NUL'
108+
local command = windowsCommand.native(qInstallPath .. '\\python.exe', {'-E', '-s', '-m', 'ensurepip', '-U', '--default-pip'})
108109
local exitCode = os.execute(command)
109110
if exitCode ~= 0 then
110111
error("Install pip failed. exit " .. exitCode)
@@ -134,7 +135,7 @@ function windowsInstallExe(path, url, version, filename)
134135
-- Extract
135136
print("Extracting installer...")
136137
local wixBin = RUNTIME.pluginDirPath .. '\\bin\\WiX\\dark.exe'
137-
local command = wixBin .. " -x " .. qInstallPath .. '\\ ' .. qInstallFile .. ' > NUL'
138+
local command = windowsCommand.native(wixBin, {'-x', qInstallPath, qInstallFile})
138139
local exitCode = os.execute(command)
139140
if exitCode ~= 0 then
140141
error("Extract failed")
@@ -150,25 +151,30 @@ function windowsInstallExe(path, url, version, filename)
150151

151152
-- Install msi
152153
print("Installing python...")
153-
local files = io.popen("dir /b " .. msiPath):lines()
154+
local listing, listErr = io.popen(windowsCommand.files(msiPath))
155+
if not listing then
156+
error('Failed to list installer packages: ' .. tostring(listErr))
157+
end
158+
local files = listing:lines()
154159
for file in files do
155160
if file:match("%.msi$") then
156-
local command = "msiexec /quiet /a " .. msiPath .. '\\' .. file .. " TargetDir=" .. qInstallPath
161+
local command = windowsCommand.msi(msiPath .. '\\' .. file, qInstallPath)
157162
local exitCode = os.execute(command)
158163
if exitCode ~= 0 then
159164
error("Install msi failed: " .. file)
160165
end
161166
os.remove(qInstallPath .. '\\' .. file)
162167
end
163168
end
169+
listing:close()
164170

165171
-- Install pip
166172
print("Installing pip...")
167173
local ensurepipPath = qInstallPath .. "\\Lib\\ensurepip\\__init__.py"
168174
local file = io.open(ensurepipPath, "r")
169175
if file then
170176
io.close(file)
171-
local command = qInstallPath .. '\\python -E -s -m ensurepip -U --default-pip > NUL'
177+
local command = windowsCommand.native(qInstallPath .. '\\python.exe', {'-E', '-s', '-m', 'ensurepip', '-U', '--default-pip'})
172178
local exitCode = os.execute(command)
173179
if exitCode ~= 0 then
174180
error("Install pip failed. exit " .. exitCode)
@@ -190,16 +196,20 @@ function windowsInstallExe(path, url, version, filename)
190196
local files = {qInstallPath .. "\\python" .. major .. ".exe", qInstallPath .. "\\python" .. majorMinor .. ".exe",
191197
qInstallPath .. "\\python" .. majorDotMinor .. ".exe"}
192198
for _, file in ipairs(files) do
193-
local command = 'copy /y ' .. pythonExePath .. ' ' .. file .. ' > NUL'
194-
os.execute(command)
199+
local command = windowsCommand.copy(pythonExePath, file)
200+
if os.execute(command) ~= 0 then
201+
error('Failed to create Python executable alias: ' .. file)
202+
end
195203
end
196204

197205
-- pythonw.exe
198206
local files = {qInstallPath .. "\\pythonw" .. major .. ".exe", qInstallPath .. "\\pythonw" .. majorMinor .. ".exe",
199207
qInstallPath .. "\\pythonw" .. majorDotMinor .. ".exe"}
200208
for _, file in ipairs(files) do
201-
local command = 'copy /y ' .. pythonwExePath .. ' ' .. file .. ' > NUL'
202-
os.execute(command)
209+
local command = windowsCommand.copy(pythonwExePath, file)
210+
if os.execute(command) ~= 0 then
211+
error('Failed to create Python executable alias: ' .. file)
212+
end
203213
end
204214

205215
-- Check if venvlauncher exists
@@ -214,8 +224,10 @@ function windowsInstallExe(path, url, version, filename)
214224
qInstallPath .. "\\Lib\\venv\\scripts\\nt\\pythonw" .. majorMinor .. ".exe",
215225
qInstallPath .. "\\Lib\\venv\\scripts\\nt\\pythonw" .. majorDotMinor .. ".exe"}
216226
for _, file in ipairs(files) do
217-
local command = 'copy /y ' .. venvlauncherExePath .. ' ' .. file .. ' > NUL'
218-
os.execute(command)
227+
local command = windowsCommand.copy(venvlauncherExePath, file)
228+
if os.execute(command) ~= 0 then
229+
error('Failed to create Python venv executable alias: ' .. file)
230+
end
219231
end
220232
end
221233

‎lib/windows_command.lua‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
local command = {}
2+
3+
-- os.execute/io.popen pass through cmd.exe before PowerShell. Do not expose
4+
-- percent expansion, delayed expansion, or quotes to that outer parser.
5+
local function literal(value)
6+
if value:find('[\r\n%z]') then
7+
error('Windows command argument contains a control character')
8+
end
9+
value = value:gsub("'", "''")
10+
value = value:gsub('[%%!\"]', function(char)
11+
return "' + [char]" .. string.byte(char) .. " + '"
12+
end)
13+
return "('" .. value .. "')"
14+
end
15+
16+
local function powershell(script)
17+
return 'powershell -NoProfile -NonInteractive -Command "' ..
18+
"$ErrorActionPreference = 'Stop'; " .. script .. '"'
19+
end
20+
21+
function command.native(program, args)
22+
local parts = {'&', literal(program)}
23+
for _, arg in ipairs(args) do
24+
table.insert(parts, literal(arg))
25+
end
26+
return powershell(table.concat(parts, ' ') .. '; exit $LASTEXITCODE')
27+
end
28+
29+
function command.msi(file, path)
30+
local function argument(value)
31+
if value:find('"', 1, true) then
32+
error('Windows installer path contains an invalid quote')
33+
end
34+
-- A backslash immediately before the closing native quote is escaped.
35+
return '"' .. value:gsub('(\\+)$', '%1%1') .. '"'
36+
end
37+
local args = '/quiet /a ' .. argument(file) .. ' ' .. argument('TargetDir=' .. path)
38+
return powershell("$process = Start-Process -FilePath msiexec.exe -Wait -PassThru -ArgumentList " ..
39+
literal(args) .. '; exit $process.ExitCode')
40+
end
41+
42+
function command.files(path)
43+
return powershell('Get-ChildItem -LiteralPath ' .. literal(path) ..
44+
" -Filter '*.msi' -File | ForEach-Object { $_.Name }")
45+
end
46+
47+
function command.copy(source, target)
48+
return powershell('Copy-Item -LiteralPath ' .. literal(source) ..
49+
' -Destination ' .. literal(target) .. ' -Force')
50+
end
51+
52+
return command

‎tests/windows_install_test.lua‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package.path = './lib/?.lua;' .. package.path
2+
RUNTIME = {osType = 'windows', archType = 'amd64', pluginDirPath = "C:\\Plugin's & %USERNAME% ! store"}
3+
OS_TYPE = 'windows'
4+
local state = {commands = {}}
5+
package.preload.http = function() return {download_file = function() return nil end} end
6+
package.preload.html = function() return {} end
7+
package.preload.json = function() return {} end
8+
local oldExecute, oldPopen, oldOpen, oldClose, oldRemove = os.execute, io.popen, io.open, io.close, os.remove
9+
os.execute = function(command)
10+
state.commands[#state.commands + 1] = command
11+
-- Native paths must never be expanded as cmd variables or tokenized on spaces.
12+
assert(command:find('powershell ', 1, true), 'unquoted Windows command: ' .. command)
13+
assert(not command:find('%%') and not command:find('!'), 'cmd expansion in command: ' .. command)
14+
return #state.commands == state.failOn and 1 or 0
15+
end
16+
io.popen = function(command)
17+
os.execute(command)
18+
return {
19+
lines = function()
20+
local files, i = {'core.msi', 'stdlib.msi'}, 0
21+
return function() i = i + 1; return files[i] end
22+
end,
23+
close = function() return true end,
24+
}
25+
end
26+
io.open = function()
27+
return {close = function() end}
28+
end
29+
io.close = function(file) return file:close() end
30+
os.remove = function() return true end
31+
require('util')
32+
local path = "C:\\Users\\Mechael Jackson's & %USERNAME% !\\python-3.14.5"
33+
windowsInstallExe(path, 'https://example.invalid/python.exe', '3.14.5', 'python.exe')
34+
assert(#state.commands >= 7, 'installer, enumeration, MSI, pip and aliases must be exercised')
35+
state.commands = {}
36+
windowsInstallMsi(path, 'https://example.invalid/python.msi', '3.4.4', 'python.msi')
37+
assert(#state.commands == 2, 'MSI and ensurepip must both run')
38+
for _, failure in ipairs({{1, 'Extract failed'}, {3, 'Install msi failed'}, {6, 'executable alias'}}) do
39+
state.commands = {}
40+
state.failOn = failure[1]
41+
local ok, err = pcall(function()
42+
windowsInstallExe(path, 'https://example.invalid/python.exe', '3.14.5', 'python.exe')
43+
end)
44+
assert(not ok and tostring(err):find(failure[2], 1, true), tostring(err))
45+
end
46+
local windows = require('windows_command')
47+
assert(not pcall(windows.msi, 'C:\\bad"path\\python.msi', path), 'invalid Windows quote must fail')
48+
assert(not pcall(windows.native, 'bad\nprogram', {}), 'control characters must fail')
49+
os.execute, io.popen, io.open, io.close, os.remove = oldExecute, oldPopen, oldOpen, oldClose, oldRemove
50+
print('Windows installer command paths and shell expansion regression passed')

0 commit comments

Comments
 (0)