mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 18:13:40 +00:00
312 lines
14 KiB
JavaScript
312 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 });
|
|
}
|
|
});
|