-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathwindows_install_test.lua
More file actions
162 lines (160 loc) · 8.71 KB
/
Copy pathwindows_install_test.lua
File metadata and controls
162 lines (160 loc) · 8.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
package.path = './lib/?.lua;' .. package.path
RUNTIME = {osType = 'windows', archType = 'amd64', pluginDirPath = "C:\\Plugin's & %USERNAME% ! store"}
OS_TYPE = 'windows'
local state = {commands = {}, closeCount = 0}
local function reset()
for key in pairs(state) do state[key] = nil end
state.commands = {}
state.closeCount = 0
end
package.preload.http = function() return {download_file = function() return nil end} end
package.preload.html = function() return {} end
package.preload.json = function() return {} end
local oldExecute, oldPopen, oldOpen, oldClose, oldRemove = os.execute, io.popen, io.open, io.close, os.remove
-- Decode the command independently so the test inspects what PowerShell will
-- execute, rather than only checking a command string prefix.
local alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
local function decodeBase64(value)
local result, accumulator, bits = {}, 0, 0
for char in value:gmatch('.') do
if char == '=' then break end
local index = assert(alphabet:find(char, 1, true), 'invalid base64') - 1
accumulator = accumulator * 64 + index
bits = bits + 6
if bits >= 8 then
bits = bits - 8
local scale = 2 ^ bits
result[#result + 1] = string.char(math.floor(accumulator / scale))
accumulator = accumulator % scale
end
end
return table.concat(result)
end
local function decodeCommand(value)
local payload = assert(value:match('^powershell %-NoProfile %-NonInteractive %-EncodedCommand ([A-Za-z0-9+/=]+)$'),
'PowerShell command must contain only an unquoted base64 payload: ' .. value)
local utf16 = decodeBase64(payload)
assert(#utf16 % 2 == 0, 'incomplete UTF-16LE code unit')
local bytes = {}
for i = 1, #utf16, 2 do
assert(utf16:byte(i) < 128 and utf16:byte(i + 1) == 0, 'script must use ASCII UTF-16LE')
bytes[#bytes + 1] = utf16:sub(i, i)
end
local script = table.concat(bytes)
assert(script:find("$ErrorActionPreference = 'Stop'; ", 1, true) == 1, 'error preference must execute')
assert(script:find("$ProgressPreference = 'SilentlyContinue'; ", 1, true), 'PowerShell progress must not pollute installer output')
return script
end
os.execute = function(command)
state.commands[#state.commands + 1] = command
-- Native paths must never be expanded as cmd variables or tokenized on spaces.
assert(command:find('powershell ', 1, true), 'unquoted Windows command: ' .. command)
assert(not command:find('%%') and not command:find('!'), 'cmd expansion in command: ' .. command)
decodeCommand(command)
return #state.commands == state.failOn and 1 or 0
end
io.popen = function(command)
local exitCode = os.execute(command)
return {
lines = function()
local files, i = state.files or {'core.msi', 'stdlib.msi'}, 0
return function()
i = i + 1
if state.readFailure and i == 2 then error('read failed') end
return files[i]
end
end,
close = function()
state.closeCount = state.closeCount + 1
if state.closeFailure then error('close failed') end
-- GopherLua process pipes return a numeric exit status, including 0.
return state.listExitCode or exitCode
end,
}
end
io.open = function()
return {close = function() end}
end
io.close = function(file) return file:close() end
os.remove = function() return true end
require('util')
local path = "C:\\Users\\Mechael Jackson's & %USERNAME% !\\python-3.14.5"
windowsInstallExe(path, 'https://example.invalid/python.exe', '3.14.5', 'python.exe')
assert(#state.commands >= 7, 'installer, enumeration, MSI, pip and aliases must be exercised')
reset()
windowsInstallMsi(path, 'https://example.invalid/python.msi', '3.4.4', 'python.msi')
assert(#state.commands == 2, 'MSI and ensurepip must both run')
for _, failure in ipairs({{1, 'Extract failed'}, {3, 'Install msi failed'}, {6, 'executable alias'}}) do
reset()
state.failOn = failure[1]
local ok, err = pcall(function()
windowsInstallExe(path, 'https://example.invalid/python.exe', '3.14.5', 'python.exe')
end)
assert(not ok and tostring(err):find(failure[2], 1, true), tostring(err))
end
for _, failure in ipairs({
{name = 'partial listing exits nonzero', files = {'core.msi'}, exitCode = 1, message = 'Failed to list installer packages'},
{name = 'listing read failure', readFailure = true, message = 'read failed'},
{name = 'listing close failure', closeFailure = true, message = 'close failed'},
{name = 'no MSI packages', files = {'readme.txt'}, message = 'No installer MSI packages'},
}) do
reset()
state.files = failure.files
state.listExitCode = failure.exitCode
state.readFailure = failure.readFailure
state.closeFailure = failure.closeFailure
local ok, err = pcall(windowsInstallExe, path, 'https://example.invalid/python.exe', '3.14.5', 'python.exe')
assert(not ok and tostring(err):find(failure.message, 1, true), failure.name .. ': ' .. tostring(err))
assert(state.closeCount == 1, failure.name .. ': listing handle must be closed')
assert(#state.commands == 2, failure.name .. ': no MSI or pip may run before enumeration succeeds')
end
local windows = require('windows_command')
for _, value in ipairs({'', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar', "C:\\用户\\Mechael's & %USERNAME% !\\", 'quotes"stay data'}) do
local script = decodeCommand(windows.native('program.exe', {value}))
local arguments = {}
for encoded in script:gmatch("FromBase64String%('([A-Za-z0-9+/=]*)'%)") do
arguments[#arguments + 1] = decodeBase64(encoded)
end
assert(#arguments == 2 and arguments[1] == 'program.exe' and arguments[2] == value,
'native argument changed after payload decoding')
assert(script:find('; exit $LASTEXITCODE', 1, true), 'native exit status must propagate')
end
for _, case in ipairs({
{
file = 'C:\\MSI files\\core.msi', path = 'C:\\User files\\Python',
expected = '/quiet /a "C:\\MSI files\\core.msi" TargetDir="C:\\User files\\Python"',
},
{
file = 'C:\\MSI files & %TEMP% !\\stdlib.msi', path = "C:\\User's & !\\Python files\\",
expected = '/quiet /a "C:\\MSI files & %TEMP% !\\stdlib.msi" TargetDir="C:\\User\'s & !\\Python files\\\\"',
},
}) do
local msi = decodeCommand(windows.msi(case.file, case.path))
assert(msi:find('Start-Process -FilePath msiexec.exe -Wait -PassThru', 1, true))
assert(msi:find('; exit $process.ExitCode', 1, true))
local encoded = assert(msi:match("FromBase64String%('([A-Za-z0-9+/=]*)'%)"))
local arguments = decodeBase64(encoded)
assert(arguments == case.expected, 'MSI arguments changed: ' .. arguments)
assert(not msi:find('Junction', 1, true), 'ordinary paths must not need a junction')
end
local junction = decodeCommand(windows.msi('C:\\MSI %SOURCE% files\\core.msi', path))
local values = {}
for encoded in junction:gmatch("FromBase64String%('([A-Za-z0-9+/=]*)'%)") do
values[#values + 1] = decodeBase64(encoded)
end
assert(values[1] == path, 'junction target must retain the literal percent path')
assert(values[2] == '/quiet /a "C:\\MSI %SOURCE% files\\core.msi" TargetDir=', 'MSI source must remain unchanged')
assert(junction:find('New-Item -ItemType Junction', 1, true), 'percent paths need a temporary junction')
assert(junction:find('[IO.Path]::GetTempPath()', 1, true), 'junction must be outside the SDK directory')
assert(junction:find("$temp.Contains('%')", 1, true), 'a percent-containing TEMP must fail safely')
assert(junction:find('[Guid]::NewGuid()', 1, true), 'junction name must be unique')
assert(junction:find('[IO.Directory]::Exists($target)', 1, true), 'junction target must already exist')
assert(junction:find('+ [char]34 + $alias + [char]34', 1, true), 'MSI TargetDir must use the quoted alias')
assert(junction:find('finally', 1, true) and junction:find('[IO.Directory]::Delete($alias)', 1, true), 'junction must be cleaned without deleting the SDK')
assert(not junction:find('-Recurse', 1, true) and not junction:find('Remove-Item', 1, true), 'cleanup must not recurse into the junction target')
assert(junction:find('-Wait -PassThru', 1, true) and junction:find('$exitCode = $process.ExitCode', 1, true), 'MSI exit code must be preserved')
assert(junction:find('; exit $exitCode', 1, true), 'cleanup must precede the MSI exit')
assert(not pcall(windows.msi, 'C:\\bad"path\\python.msi', path), 'invalid Windows quote must fail')
assert(not pcall(windows.native, 'bad\nprogram', {}), 'control characters must fail')
os.execute, io.popen, io.open, io.close, os.remove = oldExecute, oldPopen, oldOpen, oldClose, oldRemove
print('Windows installer command paths and shell expansion regression passed')