261 lines
15 KiB
JavaScript
261 lines
15 KiB
JavaScript
import { readNativeUpdateStatus } from "./native-update-status.mjs";
|
|
import { macAppBundle, macAppExecutable } from "./native-update-macos.mjs";
|
|
import { windowsUpdateInstallerRunning, stopWindowsUpdateFixtures } from "./native-update-windows.mjs";
|
|
import { createServer } from "node:http";
|
|
import { createReadStream, existsSync, readFileSync, statSync, writeFileSync, mkdirSync, chmodSync, appendFileSync } from "node:fs";
|
|
import { cp, rm } from "node:fs/promises";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { join, resolve, basename, dirname } from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
|
|
if (process.env.GITHUB_ACTIONS !== "true" || process.env.GITHUB_REPOSITORY !== "tianma-if/edgeever") {
|
|
throw new Error("Native installer verification is restricted to the official disposable CI runners");
|
|
}
|
|
const root = resolve(process.argv[2]);
|
|
const versions = JSON.parse(readFileSync(join(root, "versions.json"), "utf8"));
|
|
const [oldVersion, intermediateVersion, latestVersion] = versions;
|
|
const evidence = join(root, "evidence");
|
|
mkdirSync(evidence, { recursive: true });
|
|
const report = { platform: process.platform, arch: process.arch, sourceSha: process.env.GITHUB_SHA, versions,
|
|
instrumentation: "Full EdgeEver builds with isolated app identity, local feed, command harness and ephemeral Windows trust key; production update controller and native installers are unchanged.",
|
|
fixtureRun: process.env.FIXTURE_RUN || null,
|
|
scenarios: [] };
|
|
let feedVersion = intermediateVersion;
|
|
let corruptSignature = false;
|
|
let corruptPackage = false;
|
|
const requests = [];
|
|
const metadataName = process.platform === "darwin" ? "latest-mac.yml" : process.platform === "win32" ? "latest.yml" : "latest-linux.yml";
|
|
const packageName = (version) => process.platform === "darwin" ? `EdgeEver-${version}-mac-${process.arch}.zip`
|
|
: process.platform === "win32" ? `EdgeEver-${version}-windows-x64.exe` : `EdgeEver-${version}-linux-x64.AppImage`;
|
|
const server = createServer((request, response) => {
|
|
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
|
|
const parts = pathname.split("/").filter(Boolean);
|
|
let version = feedVersion;
|
|
let name;
|
|
if (parts[0] === "feed" && parts.length === 2) name = parts[1];
|
|
else if (parts[0] === "release" && parts.length === 3 && versions.includes(parts[1])) {
|
|
version = parts[1]; name = parts[2];
|
|
}
|
|
if (!name || basename(name) !== name || ![metadataName, "latest-windows.json", "latest-windows.json.sig", packageName(version)].includes(name)) {
|
|
response.writeHead(404).end(); return;
|
|
}
|
|
const path = join(root, version, name);
|
|
if (!existsSync(path)) { response.writeHead(404).end(); return; }
|
|
requests.push({ at: new Date().toISOString(), version, name });
|
|
if (corruptSignature && name === "latest-windows.json.sig") {
|
|
const signature = JSON.parse(readFileSync(path, "utf8"));
|
|
signature.signature = Buffer.alloc(64).toString("base64");
|
|
response.writeHead(200).end(JSON.stringify(signature)); return;
|
|
}
|
|
response.writeHead(200, { "Content-Length": statSync(path).size, "Content-Type": name.endsWith(".yml") ? "text/yaml" : "application/octet-stream" });
|
|
if (corruptPackage && name === packageName(version)) {
|
|
const bytes = readFileSync(path);
|
|
bytes[bytes.length - 1] ^= 1;
|
|
response.end(bytes); return;
|
|
}
|
|
createReadStream(path).pipe(response);
|
|
});
|
|
await new Promise((yes, no) => { server.once("error", no); server.listen(0, "127.0.0.1", yes); });
|
|
const feed = `http://127.0.0.1:${server.address().port}`;
|
|
let commandId = 0;
|
|
const children = [];
|
|
const readEvents = (path) => !existsSync(path) ? [] : readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
|
const wait = (ms) => new Promise((yes) => setTimeout(yes, ms));
|
|
const until = async (predicate, description, timeout = 180_000) => {
|
|
const deadline = Date.now() + timeout;
|
|
while (Date.now() < deadline) {
|
|
const value = predicate();
|
|
if (value) return value;
|
|
await wait(200);
|
|
}
|
|
throw new Error(`Timed out: ${description}`);
|
|
};
|
|
const installBaseline = async (directory) => {
|
|
const installed = join(directory, "installed");
|
|
mkdirSync(installed, { recursive: true });
|
|
if (process.platform === "darwin") {
|
|
const unpack = spawnSync("ditto", ["-x", "-k", join(root, oldVersion, packageName(oldVersion)), installed], { encoding: "utf8" });
|
|
if (unpack.status !== 0) throw new Error(unpack.stderr);
|
|
return macAppExecutable(macAppBundle(installed));
|
|
}
|
|
if (process.platform === "win32") {
|
|
stopWindowsUpdateFixtures();
|
|
const installer = join(root, oldVersion, packageName(oldVersion));
|
|
let result;
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
result = spawnSync(installer, ["/S", `/D=${installed}`], {
|
|
stdio: "ignore", windowsHide: true, timeout: 120_000,
|
|
env: { ...process.env, EDGE_EVER_DISABLE_AUTO_UPDATE: "1" },
|
|
});
|
|
if (result.status === 0) break;
|
|
stopWindowsUpdateFixtures();
|
|
await wait(2_000);
|
|
}
|
|
if (result.status !== 0) throw new Error(`Baseline NSIS install failed: ${result.status}`);
|
|
const executable = join(installed, "EdgeEverUpdateVerifier.exe");
|
|
spawnSync("powershell", ["-NoProfile", "-Command", `Get-CimInstance Win32_Process -Filter "Name='EdgeEverUpdateVerifier.exe'" | Where-Object { $_.ExecutablePath -eq $env:VERIFIER_INSTALLED_EXE } | ForEach-Object { taskkill /PID $_.ProcessId /T /F | Out-Null }`], {
|
|
stdio: "ignore", env: { ...process.env, VERIFIER_INSTALLED_EXE: executable }, timeout: 30_000,
|
|
});
|
|
return executable;
|
|
}
|
|
const appImage = join(installed, "EdgeEver.AppImage");
|
|
await cp(join(root, oldVersion, packageName(oldVersion)), appImage);
|
|
chmodSync(appImage, 0o755);
|
|
return appImage;
|
|
};
|
|
const stopFixture = async (events) => {
|
|
// Only PIDs written by the isolated test app are eligible for termination.
|
|
for (const entry of events.filter((value) => value.event === "boot")) {
|
|
if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(entry.pid), "/T", "/F"], { stdio: "ignore" });
|
|
else {
|
|
const current = spawnSync("ps", ["-p", String(entry.pid), "-o", "command="], { encoding: "utf8" });
|
|
if (current.stdout?.trim().startsWith(entry.executable)) {
|
|
spawnSync("pkill", ["-KILL", "-P", String(entry.pid)], { stdio: "ignore" });
|
|
try { process.kill(entry.pid, "SIGKILL"); } catch {}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
try {
|
|
for (const scenario of ["install-latest", "quit-latest", "reject-corrupt-update"]) {
|
|
const directory = join(evidence, scenario);
|
|
mkdirSync(directory, { recursive: true });
|
|
feedVersion = intermediateVersion;
|
|
const eventsPath = join(directory, "events.jsonl");
|
|
const commandPath = join(directory, "command.json");
|
|
const profile = join(directory, "profile");
|
|
mkdirSync(profile, { recursive: true });
|
|
const env = { ...process.env,
|
|
EDGE_EVER_UPDATE_VERIFIER_PROFILE: profile, EDGE_EVER_UPDATE_VERIFIER_LOG: eventsPath,
|
|
EDGE_EVER_UPDATE_VERIFIER_COMMAND: commandPath, EDGE_EVER_UPDATE_VERIFIER_FEED: feed,
|
|
EDGE_EVER_UPDATE_VERIFIER_PUBLIC_KEY: join(root, "public-key.pem"),
|
|
};
|
|
// Squirrel/LaunchServices can relaunch without inheriting environment.
|
|
// Every signed fixture embeds only this fixed config path, never secrets.
|
|
writeFileSync(join(root, "runtime.json"), JSON.stringify({
|
|
EDGE_EVER_UPDATE_VERIFIER_PROFILE: profile, EDGE_EVER_UPDATE_VERIFIER_LOG: eventsPath,
|
|
EDGE_EVER_UPDATE_VERIFIER_COMMAND: commandPath, EDGE_EVER_UPDATE_VERIFIER_FEED: feed,
|
|
EDGE_EVER_UPDATE_VERIFIER_PUBLIC_KEY: join(root, "public-key.pem"),
|
|
}));
|
|
const executable = await installBaseline(directory);
|
|
let debuggerEndpoint;
|
|
const launch = () => {
|
|
if (!existsSync(executable)) throw new Error(`Installed fixture executable missing: ${executable}`);
|
|
const child = spawn(executable, ["--inspect=127.0.0.1:0"], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
child.on("error", (error) => appendFileSync(join(directory, "process.log"), `${error.stack}\n`));
|
|
child.stdout.on("data", (data) => appendFileSync(join(directory, "process.log"), data));
|
|
child.stderr.on("data", (data) => {
|
|
appendFileSync(join(directory, "process.log"), data);
|
|
debuggerEndpoint ||= /Debugger listening on (ws:\/\/[^\s]+)/.exec(String(data))?.[1];
|
|
});
|
|
children.push(child);
|
|
return child;
|
|
};
|
|
const events = () => readEvents(eventsPath);
|
|
const command = async (action) => {
|
|
const id = ++commandId;
|
|
writeFileSync(commandPath, JSON.stringify({ id, action }));
|
|
return until(() => events().find((entry) => entry.id === id), `command ${action}`);
|
|
};
|
|
try {
|
|
launch();
|
|
await until(() => {
|
|
const failure = readEvents(join(profile, "logs/desktop.log")).find((entry) => entry.event === "main.startup-failed");
|
|
if (failure) throw new Error(`Fixture startup failed: ${failure.message}`);
|
|
return events().some((entry) => entry.event === "ready" && entry.version === intermediateVersion);
|
|
}, "intermediate package downloaded");
|
|
feedVersion = latestVersion;
|
|
if (scenario === "reject-corrupt-update") {
|
|
if (process.platform === "win32") corruptSignature = true;
|
|
else corruptPackage = true;
|
|
const result = await command("check");
|
|
const diagnostics = readEvents(join(profile, "logs/desktop.log"));
|
|
if (!diagnostics.some((entry) => entry.event === "update.check-failed" || entry.event === "update.download-failed")) {
|
|
throw new Error(`Corrupt metadata/package was not rejected: ${JSON.stringify(result)}`);
|
|
}
|
|
if (diagnostics.some((entry) => entry.event === "update.install-confirmed")) throw new Error("An invalid update triggered installation");
|
|
corruptSignature = false;
|
|
corruptPackage = false;
|
|
await command("check");
|
|
await until(() => events().some((entry) => entry.event === "ready" && entry.version === latestVersion), "valid replacement recovers after corruption");
|
|
}
|
|
if (scenario === "quit-latest") {
|
|
await command("quit");
|
|
await until(() => {
|
|
const log = readEvents(join(profile, "logs/desktop.log"));
|
|
return log.some((entry) => entry.event === "session.quit");
|
|
}, "normal application shutdown");
|
|
const digest = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
const expectedPayload = () => process.platform === "darwin"
|
|
? join(macAppBundle(join(root, latestVersion, process.arch === "arm64" ? "mac-arm64" : "mac")), "Contents/Resources/app.asar")
|
|
: process.platform === "win32" ? join(root, latestVersion, "win-unpacked/resources/app.asar")
|
|
: join(root, latestVersion, packageName(latestVersion));
|
|
const installedPayload = process.platform === "darwin" ? join(dirname(executable), "../Resources/app.asar")
|
|
: process.platform === "win32" ? join(dirname(executable), "resources/app.asar") : executable;
|
|
const recordedDigest = join(root, latestVersion, "payload.sha256");
|
|
const expectedDigest = existsSync(recordedDigest) ? readFileSync(recordedDigest, "utf8").trim() : digest(expectedPayload());
|
|
await until(() => {
|
|
try { return digest(installedPayload) === expectedDigest; } catch { return false; }
|
|
}, "native installation replaces the actual installed payload", 240_000);
|
|
if (process.platform === "win32") {
|
|
await until(() => !windowsUpdateInstallerRunning(packageName(latestVersion)), "Windows native installer completes");
|
|
}
|
|
await wait(1_000);
|
|
if (events().filter((entry) => entry.event === "boot").length !== 1) throw new Error("Normal quit relaunched the application");
|
|
// Confirm the installed executable itself now contains the new version.
|
|
launch();
|
|
} else {
|
|
const installation = await command("install");
|
|
if (installation.status?.state === "idle" || installation.event === "command-error") {
|
|
const status = await readNativeUpdateStatus(debuggerEndpoint).catch((error) => ({ error: error.message }));
|
|
writeFileSync(join(directory, "native-status.json"), JSON.stringify(status, null, 2));
|
|
throw new Error(`Native installation failed: ${JSON.stringify(status)}`);
|
|
}
|
|
}
|
|
await until(() => events().some((entry) => entry.event === "boot" && entry.version === latestVersion), "latest installed application launches", 240_000);
|
|
await until(() => {
|
|
const entries = readEvents(join(profile, "logs/desktop.log"));
|
|
const lastSession = entries.findLastIndex((entry) => entry.event === "session.started");
|
|
const currentSession = entries.slice(lastSession);
|
|
return currentSession.some((entry) => entry.event === "renderer.bootstrap-ready") && currentSession.some((entry) => entry.event === "sidecar.ready");
|
|
}, "latest installed renderer and sidecar are ready");
|
|
const diagnostics = readEvents(join(profile, "logs/desktop.log"));
|
|
const installs = diagnostics.filter((entry) => entry.event === "update.install-confirmed");
|
|
const boots = events().filter((entry) => entry.event === "boot").map((entry) => entry.version);
|
|
if (boots.includes(intermediateVersion) || installs.length !== 1 || installs[0].version !== latestVersion) {
|
|
throw new Error(`Unexpected installation sequence: ${JSON.stringify({ boots, installs })}`);
|
|
}
|
|
if (process.platform === "linux") {
|
|
const digest = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
if (digest(executable) !== digest(join(root, latestVersion, packageName(latestVersion)))) throw new Error("Installed AppImage digest differs from the selected release");
|
|
}
|
|
report.scenarios.push({ scenario, ok: true, boots, installs, executable });
|
|
writeFileSync(join(directory, "result.json"), JSON.stringify(report.scenarios.at(-1), null, 2));
|
|
} finally {
|
|
await stopFixture(events());
|
|
if (process.platform === "win32") {
|
|
stopWindowsUpdateFixtures();
|
|
await until(() => !windowsUpdateInstallerRunning(packageName(latestVersion)), "previous fixture installer releases its process lock");
|
|
}
|
|
await wait(1_000);
|
|
}
|
|
}
|
|
report.ok = true;
|
|
} catch (error) {
|
|
report.ok = false;
|
|
report.error = error.stack;
|
|
process.exitCode = 1;
|
|
} finally {
|
|
writeFileSync(join(evidence, "requests.json"), JSON.stringify(requests, null, 2));
|
|
writeFileSync(join(evidence, "result.json"), JSON.stringify(report, null, 2));
|
|
console.log(JSON.stringify(report, null, 2));
|
|
for (const child of children) { if (child.exitCode === null) child.kill("SIGKILL"); }
|
|
server.closeAllConnections();
|
|
server.close();
|
|
// Keep logs; remove installed fixture apps and anonymous databases from artifacts.
|
|
for (const scenario of ["install-latest", "quit-latest", "reject-corrupt-update"]) {
|
|
await rm(join(evidence, scenario, "installed"), { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|