Implement salt-based opcode lookup to support external conversions.

This commit is contained in:
Lu Yifei committed 2026-08-06 11:27:46 +08:00
1 parent a5286f0231
commit 061f4bd5ee
4 files changed
+152 -1

No files matched your search

+13
View File
@@ -125,6 +125,19 @@ This format removes the original Scratch block source but does not make client-s
engineer. Generated JavaScript, literal values, runtime calls, and extension implementations remain observable to the
client.
## Salted opcode lookup for external transforms
The compiler continues to emit `runtime.getOpcodeFunction(opcode)` for compatibility. External trusted transforms can
remove literal opcode names by deriving a 48-bit ID and calling `runtime.getOpcodeFunctionById(id, salt)` instead. The
ID is the first six bytes, interpreted as a big-endian integer, of
`SHA-256("sb3c-opcode-v1\0" + salt + "\0" + opcode)`.
`id` must be a non-negative JavaScript safe integer and `salt` must be a non-empty string. Invalid inputs throw a
`TypeError`; an unknown ID returns `undefined`, matching the legacy lookup. The runtime caches the reverse mapping for
one salt, invalidates it when primitives are registered or refreshed, and rejects collisions between registered
opcodes. Salts are public build inputs rather than secrets; salted IDs increase static analysis cost but do not encrypt
extension behavior.
## Try the two-stage playground
Run `npm start`, then open `http://localhost:8073/playground/compiled-project.html`. The left side loads a full SB3 and creates a
+35 -1
View File
@@ -24,6 +24,7 @@ const fetchWithTimeout = require('../util/fetch-with-timeout');
const platform = require('./tw-platform.js');
const safeStringify = require('../util/tw-safe-stringify.js');
const MonitorState = require('./tw-monitor-state.js');
const {createOpcodeIdMap} = require('../util/opcode-id');
// Virtual I/O devices.
const Clock = require('../io/clock');
@@ -260,6 +261,14 @@ class Runtime extends EventEmitter {
*/
this._primitives = {};
/**
* Single-salt cache used by getOpcodeFunctionById. The values are opcode
* strings rather than functions so extension primitive refreshes remain visible.
* @type {?{salt: string, opcodes: Map<number, string>}}
* @private
*/
this._opcodeFunctionIdCache = null;
/**
* Map to look up all block information by extended opcode.
* @type {Array.<CategoryInfo>}
@@ -1016,6 +1025,7 @@ class Runtime extends EventEmitter {
this.compilerRegisterExtension(packageName, packageObject);
}
}
this._opcodeFunctionIdCache = null;
}
compilerRegisterExtension (name, extensionObject) {
@@ -1189,6 +1199,7 @@ class Runtime extends EventEmitter {
log.error('Error parsing block: ', {block: blockInfo, error: e});
}
}
this._opcodeFunctionIdCache = null;
}
/**
@@ -1902,12 +1913,35 @@ class Runtime extends EventEmitter {
/**
* Retrieve the function associated with the given opcode.
* @param {!string} opcode The opcode to look up.
* @return {Function} The function which implements the opcode.
* @return {Function|undefined} The function which implements the opcode, if known.
*/
getOpcodeFunction (opcode) {
return this._primitives[opcode];
}
/**
* Retrieve the function associated with a salted, 48-bit opcode identifier.
* @param {!number} id The opcode identifier to look up.
* @param {!string} salt The public build salt used to derive the identifier.
* @return {Function|undefined} The function which implements the opcode, if known.
*/
getOpcodeFunctionById (id, salt) {
if (!Number.isSafeInteger(id) || id < 0) {
throw new TypeError('Opcode ID must be a non-negative safe integer');
}
if (typeof salt !== 'string' || salt.length === 0) {
throw new TypeError('Opcode ID salt must be a non-empty string');
}
if (!this._opcodeFunctionIdCache || this._opcodeFunctionIdCache.salt !== salt) {
this._opcodeFunctionIdCache = {
salt,
opcodes: createOpcodeIdMap(Object.keys(this._primitives), salt)
};
}
const opcode = this._opcodeFunctionIdCache.opcodes.get(id);
return typeof opcode === 'undefined' ? void 0 : this._primitives[opcode];
}
/**
* Return whether an opcode represents a hat block.
* @param {!string} opcode The opcode to look up.
+43
View File
@@ -0,0 +1,43 @@
// @ts-check
const {sha256} = require('./sha256');
const OPCODE_ID_DOMAIN = 'sb3c-opcode-v1';
/**
* Derive the 48-bit numeric identifier for an opcode and public build salt.
* @param {string} opcode Opcode to identify.
* @param {string} salt Non-secret public build salt.
* @returns {number} A non-negative JavaScript safe integer.
*/
const getOpcodeId = (opcode, salt) => {
const digest = sha256(`${OPCODE_ID_DOMAIN}\0${salt}\0${opcode}`);
return (digest[0] * 0x10000000000) +
(digest[1] * 0x100000000) +
(digest[2] * 0x1000000) +
(digest[3] * 0x10000) +
(digest[4] * 0x100) +
digest[5];
};
/**
* Build a reverse opcode lookup and reject ambiguous truncated hashes.
* @param {string[]} opcodes Opcodes to index.
* @param {string} salt Non-secret public build salt.
* @param {Function} [deriveId] ID function, injectable for deterministic collision tests.
* @returns {Map<number, string>} ID-to-opcode lookup.
*/
const createOpcodeIdMap = (opcodes, salt, deriveId = getOpcodeId) => {
const result = new Map();
for (const opcode of opcodes) {
const id = deriveId(opcode, salt);
const existing = result.get(id);
if (typeof existing !== 'undefined' && existing !== opcode) {
throw new Error(`Opcode ID collision between ${JSON.stringify(existing)} and ${JSON.stringify(opcode)}`);
}
result.set(id, opcode);
}
return result;
};
module.exports = {OPCODE_ID_DOMAIN, createOpcodeIdMap, getOpcodeId};
+61
View File
@@ -0,0 +1,61 @@
const {test} = require('tap');
const BlockType = require('../../src/extension-support/block-type');
const Runtime = require('../../src/engine/runtime');
const {createOpcodeIdMap, getOpcodeId} = require('../../src/util/opcode-id');
test('opcode ID derivation matches fixed SHA-256 vectors', t => {
t.equal(getOpcodeId('looks_say', 'release-2026'), 241901085521657);
t.equal(getOpcodeId('testbuiltin_test', 'abc'), 168049635399280);
t.equal(getOpcodeId('音乐_播放', '盐'), 244155180237300);
t.not(getOpcodeId('looks_say', 'other-release'), getOpcodeId('looks_say', 'release-2026'));
t.end();
});
test('opcode ID maps reject truncated hash collisions', t => {
t.throws(
() => createOpcodeIdMap(['first', 'second'], 'salt', () => 7),
/Opcode ID collision between "first" and "second"/
);
t.end();
});
test('runtime resolves opcode IDs and preserves the legacy lookup', t => {
const runtime = new Runtime();
const salt = 'runtime-test';
const opcode = 'looks_say';
const expected = runtime.getOpcodeFunction(opcode);
t.type(expected, 'function');
t.equal(runtime.getOpcodeFunctionById(getOpcodeId(opcode, salt), salt), expected);
t.equal(runtime.getOpcodeFunctionById(Number.MAX_SAFE_INTEGER, salt), undefined);
t.throws(() => runtime.getOpcodeFunctionById(-1, salt), TypeError);
t.throws(() => runtime.getOpcodeFunctionById(1.5, salt), TypeError);
t.throws(() => runtime.getOpcodeFunctionById(1, ''), TypeError);
t.throws(() => runtime.getOpcodeFunctionById(1, null), TypeError);
t.end();
});
test('extension registration invalidates opcode ID lookup cache', t => {
const runtime = new Runtime();
const salt = 'extension-cache';
runtime.getOpcodeFunctionById(getOpcodeId('looks_say', salt), salt);
const implementation = () => 'extension result';
runtime._registerExtensionPrimitives({
id: 'opcodeidtest',
name: 'Opcode ID Test',
blocks: [{
opcode: 'run',
blockType: BlockType.REPORTER,
text: 'run',
func: implementation
}],
menus: {},
customFieldTypes: {}
});
const resolved = runtime.getOpcodeFunctionById(getOpcodeId('opcodeidtest_run', salt), salt);
t.equal(resolved, implementation);
t.equal(resolved(), 'extension result');
t.end();
});