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"] +}