fix(desktop): harden Windows startup validation
This commit is contained in:
1 parent
10b50829ee
commit
263fafd433
19 files changed
+437
-17
No files matched your search
@@ -4,6 +4,8 @@ import { describe, expect, test } from "bun:test";
|
||||
const workflow = readFileSync(new URL("../.github/workflows/desktop-build.yml", import.meta.url), "utf8");
|
||||
const mobileWorkflow = readFileSync(new URL("../.github/workflows/mobile-build.yml", import.meta.url), "utf8");
|
||||
const desktopPackageVerifier = readFileSync(new URL("./verify-desktop-package.mjs", import.meta.url), "utf8");
|
||||
const packagedStartupVerifier = readFileSync(new URL("./verify-packaged-desktop-startup.mjs", import.meta.url), "utf8");
|
||||
const cargoConfig = readFileSync(new URL("../.cargo/config.toml", import.meta.url), "utf8");
|
||||
|
||||
function step(name) {
|
||||
const start = workflow.indexOf(` - name: ${name}\n`);
|
||||
@@ -83,6 +85,13 @@ describe("desktop release workflow", () => {
|
||||
expect(workflow).toContain("allow-missing-windows-signature");
|
||||
expect(workflow).toContain("name: Audit signed Windows update");
|
||||
expect(workflow).toContain("verify-windows-update-release.mjs");
|
||||
expect(workflow).toContain("name: Run packaged Windows sidecar integration tests");
|
||||
expect(workflow).toContain("name: Verify packaged Windows first launch");
|
||||
expect(workflow).toContain("verify:packaged-desktop-startup");
|
||||
expect(packagedStartupVerifier).toContain('new Set(["sidecar.ready", "renderer.bootstrap-ready"])');
|
||||
expect(desktopPackageVerifier).toContain("isVisualCppRuntimeDll");
|
||||
expect(cargoConfig).toContain('target.x86_64-pc-windows-msvc');
|
||||
expect(cargoConfig).toContain('target-feature=+crt-static');
|
||||
expect(desktopPackageVerifier).toContain(
|
||||
'path.replaceAll("\\\\", "/")',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const readCString = (bytes, offset, maxLength = 260) => {
|
||||
const endLimit = Math.min(bytes.length, offset + maxLength);
|
||||
let end = offset;
|
||||
while (end < endLimit && bytes[end] !== 0) end += 1;
|
||||
if (end === endLimit) throw new Error("PE import name is not null-terminated");
|
||||
return bytes.toString("ascii", offset, end);
|
||||
};
|
||||
|
||||
export const readPeImportedDlls = (input) => {
|
||||
const bytes = Buffer.isBuffer(input) ? input : readFileSync(input);
|
||||
if (bytes.length < 0x40 || bytes.toString("ascii", 0, 2) !== "MZ") throw new Error("Invalid PE DOS header");
|
||||
|
||||
const peOffset = bytes.readUInt32LE(0x3c);
|
||||
if (peOffset + 24 > bytes.length || bytes.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") {
|
||||
throw new Error("Invalid PE signature");
|
||||
}
|
||||
|
||||
const coffOffset = peOffset + 4;
|
||||
const sectionCount = bytes.readUInt16LE(coffOffset + 2);
|
||||
const optionalHeaderSize = bytes.readUInt16LE(coffOffset + 16);
|
||||
const optionalHeaderOffset = coffOffset + 20;
|
||||
const optionalHeaderMagic = bytes.readUInt16LE(optionalHeaderOffset);
|
||||
const dataDirectoryOffset = optionalHeaderOffset + (optionalHeaderMagic === 0x20b ? 112 : optionalHeaderMagic === 0x10b ? 96 : 0);
|
||||
if (!dataDirectoryOffset || dataDirectoryOffset + 16 > bytes.length) throw new Error("Unsupported PE optional header");
|
||||
|
||||
const importTableRva = bytes.readUInt32LE(dataDirectoryOffset + 8);
|
||||
const importTableSize = bytes.readUInt32LE(dataDirectoryOffset + 12);
|
||||
if (!importTableRva || !importTableSize) return [];
|
||||
|
||||
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
|
||||
const sections = [];
|
||||
for (let index = 0; index < sectionCount; index += 1) {
|
||||
const offset = sectionTableOffset + index * 40;
|
||||
if (offset + 40 > bytes.length) throw new Error("PE section table is truncated");
|
||||
sections.push({
|
||||
virtualSize: bytes.readUInt32LE(offset + 8),
|
||||
virtualAddress: bytes.readUInt32LE(offset + 12),
|
||||
rawSize: bytes.readUInt32LE(offset + 16),
|
||||
rawOffset: bytes.readUInt32LE(offset + 20),
|
||||
});
|
||||
}
|
||||
|
||||
const fileOffsetFromRva = (rva) => {
|
||||
const section = sections.find(({ virtualAddress, virtualSize, rawSize }) =>
|
||||
rva >= virtualAddress && rva < virtualAddress + Math.max(virtualSize, rawSize));
|
||||
if (!section) throw new Error(`PE RVA is outside every section: 0x${rva.toString(16)}`);
|
||||
const offset = section.rawOffset + (rva - section.virtualAddress);
|
||||
if (offset >= bytes.length) throw new Error("PE RVA points outside the file");
|
||||
return offset;
|
||||
};
|
||||
|
||||
const imports = [];
|
||||
let descriptorOffset = fileOffsetFromRva(importTableRva);
|
||||
const descriptorLimit = Math.min(bytes.length, descriptorOffset + importTableSize);
|
||||
while (descriptorOffset + 20 <= descriptorLimit) {
|
||||
const originalFirstThunk = bytes.readUInt32LE(descriptorOffset);
|
||||
const nameRva = bytes.readUInt32LE(descriptorOffset + 12);
|
||||
const firstThunk = bytes.readUInt32LE(descriptorOffset + 16);
|
||||
if (originalFirstThunk === 0 && nameRva === 0 && firstThunk === 0) break;
|
||||
if (!nameRva) throw new Error("PE import descriptor has no DLL name");
|
||||
imports.push(readCString(bytes, fileOffsetFromRva(nameRva)));
|
||||
descriptorOffset += 20;
|
||||
}
|
||||
return imports;
|
||||
};
|
||||
|
||||
export const isVisualCppRuntimeDll = (name) => /^(?:vcruntime|msvcp|concrt)\d[^/\\]*\.dll$/i.test(name);
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isVisualCppRuntimeDll, readPeImportedDlls } from "./pe-imports.mjs";
|
||||
|
||||
const peWithImport = (dllName) => {
|
||||
const bytes = Buffer.alloc(0x400);
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
bytes.writeUInt32LE(0x80, 0x3c);
|
||||
bytes.write("PE\0\0", 0x80, "ascii");
|
||||
const coff = 0x84;
|
||||
bytes.writeUInt16LE(0x8664, coff);
|
||||
bytes.writeUInt16LE(1, coff + 2);
|
||||
bytes.writeUInt16LE(0xf0, coff + 16);
|
||||
const optional = coff + 20;
|
||||
bytes.writeUInt16LE(0x20b, optional);
|
||||
bytes.writeUInt32LE(16, optional + 108);
|
||||
bytes.writeUInt32LE(0x1000, optional + 120);
|
||||
bytes.writeUInt32LE(40, optional + 124);
|
||||
const section = optional + 0xf0;
|
||||
bytes.write(".rdata", section, "ascii");
|
||||
bytes.writeUInt32LE(0x200, section + 8);
|
||||
bytes.writeUInt32LE(0x1000, section + 12);
|
||||
bytes.writeUInt32LE(0x200, section + 16);
|
||||
bytes.writeUInt32LE(0x200, section + 20);
|
||||
bytes.writeUInt32LE(0x1040, 0x200);
|
||||
bytes.writeUInt32LE(0x1080, 0x20c);
|
||||
bytes.writeUInt32LE(0x1040, 0x210);
|
||||
bytes.write(`${dllName}\0`, 0x280, "ascii");
|
||||
return bytes;
|
||||
};
|
||||
|
||||
describe("PE imports", () => {
|
||||
test("reads imported DLL names from a PE32+ image", () => {
|
||||
expect(readPeImportedDlls(peWithImport("VCRUNTIME140.dll"))).toEqual(["VCRUNTIME140.dll"]);
|
||||
});
|
||||
|
||||
test("identifies Visual C++ runtime dependencies", () => {
|
||||
expect(isVisualCppRuntimeDll("VCRUNTIME140.dll")).toBe(true);
|
||||
expect(isVisualCppRuntimeDll("MSVCP140_1.dll")).toBe(true);
|
||||
expect(isVisualCppRuntimeDll("KERNEL32.dll")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -48,16 +48,19 @@ export const planNativeRelease = (platform, changedFiles) => {
|
||||
...mobileOnlyDependencyPatches,
|
||||
])
|
||||
: new Set([
|
||||
".cargo/config.toml",
|
||||
".github/workflows/desktop-build.yml",
|
||||
"bun.lock",
|
||||
"scripts/create-mac-update-metadata.mjs",
|
||||
"scripts/create-windows-update-metadata.mjs",
|
||||
"scripts/desktop-icns.mjs",
|
||||
"scripts/prepare-desktop-icons.mjs",
|
||||
"scripts/pe-imports.mjs",
|
||||
"scripts/run-desktop-builder.mjs",
|
||||
"scripts/sign-windows-update-manifest.mjs",
|
||||
"scripts/verify-windows-update-release.mjs",
|
||||
"scripts/verify-desktop-package.mjs",
|
||||
"scripts/verify-packaged-desktop-startup.mjs",
|
||||
]);
|
||||
|
||||
const relevantChanges = runtimeChangedFiles.filter(
|
||||
|
||||
@@ -69,14 +69,17 @@ describe("native release planning", () => {
|
||||
|
||||
test("rebuilds desktop when its architecture packaging pipeline changes", () => {
|
||||
const changedFiles = [
|
||||
".cargo/config.toml",
|
||||
".github/workflows/desktop-build.yml",
|
||||
"scripts/create-mac-update-metadata.mjs",
|
||||
"scripts/create-windows-update-metadata.mjs",
|
||||
"scripts/prepare-desktop-icons.mjs",
|
||||
"scripts/desktop-icns.mjs",
|
||||
"scripts/pe-imports.mjs",
|
||||
"scripts/run-desktop-builder.mjs",
|
||||
"scripts/sign-windows-update-manifest.mjs",
|
||||
"scripts/verify-windows-update-release.mjs",
|
||||
"scripts/verify-packaged-desktop-startup.mjs",
|
||||
];
|
||||
expect(planNativeRelease("desktop", changedFiles)).toEqual({
|
||||
rebuild: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { existsSync, openSync, closeSync, readFileSync, readSync, readdirSync, r
|
||||
import { basename, join } from "node:path";
|
||||
import { listPackage } from "@electron/asar";
|
||||
import { assertMacIcnsComplete } from "./desktop-icns.mjs";
|
||||
import { isVisualCppRuntimeDll, readPeImportedDlls } from "./pe-imports.mjs";
|
||||
|
||||
const outputDirectory = join(process.cwd(), "release", "desktop");
|
||||
const version = JSON.parse(
|
||||
@@ -101,6 +102,12 @@ if (requestedPlatform === "darwin") {
|
||||
assert.ok(existsSync(join(resources, "migrations")), "Windows app bundle is missing migrations");
|
||||
verifyPeX64(executable, "Electron executable");
|
||||
verifyPeX64(sidecar, "Rust sidecar");
|
||||
const sidecarRuntimeImports = readPeImportedDlls(sidecar).filter(isVisualCppRuntimeDll);
|
||||
assert.deepEqual(
|
||||
sidecarRuntimeImports,
|
||||
[],
|
||||
`Windows sidecar must statically link the Visual C++ runtime; found: ${sidecarRuntimeImports.join(", ")}`,
|
||||
);
|
||||
const asarPath = join(resources, "app.asar");
|
||||
assert.ok(existsSync(asarPath), `Windows app bundle is missing app.asar: ${asarPath}`);
|
||||
const asarFiles = listAsarFiles(asarPath);
|
||||
|
||||
@@ -49,7 +49,7 @@ try {
|
||||
|
||||
await verifyEntry({
|
||||
filePath: entryPath,
|
||||
readySelector: "#root > *",
|
||||
readySelector: '[data-edgeever-renderer-ready="true"] #root > *',
|
||||
label: "Desktop renderer",
|
||||
});
|
||||
await verifyEntry({
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const requestedExecutablePath = process.argv[2]?.trim();
|
||||
const executablePath = requestedExecutablePath ? resolve(requestedExecutablePath) : "";
|
||||
if (!executablePath || !existsSync(executablePath) || !statSync(executablePath).isFile()) {
|
||||
throw new Error(`Packaged desktop executable is missing: ${executablePath || "<not provided>"}`);
|
||||
}
|
||||
|
||||
const userDataDirectory = mkdtempSync(join(tmpdir(), "edgeever-packaged-startup-"));
|
||||
const diagnosticLogPath = join(userDataDirectory, "logs", "desktop.log");
|
||||
const output = [];
|
||||
let processError = null;
|
||||
const child = spawn(executablePath, [`--user-data-dir=${userDataDirectory}`], {
|
||||
env: {
|
||||
...process.env,
|
||||
EDGE_EVER_DISABLE_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
child.on("error", (error) => { processError = error; });
|
||||
child.stdout.on("data", (chunk) => output.push(chunk.toString()));
|
||||
child.stderr.on("data", (chunk) => output.push(chunk.toString()));
|
||||
|
||||
const timeoutMs = Number(process.env.EDGE_EVER_DESKTOP_STARTUP_TIMEOUT_MS) || 45_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let startupReady = false;
|
||||
let lastLog = "";
|
||||
const requiredEvents = new Set(["sidecar.ready", "renderer.bootstrap-ready"]);
|
||||
const observedEvents = new Set();
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(diagnosticLogPath)) {
|
||||
lastLog = readFileSync(diagnosticLogPath, "utf8");
|
||||
for (const line of lastLog.split(/\r?\n/)) {
|
||||
try {
|
||||
const event = JSON.parse(line).event;
|
||||
if (typeof event === "string") observedEvents.add(event);
|
||||
} catch { /* Ignore a partial trailing log line. */ }
|
||||
}
|
||||
if ([...requiredEvents].every((event) => observedEvents.has(event))) {
|
||||
startupReady = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (processError || child.exitCode !== null) break;
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
||||
}
|
||||
|
||||
if (!startupReady) {
|
||||
throw new Error([
|
||||
`Packaged desktop did not report renderer readiness within ${timeoutMs}ms (exit=${child.exitCode ?? "running"}).`,
|
||||
`Missing events: ${[...requiredEvents].filter((event) => !observedEvents.has(event)).join(", ") || "none"}`,
|
||||
processError && `Process error: ${processError.message}`,
|
||||
lastLog && `Diagnostic log:\n${lastLog}`,
|
||||
output.length > 0 && `Process output:\n${output.join("")}`,
|
||||
].filter(Boolean).join("\n\n"));
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, executablePath, events: [...requiredEvents] }));
|
||||
} finally {
|
||||
if (child.exitCode === null && child.pid) {
|
||||
if (process.platform === "win32") {
|
||||
spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
||||
} else {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
rmSync(userDataDirectory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
Reference in new issue
Block a user