From 9e7cdaf06ad81e7da597c3caf83c10e76fed5bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20W=C3=A4rting?= Date: Mon, 14 Sep 2026 23:02:31 +0200 Subject: [PATCH 1/4] Add native FontForge scripting WASM feasibility proof --- .github/workflows/scripting.yml | 41 ++++++++++ README.md | 6 ++ ROADMAP.md | 5 ++ build/Dockerfile | 14 ++++ build/Dockerfile.scripting-native | 17 +++++ docs/native-scripting-proof.md | 119 ++++++++++++++++++++++++++++++ native/CMakeLists.txt | 14 ++++ native/script.c | 21 ++++++ test/scripting/browser-worker.mjs | 6 ++ test/scripting/browser.mjs | 79 ++++++++++++++++++++ test/scripting/node-worker.mjs | 7 ++ test/scripting/parity.mjs | 36 +++++++++ test/scripting/proof.test.mjs | 55 ++++++++++++++ test/scripting/runner.mjs | 27 +++++++ test/scripting/runtime.mjs | 66 +++++++++++++++++ test/scripting/verify-parity.py | 22 ++++++ 16 files changed, 535 insertions(+) create mode 100644 .github/workflows/scripting.yml create mode 100644 build/Dockerfile.scripting-native create mode 100644 docs/native-scripting-proof.md create mode 100644 native/script.c create mode 100644 test/scripting/browser-worker.mjs create mode 100644 test/scripting/browser.mjs create mode 100644 test/scripting/node-worker.mjs create mode 100644 test/scripting/parity.mjs create mode 100644 test/scripting/proof.test.mjs create mode 100644 test/scripting/runner.mjs create mode 100644 test/scripting/runtime.mjs create mode 100644 test/scripting/verify-parity.py diff --git a/.github/workflows/scripting.yml b/.github/workflows/scripting.yml new file mode 100644 index 0000000..844a520 --- /dev/null +++ b/.github/workflows/scripting.yml @@ -0,0 +1,41 @@ +name: Native scripting proof +on: + workflow_dispatch: + pull_request: + paths: + - 'native/**' + - 'build/**' + - 'test/scripting/**' + - '.github/workflows/scripting.yml' +permissions: + contents: read +jobs: + scripting: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + file: build/Dockerfile + target: scripting-export + outputs: type=local,dest=artifacts/scripting + cache-from: type=gha,scope=scripting + cache-to: type=gha,scope=scripting,mode=max + - run: npm ci + - run: node --test test/scripting/proof.test.mjs + - run: docker build -f build/Dockerfile.scripting-native -t fontforge-wasm:scripting-native . + - run: node test/scripting/parity.mjs + - run: | + python3 -m venv .venv + .venv/bin/pip install fonttools==4.59.0 brotli==1.2.0 + .venv/bin/python test/scripting/verify-parity.py + - run: npx playwright install --with-deps chromium webkit + - run: node test/scripting/browser.mjs + env: + BROWSER_CHANNEL: chromium diff --git a/README.md b/README.md index 0c5e7d4..d87369e 100644 --- a/README.md +++ b/README.md @@ -197,3 +197,9 @@ not remove the license obligations of applications distributing a combined work. The live demo is hosted on GitHub Pages. Successful builds of `main` deploy it automatically after the native and browser tests pass. Run `node build/pages.mjs` after building to assemble the same static site locally. + +### Native scripting development + +An opt-in [native scripting proof](docs/native-scripting-proof.md) runs the real +FontForge interpreter in a separate WASM build. See the reproduction instructions +and remaining API/sandbox work there. It is not part of the released converter. diff --git a/ROADMAP.md b/ROADMAP.md index 19f5d9a..c210bfd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,6 +47,11 @@ and only missing server-supported outputs use fallback. ## Milestone 2 — FontForge native scripting and CLI semantics +The first [native scripting proof](docs/native-scripting-proof.md) is implemented +as an opt-in development target: real upstream scripts, disposable workers, +exit/log capture, native parity and browser tests. It is not yet a public SDK API +or a released scripting feature; the work below remains the milestone scope. + Enable FontForge's own scripting interpreter in a separate build target. Start with `fontforge -lang=ff -script` semantics and then `-c`; do not label a custom command parser as CLI compatibility. Python support is a later milestone. diff --git a/build/Dockerfile b/build/Dockerfile index cf36d40..818b55a 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -81,5 +81,19 @@ RUN mkdir -p /output/dist /output/sources \ COPY build/licenses.py /product/build/licenses.py RUN python3 /product/build/licenses.py \ && tar czf /output/sources/emscripten-runtime.tar.gz -C /emsdk/upstream/emscripten system src LICENSE +# Opt-in proof build: the normal conversion artifact remains unchanged. +FROM builder AS scripting-builder +# FreeType also uses setjmp; it must agree with the interpreter's exception ABI. +RUN emcmake cmake -S /work/freetype -B /work/freetype/out \ + -DCMAKE_C_FLAGS="-O2 -I/opt/wasm/include -fwasm-exceptions -sSUPPORT_LONGJMP=wasm" \ + && cmake --build /work/freetype/out --target install -j4 +RUN emcmake cmake -S /work/fontforge -B /work/fontforge/out \ + -DENABLE_NATIVE_SCRIPTING=ON -DCMAKE_C_FLAGS="-O2 -I/opt/wasm/include -fwasm-exceptions -sSUPPORT_LONGJMP=wasm" \ + && cmake --build /work/fontforge/out --target fontforge-script -j4 +RUN mkdir -p /script-output \ + && cp /work/fontforge/out/wasm-wrapper/fontforge-script.* /script-output/ +FROM scratch AS scripting-export +COPY --from=scripting-builder /script-output/ / + FROM scratch AS export COPY --from=builder /output/ / diff --git a/build/Dockerfile.scripting-native b/build/Dockerfile.scripting-native new file mode 100644 index 0000000..cee6bac --- /dev/null +++ b/build/Dockerfile.scripting-native @@ -0,0 +1,17 @@ +# Native reference uses the same hash-verified FontForge source as the WASM build. +FROM emscripten/emsdk:5.0.7@sha256:4e332f7343b6f66320bf72f7ecc01a3d9f3866721a13b0e5c7b96505d6ab148a +RUN apt-get update && apt-get install -y --no-install-recommends \ + ninja-build gettext libglib2.0-dev libfreetype-dev libxml2-dev libltdl-dev \ + libwoff-dev libbrotli-dev && rm -rf /var/lib/apt/lists/* +COPY build/fetch.py build/sources.json /product/build/ +RUN mkdir -p /work /sources && python3 /product/build/fetch.py fontforge +RUN cmake -S /work/fontforge -B /work/fontforge/out -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \ + -DENABLE_GUI=OFF -DENABLE_NATIVE_SCRIPTING=ON \ + -DENABLE_PYTHON_SCRIPTING=OFF -DENABLE_PYTHON_EXTENSION=OFF \ + -DENABLE_LIBSPIRO=OFF -DENABLE_LIBGIF=OFF -DENABLE_LIBJPEG=OFF \ + -DENABLE_LIBPNG=OFF -DENABLE_LIBREADLINE=OFF -DENABLE_LIBTIFF=OFF \ + -DENABLE_WOFF2=ON -DENABLE_HARFBUZZ=OFF -DENABLE_DOCS=OFF \ + && cmake --build /work/fontforge/out --target fontforgeexe -j4 +RUN cp /work/fontforge/out/bin/fontforge /usr/local/bin/fontforge-reference +ENTRYPOINT ["/usr/local/bin/fontforge-reference"] diff --git a/docs/native-scripting-proof.md b/docs/native-scripting-proof.md new file mode 100644 index 0000000..a2a9e50 --- /dev/null +++ b/docs/native-scripting-proof.md @@ -0,0 +1,119 @@ +# Native scripting proof + +This implements the first feasibility gate in [Epic #1](https://github.com/warting/fontforge-wasm/issues/1). +It is an opt-in development build, not a released `execute()` API or a public +script playground. The existing conversion distribution and OFC integration +are unchanged. + +## What runs + +`native/script.c` initializes headless FontForge and calls upstream +`ProcessNativeScript`. It accepts only `-lang=ff -script file [args...]` and +`-lang=ff -c script [args...]`. It does not implement a replacement parser or +pretend to support the full CLI, stdin, Python or desktop GUI. + +The proof runs this native script against an SFD source: + +```text +Open($1); +Print($fontname); +Print($2); +Generate($2); +``` + +Arguments are passed as argv entries, without a shell. Tests cover Unicode and +shell-like characters, syntax errors, missing files and `Quit(7)`. + +## Lifecycle decision + +The upstream interpreter owns process-global state and terminates with `exit`. +Normal completion exits 0; non-interactive script errors exit 1. Do not patch +these into returns or reuse a module after exit. One job owns one fresh worker, +module and MEMFS. The supervisor terminates the worker on completion, error, +timeout or cancellation, then releases its browser blob URL. + +The generated module uses `noInitialRun`, exported `callMain`, `onExit`, and +`EXIT_RUNTIME=1`. Its filesystem remains readable after a normal interpreter +exit, allowing the harness to copy explicitly requested output files. An +unexpected WASM trap is an execution failure, not a successful script exit. + +The conversion build's function-pointer emulation combined with JS-based +setjmp/longjmp produced invalid output from wasm-opt when scripting was linked. +The separate script target uses WASM exceptions (`-fwasm-exceptions` and +`SUPPORT_LONGJMP=wasm`). FreeType must be rebuilt with the same longjmp ABI. +Conversion retains its existing flags and dependency artifacts. + +## Reproduce + +Prerequisites: Docker, Node >=22, npm dependencies, Python with +`fonttools==4.59.0` and `brotli==1.2.0`, and Playwright Chrome/Chromium + WebKit. +Run from the repository root: + +```sh +npm ci +docker build -f build/Dockerfile --target scripting-export --output type=local,dest=artifacts/scripting . +node --test test/scripting/proof.test.mjs +docker build -f build/Dockerfile.scripting-native -t fontforge-wasm:scripting-native . +node test/scripting/parity.mjs +python test/scripting/verify-parity.py +node test/scripting/browser.mjs +``` + +The browser test defaults to installed Google Chrome. Set `BROWSER_CHANNEL=chromium` +for Playwright's bundled Chromium. `SCRIPT_BROWSER=Chrome` or `WebKit` selects +one engine. Use `PLAYWRIGHT_BROWSERS_PATH` when browsers are installed elsewhere. + +The native reference independently downloads and hash-verifies the same pinned +FontForge source in `build/sources.json`; it uses native system dependencies +rather than the WASM dependency builds. Its container has networking disabled +at execution and uses `LANG=C.UTF-8`, matching the proof's UTF-8 arguments. + +Results are written below ignored `test/results/scripting/`. Native and WASM +stdout/exit codes are compared. FontTools independently compares cmap, glyph +order, names, metrics, OpenType layout tables and every decomposed glyph outline. +Build timestamps are not required to match. + +## Browser verification + +The browser harness bundles the same runtime into a disposable worker. It tests +SFD-to-OTF, script failure, concurrent jobs, cancellation of an infinite loop, +timeout and successful execution after cancellation. It loads the worker code, +WASM and input explicitly before running the network-independent tests. + +Chrome runs with Playwright offline mode enabled. On the tested macOS WebKit +runner, `setOffline(true)` prevents even a standalone `postMessage("ok")` blob +worker from starting. WebKit therefore runs with HTTP/HTTPS requests blocked, +and the test asserts that execution makes zero such requests. This proves no +network dependency during execution; it does not verify Safari service-worker +caching, offline reload or every Safari release. + +## Measured build + +With pinned Emscripten 5.0.7, the script WASM is 7,312,950 bytes and its generated +module is 80,624 bytes (uncompressed). The existing conversion WASM is 4,976,470 +bytes. Keep scripting as a separate, opt-in download. These are local build +measurements, not a versioned release promise. + +## Boundaries still to implement + +The internal harness limits supplied files, copied outputs, logs and execution +time. WASM memory is capped at 512 MiB. It is **not** a production sandbox/API: + +- Writable filesystem quotas must apply while scripts run, not just when copying + output. The current harness only limits input to 16 MiB, returned output to + 32 MiB, supplied/selected file counts to 64, and logs to 64 KiB. +- Audit subprocess/network-dependent native commands and provide structured + capability errors. Existing platform stubs do not establish comprehensive + command compatibility. +- Define public argv/$0, working-directory, filename, partial-output, directory + output and error contracts. Tests currently select ordinary output files only. +- Add typed browser/Node `execute()`, complete resource validation, streaming + diagnostics, bounded assets and browser crash recovery. +- Expand native parity to selection, subsetting, metrics, transforms, contour + edits, source preservation and multi-file outputs. +- Build the public playground, cache/update integration and offline reload tests + only after those contracts are ready. + +Do not publish this harness as a general-purpose script execution service. +Do not enable it on OFC's server/API endpoints. No product deployment is needed +for this feasibility gate. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 84a8864..c3d580f 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -17,3 +17,17 @@ target_link_options(fontforge-wasm PRIVATE "-sEXPORTED_FUNCTIONS=['_ff_convert','_ff_decode_webfont','_ff_inflate_raw']" "-sEXPORTED_RUNTIME_METHODS=['FS','ccall']" ) + +# Experimental native-script executable. Only built in the scripting target. +if(ENABLE_NATIVE_SCRIPTING) + add_executable(fontforge-script script.c platform.c) + target_link_libraries(fontforge-script PRIVATE fontforge ${GLIB_STATIC_FLAGS}) + set_target_properties(fontforge-script PROPERTIES OUTPUT_NAME fontforge-script SUFFIX .mjs + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}") + target_link_options(fontforge-script PRIVATE + -O2 -sEMULATE_FUNCTION_POINTER_CASTS=1 -fwasm-exceptions -sSUPPORT_LONGJMP=wasm + -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,worker,node + -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=536870912 -sSTACK_SIZE=8388608 + -sFILESYSTEM=1 -sFORCE_FILESYSTEM=1 -sEXIT_RUNTIME=1 + "-sEXPORTED_RUNTIME_METHODS=['FS','callMain']") +endif() diff --git a/native/script.c b/native/script.c new file mode 100644 index 0000000..dc53b28 --- /dev/null +++ b/native/script.c @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include "fontforge.h" +#include "start.h" +#include "scripting.h" +#include +#include + +int main(int argc, char **argv) { + // Constrain the proof to documented native-script invocations. In particular, + // do not enter stdin or claim support for the desktop CLI or Python. + if (argc < 4 || strcmp(argv[1], "-lang=ff") || + (strcmp(argv[2], "-script") && strcmp(argv[2], "-c"))) { + fprintf(stderr, "Usage: fontforge-script -lang=ff {-script file|-c script} [args...]\n"); + return 2; + } + doinitFontForgeMain(); + no_windowing_ui = true; + ProcessNativeScript(argc, argv, NULL); + return 0; +} diff --git a/test/scripting/browser-worker.mjs b/test/scripting/browser-worker.mjs new file mode 100644 index 0000000..ff0d251 --- /dev/null +++ b/test/scripting/browser-worker.mjs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { run } from './runtime.mjs'; +self.onmessage = async ({ data }) => { + try { self.postMessage({ result: await run(data, log => self.postMessage({ log })) }); } + catch (error) { self.postMessage({ error: `${error?.name}: ${error?.message || String(error)}\n${error?.stack || ""}` }); } +}; diff --git a/test/scripting/browser.mjs b/test/scripting/browser.mjs new file mode 100644 index 0000000..0e65a28 --- /dev/null +++ b/test/scripting/browser.mjs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { build } from 'esbuild'; +import { chromium, webkit } from 'playwright'; +import { createServer } from 'node:http'; +import { readFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import assert from 'node:assert/strict'; +await build({ entryPoints: ['test/scripting/browser-worker.mjs'], outfile: 'artifacts/scripting/browser-worker.mjs', bundle: true, platform: 'browser', format: 'iife', define: { 'import.meta.url': 'self.location.href' }, external: ['node:*'] }); +const routes = { + '/worker.mjs': ['artifacts/scripting/browser-worker.mjs', 'text/javascript'], + '/fontforge-script.wasm': ['artifacts/scripting/fontforge-script.wasm', 'application/wasm'], + '/input.sfd': ['test/results/scripting/input.sfd', 'application/octet-stream'], +}; +const server = createServer(async (req, res) => { + if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); res.end('Native scripting proof'); return; } + const route = routes[req.url]; + if (!route) { res.writeHead(404); res.end(); return; } + try { res.setHeader('Content-Type', route[1]); res.end(await readFile(route[0])); } + catch { res.writeHead(500); res.end(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +try { + for (const [name, engine] of [['Chrome', chromium], ['WebKit', webkit]].filter(([name]) => !process.env.SCRIPT_BROWSER || process.env.SCRIPT_BROWSER === name)) { + const profile = await mkdtemp(join(tmpdir(), 'fontforge-script-browser-')); + const browser = await engine.launchPersistentContext(profile, name === 'Chrome' ? { channel: process.env.BROWSER_CHANNEL || 'chrome' } : {}); + try { + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + const result = page.evaluate(async () => { + const wasmBinary = await (await fetch('/fontforge-script.wasm')).arrayBuffer(); + const input = new Uint8Array(await (await fetch('/input.sfd')).arrayBuffer()); + const workerCode = await (await fetch('/worker.mjs')).text(); + const execute = (request, abortOnLog = false, timeoutMs = 10000) => new Promise((resolve, reject) => { + const url = URL.createObjectURL(new Blob([workerCode], { type: 'text/javascript' })); + const worker = new Worker(url); + const done = (error, value) => { clearTimeout(timer); worker.terminate(); URL.revokeObjectURL(url); error ? reject(error) : resolve(value); }; + const timer = setTimeout(() => done(new Error('TIMEOUT')), timeoutMs); + worker.onerror = event => done(new Error(`Worker error: ${event.message} ${event.filename}:${event.lineno}`)); + worker.onmessage = ({ data }) => { + if (data.log?.fatal) { done(new Error(data.log.fatal)); return; } + if (data.log) { if (abortOnLog && data.log.message === 'ready') done(null, 'cancelled'); return; } + done(data.error ? new Error(data.error) : null, data.result); + }; + worker.postMessage({ ...request, wasmBinary }); + }); + // Block all further network access: everything required is already loaded. + self.proofReady = true; + await new Promise(resolve => { self.resumeProof = resolve; }); + { + const converted = await execute({ script: 'Open($1); Print($fontname); Generate($2);', args: ['/work/input.sfd', '/work/out.otf'], files: { '/work/input.sfd': input }, outputPaths: ['/work/out.otf'] }); + const failed = await execute({ script: 'UnknownCommand();' }); + const concurrent = await Promise.all(['one', 'two'].map(word => execute({ script: 'Print($1);', args: [word] }))); + const cancelled = await execute({ script: 'Print("ready"); while (1)\nendloop' }, true); + const timedOut = await execute({ script: 'while (1)\nendloop' }, false, 500).then(() => false, error => error.message === 'TIMEOUT'); + const after = await execute({ script: 'Print("alive");' }); + return { exit: converted.exitCode, signature: new TextDecoder().decode(converted.files['/work/out.otf'].slice(0, 4)), failed: failed.exitCode, logs: converted.stdout, concurrent: concurrent.map(r => r.stdout), cancelled, timedOut, after: after.stdout }; + } + }); + await page.waitForFunction(() => self.proofReady); + // On the tested macOS WebKit runner, setOffline(true) prevents even a + // trivial blob worker from starting. Block HTTP instead; no SDK upload or + // asset request is allowed after the explicitly loaded inputs above. + const unexpectedRequests = []; + await page.context().route(/^https?:\/\//, route => { + unexpectedRequests.push(route.request().url()); + return route.abort(); + }); + if (name === 'Chrome') await page.context().setOffline(true); + await page.evaluate(() => self.resumeProof()); + const actual = await result; + assert.deepEqual(unexpectedRequests, [], "Script execution must not request network resources"); + assert.equal(actual.exit, 0); assert.equal(actual.signature, 'OTTO'); assert.ok(actual.logs.length); + assert.notEqual(actual.failed, 0); assert.deepEqual(actual.concurrent, ['one', 'two']); + assert.equal(actual.cancelled, 'cancelled'); assert.equal(actual.timedOut, true); assert.equal(actual.after, 'alive'); + console.log(`${name}: native script export, errors, concurrency, cancellation and timeout passed (${name === "Chrome" ? "offline" : "HTTP blocked"})`); + } finally { await browser.close(); await rm(profile, { recursive: true, force: true }); } + } +} finally { server.close(); } diff --git a/test/scripting/node-worker.mjs b/test/scripting/node-worker.mjs new file mode 100644 index 0000000..80e6e28 --- /dev/null +++ b/test/scripting/node-worker.mjs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { parentPort } from 'node:worker_threads'; +import { run } from './runtime.mjs'; +parentPort.once('message', async request => { + try { parentPort.postMessage({ result: await run(request, log => parentPort.postMessage({ log })) }); } + catch (error) { parentPort.postMessage({ error: error.message }); } +}); diff --git a/test/scripting/parity.mjs b/test/scripting/parity.mjs new file mode 100644 index 0000000..9d11ad9 --- /dev/null +++ b/test/scripting/parity.mjs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFile, writeFile, mkdtemp, cp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { executeProof } from './runner.mjs'; +const dir = await mkdtemp(join(tmpdir(), 'fontforge-parity-')); +try { + await cp('test/results/scripting/input.sfd', join(dir, 'input.sfd')); + const input = new Uint8Array(await readFile(join(dir, 'input.sfd'))); + for (const [name, script, args, mode] of [ + ['export', 'Open($1); Print($fontname); Print($2); Generate($2);', ['/work/input.sfd', '/work/output.otf'], '-script'], + ['arguments', 'Print($1);', ['å字 $(not-a-shell); "quoted"'], '-c'], + ['syntax-error', 'UnknownCommand();', [], '-script'], + ['missing-file', 'Open("/work/missing.sfd");', [], '-script'], + ['exit', 'Quit(7);', [], '-script'], + ]) { + await writeFile(join(dir, 'script.pe'), script); + let native; + try { + native = { status: 0, stdout: execFileSync('docker', ['run', '--rm', '--network=none', '-e', 'LANG=C.UTF-8', '-v', `${dir}:/work`, '-w', '/work', 'fontforge-wasm:scripting-native', '-lang=ff', mode, mode === '-c' ? script : '/work/script.pe', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) }; + } catch (error) { + if (typeof error.status !== 'number') throw error; + native = error; + } + const wasm = await executeProof({ script, args, mode, files: { '/work/input.sfd': input }, outputPaths: name === 'export' ? ['/work/output.otf'] : [] }); + assert.equal(wasm.exitCode, native.status, name); + assert.equal(wasm.stdout.trim(), native.stdout.trim(), name); + if (name === 'export') { + await cp(join(dir, 'output.otf'), 'test/results/scripting/native.otf'); + await writeFile('test/results/scripting/wasm.otf', wasm.files['/work/output.otf']); + } + console.log(`Native/WASM parity: ${name}, exit ${wasm.exitCode}`); + } +} finally { await rm(dir, { recursive: true, force: true }); } diff --git a/test/scripting/proof.test.mjs b/test/scripting/proof.test.mjs new file mode 100644 index 0000000..cb145c7 --- /dev/null +++ b/test/scripting/proof.test.mjs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { executeProof } from './runner.mjs'; +const input = new Uint8Array(await readFile(new URL('../fixtures/fixture.ttf', import.meta.url))); +test('native interpreter opens SFD, reads metadata and generates OTF', async () => { + const preparation = await executeProof({ script: 'Open($1); Save($2);', args: ['/work/input.ttf', '/work/input.sfd'], files: { '/work/input.ttf': input }, outputPaths: ['/work/input.sfd'] }); + assert.equal(preparation.exitCode, 0, preparation.stderr); + const script = 'Open($1); Print($fontname); Print($2); Generate($2);'; + const result = await executeProof({ script, args: ['/work/input.sfd', '/work/output.otf'], files: preparation.files, outputPaths: ['/work/output.otf'] }); + assert.equal(result.exitCode, 0, result.stderr); + assert.match(result.stdout, /\/work\/output.otf/); + assert.equal(new TextDecoder().decode(result.files['/work/output.otf'].slice(0, 4)), 'OTTO'); + await mkdir('test/results/scripting', { recursive: true }); + await writeFile('test/results/scripting/input.sfd', preparation.files['/work/input.sfd']); + await writeFile('test/results/scripting/proof.pe', script); + await writeFile('test/results/scripting/wasm.otf', result.files['/work/output.otf']); + await writeFile('test/results/scripting/wasm.json', JSON.stringify(result)); +}); +test('inline native script preserves Unicode and arguments without shell parsing', async () => { + const result = await executeProof({ mode: '-c', script: 'Print($1);', args: ['å字 $(not-a-shell); "quoted"'] }); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, 'å字 $(not-a-shell); "quoted"'); +}); +test('native errors and explicit exits preserve exit code and stderr', async () => { + for (const script of ['UnknownCommand();', 'Open("/work/missing.sfd");']) { + const result = await executeProof({ script }); + assert.notEqual(result.exitCode, 0); + assert.ok(result.stderr.length); + } + assert.equal((await executeProof({ script: 'Quit(7);' })).exitCode, 7); +}); +test('concurrent and repeated scripts cannot share globals or virtual files', async () => { + const results = await Promise.all(['one', 'two'].map(value => executeProof({ script: 'Print($1);', args: [value] }))); + assert.deepEqual(results.map(r => r.stdout), ['one', 'two']); + assert.notEqual((await executeProof({ script: 'Open("/work/input.sfd");' })).exitCode, 0); +}); +test('timeout and cancellation stop a running interpreter', async () => { + await assert.rejects(executeProof({ script: 'while (1)\nendloop' }, { timeoutMs: 1000 }), /TIMEOUT/); + const controller = new AbortController(); + await assert.rejects(executeProof({ script: 'Print("ready"); while (1)\nendloop' }, { signal: controller.signal, onLog: () => controller.abort() }), /ABORTED/); + assert.equal((await executeProof({ script: 'Print("still works");' })).stdout, 'still works'); +}); + +test('bounded logs fail explicitly; observer exceptions do not kill scripts', async () => { + await assert.rejects(executeProof({ script: 'while (1)\nPrint("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");\nendloop' }), /log limit/i); + const result = await executeProof({ script: 'Print("ok");' }, { onLog: () => { throw new Error('observer'); } }); + assert.equal(result.stdout, 'ok'); +}); +test('invalid paths and arguments fail before script execution', async () => { + await assert.rejects(executeProof({ script: 'Print("no");', args: ['bad\0argument'] }), /Invalid arguments/); + await assert.rejects(executeProof({ script: 'Print("no");', files: { '/work/../escaped': input } }), /Invalid input file/); + await assert.rejects(executeProof({ script: 'Print("no");', outputPaths: ['/etc/passwd'] }), /Invalid output path/); +}); diff --git a/test/scripting/runner.mjs b/test/scripting/runner.mjs new file mode 100644 index 0000000..40cacca --- /dev/null +++ b/test/scripting/runner.mjs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { Worker } from 'node:worker_threads'; +export function executeProof(request, { timeoutMs = 10000, signal, onLog } = {}) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { reject(new Error('ABORTED')); return; } + const worker = new Worker(new URL('./node-worker.mjs', import.meta.url), { execArgv: [] }); + let finished = false; + const finish = (error, result) => { + if (finished) return; + finished = true; + clearTimeout(timer); signal?.removeEventListener('abort', abort); + worker.terminate(); + error ? reject(error) : resolve(result); + }; + const abort = () => finish(new Error('ABORTED')); + const timer = setTimeout(() => finish(new Error('TIMEOUT')), timeoutMs); + signal?.addEventListener('abort', abort, { once: true }); + worker.on('message', message => { + if (message.log?.fatal) { finish(new Error(message.log.fatal)); return; } + if (message.log) { try { onLog?.(message.log); } catch {} return; } + finish(message.error ? new Error(message.error) : null, message.result); + }); + worker.on('error', error => finish(error)); + worker.on('exit', code => { if (!finished) finish(new Error(`Worker exited without result: ${code}`)); }); + try { worker.postMessage(request); } catch (error) { finish(error); } + }); +} diff --git a/test/scripting/runtime.mjs b/test/scripting/runtime.mjs new file mode 100644 index 0000000..20b5811 --- /dev/null +++ b/test/scripting/runtime.mjs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Internal proof harness, not the public execute() API. +import createModule from '../../artifacts/scripting/fontforge-script.mjs'; +export async function run({ script, args = [], files = {}, outputPaths = [], mode = '-script', wasmBinary }, report = () => {}) { + if (typeof script !== 'string' || script.includes('\0')) throw new Error('Invalid script'); + if (!Array.isArray(args) || args.length > 64 || args.some(arg => typeof arg !== 'string' || arg.includes('\0'))) throw new Error('Invalid arguments'); + const scriptBytes = new TextEncoder().encode(script).length; + const argumentBytes = args.reduce((sum, arg) => sum + new TextEncoder().encode(arg).length, 0); + if (scriptBytes + argumentBytes > 16 * 1024 * 1024) throw new Error('Input limit exceeded'); + let exitCode; + let logBytes = 0; + let logLimitExceeded = false; + const logs = { stdout: [], stderr: [] }; + const started = performance.now(); + const log = stream => message => { + if (logLimitExceeded) return; + logBytes += new TextEncoder().encode(message).length; + if (logBytes > 65536) { + // libc can swallow exceptions thrown by print callbacks as write errors. + // Notify the supervisor instead; only worker termination reliably stops C. + logLimitExceeded = true; + report({ fatal: 'Script log limit exceeded' }); + return; + } + logs[stream].push(message); + report({ stream, message, elapsedMs: performance.now() - started }); + }; + const module = await createModule({ noInitialRun: true, ...(wasmBinary ? { wasmBinary, locateFile: name => name } : {}), + print: log('stdout'), printErr: log('stderr'), + onExit: code => { exitCode = code; }, + // Do not let Emscripten change a Node worker's process exit status. + quit: (code, error) => { throw error; }, + }); + const { FS } = module; + FS.mkdir('/work'); FS.chdir('/work'); + const validPath = path => typeof path === 'string' && /^\/work\/(?!\.\.?($|\/))[^\0\\]+$/.test(path) + && path.split('/').every(part => part !== '..' && part !== '.'); + let inputBytes = scriptBytes + argumentBytes; + if (Object.keys(files).length > 64 || outputPaths.length > 64) throw new Error('File count limit exceeded'); + for (const [path, bytes] of Object.entries(files)) { + if (!validPath(path) || path === '/work/script.pe' || !(bytes instanceof Uint8Array)) throw new Error('Invalid input file'); + inputBytes += bytes.byteLength; + if (inputBytes > 16 * 1024 * 1024) throw new Error('Input limit exceeded'); + FS.mkdirTree(path.slice(0, path.lastIndexOf('/'))); + FS.writeFile(path, bytes); + } + for (const path of outputPaths) { + if (!validPath(path) || path === '/work/script.pe' || Object.hasOwn(files, path)) throw new Error('Invalid output path'); + FS.mkdirTree(path.slice(0, path.lastIndexOf('/'))); + } + if (mode !== '-script' && mode !== '-c') throw new Error('Invalid script mode'); + FS.writeFile('/work/script.pe', script); + const status = module.callMain(['-lang=ff', mode, mode === '-c' ? script : '/work/script.pe', ...args]); + if (logLimitExceeded) throw new Error('Script log limit exceeded'); + if (exitCode === undefined) throw new Error(`Interpreter did not exit normally (${status})`); + const outputs = {}; + let total = 0; + for (const path of outputPaths) { + if (!FS.analyzePath(path).exists) continue; // A script error may leave partial output. + const size = FS.stat(path).size; + total += size; + if (total > 32 * 1024 * 1024) throw new Error('Output limit exceeded'); + outputs[path] = FS.readFile(path).slice(); + } + return { exitCode, files: outputs, stdout: logs.stdout.join('\n'), stderr: logs.stderr.join('\n') }; +} diff --git a/test/scripting/verify-parity.py b/test/scripting/verify-parity.py new file mode 100644 index 0000000..5f25172 --- /dev/null +++ b/test/scripting/verify-parity.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Compare independently decoded font semantics, excluding build timestamps.""" +from fontTools.ttLib import TTFont +from fontTools.pens.recordingPen import RecordingPen +from pathlib import Path +root = Path('test/results/scripting') +native, wasm = [TTFont(root / name, checkChecksums=2) for name in ['native.otf', 'wasm.otf']] +assert set(native.keys()) == set(wasm.keys()) +assert native.getBestCmap() == wasm.getBestCmap() +assert native.getGlyphOrder() == wasm.getGlyphOrder() +assert native['head'].unitsPerEm == wasm['head'].unitsPerEm +assert native['hmtx'].metrics == wasm['hmtx'].metrics +assert [(n.nameID, n.platformID, n.platEncID, n.langID, n.toUnicode()) for n in native['name'].names] == [(n.nameID, n.platformID, n.platEncID, n.langID, n.toUnicode()) for n in wasm['name'].names] +for tag in ['GSUB', 'GPOS', 'OS/2', 'hhea', 'maxp', 'post']: + assert native[tag].compile(native) == wasm[tag].compile(wasm), tag +ng, wg = native.getGlyphSet(), wasm.getGlyphSet() +for name in native.getGlyphOrder(): + a, b = RecordingPen(), RecordingPen() + ng[name].draw(a) + wg[name].draw(b) + assert a.value == b.value, name +print('Independent FontTools parity: cmap, names, metrics, layout tables and every glyph contour') From 3d40d59cf1c8dd66c5b9a757b82640d0d4bb5bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20W=C3=A4rting?= Date: Tue, 15 Sep 2026 07:04:10 +0200 Subject: [PATCH 2/4] Expose native script execution with worker isolation and runtime quotas --- .github/workflows/ci.yml | 2 + .github/workflows/scripting.yml | 7 ++ CONTRIBUTING.md | 3 +- README.md | 10 +-- ROADMAP.md | 4 ++ build/Dockerfile | 2 + build/assemble-scripting.mjs | 6 ++ build/check-package.mjs | 4 ++ build/patch-scripting.py | 15 +++++ demo-service-worker.js | 4 +- docs/native-scripting-proof.md | 4 ++ docs/scripting-api.md | 116 ++++++++++++++++++++++++++++++++ native/script.c | 19 ++++++ package.json | 6 +- src/execute.js | 85 +++++++++++++++++++++++ src/index.d.ts | 39 +++++++++++ src/index.js | 3 + src/script-browser-worker.js | 6 ++ src/script-node-worker.js | 7 ++ src/script-quota.js | 45 +++++++++++++ src/script-runtime.js | 71 +++++++++++++++++++ src/script-validate.js | 30 +++++++++ test/scripting/api-browser.mjs | 48 +++++++++++++ test/scripting/api.test.mjs | 50 ++++++++++++++ test/scripting/quota.test.mjs | 34 ++++++++++ 25 files changed, 612 insertions(+), 8 deletions(-) create mode 100644 build/assemble-scripting.mjs create mode 100644 build/patch-scripting.py create mode 100644 docs/scripting-api.md create mode 100644 src/execute.js create mode 100644 src/script-browser-worker.js create mode 100644 src/script-node-worker.js create mode 100644 src/script-quota.js create mode 100644 src/script-runtime.js create mode 100644 src/script-validate.js create mode 100644 test/scripting/api-browser.mjs create mode 100644 test/scripting/api.test.mjs create mode 100644 test/scripting/quota.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 749e2db..bc9ea27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: cache-to: type=gha,mode=max - run: npm ci - run: node build/assemble.mjs + - run: npm run build:scripting + - run: node --test test/scripting/api.test.mjs test/scripting/quota.test.mjs - run: | python3 -m venv .venv .venv/bin/pip install fonttools==4.59.0 brotli==1.2.0 diff --git a/.github/workflows/scripting.yml b/.github/workflows/scripting.yml index 844a520..745852d 100644 --- a/.github/workflows/scripting.yml +++ b/.github/workflows/scripting.yml @@ -6,6 +6,8 @@ on: - 'native/**' - 'build/**' - 'test/scripting/**' + - 'src/**' + - 'package*.json' - '.github/workflows/scripting.yml' permissions: contents: read @@ -28,6 +30,8 @@ jobs: cache-from: type=gha,scope=scripting cache-to: type=gha,scope=scripting,mode=max - run: npm ci + - run: node build/assemble-scripting.mjs + - run: node --test test/scripting/api.test.mjs test/scripting/quota.test.mjs - run: node --test test/scripting/proof.test.mjs - run: docker build -f build/Dockerfile.scripting-native -t fontforge-wasm:scripting-native . - run: node test/scripting/parity.mjs @@ -39,3 +43,6 @@ jobs: - run: node test/scripting/browser.mjs env: BROWSER_CHANNEL: chromium + - run: node test/scripting/api-browser.mjs + env: + BROWSER_CHANNEL: chromium diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a980bc..823ba19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,8 @@ conversion and browser font loading. No external application is needed. `build/sources.json` pins archives and their SHA-256 digests. `build/patch.py` contains small checked platform changes; `native/patch-formats.py` preserves Unicode information in Type 11 exports. The C adapter exposes only a narrow -conversion ABI, not a general scripting interface. Keep browser filesystem access +conversion ABI. A separate scripting target exposes the upstream interpreter; +see docs/scripting-api.md and run both scripting test suites for changes to it. Keep browser filesystem access inside Emscripten's in-memory filesystem and run each job in its own worker. The WASM memory ceiling is a linear-memory limit, not a total process limit. diff --git a/README.md b/README.md index d87369e..8329f2d 100644 --- a/README.md +++ b/README.md @@ -198,8 +198,10 @@ The live demo is hosted on GitHub Pages. Successful builds of `main` deploy it automatically after the native and browser tests pass. Run `node build/pages.mjs` after building to assemble the same static site locally. -### Native scripting development +### Native scripting (unreleased) -An opt-in [native scripting proof](docs/native-scripting-proof.md) runs the real -FontForge interpreter in a separate WASM build. See the reproduction instructions -and remaining API/sandbox work there. It is not part of the released converter. +The development branch exposes a typed `execute(script, options)` API for Node and +browsers, with isolated workers, streamed logs, cancellation and runtime filesystem +budgets. See the [API contract and example](docs/scripting-api.md). Build its +separate assets with `npm run build:scripting`; `convert()` loads only the existing +conversion engine. This API has not yet been published in a release. diff --git a/ROADMAP.md b/ROADMAP.md index c210bfd..5b2d823 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -143,3 +143,7 @@ measure memory/download/startup costs, publish browser support and compatibility matrices, review third-party source distribution, and define semver guarantees for the SDK, worker protocol and native ABI. Keep non-stable APIs explicitly experimental until these gates pass. + +The unreleased [execute API](docs/scripting-api.md) now adds typed Node/browser +execution, live diagnostics, cumulative filesystem budgets and explicit capability +errors. Public playground and broader editing-operation parity remain next. diff --git a/build/Dockerfile b/build/Dockerfile index 818b55a..c7e0af2 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -83,6 +83,8 @@ RUN python3 /product/build/licenses.py \ && tar czf /output/sources/emscripten-runtime.tar.gz -C /emsdk/upstream/emscripten system src LICENSE # Opt-in proof build: the normal conversion artifact remains unchanged. FROM builder AS scripting-builder +COPY build/patch-scripting.py /product/build/patch-scripting.py +RUN python3 /product/build/patch-scripting.py # FreeType also uses setjmp; it must agree with the interpreter's exception ABI. RUN emcmake cmake -S /work/freetype -B /work/freetype/out \ -DCMAKE_C_FLAGS="-O2 -I/opt/wasm/include -fwasm-exceptions -sSUPPORT_LONGJMP=wasm" \ diff --git a/build/assemble-scripting.mjs b/build/assemble-scripting.mjs new file mode 100644 index 0000000..6b74a2b --- /dev/null +++ b/build/assemble-scripting.mjs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { cp, mkdir } from 'node:fs/promises'; +import { build } from 'esbuild'; +await mkdir('dist', { recursive: true }); +for (const name of ['fontforge-script.mjs', 'fontforge-script.wasm']) await cp(`artifacts/scripting/${name}`, `dist/${name}`); +await build({ entryPoints: ['src/script-browser-worker.js'], outfile: 'dist/script-browser-worker.mjs', bundle: true, platform: 'browser', format: 'iife', define: { 'import.meta.url': 'self.location.href' }, external: ['node:*'] }); diff --git a/build/check-package.mjs b/build/check-package.mjs index e1cdfa2..430e79e 100644 --- a/build/check-package.mjs +++ b/build/check-package.mjs @@ -5,3 +5,7 @@ for (const file of ['dist/browser-worker.mjs', 'dist/fontforge-core.mjs', 'dist/ } const wasm = await readFile('dist/fontforge-core.wasm'); if (wasm.subarray(0, 4).toString('hex') !== '0061736d') throw new Error('Invalid WASM artifact.'); + +for (const name of ['fontforge-script.mjs', 'fontforge-script.wasm', 'script-browser-worker.mjs']) { + if (!(await stat(`dist/${name}`)).size) throw new Error('Run npm run build:scripting before packaging.'); +} diff --git a/build/patch-scripting.py b/build/patch-scripting.py new file mode 100644 index 0000000..c999b2e --- /dev/null +++ b/build/patch-scripting.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +from pathlib import Path +p = Path('/work/fontforge/fontforge/scripting.c') +s = p.read_text() +needle = 'if ( found!=NULL ) {' +assert s.count(needle) == 1, 'Upstream dispatch changed; review capability guard' +guard = """if ( found!=NULL ) { + if (!strcmp(name, "AutoTrace") || !strcmp(name, "Autotrace") || + !strcmp(name, "AskUser")) { + EM_ASM({ Module['onCapabilityError']?.(); }); + ScriptError(&sub, "Command requires unavailable external programs or interactive UI"); + goto docall_skipfunc; + } +""" +p.write_text('#include \n' + s.replace(needle, guard)) diff --git a/demo-service-worker.js b/demo-service-worker.js index 9b5b0d2..d0bf341 100644 --- a/demo-service-worker.js +++ b/demo-service-worker.js @@ -4,7 +4,9 @@ const VERSION = 'fontforge-wasm-demo-0.2.0-alpha.1-formats'; const ASSETS = [ 'examples/browser/', 'examples/browser/demo.js', 'examples/fonts/Roboto-Regular.ttf', 'examples/fonts/Roboto-Regular.otf', 'examples/fonts/LICENSE.txt', - 'src/formats.js', 'src/containers.js', 'src/index.js', 'src/validate.js', 'src/browser-worker.js', 'src/runtime.js', + 'src/formats.js', 'src/containers.js', 'src/index.js', + 'src/execute.js', + 'src/script-validate.js', 'src/validate.js', 'src/browser-worker.js', 'src/runtime.js', 'dist/browser-worker.mjs', 'dist/fontforge-core.wasm', ].map(path => new URL(path, self.registration.scope).href); self.addEventListener('install', event => { diff --git a/docs/native-scripting-proof.md b/docs/native-scripting-proof.md index a2a9e50..6fddbb9 100644 --- a/docs/native-scripting-proof.md +++ b/docs/native-scripting-proof.md @@ -1,5 +1,9 @@ # Native scripting proof +Historical feasibility baseline. The follow-up [public API](scripting-api.md) +adds typed execution and runtime filesystem budgets; the original harness below +remains an internal parity test. + This implements the first feasibility gate in [Epic #1](https://github.com/warting/fontforge-wasm/issues/1). It is an opt-in development build, not a released `execute()` API or a public script playground. The existing conversion distribution and OFC integration diff --git a/docs/scripting-api.md b/docs/scripting-api.md new file mode 100644 index 0000000..597ea14 --- /dev/null +++ b/docs/scripting-api.md @@ -0,0 +1,116 @@ +# Native scripting API (unreleased) + +`execute()` runs FontForge's native scripting language in a disposable worker in +Node >=22 and supported browsers. It is separate from `convert()`: conversion +consumers do not load the scripting WASM. This branch is not an npm release. +Python, the desktop GUI and external helper programs are not provided. + +```js +import { execute } from '@warting/fontforge-wasm'; + +const result = await execute(` + Open($1); + Print($fontname); + Generate($2); +`, { + args: ['/work/input.sfd', '/work/output.otf'], + files: { '/work/input.sfd': sourceBytes }, // Uint8Array + outputPaths: ['/work/output.otf'], + timeoutMs: 30_000, + onLog: ({ stream, message, elapsedMs }) => console.log(elapsedMs, stream, message), +}); +if (result.exitCode !== 0) throw new Error(result.stderr); +const converted = result.files['/work/output.otf']; +``` + +## Contract + +- Each call has fresh native globals and an in-memory filesystem. No host + directories are mounted. Inputs are copied and never detached or changed. +- `script` is UTF-8 text without NUL. `$0` is `/work/script.pe`; `$1` onward are + individual `args` entries. Arguments are never shell-parsed. The working + directory is `/work`; native relative paths resolve there. +- Supply up to 64 files and select up to 64 unique regular output files. Public + paths must be absolute `/work/...` paths without empty, dot or parent segments, + NUL or backslashes. `/work/script.pe` is reserved. Selected output paths cannot equal + supplied input paths; scripts may modify their own private input copies. Parent directories are created before the script starts. +- Script text, argument text and input bytes total at most 16 MiB. Paths are + limited to 1024 characters. Argument count is at most 64. +- Native script failures **resolve** with nonzero `exitCode` and captured stderr. + Requested files that exist are returned, including partial files after failure. + Missing outputs are omitted. Directories and symlinks are not returned; select + their individual regular files. Successful exit does not guarantee a requested + output exists or that a partial font is usable. +- `stdout` and `stderr` join captured lines with `\n`, without a trailing newline. + `onLog` receives each line with elapsed milliseconds measured inside the worker. + Exceptions in observers are ignored. `onProgress` reports asset loading and + worker startup. Diagnostics use a shared UTF-8 budget including line separators. +- `signal` aborts execution by terminating the worker. `timeoutMs` includes asset + loading and startup, defaults to 30 seconds, and must be 1–300000 ms. A finished + job's worker is always discarded. No partial files are returned on cancellation + or infrastructure/resource failures. +- Browser assets are cached in memory between calls. `cache: 'no-store'` bypasses + both this cache and the browser HTTP cache. First use still needs accessible + assets; offline reload and application service-worker caching are separate work. + Serve `src/` and `dist/` with the package layout preserved. Browser CSP must allow + WASM compilation and blob workers. The host application controls concurrency. + +## Limits and errors + +`SCRIPT_LIMITS` exposes defaults and ceilings; `limits` may lower them: + +| Option | Default / maximum | +| --- | --- | +| `maxFileSystemBytes` | 64 MiB | +| `maxEntries` | 1024 | +| `maxOutputBytes` | 32 MiB | +| `maxLogBytes` | 64 KiB | + +Filesystem limits apply **during execution**, including native temporary files +outside `/work`. The byte budget charges peak allocated regular-file storage per MEMFS inode; +truncating or deleting a file does not refund it during that job. Entry counts +include created directories and links, even after deletion. This intentionally +bounds repeated temporary-file creation. Fixed Emscripten startup entries are +excluded. Sparse writes, reallocations, truncation and mmap writeback are guarded +before file growth. These budgets depend on the pinned Emscripten MEMFS internals, +covered by tests; an Emscripten upgrade requires reviewing those hooks. + +A quota failure emits a fatal event to the supervisor, which terminates the +worker even if native code ignores the filesystem error. This is necessary because +libc can swallow callback exceptions. WASM linear memory is capped at 512 MiB; +that is not a total process-memory cap (JS objects, copies and reallocation can +use additional memory). This experimental API is not a hosted untrusted-script +sandbox. Use OS-level limits for hostile server workloads. + +Infrastructure failures reject with `FontForgeError.code`: `INVALID_REQUEST`, +`FILESYSTEM_LIMIT`, `OUTPUT_LIMIT`, `LOG_LIMIT`, `UNSUPPORTED_CAPABILITY`, +`INVALID_OUTPUT`, `ABORTED`, `TIMEOUT`, `EXECUTION_ERROR`, or `WORKER_ERROR`. +Invalid timeout/cache options throw RangeError/TypeError. + +`AskUser`, `AutoTrace` and its `Autotrace` alias explicitly reject as unsupported. +Native `system()` and `popen()` cannot delegate to the Node host; attempted calls +report unsupported capability. Other native features may still report ordinary +script errors when optional libraries, GUI functionality or external resources +are unavailable. This is not a promise that every FontForge command works. + +## Build and verify + +```sh +npm ci +npm run build +npm run build:scripting +node --test test/scripting/api.test.mjs test/scripting/quota.test.mjs +node test/scripting/api-browser.mjs +npm test +npm pack --dry-run +``` + +Browser tests default to installed Chrome; set `BROWSER_CHANNEL=chromium` for +Playwright Chromium. WebKit blocks HTTP/HTTPS instead of Playwright offline mode +because the tested macOS runner cannot start even a trivial blob worker in that +mode. Both engines verify zero execution-time HTTP requests after loading assets. + +The existing native parity proof remains in `test/scripting/`; see +[native scripting proof](native-scripting-proof.md). Next milestones are more +editing-operation parity cases and a public script playground. OFC's API/backend +and its use of `convert()` do not change in this milestone. diff --git a/native/script.c b/native/script.c index dc53b28..73710d6 100644 --- a/native/script.c +++ b/native/script.c @@ -4,6 +4,9 @@ #include "start.h" #include "scripting.h" #include +#include +#include +#include #include int main(int argc, char **argv) { @@ -19,3 +22,19 @@ int main(int argc, char **argv) { ProcessNativeScript(argc, argv, NULL); return 0; } + +// Never delegate script-triggered shell operations to the Node host. +static void unavailable(void) { + EM_ASM({ Module['onCapabilityError']?.(); }); + errno = ENOSYS; +} +int system(const char *command) { + if (!command) return 0; + unavailable(); + return -1; +} +FILE *popen(const char *command, const char *mode) { + (void)command; (void)mode; + unavailable(); + return NULL; +} diff --git a/package.json b/package.json index 499dfb5..1c4fc72 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "licenses", "LICENSE", "NOTICE.md", - "README.md" + "README.md", + "docs" ], "engines": { "node": ">=22" @@ -42,7 +43,8 @@ "build": "docker build -f build/Dockerfile --output type=local,dest=artifacts . && node build/assemble.mjs", "test": "node --test test/*.test.mjs", "test:browser": "node test/browser.mjs", - "prepack": "node build/check-package.mjs" + "prepack": "node build/check-package.mjs", + "build:scripting": "docker build -f build/Dockerfile --target scripting-export --output type=local,dest=artifacts/scripting . && node build/assemble-scripting.mjs" }, "publishConfig": { "access": "public" diff --git a/src/execute.js b/src/execute.js new file mode 100644 index 0000000..48d33c5 --- /dev/null +++ b/src/execute.js @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { FontForgeError } from './validate.js'; +import { validateScript } from './script-validate.js'; +let browserAssets; +function loadBrowserAssets(cache) { + const load = () => Promise.all([ + fetch(new URL('../dist/script-browser-worker.mjs', import.meta.url), { cache }), + fetch(new URL('../dist/fontforge-script.wasm', import.meta.url), { cache }), + ]).then(async ([script, wasm]) => { + if (!script.ok || !wasm.ok) throw new Error('Could not load FontForge engine assets.'); + return { code: await script.text(), wasmBinary: await wasm.arrayBuffer() }; + }).catch(error => { browserAssets = undefined; throw error; }); + if (cache === 'no-store') return load(); + return browserAssets ??= load(); +} + +/** Execute a native FontForge script in one disposable worker. */ +export async function execute(script, { args = [], files = {}, outputPaths = [], limits: suppliedLimits = {}, signal, timeoutMs = 30_000, onLog, onProgress, cache = 'default' } = {}) { + const request = validateScript({ script, args, files, outputPaths, limits: suppliedLimits }); + if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) throw new RangeError('timeoutMs must be between 1 and 300000.'); + if (cache !== 'default' && cache !== 'no-store') throw new TypeError('cache must be default or no-store.'); + if (signal && (typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function' || typeof signal.aborted !== 'boolean')) throw new FontForgeError('INVALID_REQUEST', 'signal must be an AbortSignal.'); + if (signal?.aborted) throw new FontForgeError('ABORTED', 'Execution was cancelled.'); + const isNode = typeof process !== 'undefined' && !!process.versions?.node; + return new Promise((resolve, reject) => { + let worker, workerUrl, timer; + let finished = false; + const finish = (error, result) => { + if (finished) return; + finished = true; + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + worker?.terminate(); + if (workerUrl) URL.revokeObjectURL(workerUrl); + if (error) reject(error); + else resolve(result); + }; + const abort = () => finish(new FontForgeError('ABORTED', 'Execution was cancelled.')); + const report = event => { + if (finished) return; + try { onProgress?.(event); } catch { /* Observers cannot interrupt script execution. */ } + }; + const receive = data => { + if (finished) return; + if (data.log?.fatal) { finish(new FontForgeError(data.log.code, data.log.fatal)); return; } + if (data.log) { try { onLog?.(data.log); } catch {} return; } + if (data.progress) { report(data.progress); return; } + if (data.error) finish(new FontForgeError(data.error.code, data.error.message)); + else finish(null, data.result); + }; + const failed = error => finish(new FontForgeError('WORKER_ERROR', error.message || 'The script execution worker failed.')); + timer = setTimeout(() => finish(new FontForgeError('TIMEOUT', 'Execution exceeded its time limit.')), timeoutMs); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) { abort(); return; } + (async () => { + let wasmBinary; + if (isNode) { + report({ stage: 'worker', message: 'Starting script execution worker' }); + const { Worker } = await import('node:worker_threads'); + if (finished) return; + worker = new Worker(new URL('./script-node-worker.js', import.meta.url), { execArgv: [] }); + worker.on('message', receive); + worker.on('error', failed); + worker.on('exit', code => { + if (!finished) failed(new Error(`Worker exited before returning a script result (${code}).`)); + }); + } else { + report({ stage: 'assets', message: cache === 'no-store' ? 'Fetching engine assets with browser cache bypassed' : browserAssets ? 'Reusing engine assets in memory' : 'Loading engine assets (network or browser cache)' }); + const assets = await loadBrowserAssets(cache); + if (finished) return; + // Keep engine assets in memory so fresh workers need no network, even + // where a browser does not cache module-worker dependency requests. + report({ stage: 'worker', message: 'Starting script execution worker' }); + if (finished) return; + workerUrl = URL.createObjectURL(new Blob([assets.code], { type: 'text/javascript' })); + worker = new Worker(workerUrl); + wasmBinary = assets.wasmBinary; + worker.onmessage = event => receive(event.data); + worker.onerror = failed; + worker.onmessageerror = () => failed(new Error('Invalid message from script execution worker.')); + } + worker.postMessage({ ...request, wasmBinary }); + })().catch(failed); + }); +} diff --git a/src/index.d.ts b/src/index.d.ts index 51ba7d2..2d54168 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -30,3 +30,42 @@ export declare const MAX_INPUT_BYTES: number; export declare function convert(input: Uint8Array, options: ConvertOptions): Promise; export declare const FORMATS: readonly Readonly<{ id: FontFormat; native: number; label: string; input: boolean; output: boolean; preview?: boolean; extension?: string; note?: string }>[]; + +export interface ScriptLimits { + /** Cumulative per-inode peak storage, including deleted files. Maximum 64 MiB. */ + maxFileSystemBytes?: number; + /** Cumulative created files/directories/links, including deleted entries. Maximum 1024. */ + maxEntries?: number; + /** Maximum copied result data, 32 MiB. */ + maxOutputBytes?: number; + /** Combined UTF-8 stdout/stderr budget including line separators, 64 KiB. */ + maxLogBytes?: number; +} +export interface ScriptLog { + stream: 'stdout' | 'stderr'; + message: string; + elapsedMs: number; +} +export interface ExecuteOptions { + args?: string[]; + /** Absolute /work/ paths; input buffers are copied, never detached. */ + files?: Record; + /** Explicit regular files to return. Missing outputs are omitted, including after errors. */ + outputPaths?: string[]; + limits?: ScriptLimits; + signal?: AbortSignal; + timeoutMs?: number; + cache?: 'default' | 'no-store'; + onLog?: (event: ScriptLog) => void; + onProgress?: (event: { stage: 'assets' | 'worker'; message: string }) => void; +} +export interface ExecuteResult { + /** Native script errors resolve with nonzero status; infrastructure/limit failures reject. */ + exitCode: number; + files: Record; + stdout: string; + stderr: string; +} +export declare const SCRIPT_LIMITS: Readonly>; +/** Executes the FontForge native language, not Python or a shell. One worker per call. */ +export declare function execute(script: string, options?: ExecuteOptions): Promise; diff --git a/src/index.js b/src/index.js index 909e887..f293f4e 100644 --- a/src/index.js +++ b/src/index.js @@ -85,3 +85,6 @@ export async function convert(input, { format, signal, timeoutMs = 30_000, onPro })().catch(failed); }); } + +export { execute } from './execute.js'; +export { SCRIPT_LIMITS } from './script-validate.js'; diff --git a/src/script-browser-worker.js b/src/script-browser-worker.js new file mode 100644 index 0000000..5f86433 --- /dev/null +++ b/src/script-browser-worker.js @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { run } from './script-runtime.js'; +self.onmessage = async ({ data }) => { + try { self.postMessage({ result: await run(data, log => self.postMessage({ log })) }); } + catch (error) { self.postMessage({ error: { code: error.code || 'EXECUTION_ERROR', message: error.message } }); } +}; diff --git a/src/script-node-worker.js b/src/script-node-worker.js new file mode 100644 index 0000000..0f3a157 --- /dev/null +++ b/src/script-node-worker.js @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { parentPort } from 'node:worker_threads'; +import { run } from './script-runtime.js'; +parentPort.once('message', async request => { + try { parentPort.postMessage({ result: await run(request, log => parentPort.postMessage({ log })) }); } + catch (error) { parentPort.postMessage({ error: { code: error.code || 'EXECUTION_ERROR', message: error.message } }); } +}); diff --git a/src/script-quota.js b/src/script-quota.js new file mode 100644 index 0000000..bd13336 --- /dev/null +++ b/src/script-quota.js @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Charge peak capacity per inode, including deleted files, for the job lifetime. +// This also bounds temporary files and repeated create/delete loops. +export function installQuota(FS, limits, report) { + const mem = FS.filesystems.MEMFS; + const peaks = new Map(); + let bytes = 0, entries = 0, failed = false; + const fail = () => { + if (!failed) report({ fatal: 'Script filesystem budget exceeded.', code: 'FILESYSTEM_LIMIT' }); + failed = true; + throw new FS.ErrnoError(51); // ENOSPC; parent also terminates native loops. + }; + const charge = (node, size) => { + const previous = peaks.get(node) || 0; + if (!Number.isSafeInteger(size) || size < 0 || failed || bytes + Math.max(0, size - previous) > limits.maxFileSystemBytes) fail(); + bytes += Math.max(0, size - previous); + peaks.set(node, Math.max(previous, size)); + }; + const create = mem.createNode; + mem.createNode = function(...args) { + if (failed || entries >= limits.maxEntries) fail(); + const node = create.apply(this, args); + entries++; + return node; + }; + const expand = mem.expandFileStorage; + mem.expandFileStorage = function(node, capacity) { + const previous = node.contents.length; + if (capacity > previous) { + capacity = Math.max(capacity, previous * (previous < 1048576 ? 2 : 1.125) >>> 0); + if (previous) capacity = Math.max(capacity, 256); + } + charge(node, Math.max(capacity, previous)); + return expand.call(this, node, capacity); + }; + const resize = mem.resizeFileStorage; + mem.resizeFileStorage = function(node, size) { charge(node, size); return resize.call(this, node, size); }; + const write = mem.stream_ops.write; + const guardedWrite = function(stream, buffer, offset, length, position, canOwn) { + charge(stream.node, Math.max(stream.node.contents.length, position + length)); + return write.call(this, stream, buffer, offset, length, position, canOwn); + }; + // msync calls mem.stream_ops.write directly; normal writes use ops_table. + mem.stream_ops.write = mem.ops_table.file.stream.write = guardedWrite; +} diff --git a/src/script-runtime.js b/src/script-runtime.js new file mode 100644 index 0000000..00a801b --- /dev/null +++ b/src/script-runtime.js @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import { installQuota } from './script-quota.js'; +import createModule from '../dist/fontforge-script.mjs'; +export async function run({ script, args = [], files = {}, outputPaths = [], mode = '-script', wasmBinary, limits }, report = () => {}) { + if (typeof script !== 'string' || script.includes('\0')) throw new Error('Invalid script'); + if (!Array.isArray(args) || args.length > 64 || args.some(arg => typeof arg !== 'string' || arg.includes('\0'))) throw new Error('Invalid arguments'); + const scriptBytes = new TextEncoder().encode(script).length; + const argumentBytes = args.reduce((sum, arg) => sum + new TextEncoder().encode(arg).length, 0); + if (scriptBytes + argumentBytes > 16 * 1024 * 1024) throw new Error('Input limit exceeded'); + let exitCode; + let logBytes = 0; + let logLimitExceeded = false; + const logs = { stdout: [], stderr: [] }; + const started = performance.now(); + const log = stream => message => { + if (logLimitExceeded) return; + logBytes += new TextEncoder().encode(message).length + 1; + if (logBytes > limits.maxLogBytes) { + // libc can swallow exceptions thrown by print callbacks as write errors. + // Notify the supervisor instead; only worker termination reliably stops C. + logLimitExceeded = true; + report({ fatal: 'Script log limit exceeded', code: 'LOG_LIMIT' }); + return; + } + logs[stream].push(message); + report({ stream, message, elapsedMs: performance.now() - started }); + }; + const module = await createModule({ noInitialRun: true, ...(wasmBinary ? { wasmBinary, locateFile: name => name } : {}), + print: log('stdout'), printErr: log('stderr'), + onCapabilityError: () => report({ fatal: 'This command requires external programs or interactive UI unavailable in WASM.', code: 'UNSUPPORTED_CAPABILITY' }), + onExit: code => { exitCode = code; }, + // Do not let Emscripten change a Node worker's process exit status. + quit: (code, error) => { throw error; }, + }); + const { FS } = module; + installQuota(FS, limits, report); + FS.mkdir('/work'); FS.chdir('/work'); + const validPath = path => typeof path === 'string' && /^\/work\/(?!\.\.?($|\/))[^\0\\]+$/.test(path) + && path.split('/').every(part => part !== '..' && part !== '.'); + let inputBytes = scriptBytes + argumentBytes; + if (Object.keys(files).length > 64 || outputPaths.length > 64) throw new Error('File count limit exceeded'); + for (const [path, bytes] of Object.entries(files)) { + if (!validPath(path) || path === '/work/script.pe' || !(bytes instanceof Uint8Array)) throw new Error('Invalid input file'); + inputBytes += bytes.byteLength; + if (inputBytes > 16 * 1024 * 1024) throw new Error('Input limit exceeded'); + FS.mkdirTree(path.slice(0, path.lastIndexOf('/'))); + FS.writeFile(path, bytes); + } + for (const path of outputPaths) { + if (!validPath(path) || path === '/work/script.pe' || Object.hasOwn(files, path)) throw new Error('Invalid output path'); + FS.mkdirTree(path.slice(0, path.lastIndexOf('/'))); + } + if (mode !== '-script' && mode !== '-c') throw new Error('Invalid script mode'); + FS.writeFile('/work/script.pe', script); + const status = module.callMain(['-lang=ff', mode, mode === '-c' ? script : '/work/script.pe', ...args]); + if (logLimitExceeded) throw new Error('Script log limit exceeded'); + if (exitCode === undefined) throw new Error(`Interpreter did not exit normally (${status})`); + const outputs = {}; + let total = 0; + for (const path of outputPaths) { + if (!FS.analyzePath(path).exists) continue; // A script error may leave partial output. + const stat = FS.lstat(path); + if (!FS.isFile(stat.mode)) throw Object.assign(new Error('Select regular output files, not directories or links.'), { code: 'INVALID_OUTPUT' }); + const size = stat.size; + total += size; + if (total > limits.maxOutputBytes) throw Object.assign(new Error('Output limit exceeded'), { code: 'OUTPUT_LIMIT' }); + outputs[path] = FS.readFile(path).slice(); + } + return { exitCode, files: outputs, stdout: logs.stdout.join('\n'), stderr: logs.stderr.join('\n') }; +} diff --git a/src/script-validate.js b/src/script-validate.js new file mode 100644 index 0000000..dd8fc38 --- /dev/null +++ b/src/script-validate.js @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { FontForgeError } from './validate.js'; +export const SCRIPT_LIMITS = Object.freeze({ maxFileSystemBytes: 64 * 1024 * 1024, maxEntries: 1024, maxOutputBytes: 32 * 1024 * 1024, maxLogBytes: 65536 }); +const invalid = message => { throw new FontForgeError('INVALID_REQUEST', message); }; +export function validateScript({ script, args, files, outputPaths, limits }) { + if (typeof script !== 'string' || script.includes('\0')) invalid('script must be a string without NUL.'); + if (!Array.isArray(args) || args.length > 64 || args.some(a => typeof a !== 'string' || a.includes('\0'))) invalid('args must contain up to 64 strings without NUL.'); + if (!files || typeof files !== 'object' || Array.isArray(files)) invalid('files must map paths to Uint8Array data.'); + if (!Array.isArray(outputPaths) || outputPaths.length > 64 || new Set(outputPaths).size !== outputPaths.length) invalid('Select up to 64 unique output paths.'); + if (!limits || typeof limits !== 'object' || Array.isArray(limits)) invalid('Invalid limits.'); + const budgets = { ...SCRIPT_LIMITS }; + for (const [key, value] of Object.entries(limits)) { + if (!Object.hasOwn(budgets, key) || !Number.isSafeInteger(value) || value < 1 || value > budgets[key]) invalid('Limits must be positive integers no higher than SCRIPT_LIMITS.'); + budgets[key] = value; + } + const pathOK = path => typeof path === 'string' && path.startsWith('/work/') && path.length <= 1024 && path !== '/work/script.pe' && path.split('/').slice(2).every(p => p && p !== '.' && p !== '..' && !/[\\\0]/.test(p)); + let size = new TextEncoder().encode(script).length + args.reduce((n, a) => n + new TextEncoder().encode(a).length, 0); + const copied = {}; + const entries = Object.entries(files); + if (entries.length > 64) invalid('Supply up to 64 files.'); + for (const [path, data] of entries) { + if (!pathOK(path) || !(data instanceof Uint8Array)) invalid('Invalid input file.'); + size += data.byteLength; + if (size > 16 * 1024 * 1024) invalid('Inputs exceed 16 MiB.'); + copied[path] = Uint8Array.from(data); + } + if (size > 16 * 1024 * 1024) invalid('Inputs exceed 16 MiB.'); + for (const path of outputPaths) if (!pathOK(path) || Object.hasOwn(files, path)) invalid('Invalid output path.'); + return { script, args: [...args], files: copied, outputPaths: [...outputPaths], limits: budgets }; +} diff --git a/test/scripting/api-browser.mjs b/test/scripting/api-browser.mjs new file mode 100644 index 0000000..e31b71b --- /dev/null +++ b/test/scripting/api-browser.mjs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { chromium, webkit } from 'playwright'; +import { createServer } from 'node:http'; +import { readFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import assert from 'node:assert/strict'; +const server = createServer(async (req, res) => { + if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); res.end('Public scripting API test'); return; } + if (!/^\/(src|dist)\/[a-z-]+\.(js|mjs|wasm)$/.test(req.url) && req.url !== '/test/fixtures/fixture.ttf') { res.writeHead(404); res.end(); return; } + try { res.setHeader('Content-Type', req.url.endsWith('.wasm') ? 'application/wasm' : 'text/javascript'); res.end(await readFile('.'+req.url)); } + catch { res.writeHead(404); res.end(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +try { + for (const [name, engine] of [['Chrome', chromium], ['WebKit', webkit]]) { + const profile = await mkdtemp(join(tmpdir(), 'ff-api-')); + const context = await engine.launchPersistentContext(profile, name === 'Chrome' ? { channel: process.env.BROWSER_CHANNEL || 'chrome' } : {}); + try { + const page = await context.newPage(); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + await page.evaluate(async () => { + self.sdk = await import('/src/index.js'); + self.font = new Uint8Array(await (await fetch('/test/fixtures/fixture.ttf')).arrayBuffer()); + await self.sdk.execute('Print("loaded");'); + }); + const requests = []; + await context.route(/^https?:\/\//, route => { requests.push(route.request().url()); return route.abort(); }); + if (name === 'Chrome') await context.setOffline(true); + const actual = await page.evaluate(async () => { + const { execute } = self.sdk; + const result = await execute('Open($1); Generate($2);', { args: ['/work/in.ttf', '/work/out.otf'], files: { '/work/in.ttf': self.font }, outputPaths: ['/work/out.otf'] }); + const code = async promise => { try { await promise; return 'unexpected success'; } catch(e) { return e.code; } }; + const quota = await code(execute('while (1)\nWriteStringToFile("1234567890", "/work/out", 1);\nendloop', { limits: { maxFileSystemBytes: 8192 } })); + const capability = await code(execute('AskUser("hello");')); + const logs = await code(execute('while (1)\nPrint("");\nendloop', { limits: { maxLogBytes: 16 } })); + const controller = new AbortController(); + const abort = await code(execute('Print("ready"); while (1)\nendloop', { signal: controller.signal, onLog: () => controller.abort() })); + const timeout = await code(execute('while (1)\nendloop', { timeoutMs: 500 })); + const parallel = await Promise.all(['one', 'two'].map(x => execute('Print($1);', { args: [x] }))); + return { exit: result.exitCode, signature: new TextDecoder().decode(result.files['/work/out.otf'].slice(0,4)), quota, capability, logs, abort, timeout, parallel: parallel.map(r => r.stdout) }; + }); + assert.deepEqual(actual, { exit: 0, signature: 'OTTO', quota: 'FILESYSTEM_LIMIT', capability: 'UNSUPPORTED_CAPABILITY', logs: 'LOG_LIMIT', abort: 'ABORTED', timeout: 'TIMEOUT', parallel: ['one', 'two'] }); + assert.deepEqual(requests, []); + console.log(`${name}: public execute API, quotas, capabilities, cancellation and concurrency passed without HTTP`); + } finally { await context.close(); await rm(profile, { recursive: true, force: true }); } + } +} finally { server.close(); } diff --git a/test/scripting/api.test.mjs b/test/scripting/api.test.mjs new file mode 100644 index 0000000..0811dd7 --- /dev/null +++ b/test/scripting/api.test.mjs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { execute, SCRIPT_LIMITS, FontForgeError } from '../../src/index.js'; +const font = new Uint8Array(await readFile(new URL('../fixtures/fixture.ttf', import.meta.url))); +const hasCode = code => error => error instanceof FontForgeError && error.code === code; +test('public API exports fonts, preserves caller bytes and returns partial results on native errors', async () => { + const original = font.slice(); + const result = await execute('Open($1); Print($fontname); Generate($2); Quit(7);', { args: ['/work/input.ttf', '/work/out.otf'], files: { '/work/input.ttf': font }, outputPaths: ['/work/out.otf', '/work/missing'] }); + assert.equal(result.exitCode, 7); + assert.equal(new TextDecoder().decode(result.files['/work/out.otf'].slice(0, 4)), 'OTTO'); + assert.ok(result.stdout); assert.deepEqual(font, original); + assert.equal(result.files['/work/missing'], undefined); + const failed = await execute('UnknownCommand();'); + assert.notEqual(failed.exitCode, 0); assert.ok(failed.stderr); +}); +test('argv, logs, isolated concurrent jobs and observer exceptions', async () => { + const logs = []; + const result = await execute('Print($0); Print($1);', { args: ['å字 $(shell);'], onLog: event => { logs.push(event); throw Error('observer'); } }); + assert.equal(result.stdout, '/work/script.pe\nå字 $(shell);'); + assert.ok(logs.every(e => e.stream === 'stdout' && e.elapsedMs >= 0)); + assert.deepEqual((await Promise.all(['one','two'].map(x => execute('Print($1);', { args: [x] })))).map(r => r.stdout), ['one','two']); + assert.notEqual((await execute('Open("/work/input.ttf");')).exitCode, 0); +}); +test('filesystem budget stops repeated writes even when native code ignores ENOSPC', async () => { + await assert.rejects(execute('while (1)\nWriteStringToFile("0123456789", "/work/out", 1);\nendloop', { limits: { maxFileSystemBytes: 8192 } }), hasCode('FILESYSTEM_LIMIT')); + await assert.rejects(execute('i=0; while (1)\nWriteStringToFile("", "/tmp/file"+ToString(i)); i=i+1;\nendloop', { limits: { maxEntries: 16 } }), hasCode('FILESYSTEM_LIMIT')); +}); +test('log and copied-output budgets have structured errors', async () => { + await assert.rejects(execute('while (1)\nPrint("");\nendloop', { limits: { maxLogBytes: 16 } }), hasCode('LOG_LIMIT')); + await assert.rejects(execute('WriteStringToFile("1234", "/work/out");', { outputPaths: ['/work/out'], limits: { maxOutputBytes: 3 } }), hasCode('OUTPUT_LIMIT')); +}); +test('unavailable native commands produce capability errors', async () => { + for (const script of ['AskUser("question");', 'New(); AutoTrace();']) await assert.rejects(execute(script), hasCode('UNSUPPORTED_CAPABILITY')); +}); +test('cancellation, timeout and recovery', async () => { + const controller = new AbortController(); + await assert.rejects(execute('Print("ready"); while (1)\nendloop', { signal: controller.signal, onLog: () => controller.abort() }), hasCode('ABORTED')); + await assert.rejects(execute('while (1)\nendloop', { timeoutMs: 500 }), hasCode('TIMEOUT')); + assert.equal((await execute('Print("alive");')).stdout, 'alive'); +}); +test('request validation rejects invalid limits, paths and nonbytes', async () => { + for (const options of [{ limits: { maxEntries: SCRIPT_LIMITS.maxEntries + 1 } }, { files: { '/work/../x': font } }, { files: { '/work/x': 'bad' } }, { args: ['a\0b'] }, { outputPaths: ['/work/script.pe'] }, { outputPaths: ['/work//x'] }]) await assert.rejects(execute('', options), hasCode('INVALID_REQUEST')); +}); + +test('script binary has no Emscripten host-shell import', async () => { + const module = await WebAssembly.compile(await readFile(new URL('../../dist/fontforge-script.wasm', import.meta.url))); + assert.ok(!WebAssembly.Module.imports(module).some(entry => /system|spawn|execve/.test(entry.name))); +}); diff --git a/test/scripting/quota.test.mjs b/test/scripting/quota.test.mjs new file mode 100644 index 0000000..2d7902a --- /dev/null +++ b/test/scripting/quota.test.mjs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import createModule from '../../dist/fontforge-script.mjs'; +import { installQuota } from '../../src/script-quota.js'; +import { SCRIPT_LIMITS } from '../../src/script-validate.js'; +async function fixture(limits) { + const { FS } = await createModule({ noInitialRun: true }); + const events = []; + installQuota(FS, { ...SCRIPT_LIMITS, ...limits }, e => events.push(e)); + return { FS, events }; +} +test('real MEMFS sparse writes and truncation are checked before allocating storage', async () => { + for (const operation of ['write', 'truncate', 'msync']) { + const { FS, events } = await fixture({ maxFileSystemBytes: 1024 }); + const stream = FS.open('/tmp/test', 'w+'); + const execute = operation === 'truncate' ? () => FS.truncate('/tmp/test', 1025) : operation === 'msync' ? () => stream.stream_ops.msync(stream, new Uint8Array(1), 1024, 1, 0) : () => FS.write(stream, new Uint8Array(1), 0, 1, 1024); + assert.throws(execute); + assert.equal(stream.node.contents.length, 0); + assert.equal(events[0].code, 'FILESYSTEM_LIMIT'); + } +}); +test('delete/recreate and truncation cannot reset cumulative budgets', async () => { + const { FS, events } = await fixture({ maxFileSystemBytes: 8 }); + FS.writeFile('/tmp/first', new Uint8Array(8)); + FS.truncate('/tmp/first', 0); + FS.unlink('/tmp/first'); + assert.throws(() => FS.writeFile('/tmp/second', new Uint8Array(1))); + assert.equal(events[0].code, 'FILESYSTEM_LIMIT'); + const entry = await fixture({ maxEntries: 1 }); + entry.FS.mkdir('/tmp/one'); entry.FS.rmdir('/tmp/one'); + assert.throws(() => entry.FS.mkdir('/tmp/two')); + assert.equal(entry.events[0].code, 'FILESYSTEM_LIMIT'); +}); From a3e6f6a33abc934a95dd24de377cc1b7f856e166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20W=C3=A4rting?= Date: Tue, 15 Sep 2026 07:05:53 +0200 Subject: [PATCH 3/4] Report the required CI status from the build and test result --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc9ea27..4615544 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: npm ci npx playwright install --with-deps chromium webkit npm run test:browser + node test/scripting/api-browser.mjs node test/pages-cache.mjs - run: node build/release.mjs - uses: actions/upload-artifact@v4 @@ -54,6 +55,16 @@ jobs: uses: actions/upload-pages-artifact@v3 with: path: _site + ci: + name: CI + if: always() + needs: wasm + runs-on: ubuntu-24.04 + steps: + - name: Require successful build and tests + env: + BUILD_RESULT: ${{ needs.wasm.result }} + run: test "$BUILD_RESULT" = success pages: if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' needs: wasm From 66b8decb192e2fdb3dbff80e076f0ccd5dfac754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20W=C3=A4rting?= Date: Tue, 15 Sep 2026 07:06:17 +0200 Subject: [PATCH 4/4] Use the installed Playwright Chromium in scripting CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4615544..7754eb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: npm ci npx playwright install --with-deps chromium webkit npm run test:browser - node test/scripting/api-browser.mjs + BROWSER_CHANNEL=chromium node test/scripting/api-browser.mjs node test/pages-cache.mjs - run: node build/release.mjs - uses: actions/upload-artifact@v4