Files
scratch-vm/test/integration/tw_compiled_project.js
T
Lu Yifei a5286f0231 Add source-stripped compiled project packages
- serialize and load precompiled JavaScript artifacts
- support runtime bindings and ABI validation
- add SHA-256 integrity manifests and optional signatures
- preserve assets and extension dependencies in compiled packages
- add playground, documentation, and integration coverage
2026-08-05 19:51:18 +08:00

531 lines
21 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const {test} = require('tap');
const JSZip = require('@turbowarp/jszip');
const VM = require('../../src/virtual-machine');
const JSGenerator = require('../../src/compiler/jsgen');
const BlockType = require('../../src/extension-support/block-type');
const makeTestStorage = require('../fixtures/make-test-storage');
const makeVM = () => {
const vm = new VM();
vm.attachStorage(makeTestStorage());
return vm;
};
const fixture = fs.readFileSync(path.join(
__dirname,
'..',
'fixtures',
'execute',
'tw-simple-string-operations.sb3'
));
const extensionFixture = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'tw-add-builtin-extension.sb3'));
const broadcastFixture = fs.readFileSync(path.join(
__dirname,
'..',
'fixtures',
'execute',
'tw-broadcast-id-and-name-desync.sb3'
));
class TestBuiltinExtension {
getInfo () {
return {
id: 'testbuiltin',
name: 'Test Builtin',
blocks: [{
opcode: 'test',
blockType: BlockType.REPORTER,
text: 'test'
}]
};
}
test () {
return 'extension result';
}
}
const addRequiredBinding = artifact => {
artifact.requiredBindings = ['__testBinding'];
for (const target of artifact.targets) {
for (const script of target.scripts) {
script.startingFunction = `__testBinding(typeof __notDeclared, ${script.startingFunction})`;
for (const procedureCode of Object.keys(script.procedures)) {
script.procedures[procedureCode] =
`__testBinding(typeof __notDeclared, ${script.procedures[procedureCode]})`;
}
}
}
return artifact;
};
test('compiled projects survive JSON serialization and load without recompiling', async t => {
const compilerVM = makeVM();
compilerVM.setCompilerOptions({enabled: true, warpTimer: false});
await compilerVM.loadProject(fixture);
const artifact = JSON.parse(JSON.stringify(compilerVM.exportCompiledProject()));
t.equal(artifact.format, 'scratch-vm-compiled-project');
t.equal(artifact.formatVersion, 1);
t.equal(artifact.compilerVersion, 1);
t.equal(artifact.runtimeABI, 1);
t.ok(artifact.targets.some(target => target.scripts.length > 0), 'contains compiled scripts');
const playerVM = makeVM();
playerVM.setCompilerOptions({enabled: true, warpTimer: false});
await playerVM.loadProject(fixture);
t.not(
compilerVM.runtime.executableTargets[0].id,
playerVM.runtime.executableTargets[0].id,
'artifact does not rely on process-local target IDs'
);
playerVM.loadCompiledProject(artifact);
const originalCompileToSource = JSGenerator.prototype.compileToSource;
JSGenerator.prototype.compileToSource = () => {
throw new Error('script was unexpectedly recompiled');
};
t.teardown(() => {
JSGenerator.prototype.compileToSource = originalCompileToSource;
compilerVM.quit();
playerVM.quit();
});
const threads = playerVM.runtime.startHats('event_whenflagclicked');
t.ok(threads.length > 0, 'green flag scripts started');
t.ok(threads.every(thread => thread.isCompiled), 'all scripts used the loaded compiled artifact');
});
test('compiled protection build input contains independent artifact and full project clones', async t => {
const vm = makeVM();
vm.setCompilerOptions({enabled: true});
await vm.loadProject(fixture);
const first = vm.exportCompiledProjectBuildInput();
const second = vm.exportCompiledProjectBuildInput();
t.equal(first.artifact.format, 'scratch-vm-compiled-project');
t.ok(first.project.targets.some(target => Object.keys(target.blocks).length > 1), 'project retains block sources');
first.artifact.targets[0].name = 'changed artifact';
first.project.targets[0].name = 'changed project';
t.not(second.artifact.targets[0].name, 'changed artifact', 'artifact is cloned');
t.not(second.project.targets[0].name, 'changed project', 'project is cloned');
vm.quit();
});
test('compiled project loader rejects incompatible ABI without changing caches', async t => {
const vm = makeVM();
vm.setCompilerOptions({enabled: true});
await vm.loadProject(fixture);
const artifact = vm.exportCompiledProject();
artifact.runtimeABI++;
for (const target of vm.runtime.executableTargets) {
target.blocks.resetCache();
}
t.throws(
() => vm.loadCompiledProject(artifact),
/unsupported runtime ABI/,
'rejects an incompatible runtime ABI'
);
for (const target of vm.runtime.executableTargets) {
for (const topBlockId of target.blocks.getScripts()) {
t.equal(target.blocks.getCachedCompileResult(topBlockId), null, 'cache was not changed');
}
}
vm.quit();
});
test('compiled project loader injects only declared runtime bindings into every factory', async t => {
const compilerVM = makeVM();
compilerVM.setCompilerOptions({enabled: true, warpTimer: false});
await compilerVM.loadProject(fixture);
const artifact = addRequiredBinding(compilerVM.exportCompiledProject());
const sourceCount = artifact.targets.reduce((total, target) => total + target.scripts.reduce(
(scriptTotal, script) => scriptTotal + 1 + Object.keys(script.procedures).length,
0
), 0);
const playerVM = makeVM();
playerVM.setCompilerOptions({enabled: true, warpTimer: false});
await playerVM.loadProject(fixture);
let bindingCalls = 0;
let undeclaredWasVisible = false;
playerVM.loadCompiledProject(artifact, {
bindings: {
__testBinding: (undeclaredType, factory) => {
bindingCalls++;
undeclaredWasVisible = undeclaredWasVisible || undeclaredType !== 'undefined';
return factory;
},
__notDeclared: true
}
});
t.equal(bindingCalls, sourceCount, 'entry points and procedures received the binding');
t.equal(undeclaredWasVisible, false, 'undeclared values were not injected');
const threads = playerVM.runtime.startHats('event_whenflagclicked');
t.ok(threads.every(thread => thread.isCompiled), 'bound factories execute as compiled scripts');
compilerVM.quit();
playerVM.quit();
});
test('compiled project validates binding declarations before changing caches', async t => {
const compilerVM = makeVM();
compilerVM.setCompilerOptions({enabled: true});
await compilerVM.loadProject(fixture);
const artifact = addRequiredBinding(compilerVM.exportCompiledProject());
const playerVM = makeVM();
playerVM.setCompilerOptions({enabled: true});
await playerVM.loadProject(fixture);
t.throws(
() => playerVM.loadCompiledProject(artifact),
/required binding __testBinding is missing/,
'missing binding is rejected'
);
for (const target of playerVM.runtime.executableTargets) {
for (const topBlockId of target.blocks.getScripts()) {
t.equal(target.blocks.getCachedCompileResult(topBlockId), null, 'cache was not changed');
}
}
const invalid = compilerVM.exportCompiledProject();
invalid.requiredBindings = ['not-valid-name!'];
t.throws(() => playerVM.loadCompiledProject(invalid), /invalid required binding/);
invalid.requiredBindings = ['same', 'same'];
t.throws(() => playerVM.loadCompiledProject(invalid), /duplicate required binding/);
compilerVM.quit();
playerVM.quit();
});
test('compiled project calls extensions provided by the player runtime', async t => {
const compilerVM = makeVM();
compilerVM.setCompilerOptions({enabled: true});
compilerVM.extensionManager.addBuiltinExtension('testbuiltin', TestBuiltinExtension);
await compilerVM.loadProject(extensionFixture);
const artifact = JSON.parse(JSON.stringify(compilerVM.exportCompiledProject()));
const playerVM = makeVM();
playerVM.setCompilerOptions({enabled: true});
playerVM.extensionManager.addBuiltinExtension('testbuiltin', TestBuiltinExtension);
await playerVM.loadProject(extensionFixture);
playerVM.loadCompiledProject(artifact);
let said;
playerVM.runtime.on('SAY', (target, type, text) => {
said = text;
});
playerVM.greenFlag();
playerVM.runtime._step();
t.equal(said, 'extension result', 'extension implementation was resolved in the player VM');
compilerVM.quit();
playerVM.quit();
});
test('compiled project package removes script bodies and runs without recompiling', async t => {
const compilerVM = makeVM();
compilerVM.setCompilerOptions({enabled: true});
compilerVM.extensionManager.addBuiltinExtension('testbuiltin', TestBuiltinExtension);
await compilerVM.loadProject(extensionFixture);
const packageData = await compilerVM.saveCompiledProject('nodebuffer');
const zip = await JSZip.loadAsync(packageData);
const project = JSON.parse(await zip.file('project.json').async('string'));
const sprite = project.targets.find(target => !target.isStage);
const opcodes = Object.values(sprite.blocks)
.filter(block => !Array.isArray(block))
.map(block => block.opcode);
t.same(opcodes, ['event_whenflagclicked'], 'only the hat remains in project metadata');
t.notOk(JSON.stringify(project).includes('looks_say'), 'script body is absent');
t.ok(zip.file('compiled-project.json'), 'package contains compiled code');
const manifest = JSON.parse(await zip.file('compiled-project.integrity.json').async('string'));
t.equal(manifest.format, 'scratch-vm-compiled-project-integrity');
t.same(
manifest.files.map(file => file.path),
manifest.files.map(file => file.path).slice()
.sort(),
'integrity entries are sorted'
);
const playerVM = makeVM();
playerVM.extensionManager.addBuiltinExtension('testbuiltin', TestBuiltinExtension);
let projectLoadedWithCompiledScripts = false;
playerVM.runtime.on('PROJECT_LOADED', () => {
projectLoadedWithCompiledScripts = playerVM.runtime.executableTargets.every(target =>
target.blocks.getScripts().every(topBlockId => target.blocks.getCachedCompileResult(topBlockId))
);
});
await playerVM.loadCompiledProjectPackage(packageData);
t.equal(playerVM.runtime.compilerOptions.enabled, true, 'compiled execution was enabled');
t.equal(projectLoadedWithCompiledScripts, true, 'PROJECT_LOADED was emitted after compiled scripts were ready');
const originalCompileToSource = JSGenerator.prototype.compileToSource;
JSGenerator.prototype.compileToSource = () => {
throw new Error('script was unexpectedly recompiled');
};
t.teardown(() => {
JSGenerator.prototype.compileToSource = originalCompileToSource;
compilerVM.quit();
playerVM.quit();
});
let said;
playerVM.runtime.on('SAY', (target, type, text) => {
said = text;
});
playerVM.greenFlag();
playerVM.runtime._step();
t.equal(said, 'extension result', 'compiled extension script ran from stripped package');
});
test('compiled package integrity and signatures are checked before VM state changes', async t => {
const compilerVM = makeVM();
compilerVM.setCompilerOptions({enabled: true});
await compilerVM.loadProject(fixture);
const deterministicArtifact = compilerVM.exportCompiledProject();
const unsignedPackage = await compilerVM.saveCompiledProject('nodebuffer', {artifact: deterministicArtifact});
const unsignedAgain = await compilerVM.saveCompiledProject('nodebuffer', {artifact: deterministicArtifact});
t.same(unsignedPackage, unsignedAgain, 'default integrity packages are deterministic');
const legacyPackage = await compilerVM.saveCompiledProject('nodebuffer', {integrity: false});
const legacyPlayer = makeVM();
await legacyPlayer.loadCompiledProjectPackage(legacyPackage);
const strictLegacyPlayer = makeVM();
await t.rejects(
strictLegacyPlayer.loadCompiledProjectPackage(legacyPackage, {requireIntegrity: true}),
/integrity manifest is required/
);
t.equal(strictLegacyPlayer.runtime.targets.length, 0, 'legacy policy failure is atomic');
const unsignedStrictPlayer = makeVM();
await t.rejects(
unsignedStrictPlayer.loadCompiledProjectPackage(unsignedPackage, {requireSignature: true}),
/integrity signature is required/
);
const signManifest = ({payload}) => ({
algorithm: 'test-bytes-v1',
keyId: 'test-key',
value: Buffer.from(payload).toString('base64')
});
const signedPackage = await compilerVM.saveCompiledProject('nodebuffer', {integrity: {signManifest}});
const missingVerifier = makeVM();
await t.rejects(
missingVerifier.loadCompiledProjectPackage(signedPackage),
/signature verifier is required/
);
t.equal(missingVerifier.runtime.targets.length, 0, 'missing verifier failure is atomic');
const rejectingVerifier = makeVM();
await t.rejects(
rejectingVerifier.loadCompiledProjectPackage(signedPackage, {
verifyIntegritySignature: () => false
}),
/integrity signature is invalid/
);
const signedPlayer = makeVM();
await signedPlayer.loadCompiledProjectPackage(signedPackage, {
requireIntegrity: true,
requireSignature: true,
verifyIntegritySignature: ({payload, signature}) =>
signature.algorithm === 'test-bytes-v1' &&
signature.keyId === 'test-key' &&
signature.value === Buffer.from(payload).toString('base64')
});
t.ok(signedPlayer.runtime.targets.length > 0, 'valid signed package loads');
const tamperedZip = await JSZip.loadAsync(unsignedPackage);
tamperedZip.file('compiled-project.json', '{}');
const tamperedPackage = await tamperedZip.generateAsync({type: 'nodebuffer'});
const tamperedPlayer = makeVM();
await t.rejects(
tamperedPlayer.loadCompiledProjectPackage(tamperedPackage),
/integrity check failed for compiled-project.json/
);
t.equal(tamperedPlayer.runtime.targets.length, 0, 'file tampering failure is atomic');
const extraZip = await JSZip.loadAsync(unsignedPackage);
extraZip.file('unlisted.txt', 'extra');
const extraPlayer = makeVM();
await t.rejects(
extraPlayer.loadCompiledProjectPackage(await extraZip.generateAsync({type: 'nodebuffer'})),
/file list does not match package contents/
);
const resourceZip = await JSZip.loadAsync(unsignedPackage);
const resourcePath = Object.keys(resourceZip.files).find(name => /\.(?:svg|png|wav)$/.test(name));
resourceZip.file(resourcePath, 'tampered resource');
const resourcePlayer = makeVM();
await t.rejects(
resourcePlayer.loadCompiledProjectPackage(await resourceZip.generateAsync({type: 'nodebuffer'})),
new RegExp(`integrity check failed for ${resourcePath.replace('.', '\\.')}`)
);
const removedZip = await JSZip.loadAsync(unsignedPackage);
removedZip.remove(resourcePath);
const removedPlayer = makeVM();
await t.rejects(
removedPlayer.loadCompiledProjectPackage(await removedZip.generateAsync({type: 'nodebuffer'})),
/file list does not match package contents/
);
for (const vm of [
compilerVM,
legacyPlayer,
strictLegacyPlayer,
unsignedStrictPlayer,
missingVerifier,
rejectingVerifier,
signedPlayer,
tamperedPlayer,
extraPlayer,
resourcePlayer,
removedPlayer
]) vm.quit();
});
test('default integrity packaging rejects missing referenced resources', async t => {
const vm = new VM();
vm.setCompilerOptions({enabled: true});
await vm.loadProject(fixture);
await t.rejects(vm.saveCompiledProject('nodebuffer'), /referenced resource .* is missing/);
vm.quit();
});
test('externally transformed artifacts can be packaged and require bindings before project load', async t => {
const compilerVM = makeVM();
compilerVM.setCompilerOptions({enabled: true, warpTimer: false});
await compilerVM.loadProject(fixture);
const artifact = addRequiredBinding(compilerVM.exportCompiledProject());
const packageData = await compilerVM.saveCompiledProject('nodebuffer', {artifact});
const missingBindingVM = makeVM();
await t.rejects(
missingBindingVM.loadCompiledProjectPackage(packageData),
/required binding __testBinding is missing/,
'missing package binding is rejected'
);
t.equal(missingBindingVM.runtime.targets.length, 0, 'package rejection did not modify the VM');
const playerVM = makeVM();
let bindingCalls = 0;
await playerVM.loadCompiledProjectPackage(packageData, {
bindings: {
__testBinding: (_undeclaredType, factory) => {
bindingCalls++;
return factory;
}
}
});
t.ok(bindingCalls > 0, 'package factories received their binding');
const threads = playerVM.runtime.startHats('event_whenflagclicked');
t.ok(threads.every(thread => thread.isCompiled), 'packaged external artifact executes');
const mismatched = compilerVM.exportCompiledProject();
mismatched.targets[0].sourceHash = 'not-the-project-hash';
t.throws(
() => compilerVM.saveCompiledProject('nodebuffer', {artifact: mismatched}),
/source for .* does not match/,
'mismatched source cannot be packaged'
);
const incompatible = compilerVM.exportCompiledProject();
incompatible.runtimeABI++;
t.throws(
() => compilerVM.saveCompiledProject('nodebuffer', {artifact: incompatible}),
/unsupported runtime ABI/,
'incompatible artifact cannot be packaged'
);
compilerVM.quit();
missingBindingVM.quit();
playerVM.quit();
});
test('compiled project loader accepts legacy artifacts without requiredBindings', async t => {
const compilerVM = makeVM();
compilerVM.setCompilerOptions({enabled: true});
await compilerVM.loadProject(fixture);
const artifact = compilerVM.exportCompiledProject();
delete artifact.requiredBindings;
const playerVM = makeVM();
playerVM.setCompilerOptions({enabled: true});
await playerVM.loadProject(fixture);
playerVM.loadCompiledProject(artifact);
const threads = playerVM.runtime.startHats('event_whenflagclicked');
t.ok(threads.every(thread => thread.isCompiled), 'legacy artifact loaded');
compilerVM.quit();
playerVM.quit();
});
const runCompiledFixture = async fixtureData => {
const compilerVM = makeVM();
compilerVM.setCompilerOptions({enabled: true, warpTimer: false});
await compilerVM.loadProject(fixtureData);
const packageData = await compilerVM.saveCompiledProject('nodebuffer');
const playerVM = makeVM();
await playerVM.loadCompiledProjectPackage(packageData);
const messages = [];
playerVM.runtime.on('SAY', (target, type, text) => {
messages.push(text);
});
playerVM.greenFlag();
for (let i = 0; i < 100 && !messages.includes('end'); i++) {
playerVM.runtime._step();
}
compilerVM.quit();
playerVM.quit();
return {messages, packageData};
};
test('compiled package runs custom procedures after definitions are removed', async t => {
const {messages, packageData} = await runCompiledFixture(fixture);
const zip = await JSZip.loadAsync(packageData);
const projectJSON = await zip.file('project.json').async('string');
t.notOk(projectJSON.includes('procedures_definition'), 'procedure definitions are absent');
t.same(messages, ['plan 1', 'pass', 'end'], 'compiled procedure executed successfully');
});
test('compiled package preserves broadcast hat matching metadata', async t => {
const {messages, packageData} = await runCompiledFixture(broadcastFixture);
const zip = await JSZip.loadAsync(packageData);
const project = JSON.parse(await zip.file('project.json').async('string'));
const opcodes = Object.values(project.targets.find(target => !target.isStage).blocks)
.filter(block => !Array.isArray(block))
.map(block => block.opcode);
t.ok(opcodes.includes('event_whenbroadcastreceived'), 'broadcast hat metadata remains');
t.same(messages, ['plan 1', 'pass', 'end'], 'broadcast started the compiled receiver');
});
test('compiled package rejects mismatched runtime metadata before loading', async t => {
const compilerVM = makeVM();
compilerVM.setCompilerOptions({enabled: true});
await compilerVM.loadProject(fixture);
const packageData = await compilerVM.saveCompiledProject('nodebuffer');
const zip = await JSZip.loadAsync(packageData);
const projectFile = zip.file('project.json');
const projectJSON = (await projectFile.async('string')).replace('Sprite1', 'Changed Sprite');
zip.file('project.json', projectJSON);
const tamperedPackage = await zip.generateAsync({type: 'nodebuffer'});
const playerVM = makeVM();
await t.rejects(
playerVM.loadCompiledProjectPackage(tamperedPackage),
/integrity check failed for project.json/,
'mismatched metadata is rejected'
);
t.equal(playerVM.runtime.targets.length, 0, 'the player VM was not modified');
compilerVM.quit();
playerVM.quit();
});