From dd835c86081b06b6089e1d823f6d3eada2e7410d Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Wed, 26 Aug 2026 00:54:31 -0300 Subject: [PATCH] Translate errnos at the Emscripten boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZenFS and kerium throw Linux errno numbers; Emscripten's FS checks against its own WASI-derived table. Nothing translated between them, and the two tables do not agree on a single one of the 130 errnos kerium defines. On the two that matter most they disagree by swapping: ENOENT is 2 on Linux and 44 in Emscripten, while 2 in Emscripten means EACCES. That breaks file creation. FS.lookupPath forgives a missing final component under O_CREAT only when it sees its own ENOENT, 44. It saw 2, so the create was not forgiven, and the 2 it handed back reads as "Permission denied" for a directory that is perfectly writable. Translation happens where the error is raised, once. A second pass is not idempotent — 13 maps to 2 and 2 maps on to 44 — so translating anywhere else turns one error into another. Raising covers the two sites that passed kerium's Errno straight to em_fs.ErrnoError, and mount(), which stats the mount root outside both op tables. The table covers all of kerium's Errno rather than the few a filesystem is likeliest to raise. Since no value survives translation unchanged, a missing row would not be an untranslated errno but a different named one — ENODATA, what getxattr returns for a missing attribute, would arrive as EOVERFLOW. `satisfies Record` is what keeps the table complete: a kerium release that adds an errno fails the build rather than mistranslating it. Four of the five plugin tests fail against this commit's parent. The repo had no test setup though its eslint config already expected one, so this adds a `test` script and a CI step for them — zenfs-test --common on plain node, the way core runs it, installing nothing new. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yaml | 3 + .gitignore | 8 ++- package.json | 3 +- src/errno.ts | 145 ++++++++++++++++++++++++++++++++++++++ src/plugin.ts | 37 +++++----- tests/errno.test.ts | 29 ++++++++ tests/plugin.test.ts | 104 +++++++++++++++++++++++++++ tests/tsconfig.json | 13 ++++ 8 files changed, 322 insertions(+), 20 deletions(-) create mode 100644 src/errno.ts create mode 100644 tests/errno.test.ts create mode 100644 tests/plugin.test.ts create mode 100644 tests/tsconfig.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 710157a..e73bf21 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,3 +38,6 @@ jobs: - name: Build run: npm run build + + - name: Unit tests + run: npx zenfs-test -vn --common diff --git a/.gitignore b/.gitignore index d53184c..f5155f9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,10 @@ dist docs *.log tmp -build \ No newline at end of file +build +.* +!.github +!.vscode +!.gitignore +!.prettierignore +!.gitattributes diff --git a/package.json b/package.json index 4333c95..a329209 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "scripts": { "format": "prettier --write .", "format:check": "prettier --check .", - "lint": "eslint src", + "lint": "eslint src tests", + "test": "npx zenfs-test -bcn", "build": "tsc -p tsconfig.json", "build:docs": "typedoc --out docs --name 'ZenFS Emscripten' src/index.ts", "prepublishOnly": "npm run build" diff --git a/src/errno.ts b/src/errno.ts new file mode 100644 index 0000000..19c10aa --- /dev/null +++ b/src/errno.ts @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +import { Errno } from 'kerium'; + +/** + * ZenFS and Emscripten do not number their errnos the same way, and this is the translation between them. + */ +const linuxToEmscripten: Record = { + [Errno.EPERM]: 63, + [Errno.ENOENT]: 44, + [Errno.ESRCH]: 71, + [Errno.EINTR]: 27, + [Errno.EIO]: 29, + [Errno.ENXIO]: 60, + [Errno.E2BIG]: 1, + [Errno.ENOEXEC]: 45, + [Errno.EBADF]: 8, + [Errno.ECHILD]: 12, + [Errno.EAGAIN]: 6, + [Errno.ENOMEM]: 48, + [Errno.EACCES]: 2, + [Errno.EFAULT]: 21, + [Errno.ENOTBLK]: 105, + [Errno.EBUSY]: 10, + [Errno.EEXIST]: 20, + [Errno.EXDEV]: 75, + [Errno.ENODEV]: 43, + [Errno.ENOTDIR]: 54, + [Errno.EISDIR]: 31, + [Errno.EINVAL]: 28, + [Errno.ENFILE]: 41, + [Errno.EMFILE]: 33, + [Errno.ETXTBSY]: 74, + [Errno.EFBIG]: 22, + [Errno.ENOSPC]: 51, + [Errno.ESPIPE]: 70, + [Errno.EROFS]: 69, + [Errno.EMLINK]: 34, + [Errno.EPIPE]: 64, + [Errno.EDOM]: 18, + [Errno.ERANGE]: 68, + [Errno.EDEADLK]: 16, + [Errno.ENAMETOOLONG]: 37, + [Errno.ENOLCK]: 46, + [Errno.ENOSYS]: 52, + [Errno.ENOTEMPTY]: 55, + [Errno.ELOOP]: 32, + [Errno.ENOMSG]: 49, + [Errno.EIDRM]: 24, + [Errno.ECHRNG]: 106, + [Errno.EL2NSYNC]: 156, + [Errno.EL3HLT]: 107, + [Errno.EL3RST]: 108, + [Errno.ENRNG]: 109, // ELNRNG on Linux + [Errno.EUNATCH]: 110, + [Errno.ECSI]: 111, // ENOCSI on Linux + [Errno.EL2HLT]: 112, + [Errno.EBADE]: 113, + [Errno.EBADR]: 114, + [Errno.EXFULL]: 115, + [Errno.ENOANO]: 104, + [Errno.EBADRQC]: 103, + [Errno.EBADSLT]: 102, + [Errno.EBFONT]: 101, + [Errno.ENOSTR]: 100, + [Errno.ENODATA]: 116, + [Errno.ETIME]: 117, + [Errno.ENOSR]: 118, + [Errno.ENONET]: 119, + [Errno.ENOPKG]: 120, + [Errno.EREMOTE]: 121, + [Errno.ENOLINK]: 47, + [Errno.EADV]: 122, + [Errno.ESRMNT]: 123, + [Errno.ECOMM]: 124, + [Errno.EPROTO]: 65, + [Errno.EMULTIHOP]: 36, + [Errno.EDOTDOT]: 125, + [Errno.EBADMSG]: 9, + [Errno.EOVERFLOW]: 61, + [Errno.ENOTUNIQ]: 126, + [Errno.EBADFD]: 127, + [Errno.EREMCHG]: 128, + [Errno.ELIBACC]: 129, + [Errno.ELIBBAD]: 130, + [Errno.ELIBSCN]: 131, + [Errno.ELIBMAX]: 132, + [Errno.ELIBEXEC]: 133, + [Errno.EILSEQ]: 25, + [Errno.ERESTART]: 134, + [Errno.ESTRPIPE]: 135, + [Errno.EUSERS]: 136, + [Errno.ENOTSOCK]: 57, + [Errno.EDESTADDRREQ]: 17, + [Errno.EMSGSIZE]: 35, + [Errno.EPROTOTYPE]: 67, + [Errno.ENOPROTOOPT]: 50, + [Errno.EPROTONOSUPPORT]: 66, + [Errno.ESOCKTNOSUPPORT]: 137, + [Errno.ENOTSUP]: 138, + [Errno.EPFNOSUPPORT]: 139, + [Errno.EAFNOSUPPORT]: 5, + [Errno.EADDRINUSE]: 3, + [Errno.EADDRNOTAVAIL]: 4, + [Errno.ENETDOWN]: 38, + [Errno.ENETUNREACH]: 40, + [Errno.ENETRESET]: 39, + [Errno.ECONNABORTED]: 13, + [Errno.ECONNRESET]: 15, + [Errno.ENOBUFS]: 42, + [Errno.EISCONN]: 30, + [Errno.ENOTCONN]: 53, + [Errno.ESHUTDOWN]: 140, + [Errno.ETOOMANYREFS]: 141, + [Errno.ETIMEDOUT]: 73, + [Errno.ECONNREFUSED]: 14, + [Errno.EHOSTDOWN]: 142, + [Errno.EHOSTUNREACH]: 23, + [Errno.EALREADY]: 7, + [Errno.EINPROGRESS]: 26, + [Errno.ESTALE]: 72, + [Errno.EEUCLEAN]: 143, // EUCLEAN on Linux + [Errno.ENOTNAM]: 144, + [Errno.ENAVAIL]: 145, + [Errno.EISNAM]: 146, + [Errno.EREMOTEIO]: 147, + [Errno.EDQUOT]: 19, + [Errno.ENOMEDIUM]: 148, + [Errno.EMEDIUMTYPE]: 149, + [Errno.ECANCELED]: 11, + [Errno.ENOKEY]: 150, + [Errno.EKEYEXPIRED]: 151, + [Errno.EKEYREVOKED]: 152, + [Errno.EKEYREJECTED]: 153, + [Errno.EOWNERDEAD]: 62, + [Errno.ENOTRECOVERABLE]: 56, + [Errno.ERFKILL]: 154, + [Errno.EHWPOISON]: 155, +} satisfies Record; + +/** + * A Linux errno as Emscripten numbers it. Values outside kerium's `Errno` are passed through. + */ +export function toEmscriptenErrno(errno: number): number { + return linuxToEmscripten[errno] ?? errno; +} diff --git a/src/plugin.ts b/src/plugin.ts index 896ef1b..d0dfe6a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,6 +3,7 @@ import type { Stats } from '@zenfs/core'; import { fs as zfs } from '@zenfs/core'; import { parse as parseFlag } from '@zenfs/core/vfs/flags.js'; import { Errno } from 'kerium'; +import { toEmscriptenErrno } from './errno.js'; import type { EmFS } from './emscripten.js'; import { join, normalize } from '@zenfs/core/path'; import { info } from 'kerium/log'; @@ -56,7 +57,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { public createNode(parent: Node | null, name: string, mode: number, rdev?: number): Node { if (!this.em_fs.isDir(mode) && !this.em_fs.isFile(mode) && !this.em_fs.isLink(mode)) { - throw new this.em_fs.ErrnoError(Errno.EINVAL); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(Errno.EINVAL)); } const node: Node = new this.em_fs.FSNode(parent!, name, mode, rdev!); node.node_ops = this.node_ops; @@ -73,7 +74,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } return stat.mode; } @@ -99,7 +100,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } return stat; }, @@ -123,7 +124,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { // Ignore not supported errors. Emscripten does utimesSync when it // writes files, but never really requires the value to be set. if (e.code !== 'ENOTSUP') { - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } } if (attr.size !== undefined) { @@ -133,7 +134,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } } }, @@ -158,7 +159,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } return node; }, @@ -176,7 +177,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } }, @@ -188,7 +189,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } }, @@ -200,7 +201,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } }, @@ -216,7 +217,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } }, @@ -228,7 +229,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } }, @@ -240,7 +241,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } }, }; @@ -255,7 +256,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } }, close: (stream: EmFS.FSStream): void => { @@ -267,7 +268,7 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { if (!e.code) { throw e; } - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } }, read: (stream: EmFS.FSStream, buffer: Uint8Array, offset: number, length: number, position: number): number => { @@ -275,14 +276,14 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { // debugger; return this.fs.readSync(stream.nfd!, buffer, offset, length, position); } catch (e: any) { - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } }, write: (stream: EmFS.FSStream, buffer: Uint8Array, offset: number, length: number, position: number): number => { try { return this.fs.writeSync(stream.nfd!, buffer, offset, length, position); } catch (e: any) { - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } }, llseek: (stream: EmFS.FSStream, offset: number, whence: number): number => { @@ -295,12 +296,12 @@ export default class EmscriptenPlugin implements EmscriptenNodeFS { try { position += this.fs.fstatSync(stream.nfd!).size; } catch (e: any) { - throw new this.em_fs.ErrnoError(e.errno); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(e.errno)); } } if (position < 0) { - throw new this.em_fs.ErrnoError(Errno.EINVAL); + throw new this.em_fs.ErrnoError(toEmscriptenErrno(Errno.EINVAL)); } stream.position = position; diff --git a/tests/errno.test.ts b/tests/errno.test.ts new file mode 100644 index 0000000..40843f1 --- /dev/null +++ b/tests/errno.test.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +import { Errno } from 'kerium'; +import assert from 'node:assert/strict'; +import { suite, test } from 'node:test'; +import { toEmscriptenErrno } from '@zenfs/emscripten/errno.js'; + +/** Every distinct value kerium's `Errno` can carry. */ +const errnos: number[] = Object.values(Errno).filter(value => typeof value === 'number'); + +suite('errno translation', () => { + test('ENOENT becomes the 44 that FS.open looks for', () => { + assert.equal(toEmscriptenErrno(Errno.ENOENT), 44); + assert.equal(toEmscriptenErrno(Errno.EACCES), 2); + }); + + test('no errno translates to itself', () => { + const unchanged = errnos.filter(errno => toEmscriptenErrno(errno) === errno); + assert.deepEqual(unchanged, []); + }); + + test('translating twice is not translating once', () => { + const once = toEmscriptenErrno(Errno.EACCES); + assert.notEqual(toEmscriptenErrno(once), once); + }); + + test('a number that is not an errno is left alone', () => { + assert.equal(toEmscriptenErrno(9999), 9999); + }); +}); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts new file mode 100644 index 0000000..371d509 --- /dev/null +++ b/tests/plugin.test.ts @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +import { InMemory, fs, mount } from '@zenfs/core'; +import assert from 'node:assert/strict'; +import { suite, test } from 'node:test'; +import type { EmFS } from '@zenfs/emscripten/emscripten.js'; +import EmscriptenPlugin from '@zenfs/emscripten/plugin.js'; + +// Simulate some of the stuff from emscripten so we don't have to do some weird extraction + +class ErrnoError extends Error { + public errno: number; + + public constructor(errno: number) { + super('errno ' + errno); + this.errno = errno; + } +} + +let nextId = 0; + +class FSNode { + public id = ++nextId; + public mount?: { opts: { root: string } }; + public parent: FSNode; + public name: string; + public mode: number; + public rdev: number; + + public constructor(parent: FSNode, name: string, mode: number, rdev: number) { + this.parent = parent ?? this; + this.name = name; + this.mode = mode; + this.rdev = rdev; + } +} + +const em_fs = { + ErrnoError, + FSNode, + isDir: (mode: number) => (mode & 0o170000) === 0o040000, + isFile: (mode: number) => (mode & 0o170000) === 0o100000, + isLink: (mode: number) => (mode & 0o170000) === 0o120000, +} as unknown as typeof EmFS; + +mount('/zen', InMemory.create({ label: 'plugin-test' })); +fs.mkdirSync('/zen/root'); +fs.writeFileSync('/zen/root/present.txt', 'hello'); + +const plugin = new EmscriptenPlugin(fs, em_fs); + +/** The mounted root, wired the way `FS.mount()` wires it. */ +function rooted(): EmFS.FSNode { + const node = plugin.mount({ opts: { root: '/zen/root' } } as EmFS.Mount & { opts: { root: string } }); + (node as unknown as FSNode).mount = { opts: { root: '/zen/root' } }; + return node; +} + +/** The errno an operation raised, or `undefined` if it did not raise one. */ +function errnoOf(fn: () => unknown): number | undefined { + try { + fn(); + } catch (e) { + assert.ok(e instanceof ErrnoError, 'expected an Emscripten ErrnoError, got ' + String(e)); + return e.errno; + } + return undefined; +} + +suite('errnos crossing into Emscripten', () => { + test('a missing path is 44, which is what Emscripten calls ENOENT', () => { + assert.equal( + errnoOf(() => plugin.getMode('/zen/root/absent.txt')), + 44 + ); + }); + + test('a mount root that is not there says so', () => { + assert.equal( + errnoOf(() => plugin.mount({ opts: { root: '/zen/absent' } } as EmFS.Mount & { opts: { root: string } })), + 44 + ); + }); + + test('looking up a name that is not there is 44 as well', () => { + const root = rooted(); + assert.equal( + errnoOf(() => plugin.node_ops.lookup(root, 'absent.txt')), + 44 + ); + }); + + test('an unusable mode is 28, which is what Emscripten calls EINVAL', () => { + assert.equal( + errnoOf(() => plugin.createNode(null, 'odd', 0)), + 28 + ); + }); + + test('and a file that is there still resolves', () => { + const root = rooted(); + const node = plugin.node_ops.lookup(root, 'present.txt'); + assert.equal(plugin.node_ops.getattr(node).size, 5); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..c05a404 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "type": "module", + "compilerOptions": { + "target": "ES2022", + "noEmit": true, + "rootDir": "..", + "lib": ["ESNext", "DOM"], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["**/*.ts", "*.ts"] +}