feat(ext/sw): mem-fs — dep-free in-memory fs for isomorphic-git org pushes
This commit is contained in:
98
extension/src/service-worker/__tests__/mem-fs.test.ts
Normal file
98
extension/src/service-worker/__tests__/mem-fs.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
175
extension/src/service-worker/mem-fs.ts
Normal file
175
extension/src/service-worker/mem-fs.ts
Normal file
@@ -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<string, Uint8Array>(); // normalized path -> bytes (files)
|
||||
const D = new Set<string>(['/']); // normalized dir paths
|
||||
const inos = new Map<string, number>();
|
||||
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<Uint8Array | string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
F.delete(norm(p));
|
||||
},
|
||||
|
||||
async readdir(p: string): Promise<string[]> {
|
||||
p = norm(p);
|
||||
const pre = p === '/' ? '/' : p + '/';
|
||||
const kids = new Set<string>();
|
||||
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<void> {
|
||||
D.add(norm(p));
|
||||
},
|
||||
|
||||
async rmdir(p: string): Promise<void> {
|
||||
D.delete(norm(p));
|
||||
},
|
||||
|
||||
// Org repos contain no symlinks — MUST exist for isomorphic-git bindFs.
|
||||
async readlink(p: string): Promise<string> {
|
||||
throw ENOENT(norm(p));
|
||||
},
|
||||
|
||||
// Org repos contain no symlinks — MUST exist for isomorphic-git bindFs.
|
||||
async symlink(): Promise<void> {
|
||||
/* no-op: org repos contain no symlinks */
|
||||
},
|
||||
|
||||
async stat(p: string): Promise<Stat> {
|
||||
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<Stat> {
|
||||
return promises.stat!(p) as Promise<Stat>;
|
||||
},
|
||||
};
|
||||
|
||||
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<Uint8Array | string>;
|
||||
writeFile(filepath: string, data: string | Uint8Array, options?: unknown): Promise<void>;
|
||||
unlink(filepath: string, options?: unknown): Promise<void>;
|
||||
readdir(filepath: string, options?: unknown): Promise<string[]>;
|
||||
mkdir(filepath: string, options?: unknown): Promise<void>;
|
||||
rmdir(filepath: string, options?: unknown): Promise<void>;
|
||||
stat(filepath: string, options?: unknown): Promise<Stat>;
|
||||
lstat?(filepath: string, options?: unknown): Promise<Stat>;
|
||||
readlink?(filepath: string, options?: unknown): Promise<string>;
|
||||
symlink?(target: string, filepath: string, type?: string): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user