From 0bc1f53b484b496d6014586b410806a60e27a513 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 23:51:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(ext/sw):=20mem-fs=20=E2=80=94=20dep-free?= =?UTF-8?q?=20in-memory=20fs=20for=20isomorphic-git=20org=20pushes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service-worker/__tests__/mem-fs.test.ts | 98 ++++++++++ extension/src/service-worker/mem-fs.ts | 175 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 extension/src/service-worker/__tests__/mem-fs.test.ts create mode 100644 extension/src/service-worker/mem-fs.ts diff --git a/extension/src/service-worker/__tests__/mem-fs.test.ts b/extension/src/service-worker/__tests__/mem-fs.test.ts new file mode 100644 index 0000000..7eff33d --- /dev/null +++ b/extension/src/service-worker/__tests__/mem-fs.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { createMemFs } from '../mem-fs'; + +describe('createMemFs', () => { + it('writeFile/readFile binary roundtrip (Uint8Array)', async () => { + const fs = createMemFs().promises; + const data = new Uint8Array([1, 2, 3, 4]); + await fs.writeFile('/a.bin', data); + const result = await fs.readFile('/a.bin', undefined); + expect(result).toBeInstanceOf(Uint8Array); + expect(Array.from(result as Uint8Array)).toEqual([1, 2, 3, 4]); + }); + + it('writeFile/readFile utf8 roundtrip via {encoding:"utf8"}', async () => { + const fs = createMemFs().promises; + await fs.writeFile('/hello.txt', 'hello Relicario'); + const result = await fs.readFile('/hello.txt', { encoding: 'utf8' }); + expect(result).toBe('hello Relicario'); + }); + + it('writeFile/readFile utf8 roundtrip via string encoding arg', async () => { + const fs = createMemFs().promises; + await fs.writeFile('/hello2.txt', 'hola Relicario'); + const result = await fs.readFile('/hello2.txt', 'utf8'); + expect(result).toBe('hola Relicario'); + }); + + it('writeFile copies a subarray view correctly', async () => { + const fs = createMemFs().promises; + const bigBuf = new Uint8Array([0, 1, 10, 20, 30, 99, 99]); + const view = bigBuf.subarray(2, 5); // [10, 20, 30] + await fs.writeFile('/sub.bin', view); + // Mutate the original buffer — stored copy must be unaffected + bigBuf[2] = 0xff; + bigBuf[3] = 0xff; + bigBuf[4] = 0xff; + const result = await fs.readFile('/sub.bin', undefined); + expect(Array.from(result as Uint8Array)).toEqual([10, 20, 30]); + }); + + it('readdir returns only immediate children for explicit mkdir dirs', async () => { + const fs = createMemFs().promises; + await fs.mkdir('/mydir'); + await fs.writeFile('/mydir/file1.txt', 'a'); + await fs.writeFile('/mydir/file2.txt', 'b'); + await fs.mkdir('/mydir/subdir'); + const entries = await fs.readdir('/mydir'); + expect(entries.sort()).toEqual(['file1.txt', 'file2.txt', 'subdir'].sort()); + }); + + it('readdir returns only immediate children for implicit dirs', async () => { + const fs = createMemFs().promises; + // Write a deeply nested file without explicit mkdir + await fs.writeFile('/a/b/c.txt', 'test'); + const aEntries = await fs.readdir('/a'); + expect(aEntries).toEqual(['b']); + const bEntries = await fs.readdir('/a/b'); + expect(bEntries).toEqual(['c.txt']); + }); + + it('stat: a written file isFile()', async () => { + const fs = createMemFs().promises; + await fs.writeFile('/statfile.txt', 'data'); + const st = await fs.stat('/statfile.txt'); + expect(st.isFile()).toBe(true); + expect(st.isDirectory()).toBe(false); + expect(st.isSymbolicLink()).toBe(false); + expect(typeof st.size).toBe('number'); + expect(typeof st.ino).toBe('number'); + expect(typeof st.mode).toBe('number'); + }); + + it('stat: an implicit parent directory isDirectory()', async () => { + const fs = createMemFs().promises; + await fs.writeFile('/c/items/x.enc', new Uint8Array([9])); + const st = await fs.stat('/c/items'); + expect(st.isDirectory()).toBe(true); + expect(st.isFile()).toBe(false); + }); + + it('stat: missing path throws ENOENT', async () => { + const fs = createMemFs().promises; + await expect(fs.stat('/does/not/exist')).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('unlink removes a file (subsequent readFile throws ENOENT)', async () => { + const fs = createMemFs().promises; + await fs.writeFile('/todelete.txt', 'bye'); + await fs.unlink('/todelete.txt'); + await expect(fs.readFile('/todelete.txt', undefined)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('readlink and symlink are functions (regression guard for isomorphic-git bindFs)', () => { + const { promises } = createMemFs(); + expect(typeof promises.readlink).toBe('function'); + expect(typeof promises.symlink).toBe('function'); + }); +}); diff --git a/extension/src/service-worker/mem-fs.ts b/extension/src/service-worker/mem-fs.ts new file mode 100644 index 0000000..cedf874 --- /dev/null +++ b/extension/src/service-worker/mem-fs.ts @@ -0,0 +1,175 @@ +/** + * mem-fs.ts — dep-free in-memory filesystem for isomorphic-git org pushes. + * + * Implements a minimal PromiseFsClient backed by Map/Set (pure Web APIs). + * Org repo data MUST NOT persist (security invariant — never lightning-fs / + * IndexedDB). This module is intentionally side-effect-free and dep-free. + * + * CRITICAL: `readlink` and `symlink` MUST exist even though isomorphic-git + * types them as optional. `bindFs` unconditionally calls `.bind()` on them; + * omitting either throws "Cannot read properties of undefined (reading + * 'bind')" at git.init / clone. (Verified against isomorphic-git 1.38.5.) + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Stat { + isFile: () => boolean; + isDirectory: () => boolean; + isSymbolicLink: () => boolean; + type: 'file' | 'dir'; + mode: number; + size: number; + mtimeMs: number; + ctimeMs: number; + mtime: Date; + ctime: Date; + uid: number; + gid: number; + dev: number; + ino: number; +} + +// --------------------------------------------------------------------------- +// Public factory +// --------------------------------------------------------------------------- + +export function createMemFs(): { promises: PromiseFsClient } { + const F = new Map(); // normalized path -> bytes (files) + const D = new Set(['/']); // normalized dir paths + const inos = new Map(); + let ino = 1; + + const norm = (p: string): string => { + p = ('/' + p).replace(/\/+/g, '/'); + return p.length > 1 ? p.replace(/\/$/, '') : '/'; + }; + + const inoFor = (p: string): number => { + if (!inos.has(p)) inos.set(p, ++ino); + return inos.get(p)!; + }; + + const ENOENT = (p: string): Error & { code: string } => { + const e = new Error(`ENOENT: no such file or directory, ${p}`) as Error & { code: string }; + e.code = 'ENOENT'; + return e; + }; + + const mkStat = (p: string, isFile: boolean, size: number): Stat => ({ + isFile: () => isFile, + isDirectory: () => !isFile, + isSymbolicLink: () => false, + type: isFile ? 'file' : 'dir', + mode: isFile ? 0o100644 : 0o40000, + size, + mtimeMs: 1, + ctimeMs: 1, + mtime: new Date(1), + ctime: new Date(1), + uid: 1, + gid: 1, + dev: 1, + ino: inoFor(p), + }); + + const promises: PromiseFsClient = { + async readFile(p: string, opts?: unknown): Promise { + p = norm(p); + if (!F.has(p)) throw ENOENT(p); + const enc = typeof opts === 'string' ? opts : (opts as { encoding?: string } | null | undefined)?.encoding; + return enc ? new TextDecoder().decode(F.get(p)) : F.get(p)!; + }, + + async writeFile(p: string, data: string | Uint8Array): Promise { + let bytes: Uint8Array; + if (typeof data === 'string') { + bytes = new TextEncoder().encode(data); + } else if (data instanceof Uint8Array) { + bytes = new Uint8Array(data); // copy — never alias a subarray view's backing buffer + } else { + bytes = new Uint8Array(data as ArrayBufferLike); + } + F.set(norm(p), bytes); + }, + + async unlink(p: string): Promise { + F.delete(norm(p)); + }, + + async readdir(p: string): Promise { + p = norm(p); + const pre = p === '/' ? '/' : p + '/'; + const kids = new Set(); + for (const f of F.keys()) { + if (f.startsWith(pre) && f !== p) kids.add(f.slice(pre.length).split('/')[0]); + } + for (const d of D) { + if (d.startsWith(pre) && d !== p) { + const r = d.slice(pre.length).split('/')[0]; + if (r) kids.add(r); + } + } + return [...kids]; + }, + + async mkdir(p: string): Promise { + D.add(norm(p)); + }, + + async rmdir(p: string): Promise { + D.delete(norm(p)); + }, + + // Org repos contain no symlinks — MUST exist for isomorphic-git bindFs. + async readlink(p: string): Promise { + throw ENOENT(norm(p)); + }, + + // Org repos contain no symlinks — MUST exist for isomorphic-git bindFs. + async symlink(): Promise { + /* no-op: org repos contain no symlinks */ + }, + + async stat(p: string): Promise { + p = norm(p); + if (F.has(p)) return mkStat(p, true, F.get(p)!.length); + // Implicit directories: a path with file or dir children even if never mkdir'd. + if ( + p === '/' || + D.has(p) || + [...F.keys()].some((x) => x.startsWith(p + '/')) || + [...D].some((x) => x.startsWith(p + '/')) + ) { + return mkStat(p, false, 0); + } + throw ENOENT(p); + }, + + async lstat(p: string): Promise { + return promises.stat!(p) as Promise; + }, + }; + + return { promises }; +} + +// --------------------------------------------------------------------------- +// Local type alias — mirrors the subset of PromiseFsClient used above. +// We declare it locally to avoid pulling in the isomorphic-git package here. +// --------------------------------------------------------------------------- + +interface PromiseFsClient { + readFile(filepath: string, options?: unknown): Promise; + writeFile(filepath: string, data: string | Uint8Array, options?: unknown): Promise; + unlink(filepath: string, options?: unknown): Promise; + readdir(filepath: string, options?: unknown): Promise; + mkdir(filepath: string, options?: unknown): Promise; + rmdir(filepath: string, options?: unknown): Promise; + stat(filepath: string, options?: unknown): Promise; + lstat?(filepath: string, options?: unknown): Promise; + readlink?(filepath: string, options?: unknown): Promise; + symlink?(target: string, filepath: string, type?: string): Promise; +}