Files
ctms/frontend/scripts/verify-desktop-release.mjs
T
2026-07-08 20:46:56 +08:00

250 lines
11 KiB
JavaScript

import { readdir, readFile } from "node:fs/promises";
import { extname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, "..");
const sourceDir = resolve(frontendDir, "src");
const tauriDir = resolve(frontendDir, "src-tauri");
const failures = [];
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const walk = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
return (
await Promise.all(
entries.map(async (entry) => {
const path = resolve(directory, entry.name);
return entry.isDirectory() ? walk(path) : path;
}),
)
).flat();
};
const permissionIdentifier = (permission) =>
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
const assertPathScope = (permission, expectedPrefix, description) => {
const allow = Array.isArray(permission.allow) ? permission.allow : [];
assert(allow.length > 0, `${description} must define an explicit allow list.`);
for (const item of allow) {
const path = item?.path;
assert(
typeof path === "string" && path.startsWith(expectedPrefix),
`${description} may only allow paths under ${expectedPrefix}; found ${path ?? "<missing>"}.`,
);
}
};
const verifyTauriConfig = async () => {
const tauriConfig = await readJson(resolve(tauriDir, "tauri.conf.json"));
const targets = tauriConfig.bundle?.targets;
const targetList = Array.isArray(targets) ? targets : [targets].filter(Boolean);
const csp = tauriConfig.app?.security?.csp || "";
const cspTokens = csp.split(/[;\s]+/).filter(Boolean);
const mainWindow = tauriConfig.app?.windows?.find((window) => window.label === "main") || tauriConfig.app?.windows?.[0];
assert(tauriConfig.bundle?.active === true, "Tauri bundle must be active for desktop release builds.");
assert(targetList.includes("app"), "Tauri bundle targets must include app.");
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
assert(
tauriConfig.bundle?.createUpdaterArtifacts === true,
"Tauri must create updater artifacts for signed desktop release builds.",
);
assert(
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
"Tauri updater public key must be configured.",
);
assert(csp.includes("default-src 'self'"), "Tauri CSP must keep default-src restricted to self.");
assert(csp.includes("object-src 'none'"), "Tauri CSP must disable object-src.");
assert(!csp.includes("'unsafe-eval'"), "Tauri CSP must not allow unsafe-eval.");
assert(
!cspTokens.some((token) => token === "*" || token.includes("://*")),
"Tauri CSP must not use wildcard sources.",
);
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
};
const verifyCapabilities = async () => {
const capabilitiesDir = resolve(tauriDir, "capabilities");
const files = (await readdir(capabilitiesDir)).filter((file) => file.endsWith(".json"));
assert(files.length > 0, "At least one Tauri capability file must exist.");
const bannedPermissions = new Set([
"shell:default",
"shell:allow-open",
"shell:allow-execute",
"fs:default",
"fs:allow-read-dir",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
]);
for (const file of files) {
const capability = await readJson(resolve(capabilitiesDir, file));
const permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
for (const identifier of identifiers) {
assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`);
assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`);
assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`);
}
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`);
if (fsScope && typeof fsScope !== "string") {
assertPathScope(fsScope, "$TEMP/ctms-desktop/", `${file}: fs:scope`);
}
const openerScope = permissions.find((permission) => permissionIdentifier(permission) === "opener:allow-open-path");
assert(Boolean(openerScope), `${file}: opener:allow-open-path must be explicitly scoped.`);
if (openerScope && typeof openerScope !== "string") {
assertPathScope(openerScope, "$TEMP/ctms-desktop/", `${file}: opener:allow-open-path`);
}
}
};
const verifyRustBoundary = async () => {
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
for (const token of forbiddenRust) {
assert(!libSource.includes(token), `Rust desktop boundary must not include ${token}.`);
}
const singleInstanceIndex = libSource.indexOf("tauri_plugin_single_instance::init");
const dialogIndex = libSource.indexOf("tauri_plugin_dialog::init");
assert(singleInstanceIndex >= 0, "Single-instance plugin must be registered.");
assert(
dialogIndex < 0 || singleInstanceIndex < dialogIndex,
"Single-instance plugin must be registered before other desktop plugins.",
);
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
const allowedCommands = [
"credentials::credential_get",
"credentials::credential_set",
"credentials::credential_delete",
"updates::desktop_update_check",
"updates::desktop_update_install",
];
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
const missing = allowedCommands.filter((command) => !commands.includes(command));
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
};
const verifySourceSafety = async () => {
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]);
const files = [
...(await walk(sourceDir)),
...(await walk(resolve(tauriDir, "src"))),
].filter((path) => sourceExtensions.has(extname(path)));
for (const path of files) {
const source = await readFile(path, "utf8");
const file = relative(rootDir, path);
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
assert(
!/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source),
`${file}: token-related values must not be written to console logs.`,
);
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
}
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
}
}
};
const verifyNotificationBoundary = async () => {
const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8");
assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic.");
assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic.");
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
};
const verifyUpdaterBoundary = async () => {
const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8");
assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path.");
assert(source.includes("desktop updates require HTTPS outside localhost"), "Desktop updater must reject non-local HTTP update feeds.");
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
};
const verifyWorkflowGates = async () => {
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
const requiredScripts = [
"desktop:build:macos-release",
"desktop:update-feed:create",
"desktop:update-feed:check",
"desktop:release-readiness:check",
"release:env:check",
];
for (const script of requiredScripts) {
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
}
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
const requiredCommands = [
"npm run version:check",
"npm run runtime:check",
"npm run desktop:release:check",
"npm run ui:contract",
"npm run type-check",
"npm run test:unit",
"npm run build",
"npm run desktop:build:app",
"npm run release:env:check",
];
for (const command of requiredCommands) {
assert(workflow.includes(command), `Client quality gates workflow must run ${command}.`);
}
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
const requiredReleaseWorkflowTokens = [
"REQUIRE_DESKTOP_SIGNING",
"TAURI_SIGNING_PRIVATE_KEY",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
"npm run desktop:build:macos-release",
"npm run desktop:update-feed:create",
"npm run desktop:update-feed:check",
"npm run desktop:release-readiness:check",
"actions/upload-artifact",
];
for (const token of requiredReleaseWorkflowTokens) {
assert(releaseWorkflow.includes(token), `Desktop release candidate workflow must include ${token}.`);
}
};
await verifyTauriConfig();
await verifyCapabilities();
await verifyRustBoundary();
await verifySourceSafety();
await verifyNotificationBoundary();
await verifyUpdaterBoundary();
await verifyWorkflowGates();
if (failures.length > 0) {
console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Desktop release gate passed.");
}