Implement salt-based opcode lookup to support external conversions.
This commit is contained in:
4 files changed
+152
-1
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user