From 061f4bd5ee3592aaa53db09e715c8249fcee9d9b Mon Sep 17 00:00:00 2001 From: Lu Yifei <118034848+LuYifei2011@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:27:46 +0800 Subject: [PATCH] Implement salt-based opcode lookup to support external conversions. --- docs/compiled-projects.md | 13 +++++++++ src/engine/runtime.js | 36 ++++++++++++++++++++++- src/util/opcode-id.js | 43 +++++++++++++++++++++++++++ test/unit/tw_opcode_id.js | 61 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 src/util/opcode-id.js create mode 100644 test/unit/tw_opcode_id.js diff --git a/docs/compiled-projects.md b/docs/compiled-projects.md index 95c939b7..2461d19c 100644 --- a/docs/compiled-projects.md +++ b/docs/compiled-projects.md @@ -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 diff --git a/src/engine/runtime.js b/src/engine/runtime.js index e017bcb3..4800ca75 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -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}} + * @private + */ + this._opcodeFunctionIdCache = null; + /** * Map to look up all block information by extended opcode. * @type {Array.} @@ -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. diff --git a/src/util/opcode-id.js b/src/util/opcode-id.js new file mode 100644 index 00000000..d1fe7c22 --- /dev/null +++ b/src/util/opcode-id.js @@ -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} 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}; diff --git a/test/unit/tw_opcode_id.js b/test/unit/tw_opcode_id.js new file mode 100644 index 00000000..9beb482a --- /dev/null +++ b/test/unit/tw_opcode_id.js @@ -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(); +});