Files
Genarrative/apps/desktop-shell/scripts/check-config.mjs
kdletters d33be9f869 收紧桌面壳命令白名单
桌面壳检查脚本校验 Tauri build manifest、invoke handler 与 capability 只暴露 host_bridge_request

桌面壳检查脚本拒绝残留的非白名单自动生成权限文件

宿主壳方案和共享决策记录桌面壳命令暴露边界
2026-06-18 09:04:25 +08:00

533 lines
16 KiB
JavaScript

import fs from 'node:fs';
const configPath = new URL('../src-tauri/tauri.conf.json', import.meta.url);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const packagePath = new URL('../package.json', import.meta.url);
const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const capabilityPath = new URL(
'../src-tauri/capabilities/main.json',
import.meta.url,
);
const capability = JSON.parse(fs.readFileSync(capabilityPath, 'utf8'));
const buildScriptPath = new URL('../src-tauri/build.rs', import.meta.url);
const buildScript = fs.readFileSync(buildScriptPath, 'utf8');
const cargoManifestPath = new URL('../src-tauri/Cargo.toml', import.meta.url);
const cargoManifest = fs.readFileSync(cargoManifestPath, 'utf8');
const iconDirPath = new URL('../src-tauri/icons/', import.meta.url);
const generatedPermissionDir = new URL(
'../src-tauri/permissions/autogenerated/',
import.meta.url,
);
const sharedContractPath = new URL(
'../../../packages/shared/src/contracts/hostBridge.ts',
import.meta.url,
);
const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8');
const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url);
const main = fs.readFileSync(mainPath, 'utf8');
const productionSourceRoots = [
new URL('../package.json', import.meta.url),
new URL('../src-tauri/Cargo.toml', import.meta.url),
new URL('../src-tauri/build.rs', import.meta.url),
new URL('../src-tauri/capabilities/main.json', import.meta.url),
new URL('../src-tauri/src/', import.meta.url),
new URL('../src-tauri/tauri.conf.json', import.meta.url),
];
const productionFileExtensions = new Set(['.json', '.mjs', '.rs', '.toml']);
const devScaffoldTerms = [
'mo' + 'ck',
'fa' + 'ke',
'place' + 'holder',
'st' + 'ub',
'TO' + 'DO',
'FIX' + 'ME',
'占' + '位',
'模' + '拟',
'伪' + '造',
];
const requiredBundleIcons = [
'icons/32x32.png',
'icons/128x128.png',
'icons/128x128@2x.png',
'icons/icon.icns',
'icons/icon.ico',
'icons/icon.png',
];
const requiredPngIconSizes = new Map([
['32x32.png', 32],
['128x128.png', 128],
['128x128@2x.png', 256],
['icon.png', 512],
]);
function extractCargoPackageString(source, key) {
const match = source.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, 'm'));
if (!match) {
throw new Error(`unable to read Cargo package ${key}`);
}
return match[1];
}
function collectProductionSourceFiles(entry) {
const stats = fs.statSync(entry);
if (stats.isDirectory()) {
const directory = entry.href.endsWith('/') ? entry : new URL(`${entry.href}/`);
return fs
.readdirSync(entry, { withFileTypes: true })
.flatMap((child) =>
collectProductionSourceFiles(
new URL(`${child.name}${child.isDirectory() ? '/' : ''}`, directory),
),
);
}
const path = entry.pathname;
const extension = path.match(/\.[^.]+$/)?.[0] ?? '';
if (!productionFileExtensions.has(extension)) {
return [];
}
if (path.includes('.test.') || path.endsWith('/scripts/check-config.mjs')) {
return [];
}
return [entry];
}
function assertNoDevScaffoldTerms(files) {
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
const lineStarts = [0];
for (let index = 0; index < source.length; index += 1) {
if (source[index] === '\n') {
lineStarts.push(index + 1);
}
}
for (const term of devScaffoldTerms) {
const matchIndex = source.toLowerCase().indexOf(term.toLowerCase());
if (matchIndex === -1) {
continue;
}
const line = lineStarts.filter((start) => start <= matchIndex).length;
throw new Error(
`desktop shell production source must not include ${term}: ${file.pathname}:${line}`,
);
}
}
}
assertNoDevScaffoldTerms(
productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)),
);
function readPngSize(file) {
const buffer = fs.readFileSync(file);
if (
buffer.length < 24 ||
buffer.toString('hex', 0, 8) !== '89504e470d0a1a0a'
) {
throw new Error(`desktop shell icon is not a PNG: ${file.pathname}`);
}
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
const dataStart = buffer.indexOf(Buffer.from('IDAT'));
const hasTransparencyChunk = buffer.includes(Buffer.from('tRNS'));
const hasAlphaColorType = buffer[25] === 4 || buffer[25] === 6;
return {
width,
height,
dataStart,
hasTransparency: hasTransparencyChunk || hasAlphaColorType,
};
}
function assertDesktopIconSet() {
const icons = config.bundle?.icon ?? [];
if (
icons.length !== requiredBundleIcons.length ||
requiredBundleIcons.some((icon) => !icons.includes(icon))
) {
throw new Error('desktop shell must bundle the full real desktop icon set');
}
for (const [fileName, expectedSize] of requiredPngIconSizes) {
const icon = readPngSize(new URL(fileName, iconDirPath));
if (icon.width !== expectedSize || icon.height !== expectedSize) {
throw new Error(`desktop shell icon ${fileName} must be ${expectedSize}x${expectedSize}`);
}
if (!icon.hasTransparency || icon.dataStart === -1) {
throw new Error(`desktop shell icon ${fileName} must use a real transparent brand asset`);
}
}
const ico = fs.readFileSync(new URL('icon.ico', iconDirPath));
if (
ico.length < 6 ||
ico.readUInt16LE(0) !== 0 ||
ico.readUInt16LE(2) !== 1 ||
ico.readUInt16LE(4) < 3
) {
throw new Error('desktop shell Windows icon must be a multi-size ICO');
}
const icns = fs.readFileSync(new URL('icon.icns', iconDirPath));
if (
icns.length < 16 ||
icns.toString('ascii', 0, 4) !== 'icns' ||
icns.readUInt32BE(4) !== icns.length
) {
throw new Error('desktop shell macOS icon must be a valid ICNS');
}
}
function extractStringArrayExport(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
const entries = [...match[1].matchAll(/'([^']+)'/g)].map(
(entry) => entry[1],
);
if (match[1].includes('...HOST_BRIDGE_METHODS')) {
return [
...extractStringArrayExport(source, 'HOST_BRIDGE_METHODS'),
...entries,
];
}
return entries;
}
function extractDesktopCapabilities(source) {
const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/);
if (!match) {
throw new Error('unable to read desktop shell capabilities');
}
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function resolveHostCapabilitiesFromUrl(rawUrl) {
const url = new URL(rawUrl, 'https://app.genarrative.world/');
return (url.searchParams.get('hostCapabilities') ?? '')
.split(',')
.map((capability) => capability.trim())
.filter(Boolean);
}
function assertSameList(actual, expected, label) {
if (
actual.length !== expected.length ||
actual.some((value, index) => value !== expected[index])
) {
throw new Error(
`${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`,
);
}
}
function extractTauriBuildCommands(source) {
const match = source.match(/\.commands\(\s*&\[\s*([^\]]*?)\s*\]\s*\)/);
if (!match) {
throw new Error('unable to read Tauri build manifest commands');
}
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function extractTauriInvokeCommands(source) {
const match = source.match(/tauri::generate_handler!\[\s*([^\]]*?)\s*\]/);
if (!match) {
throw new Error('unable to read Tauri invoke handler commands');
}
return match[1]
.split(',')
.map((command) => command.trim())
.filter(Boolean);
}
function assertGeneratedPermissions(commandNames) {
if (!fs.existsSync(generatedPermissionDir)) {
return;
}
const expectedPermissionFiles = new Set(
commandNames.map((command) => `${command}.toml`),
);
for (const entry of fs.readdirSync(generatedPermissionDir, {
withFileTypes: true,
})) {
if (entry.isDirectory()) {
throw new Error(
`desktop shell generated permissions must not include nested directory ${entry.name}`,
);
}
if (!expectedPermissionFiles.has(entry.name)) {
throw new Error(
`desktop shell generated permission exposes an unexpected command: ${entry.name}`,
);
}
const permissionFile = new URL(entry.name, generatedPermissionDir);
const permissionSource = fs.readFileSync(permissionFile, 'utf8');
const commandName = entry.name.replace(/\.toml$/, '');
for (const expectedSnippet of [
`identifier = "allow-${commandName.replaceAll('_', '-')}"`,
`commands.allow = ["${commandName}"]`,
`commands.deny = ["${commandName}"]`,
]) {
if (!permissionSource.includes(expectedSnippet)) {
throw new Error(
`desktop shell generated permission ${entry.name} drifted from ${commandName}`,
);
}
}
}
}
const sharedCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_CAPABILITIES',
);
const desktopCapabilities = extractDesktopCapabilities(main);
const unknownDesktopCapabilities = desktopCapabilities.filter(
(capability) => !sharedCapabilities.includes(capability),
);
if (unknownDesktopCapabilities.length > 0) {
throw new Error(
`desktop shell declares unknown HostBridge capabilities: ${unknownDesktopCapabilities.join(', ')}`,
);
}
if (config.productName !== 'Genarrative') {
throw new Error('desktop shell productName must be Genarrative');
}
if (config.identifier !== 'world.genarrative.desktop') {
throw new Error('desktop shell identifier must be world.genarrative.desktop');
}
if (config.version !== '0.1.0') {
throw new Error('desktop shell app version must be 0.1.0');
}
if (packageConfig.version !== config.version) {
throw new Error('desktop shell package version must match tauri.conf.json version');
}
if (extractCargoPackageString(cargoManifest, 'name') !== 'genarrative-desktop-shell') {
throw new Error('desktop shell Cargo package name must be genarrative-desktop-shell');
}
if (extractCargoPackageString(cargoManifest, 'version') !== config.version) {
throw new Error('desktop shell Cargo package version must match tauri.conf.json version');
}
if (config.build?.frontendDist !== '../../../dist') {
throw new Error('desktop shell must package the root H5 dist');
}
if (config.build?.beforeBuildCommand !== 'npm --prefix ../.. run build:raw && npm run typecheck') {
throw new Error('desktop shell build command must run from apps/desktop-shell');
}
const [mainWindow] = config.app?.windows ?? [];
if (!mainWindow || mainWindow.create !== false) {
throw new Error('desktop shell must create the main window from Rust setup');
}
if (String(mainWindow.url ?? '').startsWith('http')) {
throw new Error('desktop shell release window must load packaged H5 assets');
}
if (!String(mainWindow.url ?? '').startsWith('index.html?')) {
throw new Error('desktop shell release window must enter through packaged index.html');
}
if (!String(config.build?.devUrl ?? '').startsWith('http://127.0.0.1:3000/?')) {
throw new Error('desktop shell dev URL must load the local Vite H5 entry');
}
assertDesktopIconSet();
if (config.bundle?.active !== true || config.bundle?.targets !== 'all') {
throw new Error('desktop shell bundle targets must remain enabled for all platforms');
}
const csp = String(config.app?.security?.csp ?? '');
for (const blockedCspToken of ["'unsafe-eval'", 'tauri:', 'file:']) {
if (csp.includes(blockedCspToken)) {
throw new Error(`desktop shell CSP must not include ${blockedCspToken}`);
}
}
for (const requiredCspToken of [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
'connect-src',
'frame-src',
]) {
if (!csp.includes(requiredCspToken)) {
throw new Error(`desktop shell CSP missing ${requiredCspToken}`);
}
}
const requiredUrlParts = [
'clientRuntime=native_app',
'clientType=native_app',
'hostShell=tauri_desktop',
'hostPlatform=unknown',
'bridgeVersion=1',
];
for (const part of requiredUrlParts) {
if (!String(mainWindow.url ?? '').includes(part)) {
throw new Error(`desktop shell main window URL missing ${part}`);
}
if (!String(config.build?.devUrl ?? '').includes(part)) {
throw new Error(`desktop shell dev URL missing ${part}`);
}
}
assertSameList(
resolveHostCapabilitiesFromUrl(mainWindow.url),
desktopCapabilities,
'desktop shell main window hostCapabilities',
);
assertSameList(
resolveHostCapabilitiesFromUrl(config.build?.devUrl ?? ''),
desktopCapabilities,
'desktop shell dev hostCapabilities',
);
const allowedPermissions = [
'core:default',
'allow-host-bridge-request',
];
const allowedTauriCommands = ['host_bridge_request'];
const requiredMainSnippets = [
'tauri_plugin_single_instance::init',
'resolve_desktop_single_instance_action',
'tauri_plugin_clipboard_manager::init()',
'TrayIconBuilder::with_id',
'register_desktop_tray(app)',
'DESKTOP_TRAY_ID',
'TRAY_MENU_SHOW',
'TRAY_MENU_RELOAD',
'TRAY_MENU_QUIT',
'show_main_window',
'reload_main_window',
'register_desktop_window_close_events',
'resolve_desktop_window_close_action',
'WindowEvent::CloseRequested',
'api.prevent_close()',
'close_window.hide()',
'desktop tray registration failed',
'"appearance.getColorScheme"',
'"app.lifecycle"',
'"network.status"',
'"network.statusChanged"',
'"share.open"',
'"share.setTarget"',
'"navigation.openNativePage"',
'"app.reloadWebView"',
'"app.setTitle"',
'"app.setBadgeCount"',
'"clipboard.writeText"',
'"clipboard.readText"',
'"file.exportText"',
'"file.importText"',
'"file.exportImage"',
'"file.importImage"',
'"file.importAudio"',
'"file.exportAudio"',
'"file.imageDropped"',
'"notification.showLocal"',
'tauri_plugin_dialog::init()',
'tauri_plugin_notification::init()',
'tauri_plugin_notification::NotificationExt',
'"copied_to_clipboard"',
'"file export cancelled"',
'"file import cancelled"',
'BASE64_STANDARD.decode',
'blocking_pick_file',
'import_text_file_payload',
'import_image_file_payload',
'import_audio_file_payload',
'export_audio_payload',
'set_title',
'set_badge_count',
'window.reload()',
'read_text()',
'normalize_clipboard_text',
'window.theme()',
'WindowEvent::Focused',
'WindowEvent::DragDrop',
'host_bridge_event_script',
'resolve_desktop_network_status',
'network.statusChanged',
'file.imageDropped',
'app.notification().builder()',
];
assertSameList(
capability.permissions ?? [],
allowedPermissions,
'desktop shell capability permissions',
);
assertSameList(
extractTauriBuildCommands(buildScript),
allowedTauriCommands,
'desktop shell build manifest commands',
);
assertSameList(
extractTauriInvokeCommands(main),
allowedTauriCommands,
'desktop shell invoke handler commands',
);
assertGeneratedPermissions(allowedTauriCommands);
if (buildScript.includes('resolve_desktop_shell_runtime')) {
throw new Error('desktop shell build manifest exposes an unused runtime command');
}
if (!cargoManifest.includes('features = ["tray-icon"]')) {
throw new Error('desktop shell must enable the Tauri tray-icon feature');
}
if (!cargoManifest.includes('tauri-plugin-single-instance = "2.4.2"')) {
throw new Error('desktop shell must depend on tauri-plugin-single-instance');
}
if (
cargoManifest.includes('tauri-plugin-updater') ||
JSON.stringify(config).includes('updater')
) {
throw new Error('desktop shell must not configure updater without real signing and endpoint');
}
if (
main.indexOf('tauri_plugin_single_instance::init') >
main.indexOf('tauri_plugin_clipboard_manager::init()')
) {
throw new Error('desktop shell must register the single-instance plugin first');
}
if (main.includes('single-instance",') || main.includes('"single-instance"')) {
throw new Error('desktop shell must not emit secondary-instance argv to H5');
}
for (const snippet of requiredMainSnippets) {
if (!main.includes(snippet)) {
throw new Error(`desktop shell Rust host bridge missing ${snippet}`);
}
}