fix: update bundleHash to full SHA-256 for integrity verification and migrate legacy hashes
This commit is contained in:
+36
-29
@@ -77,6 +77,32 @@ function resolveBundlePath(pluginDir: string, entrypoint: string): ResolvedBundl
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bundleEntrypoint(bundlePath: string): Promise<string> {
|
||||||
|
const esbuild = await import('esbuild');
|
||||||
|
const result = await esbuild.build({
|
||||||
|
entryPoints: [bundlePath],
|
||||||
|
bundle: true,
|
||||||
|
// CJS format matches the sandbox runtime's evaluator
|
||||||
|
// (`new Function('module', 'exports', 'require', 'React', ...)`).
|
||||||
|
format: 'cjs',
|
||||||
|
platform: 'neutral',
|
||||||
|
write: false,
|
||||||
|
logLevel: 'silent',
|
||||||
|
sourcemap: 'inline',
|
||||||
|
target: ['es2020'],
|
||||||
|
// The runtime's `require` shim resolves these at evaluation time:
|
||||||
|
// react / react-dom / react-dom/client / react/jsx-runtime → host copies
|
||||||
|
// @plugin-host → the per-plugin `api` object
|
||||||
|
external: [
|
||||||
|
'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime',
|
||||||
|
'@plugin-host',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const out = result.outputFiles?.[0]?.text;
|
||||||
|
if (!out) throw new Error('esbuild produced no output');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and bundle a dev plugin's code. For `src/` sources this runs esbuild
|
* Load and bundle a dev plugin's code. For `src/` sources this runs esbuild
|
||||||
* on every call so saves are reflected immediately. Errors are surfaced as
|
* on every call so saves are reflected immediately. Errors are surfaced as
|
||||||
@@ -88,29 +114,7 @@ export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
|
|||||||
return readFile(entry.bundlePath, 'utf-8');
|
return readFile(entry.bundlePath, 'utf-8');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const esbuild = await import('esbuild');
|
return await bundleEntrypoint(entry.bundlePath);
|
||||||
const result = await esbuild.build({
|
|
||||||
entryPoints: [entry.bundlePath],
|
|
||||||
bundle: true,
|
|
||||||
// CJS format matches the sandbox runtime's evaluator
|
|
||||||
// (`new Function('module', 'exports', 'require', 'React', ...)`).
|
|
||||||
format: 'cjs',
|
|
||||||
platform: 'neutral',
|
|
||||||
write: false,
|
|
||||||
logLevel: 'silent',
|
|
||||||
sourcemap: 'inline',
|
|
||||||
target: ['es2020'],
|
|
||||||
// The runtime's `require` shim resolves these at evaluation time:
|
|
||||||
// react / react-dom / react-dom/client / react/jsx-runtime → host copies
|
|
||||||
// @plugin-host → the per-plugin `api` object
|
|
||||||
external: [
|
|
||||||
'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime',
|
|
||||||
'@plugin-host',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const out = result.outputFiles?.[0]?.text;
|
|
||||||
if (!out) throw new Error('esbuild produced no output');
|
|
||||||
return out;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
logger.warn(`[plugin-dev] esbuild failed for ${entry.plugin.id}`, { error: message });
|
logger.warn(`[plugin-dev] esbuild failed for ${entry.plugin.id}`, { error: message });
|
||||||
@@ -149,15 +153,18 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash from the on-disk source so any edit propagates. For src/ sources
|
// Hash from the exact bytes the bundle endpoint will serve so the client's
|
||||||
// we hash the source - close enough for dev-time change detection (we
|
// verifyBundle check passes. For src/ sources that means running esbuild
|
||||||
// don't need to re-hash transitive imports).
|
// here too — slightly more work per manifest list, but unavoidable since
|
||||||
|
// the source hash wouldn't match the served bundle.
|
||||||
let bundleHash: string;
|
let bundleHash: string;
|
||||||
try {
|
try {
|
||||||
const code = await readFile(resolved.bundlePath);
|
const bytes = resolved.needsBundle
|
||||||
bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
|
? await bundleEntrypoint(resolved.bundlePath)
|
||||||
|
: await readFile(resolved.bundlePath);
|
||||||
|
bundleHash = createHash('sha256').update(bytes).digest('hex');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`[plugin-dev] failed to read ${resolved.bundlePath} for ${id}`, {
|
logger.warn(`[plugin-dev] failed to hash bundle at ${resolved.bundlePath} for ${id}`, {
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -56,9 +56,12 @@ export interface ServerPlugin {
|
|||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
/**
|
/**
|
||||||
* SHA-256 hex of the bundle code (first 16 chars). Refreshed every save so
|
* Full SHA-256 hex of the bundle code (64 chars). Refreshed every save so
|
||||||
* the same version re-uploaded with new code still appears as a change to
|
* the same version re-uploaded with new code still appears as a change to
|
||||||
* the client. Also doubles as the HTTP ETag for the bundle endpoint.
|
* the client. Also doubles as the HTTP ETag for the bundle endpoint and is
|
||||||
|
* verified by the sandbox loader on every load
|
||||||
|
* (`lib/plugin-sandbox/bundle-integrity.ts`), so it must match the served
|
||||||
|
* bytes exactly.
|
||||||
*/
|
*/
|
||||||
bundleHash?: string;
|
bundleHash?: string;
|
||||||
/**
|
/**
|
||||||
@@ -130,8 +133,38 @@ async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
|
|||||||
|
|
||||||
const pluginRegistryPath = () => path.join(getPluginsDir(), 'registry.json');
|
const pluginRegistryPath = () => path.join(getPluginsDir(), 'registry.json');
|
||||||
|
|
||||||
|
const FULL_HASH_RE = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Older builds wrote a 16-char SHA-256 prefix into `bundleHash`. The current
|
||||||
|
* client-side verifyBundle requires equal-length hex (and the full digest for
|
||||||
|
* real integrity), so any registry entry with a truncated or otherwise
|
||||||
|
* malformed hash needs to be re-hashed from the on-disk bundle. If the bundle
|
||||||
|
* file is missing the hash is cleared so verifyBundle skips the check rather
|
||||||
|
* than refusing to load.
|
||||||
|
*/
|
||||||
|
async function migrateBundleHashes(registry: PluginRegistry): Promise<boolean> {
|
||||||
|
let changed = false;
|
||||||
|
for (const plugin of registry.plugins) {
|
||||||
|
if (!plugin.bundleHash || FULL_HASH_RE.test(plugin.bundleHash)) continue;
|
||||||
|
const bundlePath = path.join(getPluginsDir(), `${plugin.id}.js`);
|
||||||
|
try {
|
||||||
|
const code = await readFile(bundlePath);
|
||||||
|
plugin.bundleHash = createHash('sha256').update(code).digest('hex');
|
||||||
|
} catch {
|
||||||
|
delete plugin.bundleHash;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPluginRegistry(): Promise<PluginRegistry> {
|
export async function getPluginRegistry(): Promise<PluginRegistry> {
|
||||||
return readJsonFile<PluginRegistry>(pluginRegistryPath(), { plugins: [] });
|
const registry = await readJsonFile<PluginRegistry>(pluginRegistryPath(), { plugins: [] });
|
||||||
|
if (await migrateBundleHashes(registry)) {
|
||||||
|
try { await writeJsonFile(pluginRegistryPath(), registry); } catch { /* read-only fs ok */ }
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlugin(id: string): Promise<ServerPlugin | null> {
|
export async function getPlugin(id: string): Promise<ServerPlugin | null> {
|
||||||
@@ -158,8 +191,9 @@ export async function savePlugin(
|
|||||||
|
|
||||||
// Stamp content hash + updatedAt so clients can detect re-uploads even
|
// Stamp content hash + updatedAt so clients can detect re-uploads even
|
||||||
// when the manifest version hasn't changed. Preserve the original
|
// when the manifest version hasn't changed. Preserve the original
|
||||||
// installedAt across re-uploads.
|
// installedAt across re-uploads. The full SHA-256 is required because the
|
||||||
const bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
|
// client-side verifyBundle compares the entire digest length-checked.
|
||||||
|
const bundleHash = createHash('sha256').update(code).digest('hex');
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const registry = await getPluginRegistry();
|
const registry = await getPluginRegistry();
|
||||||
|
|||||||
Reference in New Issue
Block a user