mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 08:18:52 +00:00
308 lines
14 KiB
JavaScript
308 lines
14 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { test } from 'node:test'
|
|
|
|
const compiler = process.env.NSIS_MAKENSIS || (process.platform === 'win32'
|
|
? path.join(process.env.LOCALAPPDATA || '', 'tauri', 'NSIS', 'makensis.exe')
|
|
: 'makensis')
|
|
let compilerAvailable = false
|
|
try {
|
|
execFileSync(compiler, [process.platform === 'win32' ? '/VERSION' : '-VERSION'], { timeout: 5000 })
|
|
compilerAvailable = true
|
|
} catch {}
|
|
const available = process.platform === 'win32' && compilerAvailable
|
|
const template = readFileSync(new URL('../src-tauri/windows/nsis/installer.nsi', import.meta.url), 'utf8')
|
|
const diagnosticVariables = template.match(/^Var Dbx.*$/gm).join('\n')
|
|
const diagnostics = template.slice(template.indexOf('AllowSkipFiles off'), template.indexOf('; Probe without truncating'))
|
|
const elevationInit = template.slice(template.indexOf('Function .onInit\n') + 'Function .onInit\n'.length, template.indexOf(' \u0024{GetOptions} $CMDLINE "/P" $PassiveMode'))
|
|
const languages = ['English', 'SimpChinese', 'TradChinese'].map(language => `!insertmacro MUI_LANGUAGE "${language}"`).join('\n')
|
|
|
|
test('Windows installer access checks and elevation handoff', { skip: !available }, () => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'dbx installer test '))
|
|
const helper = template.match(/Function DbxEnsureInstallAccess\r?\n[\s\S]*?FunctionEnd/)[0]
|
|
assert.ok(elevationInit.includes('/DBX_PROFILE='))
|
|
const launch = `ExecShell "runas" "$EXEPATH" '/DBX_ELEVATED /DBX_PROFILE="$3" /DBX_LANG=$LANGUAGE $2 /D=$INSTDIR'`
|
|
assert.ok(helper.includes(launch))
|
|
// Execute the production function, replacing only the OS UAC launch with a
|
|
// recorder. Tests must never trigger an interactive prompt or install DBX.
|
|
const fixtureHelper = helper.replace(launch, `WriteINIStr "\u0024EXEDIR\\result.ini" "handoff" "args" '/DBX_ELEVATED /DBX_PROFILE="$3" /DBX_LANG=$LANGUAGE $2 /D=$INSTDIR'
|
|
\u0024{If} \u0024DbxTestCancel = 1
|
|
SetErrors
|
|
\u0024{EndIf}`)
|
|
const exe = path.join(dir, 'probe.exe')
|
|
const result = path.join(dir, 'result.ini')
|
|
const destination = path.join(dir, 'existing DBX')
|
|
mkdirSync(destination)
|
|
const binary = path.join(destination, 'dbx.exe')
|
|
writeFileSync(binary, 'original executable contents')
|
|
const script = `Unicode true
|
|
XPStyle on
|
|
!include MUI2.nsh
|
|
!include FileFunc.nsh
|
|
!define MAINBINARYNAME "dbx"
|
|
!define VERSION "1.0.1"
|
|
Name "DBX access probe"
|
|
OutFile "${exe}"
|
|
RequestExecutionLevel user
|
|
SilentInstall silent
|
|
${diagnosticVariables}
|
|
Var DbxTestCancel
|
|
Var DbxTestHandle
|
|
${languages}
|
|
${diagnostics}
|
|
${fixtureHelper}
|
|
Function .onInit
|
|
${elevationInit}
|
|
\u0024{If} $DbxElevated <> 1
|
|
StrCpy $LANGUAGE 1033
|
|
\u0024{EndIf}
|
|
\u0024{GetOptions} $CMDLINE "/TEST_ELEVATED" $0
|
|
\u0024{IfNot} \u0024{Errors}
|
|
StrCpy $DbxElevated 1
|
|
\u0024{EndIf}
|
|
\u0024{GetOptions} $CMDLINE "/TEST_LOCK" $0
|
|
\u0024{IfNot} \u0024{Errors}
|
|
System::Call 'kernel32::CreateFileW(w "$INSTDIR\\dbx.exe", i 0x80000000, i 0, p 0, i 3, i 0, p 0) p .r0'
|
|
StrCpy $DbxTestHandle $0
|
|
\u0024{EndIf}
|
|
\u0024{GetOptions} $CMDLINE "/TEST_CANCEL" $0
|
|
\u0024{IfNot} \u0024{Errors}
|
|
StrCpy $DbxTestCancel 1
|
|
\u0024{EndIf}
|
|
FunctionEnd
|
|
Section
|
|
; NSIS applies the selected language after .onInit returns.
|
|
StrCpy $0 "register zero"
|
|
StrCpy $1 "register one"
|
|
StrCpy $2 "register two"
|
|
StrCpy $3 "register three"
|
|
Call DbxEnsureInstallAccess
|
|
WriteINIStr "$EXEDIR\\result.ini" "probe" "registers" "$0|$1|$2|$3"
|
|
WriteINIStr "$EXEDIR\\result.ini" "probe" "destination" "$INSTDIR"
|
|
WriteINIStr "$EXEDIR\\result.ini" "probe" "language" "$LANGUAGE"
|
|
WriteINIStr "$EXEDIR\\result.ini" "probe" "elevation" "$DbxElevationCode"
|
|
WriteINIStr "$EXEDIR\\result.ini" "probe" "fileError" "$DbxFileProbeError"
|
|
WriteINIStr "$EXEDIR\\result.ini" "probe" "folderError" "$DbxDirectoryProbeError"
|
|
SetErrorLevel 0
|
|
Quit
|
|
SectionEnd
|
|
`
|
|
const source = path.join(dir, 'probe.nsi')
|
|
writeFileSync(source, script)
|
|
try {
|
|
// Also compile the unmodified production launch instruction and init code.
|
|
writeFileSync(source, script.replace(fixtureHelper, helper))
|
|
execFileSync(compiler, ['/V2', '/INPUTCHARSET', 'UTF8', source], { encoding: 'utf8', timeout: 30_000 })
|
|
writeFileSync(source, script)
|
|
execFileSync(compiler, ['/V2', '/INPUTCHARSET', 'UTF8', source], { encoding: 'utf8', timeout: 30_000 })
|
|
const run = (args, target = destination) => {
|
|
rmSync(result, { force: true })
|
|
let status = 0
|
|
try {
|
|
execFileSync(exe, [...args, ...(target === null ? [] : [`/D=${target}`])], { timeout: 15_000, windowsVerbatimArguments: true, argv0: `"${exe}"` })
|
|
} catch (error) {
|
|
if (typeof error.status !== 'number') throw error
|
|
status = error.status
|
|
}
|
|
return { status, output: existsSync(result) ? readFileSync(result, 'utf8') : '' }
|
|
}
|
|
const writable = run([])
|
|
assert.equal(writable.status, 0, writable.output)
|
|
assert.match(writable.output, /register zero\|register one\|register two\|register three/)
|
|
assert.doesNotMatch(writable.output, /handoff/)
|
|
assert.match(writable.output, /elevation=0/)
|
|
assert.match(writable.output, /fileError=0/)
|
|
assert.match(writable.output, /folderError=0/)
|
|
assert.equal(readFileSync(binary, 'utf8'), 'original executable contents')
|
|
|
|
const locked = run(['/TEST_LOCK'])
|
|
assert.equal(locked.status, 0)
|
|
assert.doesNotMatch(locked.output, /handoff/)
|
|
assert.match(locked.output, /fileError=32/)
|
|
assert.match(locked.output, /folderError=0/)
|
|
assert.equal(readFileSync(binary, 'utf8'), 'original executable contents')
|
|
|
|
const nested = run([], path.join(destination, 'new', 'nested DBX'))
|
|
assert.equal(nested.status, 0)
|
|
assert.doesNotMatch(nested.output, /handoff/)
|
|
assert.equal(existsSync(path.join(destination, 'new')), false)
|
|
|
|
// The read-only attribute yields ERROR_ACCESS_DENIED without changing ACLs.
|
|
execFileSync('attrib.exe', ['+R', binary])
|
|
const update = run(['/UPDATE', '/P', '/NS', '/R', '/ARGS', '"argument with spaces"'])
|
|
assert.equal(update.status, 0)
|
|
assert.match(update.output, /\/DBX_ELEVATED \/DBX_PROFILE="[^"]+" \/DBX_LANG=1033 \/UPDATE \/P \/NS \/R \/ARGS "argument with spaces" \/D=/)
|
|
assert.ok(update.output.includes(`/D=${destination}`))
|
|
assert.doesNotMatch(update.output, /registers=/)
|
|
assert.equal(readFileSync(binary, 'utf8'), 'original executable contents')
|
|
|
|
const resumed = run([update.output.match(/args=(.*)/)[1]], null)
|
|
assert.equal(resumed.status, 0)
|
|
assert.doesNotMatch(resumed.output, /handoff/)
|
|
assert.ok(resumed.output.includes(`destination=${destination}`))
|
|
assert.match(resumed.output, /language=1033/)
|
|
|
|
const cancelled = run(['/TEST_CANCEL'])
|
|
assert.equal(cancelled.status, 740)
|
|
assert.doesNotMatch(cancelled.output, /registers=/)
|
|
|
|
const elevated = run(['/TEST_ELEVATED'])
|
|
assert.equal(elevated.status, 0)
|
|
assert.doesNotMatch(elevated.output, /handoff/)
|
|
assert.match(elevated.output, /elevation=3/)
|
|
assert.match(elevated.output, /register zero\|register one\|register two\|register three/)
|
|
|
|
const wrongUser = run(['/DBX_ELEVATED', '/DBX_LANG=1033', '/DBX_PROFILE="different Windows account"'])
|
|
assert.equal(wrongUser.status, 740)
|
|
assert.equal(wrongUser.output, '')
|
|
|
|
// A machine-protected directory also requests elevation, including a
|
|
// destination that does not exist yet. No files are installed there.
|
|
const protectedPath = path.join(process.env.ProgramFiles, 'DBX access probe', 'nested')
|
|
const protectedFolder = run(['/UPDATE'], protectedPath)
|
|
assert.match(protectedFolder.output, /handoff/)
|
|
assert.equal(existsSync(protectedPath), false)
|
|
} finally {
|
|
if (existsSync(binary)) execFileSync('attrib.exe', ['-R', binary])
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
// Compile the actual diagnostic strings and write policy on any host with NSIS.
|
|
// On Windows also execute a locked-file extraction: it must stop before the
|
|
// completion marker, and its expanded dialog must contain usable diagnostics.
|
|
test('Installer write failure diagnostics compile', { skip: !compilerAvailable }, async (t) => {
|
|
const dir = mkdtempSync(path.join(tmpdir(), 'dbx write failure '))
|
|
const payload = path.join(dir, 'payload.txt')
|
|
const source = path.join(dir, 'failure.nsi')
|
|
const exe = path.join(dir, 'failure.exe')
|
|
const target = path.join(dir, 'destination')
|
|
const report = path.join(dir, 'result.ini')
|
|
mkdirSync(target)
|
|
writeFileSync(payload, 'new executable contents')
|
|
// Simulate only the user's Manual installation selection and Explorer launch.
|
|
// Keep the production extraction policy, dialog code and exit handler intact.
|
|
const fixtureDiagnostics = diagnostics
|
|
.replace('Function DbxShowWriteError', 'Function DbxShowWriteErrorActual')
|
|
.replace(' Call DbxShowWriteError\n', ' Call DbxShowWriteError\n WriteINIStr "$EXEDIR\\result.ini" "trace" "action" "$DbxWriteFailureAction"\n')
|
|
.replace(`ExecShell "open" "$WINDIR\\explorer.exe" '/select,"$EXEPATH"'`,
|
|
`WriteINIStr "$EXEDIR\\result.ini" "manual" "args" '/select,"$EXEPATH"'`)
|
|
const dialogSelection = `Function DbxShowWriteError
|
|
\u0024{If} $TestManual = 1
|
|
StrCpy $DbxWriteFailureAction \u0024{DBX_WRITE_MANUAL}
|
|
\u0024{ElseIf} $TestRetry = 1
|
|
StrCpy $TestRetry 0
|
|
System::Call 'kernel32::CloseHandle(p $TestHandle)'
|
|
StrCpy $DbxWriteFailureAction \u0024{DBX_WRITE_RETRY}
|
|
\u0024{Else}
|
|
Call DbxShowWriteErrorActual
|
|
\u0024{EndIf}
|
|
FunctionEnd`
|
|
const script = `Unicode true
|
|
XPStyle on
|
|
!include MUI2.nsh
|
|
!include FileFunc.nsh
|
|
!define VERSION "1.0.1"
|
|
!define MAINBINARYNAME "dbx"
|
|
Name "DBX required file write test"
|
|
OutFile "${exe}"
|
|
RequestExecutionLevel user
|
|
SilentInstall silent
|
|
${diagnosticVariables}
|
|
Var TestHandle
|
|
Var TestManual
|
|
Var TestRetry
|
|
${languages}
|
|
${fixtureDiagnostics}
|
|
${dialogSelection}
|
|
${template.match(/Function DbxEnsureInstallAccess\r?\n[\s\S]*?FunctionEnd/)[0]}
|
|
Function .onInit
|
|
${elevationInit}
|
|
\u0024{GetOptions} $CMDLINE "/LANG=" $0
|
|
StrCpy $LANGUAGE $0
|
|
\u0024{GetOptions} $CMDLINE "/LOCK" $0
|
|
\u0024{IfNot} \u0024{Errors}
|
|
System::Call 'kernel32::CreateFileW(w "$INSTDIR\\dbx.exe", i 0x80000000, i 0, p 0, i 3, i 0, p 0) p .s'
|
|
Pop $TestHandle
|
|
\u0024{EndIf}
|
|
\u0024{GetOptions} $CMDLINE "/MANUAL" $0
|
|
\u0024{IfNot} \u0024{Errors}
|
|
StrCpy $TestManual 1
|
|
\u0024{EndIf}
|
|
\u0024{GetOptions} $CMDLINE "/RETRY" $0
|
|
\u0024{IfNot} \u0024{Errors}
|
|
StrCpy $TestRetry 1
|
|
\u0024{EndIf}
|
|
Call DbxEnsureInstallAccess
|
|
\u0024{GetOptions} $CMDLINE "/READONLY" $0
|
|
\u0024{IfNot} \u0024{Errors}
|
|
SetFileAttributes "$INSTDIR\\dbx.exe" READONLY
|
|
\u0024{EndIf}
|
|
FunctionEnd
|
|
Section
|
|
SetOutPath $INSTDIR
|
|
; NSIS applies the selected language after .onInit returns.
|
|
Call DbxUpdateElevationStatus
|
|
StrCpy $0 "$INSTDIR\\dbx.exe"
|
|
WriteINIStr "$EXEDIR\\result.ini" "diagnostics" "dialog" "$(dbxFileWriteErrorNoIgnore)"
|
|
!insertmacro DbxExtractFile "/oname=dbx.exe" "${payload}" "$INSTDIR\\dbx.exe"
|
|
WriteINIStr "$EXEDIR\\result.ini" "install" "complete" "1"
|
|
SectionEnd
|
|
`
|
|
try {
|
|
writeFileSync(source, script)
|
|
const flag = process.platform === 'win32' ? '/' : '-'
|
|
const compilation = execFileSync(compiler, [`${flag}V2`, `${flag}INPUTCHARSET`, 'UTF8', source], { encoding: 'utf8', timeout: 30_000 })
|
|
assert.doesNotMatch(compilation, /unknown variable|unknown constant|unknown identifier/i)
|
|
await t.test('Windows locked-file extraction stops and reports diagnostics', { skip: !available }, () => {
|
|
for (const [language, diagnosticLabel, instruction] of [
|
|
['1033', 'Diagnostic code:', 'Please run the installer as administrator.'],
|
|
['2052', '诊断码:', '请以管理员权限运行安装程序。'],
|
|
['1028', '診斷碼:', '請以系統管理員權限執行安裝程式。'],
|
|
]) {
|
|
for (const action of ['writable', 'readonly', 'retry', 'cancel', 'manual']) {
|
|
t.diagnostic(`Windows extraction: language=${language}, action=${action}`)
|
|
const locked = ['retry', 'cancel', 'manual'].includes(action)
|
|
writeFileSync(path.join(target, 'dbx.exe'), 'original executable contents')
|
|
// Win32 INI writes otherwise use the runner's ANSI code page.
|
|
writeFileSync(report, '\ufeff', 'utf16le')
|
|
let exitCode = 0
|
|
try {
|
|
execFileSync(exe, ['/S', `/LANG=${language}`, ...(locked ? ['/LOCK'] : []), ...(action === 'manual' ? ['/MANUAL'] : []), ...(action === 'retry' ? ['/RETRY'] : []), ...(action === 'readonly' ? ['/READONLY'] : []), `/D=${target}`], { timeout: 15_000, windowsVerbatimArguments: true, argv0: `"${exe}"` })
|
|
} catch (error) {
|
|
if (typeof error.status !== 'number') {
|
|
const diagnostic = existsSync(report) ? readFileSync(report, 'utf16le') : 'No fixture report'
|
|
throw new Error(`language=${language}, action=${action}: ${error.message}\n${diagnostic}`, { cause: error })
|
|
}
|
|
exitCode = error.status
|
|
}
|
|
const output = readFileSync(report, 'utf16le')
|
|
assert.doesNotMatch(output, /Installer:|安装包:|安裝套件:/)
|
|
assert.ok(output.includes(target), output)
|
|
assert.ok(output.includes(diagnosticLabel), output)
|
|
assert.match(output, new RegExp(`A[01]-E[01]-F${locked ? 32 : 0}-D0`))
|
|
assert.ok(output.includes(instruction), output)
|
|
assert.ok(output.includes('1.0.1'), output)
|
|
assert.doesNotMatch(output, /Program Files|\$Dbx|\$EXEPATH|\$INSTDIR/)
|
|
if (action === 'cancel' || action === 'manual') {
|
|
assert.equal(exitCode, 2, 'A failed required-file write must stop installation')
|
|
assert.doesNotMatch(output, /complete=1/)
|
|
if (action === 'manual') assert.ok(output.includes(`args=/select,"${exe}"`), output)
|
|
else assert.doesNotMatch(output, /\[manual\]/)
|
|
assert.equal(readFileSync(path.join(target, 'dbx.exe'), 'utf8'), 'original executable contents')
|
|
} else {
|
|
assert.equal(exitCode, 0)
|
|
assert.match(output, /complete=1/)
|
|
assert.equal(readFileSync(path.join(target, 'dbx.exe'), 'utf8'), 'new executable contents')
|
|
}
|
|
}
|
|
}
|
|
})
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|