@secure-exec/core 0.2.0-rc.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generated/isolate-runtime.d.ts +1 -1
- package/dist/generated/isolate-runtime.js +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +10 -2
- package/dist/isolate-runtime/require-setup.js +145 -7
- package/dist/kernel/device-backend.d.ts +14 -0
- package/dist/kernel/device-backend.js +251 -0
- package/dist/kernel/device-layer.js +9 -0
- package/dist/kernel/index.d.ts +4 -4
- package/dist/kernel/index.js +3 -3
- package/dist/kernel/kernel.js +141 -119
- package/dist/kernel/mount-table.d.ts +75 -0
- package/dist/kernel/mount-table.js +353 -0
- package/dist/kernel/permissions.d.ts +9 -0
- package/dist/kernel/permissions.js +33 -1
- package/dist/kernel/proc-backend.d.ts +30 -0
- package/dist/kernel/proc-backend.js +428 -0
- package/dist/kernel/proc-layer.js +6 -0
- package/dist/kernel/process-table.d.ts +3 -1
- package/dist/kernel/process-table.js +23 -3
- package/dist/kernel/pty.d.ts +3 -2
- package/dist/kernel/pty.js +13 -2
- package/dist/kernel/types.d.ts +45 -4
- package/dist/kernel/types.js +9 -0
- package/dist/kernel/vfs.d.ts +30 -2
- package/dist/kernel/vfs.js +19 -2
- package/dist/shared/api-types.d.ts +6 -0
- package/dist/shared/console-formatter.js +8 -8
- package/dist/shared/in-memory-fs.d.ts +14 -62
- package/dist/shared/in-memory-fs.js +101 -636
- package/dist/shared/permissions.js +5 -0
- package/dist/test/block-store-conformance.d.ts +34 -0
- package/dist/test/block-store-conformance.js +251 -0
- package/dist/test/metadata-store-conformance.d.ts +37 -0
- package/dist/test/metadata-store-conformance.js +646 -0
- package/dist/test/vfs-conformance.d.ts +65 -0
- package/dist/test/vfs-conformance.js +842 -0
- package/dist/types.d.ts +1 -0
- package/dist/vfs/chunked-vfs.d.ts +66 -0
- package/dist/vfs/chunked-vfs.js +1290 -0
- package/dist/vfs/host-block-store.d.ts +19 -0
- package/dist/vfs/host-block-store.js +97 -0
- package/dist/vfs/memory-block-store.d.ts +16 -0
- package/dist/vfs/memory-block-store.js +45 -0
- package/dist/vfs/memory-metadata.d.ts +75 -0
- package/dist/vfs/memory-metadata.js +528 -0
- package/dist/vfs/sqlite-metadata.d.ts +91 -0
- package/dist/vfs/sqlite-metadata.js +582 -0
- package/dist/vfs/types.d.ts +210 -0
- package/dist/vfs/types.js +8 -0
- package/package.json +20 -1
- package/dist/kernel/inode-table.d.ts +0 -43
- package/dist/kernel/inode-table.js +0 -85
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared VFS conformance test suite.
|
|
3
|
+
*
|
|
4
|
+
* Every VirtualFileSystem implementation must pass the core tests in this suite.
|
|
5
|
+
* Optional test groups are gated on capability flags declared in the config.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
*
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { defineVfsConformanceTests } from "@secure-exec/core/test/vfs-conformance";
|
|
11
|
+
*
|
|
12
|
+
* defineVfsConformanceTests({
|
|
13
|
+
* name: "MyDriver",
|
|
14
|
+
* createFs: () => createMyDriver(),
|
|
15
|
+
* cleanup: () => cleanupMyDriver(),
|
|
16
|
+
* capabilities: {
|
|
17
|
+
* symlinks: true,
|
|
18
|
+
* hardLinks: true,
|
|
19
|
+
* permissions: true,
|
|
20
|
+
* utimes: true,
|
|
21
|
+
* truncate: true,
|
|
22
|
+
* pread: true,
|
|
23
|
+
* pwrite: true,
|
|
24
|
+
* mkdir: true,
|
|
25
|
+
* removeDir: true,
|
|
26
|
+
* fsync: false,
|
|
27
|
+
* copy: false,
|
|
28
|
+
* readDirStat: false,
|
|
29
|
+
* },
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* For chunked VFS implementations, pass `inlineThreshold` and `chunkSize` to
|
|
34
|
+
* enable edge case tests at storage tier boundaries.
|
|
35
|
+
*/
|
|
36
|
+
import { describe, beforeEach, afterEach, expect, test } from "vitest";
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Error code helper
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
function hasErrorCode(err, code) {
|
|
41
|
+
if (typeof err !== "object" || err === null)
|
|
42
|
+
return false;
|
|
43
|
+
const e = err;
|
|
44
|
+
if (e.code === code)
|
|
45
|
+
return true;
|
|
46
|
+
if (typeof e.message === "string" && e.message.startsWith(`${code}:`))
|
|
47
|
+
return true;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
function expectErrorCode(err, code) {
|
|
51
|
+
expect(err).toBeInstanceOf(Error);
|
|
52
|
+
expect(hasErrorCode(err, code)).toBe(true);
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
/** Create a Uint8Array of the given size filled with a repeating byte pattern. */
|
|
58
|
+
function makeData(size, seed = 0x42) {
|
|
59
|
+
const buf = new Uint8Array(size);
|
|
60
|
+
for (let i = 0; i < size; i++) {
|
|
61
|
+
buf[i] = (seed + i) & 0xff;
|
|
62
|
+
}
|
|
63
|
+
return buf;
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Test suite
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
export function defineVfsConformanceTests(config) {
|
|
69
|
+
const { name, capabilities } = config;
|
|
70
|
+
describe(name, () => {
|
|
71
|
+
let fs;
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
fs = await config.createFs();
|
|
74
|
+
});
|
|
75
|
+
afterEach(async () => {
|
|
76
|
+
if (config.cleanup)
|
|
77
|
+
await config.cleanup();
|
|
78
|
+
});
|
|
79
|
+
// ---------------------------------------------------------------
|
|
80
|
+
// Core tests (always run)
|
|
81
|
+
// ---------------------------------------------------------------
|
|
82
|
+
describe("core", () => {
|
|
83
|
+
test("writeFile + readFile round-trip (string)", async () => {
|
|
84
|
+
await fs.writeFile("/hello.txt", "hello world");
|
|
85
|
+
const data = await fs.readFile("/hello.txt");
|
|
86
|
+
expect(new TextDecoder().decode(data)).toBe("hello world");
|
|
87
|
+
});
|
|
88
|
+
test("writeFile + readFile round-trip (binary)", async () => {
|
|
89
|
+
const buf = new Uint8Array([0, 1, 2, 255, 254, 253]);
|
|
90
|
+
await fs.writeFile("/bin.dat", buf);
|
|
91
|
+
const data = await fs.readFile("/bin.dat");
|
|
92
|
+
expect(data).toEqual(buf);
|
|
93
|
+
});
|
|
94
|
+
test("writeFile + readTextFile round-trip", async () => {
|
|
95
|
+
await fs.writeFile("/text.txt", "some text");
|
|
96
|
+
const text = await fs.readTextFile("/text.txt");
|
|
97
|
+
expect(text).toBe("some text");
|
|
98
|
+
});
|
|
99
|
+
test("writeFile auto-creates parent directories", async () => {
|
|
100
|
+
await fs.writeFile("/a/b/c/deep.txt", "deep");
|
|
101
|
+
const text = await fs.readTextFile("/a/b/c/deep.txt");
|
|
102
|
+
expect(text).toBe("deep");
|
|
103
|
+
});
|
|
104
|
+
test("writeFile overwrites existing file", async () => {
|
|
105
|
+
await fs.writeFile("/ow.txt", "first");
|
|
106
|
+
await fs.writeFile("/ow.txt", "second");
|
|
107
|
+
const text = await fs.readTextFile("/ow.txt");
|
|
108
|
+
expect(text).toBe("second");
|
|
109
|
+
});
|
|
110
|
+
test("readFile throws ENOENT on missing file", async () => {
|
|
111
|
+
const err = await fs.readFile("/no-such-file.txt").catch((e) => e);
|
|
112
|
+
expectErrorCode(err, "ENOENT");
|
|
113
|
+
});
|
|
114
|
+
test("readFile on directory throws EISDIR", async () => {
|
|
115
|
+
await fs.writeFile("/d/file.txt", "x");
|
|
116
|
+
const err = await fs.readFile("/d").catch((e) => e);
|
|
117
|
+
expect(err).toBeInstanceOf(Error);
|
|
118
|
+
expect(hasErrorCode(err, "EISDIR") || hasErrorCode(err, "ENOENT")).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
test("exists returns true for file", async () => {
|
|
121
|
+
await fs.writeFile("/exists.txt", "yes");
|
|
122
|
+
expect(await fs.exists("/exists.txt")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
test("exists returns true for directory", async () => {
|
|
125
|
+
await fs.writeFile("/d/file.txt", "x");
|
|
126
|
+
expect(await fs.exists("/d")).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
test("exists returns false for nonexistent", async () => {
|
|
129
|
+
expect(await fs.exists("/nope")).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
test("stat returns correct size, mode, isDirectory, timestamps", async () => {
|
|
132
|
+
await fs.writeFile("/st.txt", "data");
|
|
133
|
+
const s = await fs.stat("/st.txt");
|
|
134
|
+
expect(s.isDirectory).toBe(false);
|
|
135
|
+
expect(s.size).toBe(4);
|
|
136
|
+
expect(s.atimeMs).toBeGreaterThan(0);
|
|
137
|
+
expect(s.mtimeMs).toBeGreaterThan(0);
|
|
138
|
+
expect(s.ctimeMs).toBeGreaterThan(0);
|
|
139
|
+
expect(s.birthtimeMs).toBeGreaterThan(0);
|
|
140
|
+
});
|
|
141
|
+
test("stat size equals exact byte length", async () => {
|
|
142
|
+
const content = "h\u00e9llo"; // 6 bytes in UTF-8
|
|
143
|
+
await fs.writeFile("/sized.txt", content);
|
|
144
|
+
const s = await fs.stat("/sized.txt");
|
|
145
|
+
expect(s.size).toBe(new TextEncoder().encode(content).length);
|
|
146
|
+
});
|
|
147
|
+
test("stat returns isDirectory for directory", async () => {
|
|
148
|
+
await fs.writeFile("/dir/child.txt", "x");
|
|
149
|
+
const s = await fs.stat("/dir");
|
|
150
|
+
expect(s.isDirectory).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
test("removeFile deletes file", async () => {
|
|
153
|
+
await fs.writeFile("/rm.txt", "bye");
|
|
154
|
+
await fs.removeFile("/rm.txt");
|
|
155
|
+
expect(await fs.exists("/rm.txt")).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
test("removeFile throws ENOENT on missing file", async () => {
|
|
158
|
+
const err = await fs.removeFile("/nonexistent.txt").catch((e) => e);
|
|
159
|
+
expectErrorCode(err, "ENOENT");
|
|
160
|
+
});
|
|
161
|
+
test("removeFile on directory throws EISDIR", async () => {
|
|
162
|
+
await fs.writeFile("/rmdir/file.txt", "x");
|
|
163
|
+
const err = await fs.removeFile("/rmdir").catch((e) => e);
|
|
164
|
+
expect(err).toBeInstanceOf(Error);
|
|
165
|
+
expect(hasErrorCode(err, "EISDIR") || hasErrorCode(err, "EPERM")).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
test("readDir excludes . and ..", async () => {
|
|
168
|
+
await fs.writeFile("/ls/a.txt", "a");
|
|
169
|
+
await fs.writeFile("/ls/b.txt", "b");
|
|
170
|
+
const entries = await fs.readDir("/ls");
|
|
171
|
+
expect(entries).not.toContain(".");
|
|
172
|
+
expect(entries).not.toContain("..");
|
|
173
|
+
expect(entries.sort()).toEqual(["a.txt", "b.txt"]);
|
|
174
|
+
});
|
|
175
|
+
test("readDirWithTypes returns correct types", async () => {
|
|
176
|
+
await fs.writeFile("/typed/file.txt", "f");
|
|
177
|
+
await fs.writeFile("/typed/sub/nested.txt", "n");
|
|
178
|
+
const entries = await fs.readDirWithTypes("/typed");
|
|
179
|
+
const names = entries.map((e) => e.name).sort();
|
|
180
|
+
expect(names).toEqual(["file.txt", "sub"]);
|
|
181
|
+
const subEntry = entries.find((e) => e.name === "sub");
|
|
182
|
+
expect(subEntry?.isDirectory).toBe(true);
|
|
183
|
+
const fileEntry = entries.find((e) => e.name === "file.txt");
|
|
184
|
+
expect(fileEntry?.isDirectory).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
test("rename same dir", async () => {
|
|
187
|
+
await fs.writeFile("/old.txt", "content");
|
|
188
|
+
await fs.rename("/old.txt", "/new.txt");
|
|
189
|
+
expect(await fs.exists("/old.txt")).toBe(false);
|
|
190
|
+
const text = await fs.readTextFile("/new.txt");
|
|
191
|
+
expect(text).toBe("content");
|
|
192
|
+
});
|
|
193
|
+
test("rename cross dir", async () => {
|
|
194
|
+
await fs.writeFile("/a/old.txt", "moved");
|
|
195
|
+
await fs.writeFile("/b/placeholder.txt", "x");
|
|
196
|
+
await fs.rename("/a/old.txt", "/b/new.txt");
|
|
197
|
+
expect(await fs.exists("/a/old.txt")).toBe(false);
|
|
198
|
+
const text = await fs.readTextFile("/b/new.txt");
|
|
199
|
+
expect(text).toBe("moved");
|
|
200
|
+
});
|
|
201
|
+
test("rename overwrites existing destination", async () => {
|
|
202
|
+
await fs.writeFile("/src.txt", "new content");
|
|
203
|
+
await fs.writeFile("/dst.txt", "old content");
|
|
204
|
+
await fs.rename("/src.txt", "/dst.txt");
|
|
205
|
+
expect(await fs.exists("/src.txt")).toBe(false);
|
|
206
|
+
const text = await fs.readTextFile("/dst.txt");
|
|
207
|
+
expect(text).toBe("new content");
|
|
208
|
+
});
|
|
209
|
+
test.skipIf(!capabilities.removeDir)("rename directory", async () => {
|
|
210
|
+
await fs.writeFile("/src/one.txt", "1");
|
|
211
|
+
await fs.writeFile("/src/two.txt", "2");
|
|
212
|
+
await fs.rename("/src", "/dst");
|
|
213
|
+
expect(await fs.exists("/src")).toBe(false);
|
|
214
|
+
expect(await fs.readTextFile("/dst/one.txt")).toBe("1");
|
|
215
|
+
expect(await fs.readTextFile("/dst/two.txt")).toBe("2");
|
|
216
|
+
});
|
|
217
|
+
test("realpath normalizes path", async () => {
|
|
218
|
+
await fs.writeFile("/real.txt", "r");
|
|
219
|
+
const rp = await fs.realpath("/real.txt");
|
|
220
|
+
expect(rp).toBe("/real.txt");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
// ---------------------------------------------------------------
|
|
224
|
+
// pwrite tests (gated)
|
|
225
|
+
// ---------------------------------------------------------------
|
|
226
|
+
describe.skipIf(!capabilities.pwrite)("pwrite", () => {
|
|
227
|
+
test("pwrite at offset 0", async () => {
|
|
228
|
+
await fs.writeFile("/pw.txt", "abcdef");
|
|
229
|
+
await fs.pwrite("/pw.txt", 0, new TextEncoder().encode("XY"));
|
|
230
|
+
const text = await fs.readTextFile("/pw.txt");
|
|
231
|
+
expect(text).toBe("XYcdef");
|
|
232
|
+
});
|
|
233
|
+
test("pwrite at middle of file", async () => {
|
|
234
|
+
await fs.writeFile("/pw2.txt", "abcdef");
|
|
235
|
+
await fs.pwrite("/pw2.txt", 2, new TextEncoder().encode("XY"));
|
|
236
|
+
const text = await fs.readTextFile("/pw2.txt");
|
|
237
|
+
expect(text).toBe("abXYef");
|
|
238
|
+
});
|
|
239
|
+
test("pwrite beyond EOF extends file with zeros", async () => {
|
|
240
|
+
await fs.writeFile("/pw3.txt", "abc");
|
|
241
|
+
await fs.pwrite("/pw3.txt", 6, new TextEncoder().encode("XY"));
|
|
242
|
+
const data = await fs.readFile("/pw3.txt");
|
|
243
|
+
expect(data.length).toBe(8);
|
|
244
|
+
expect(new TextDecoder().decode(data.slice(0, 3))).toBe("abc");
|
|
245
|
+
expect(data[3]).toBe(0);
|
|
246
|
+
expect(data[4]).toBe(0);
|
|
247
|
+
expect(data[5]).toBe(0);
|
|
248
|
+
expect(new TextDecoder().decode(data.slice(6, 8))).toBe("XY");
|
|
249
|
+
});
|
|
250
|
+
test("pwrite spanning chunk boundaries", async () => {
|
|
251
|
+
const chunkSize = config.chunkSize ?? 4 * 1024 * 1024;
|
|
252
|
+
// Write a file that spans two chunks
|
|
253
|
+
const initialData = makeData(chunkSize + 100);
|
|
254
|
+
await fs.writeFile("/span.dat", initialData);
|
|
255
|
+
// Overwrite across the boundary
|
|
256
|
+
const patch = new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]);
|
|
257
|
+
const offset = chunkSize - 2;
|
|
258
|
+
await fs.pwrite("/span.dat", offset, patch);
|
|
259
|
+
const result = await fs.readFile("/span.dat");
|
|
260
|
+
expect(result.length).toBe(chunkSize + 100);
|
|
261
|
+
expect(result[offset]).toBe(0xaa);
|
|
262
|
+
expect(result[offset + 1]).toBe(0xbb);
|
|
263
|
+
expect(result[offset + 2]).toBe(0xcc);
|
|
264
|
+
expect(result[offset + 3]).toBe(0xdd);
|
|
265
|
+
});
|
|
266
|
+
test("pwrite + pread round-trip", async () => {
|
|
267
|
+
await fs.writeFile("/prw.txt", "aaaaaaaaaa");
|
|
268
|
+
const patch = new TextEncoder().encode("XYZ");
|
|
269
|
+
await fs.pwrite("/prw.txt", 3, patch);
|
|
270
|
+
const read = await fs.pread("/prw.txt", 3, 3);
|
|
271
|
+
expect(new TextDecoder().decode(read)).toBe("XYZ");
|
|
272
|
+
});
|
|
273
|
+
test("pwrite does not affect other bytes", async () => {
|
|
274
|
+
await fs.writeFile("/noaffect.txt", "abcdefghij");
|
|
275
|
+
await fs.pwrite("/noaffect.txt", 4, new TextEncoder().encode("XX"));
|
|
276
|
+
const text = await fs.readTextFile("/noaffect.txt");
|
|
277
|
+
expect(text).toBe("abcdXXghij");
|
|
278
|
+
});
|
|
279
|
+
test("multiple sequential pwrites build up file content", async () => {
|
|
280
|
+
await fs.writeFile("/seq.txt", ".........."); // 10 dots
|
|
281
|
+
await fs.pwrite("/seq.txt", 0, new TextEncoder().encode("AB"));
|
|
282
|
+
await fs.pwrite("/seq.txt", 5, new TextEncoder().encode("CD"));
|
|
283
|
+
await fs.pwrite("/seq.txt", 8, new TextEncoder().encode("EF"));
|
|
284
|
+
const text = await fs.readTextFile("/seq.txt");
|
|
285
|
+
expect(text).toBe("AB...CD.EF");
|
|
286
|
+
});
|
|
287
|
+
test("pwrite to empty file at offset 0", async () => {
|
|
288
|
+
await fs.writeFile("/empty-pw.txt", "");
|
|
289
|
+
await fs.pwrite("/empty-pw.txt", 0, new TextEncoder().encode("hello"));
|
|
290
|
+
const text = await fs.readTextFile("/empty-pw.txt");
|
|
291
|
+
expect(text).toBe("hello");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
// ---------------------------------------------------------------
|
|
295
|
+
// Concurrency tests (gated on pwrite)
|
|
296
|
+
// ---------------------------------------------------------------
|
|
297
|
+
describe.skipIf(!capabilities.pwrite)("concurrency", () => {
|
|
298
|
+
test("two concurrent pwrites to different offsets both succeed", async () => {
|
|
299
|
+
const size = 1024;
|
|
300
|
+
await fs.writeFile("/conc1.dat", makeData(size, 0));
|
|
301
|
+
const patchA = new Uint8Array([0xaa, 0xaa, 0xaa, 0xaa]);
|
|
302
|
+
const patchB = new Uint8Array([0xbb, 0xbb, 0xbb, 0xbb]);
|
|
303
|
+
await Promise.all([
|
|
304
|
+
fs.pwrite("/conc1.dat", 100, patchA),
|
|
305
|
+
fs.pwrite("/conc1.dat", 500, patchB),
|
|
306
|
+
]);
|
|
307
|
+
const result = await fs.readFile("/conc1.dat");
|
|
308
|
+
expect(result[100]).toBe(0xaa);
|
|
309
|
+
expect(result[101]).toBe(0xaa);
|
|
310
|
+
expect(result[500]).toBe(0xbb);
|
|
311
|
+
expect(result[501]).toBe(0xbb);
|
|
312
|
+
});
|
|
313
|
+
test("two concurrent pwrites to same offset: no corruption", async () => {
|
|
314
|
+
await fs.writeFile("/conc2.dat", makeData(256, 0));
|
|
315
|
+
const patchA = new Uint8Array([0xaa, 0xaa, 0xaa, 0xaa]);
|
|
316
|
+
const patchB = new Uint8Array([0xbb, 0xbb, 0xbb, 0xbb]);
|
|
317
|
+
await Promise.all([
|
|
318
|
+
fs.pwrite("/conc2.dat", 50, patchA),
|
|
319
|
+
fs.pwrite("/conc2.dat", 50, patchB),
|
|
320
|
+
]);
|
|
321
|
+
const result = await fs.readFile("/conc2.dat");
|
|
322
|
+
// One of the two writes wins. The result must be either all 0xaa or all 0xbb at the offset.
|
|
323
|
+
const val = result[50];
|
|
324
|
+
expect(val === 0xaa || val === 0xbb).toBe(true);
|
|
325
|
+
expect(result[51]).toBe(val);
|
|
326
|
+
expect(result[52]).toBe(val);
|
|
327
|
+
expect(result[53]).toBe(val);
|
|
328
|
+
});
|
|
329
|
+
test("concurrent pwrite + readFile returns consistent data", async () => {
|
|
330
|
+
const content = "hello world!!!";
|
|
331
|
+
await fs.writeFile("/conc3.txt", content);
|
|
332
|
+
const patch = new TextEncoder().encode("XXXXX");
|
|
333
|
+
const modified = "XXXXX world!!!";
|
|
334
|
+
const [, readData] = await Promise.all([
|
|
335
|
+
fs.pwrite("/conc3.txt", 0, patch),
|
|
336
|
+
fs.readFile("/conc3.txt"),
|
|
337
|
+
]);
|
|
338
|
+
const text = new TextDecoder().decode(readData);
|
|
339
|
+
// Must be either the original or the patched version.
|
|
340
|
+
expect(text === content || text === modified).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
// ---------------------------------------------------------------
|
|
344
|
+
// Symlink tests (gated)
|
|
345
|
+
// ---------------------------------------------------------------
|
|
346
|
+
describe.skipIf(!capabilities.symlinks)("symlinks", () => {
|
|
347
|
+
test("symlink + readlink round-trip", async () => {
|
|
348
|
+
await fs.writeFile("/target.txt", "target");
|
|
349
|
+
await fs.symlink("/target.txt", "/link.txt");
|
|
350
|
+
const target = await fs.readlink("/link.txt");
|
|
351
|
+
expect(target).toBe("/target.txt");
|
|
352
|
+
});
|
|
353
|
+
test("symlink resolution for file access", async () => {
|
|
354
|
+
await fs.writeFile("/real.txt", "real content");
|
|
355
|
+
await fs.symlink("/real.txt", "/sym.txt");
|
|
356
|
+
const text = await fs.readTextFile("/sym.txt");
|
|
357
|
+
expect(text).toBe("real content");
|
|
358
|
+
});
|
|
359
|
+
test("lstat on symlink returns isSymbolicLink true", async () => {
|
|
360
|
+
await fs.writeFile("/tgt.txt", "t");
|
|
361
|
+
await fs.symlink("/tgt.txt", "/lnk.txt");
|
|
362
|
+
const s = await fs.lstat("/lnk.txt");
|
|
363
|
+
expect(s.isSymbolicLink).toBe(true);
|
|
364
|
+
});
|
|
365
|
+
test("stat follows symlink and returns target metadata", async () => {
|
|
366
|
+
await fs.writeFile("/tgt2.txt", "target data");
|
|
367
|
+
await fs.symlink("/tgt2.txt", "/lnk2.txt");
|
|
368
|
+
const s = await fs.stat("/lnk2.txt");
|
|
369
|
+
expect(s.isSymbolicLink).toBe(false);
|
|
370
|
+
expect(s.size).toBe(11);
|
|
371
|
+
});
|
|
372
|
+
test("dangling symlink: stat throws ENOENT, lstat succeeds", async () => {
|
|
373
|
+
await fs.symlink("/nonexistent-target.txt", "/dangle.txt");
|
|
374
|
+
const lstatResult = await fs.lstat("/dangle.txt");
|
|
375
|
+
expect(lstatResult.isSymbolicLink).toBe(true);
|
|
376
|
+
const statErr = await fs.stat("/dangle.txt").catch((e) => e);
|
|
377
|
+
expectErrorCode(statErr, "ENOENT");
|
|
378
|
+
});
|
|
379
|
+
test("symlink loop throws ELOOP", async () => {
|
|
380
|
+
await fs.symlink("/loop-b.txt", "/loop-a.txt");
|
|
381
|
+
await fs.symlink("/loop-a.txt", "/loop-b.txt");
|
|
382
|
+
const err = await fs.readFile("/loop-a.txt").catch((e) => e);
|
|
383
|
+
expectErrorCode(err, "ELOOP");
|
|
384
|
+
});
|
|
385
|
+
test("deep symlink chain (41 levels) throws ELOOP", async () => {
|
|
386
|
+
// Create a chain of 41 symlinks: /chain-0 -> /chain-1 -> ... -> /chain-40
|
|
387
|
+
for (let i = 0; i < 41; i++) {
|
|
388
|
+
await fs.symlink(`/chain-${i + 1}`, `/chain-${i}`);
|
|
389
|
+
}
|
|
390
|
+
await fs.writeFile("/chain-41", "end");
|
|
391
|
+
const err = await fs.readFile("/chain-0").catch((e) => e);
|
|
392
|
+
expectErrorCode(err, "ELOOP");
|
|
393
|
+
});
|
|
394
|
+
test("removeFile on symlink removes link, not target", async () => {
|
|
395
|
+
await fs.writeFile("/sym-target.txt", "target content");
|
|
396
|
+
await fs.symlink("/sym-target.txt", "/sym-link.txt");
|
|
397
|
+
await fs.removeFile("/sym-link.txt");
|
|
398
|
+
expect(await fs.exists("/sym-link.txt")).toBe(false);
|
|
399
|
+
const text = await fs.readTextFile("/sym-target.txt");
|
|
400
|
+
expect(text).toBe("target content");
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
// ---------------------------------------------------------------
|
|
404
|
+
// Hard link tests (gated)
|
|
405
|
+
// ---------------------------------------------------------------
|
|
406
|
+
describe.skipIf(!capabilities.hardLinks)("hard links", () => {
|
|
407
|
+
test("link creates second name for same file", async () => {
|
|
408
|
+
await fs.writeFile("/original.txt", "shared");
|
|
409
|
+
await fs.link("/original.txt", "/linked.txt");
|
|
410
|
+
const text = await fs.readTextFile("/linked.txt");
|
|
411
|
+
expect(text).toBe("shared");
|
|
412
|
+
});
|
|
413
|
+
test("write via one name is visible via the other", async () => {
|
|
414
|
+
await fs.writeFile("/hl-orig.txt", "original");
|
|
415
|
+
await fs.link("/hl-orig.txt", "/hl-copy.txt");
|
|
416
|
+
await fs.writeFile("/hl-copy.txt", "updated");
|
|
417
|
+
const origText = await fs.readTextFile("/hl-orig.txt");
|
|
418
|
+
expect(origText).toBe("updated");
|
|
419
|
+
});
|
|
420
|
+
test("remove one name: file still accessible via other", async () => {
|
|
421
|
+
await fs.writeFile("/src.txt", "data");
|
|
422
|
+
await fs.link("/src.txt", "/hl.txt");
|
|
423
|
+
await fs.removeFile("/src.txt");
|
|
424
|
+
const text = await fs.readTextFile("/hl.txt");
|
|
425
|
+
expect(text).toBe("data");
|
|
426
|
+
});
|
|
427
|
+
test("nlink decrement: data deleted when nlink reaches 0", async () => {
|
|
428
|
+
await fs.writeFile("/nlink.txt", "data");
|
|
429
|
+
await fs.link("/nlink.txt", "/nlink2.txt");
|
|
430
|
+
const s1 = await fs.stat("/nlink.txt");
|
|
431
|
+
expect(s1.nlink).toBe(2);
|
|
432
|
+
await fs.removeFile("/nlink.txt");
|
|
433
|
+
const s2 = await fs.stat("/nlink2.txt");
|
|
434
|
+
expect(s2.nlink).toBe(1);
|
|
435
|
+
await fs.removeFile("/nlink2.txt");
|
|
436
|
+
expect(await fs.exists("/nlink2.txt")).toBe(false);
|
|
437
|
+
});
|
|
438
|
+
test("link to directory throws EPERM", async () => {
|
|
439
|
+
await fs.writeFile("/linkdir/child.txt", "x");
|
|
440
|
+
const err = await fs.link("/linkdir", "/linkdir2").catch((e) => e);
|
|
441
|
+
expectErrorCode(err, "EPERM");
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
// ---------------------------------------------------------------
|
|
445
|
+
// Truncate tests (gated)
|
|
446
|
+
// ---------------------------------------------------------------
|
|
447
|
+
describe.skipIf(!capabilities.truncate)("truncate", () => {
|
|
448
|
+
test("truncate shrinks file", async () => {
|
|
449
|
+
await fs.writeFile("/trunc.txt", "hello world");
|
|
450
|
+
await fs.truncate("/trunc.txt", 5);
|
|
451
|
+
const text = await fs.readTextFile("/trunc.txt");
|
|
452
|
+
expect(text).toBe("hello");
|
|
453
|
+
});
|
|
454
|
+
test("truncate to 0 produces empty file", async () => {
|
|
455
|
+
await fs.writeFile("/trunc-zero.txt", "some content");
|
|
456
|
+
await fs.truncate("/trunc-zero.txt", 0);
|
|
457
|
+
const data = await fs.readFile("/trunc-zero.txt");
|
|
458
|
+
expect(data.length).toBe(0);
|
|
459
|
+
});
|
|
460
|
+
test("truncate grow with zeros", async () => {
|
|
461
|
+
await fs.writeFile("/trunc-grow.txt", "abc");
|
|
462
|
+
await fs.truncate("/trunc-grow.txt", 6);
|
|
463
|
+
const data = await fs.readFile("/trunc-grow.txt");
|
|
464
|
+
expect(data.length).toBe(6);
|
|
465
|
+
expect(new TextDecoder().decode(data.slice(0, 3))).toBe("abc");
|
|
466
|
+
expect(data[3]).toBe(0);
|
|
467
|
+
expect(data[4]).toBe(0);
|
|
468
|
+
expect(data[5]).toBe(0);
|
|
469
|
+
});
|
|
470
|
+
test.skipIf(config.inlineThreshold == null)("truncate at inlineThreshold boundary", async () => {
|
|
471
|
+
const threshold = config.inlineThreshold;
|
|
472
|
+
// Start with a file above the threshold.
|
|
473
|
+
await fs.writeFile("/trunc-boundary.dat", makeData(threshold + 100));
|
|
474
|
+
// Truncate to exactly the threshold.
|
|
475
|
+
await fs.truncate("/trunc-boundary.dat", threshold);
|
|
476
|
+
const s = await fs.stat("/trunc-boundary.dat");
|
|
477
|
+
expect(s.size).toBe(threshold);
|
|
478
|
+
const data = await fs.readFile("/trunc-boundary.dat");
|
|
479
|
+
expect(data.length).toBe(threshold);
|
|
480
|
+
});
|
|
481
|
+
test.skipIf(config.inlineThreshold == null)("truncate inline file to chunked size promotes to chunked", async () => {
|
|
482
|
+
const threshold = config.inlineThreshold;
|
|
483
|
+
// Start inline.
|
|
484
|
+
await fs.writeFile("/promote.dat", makeData(threshold));
|
|
485
|
+
// Grow past threshold via truncate.
|
|
486
|
+
await fs.truncate("/promote.dat", threshold + 100);
|
|
487
|
+
const s = await fs.stat("/promote.dat");
|
|
488
|
+
expect(s.size).toBe(threshold + 100);
|
|
489
|
+
// Verify data integrity: first threshold bytes preserved, rest zeros.
|
|
490
|
+
const data = await fs.readFile("/promote.dat");
|
|
491
|
+
const expected = makeData(threshold);
|
|
492
|
+
expect(data.slice(0, threshold)).toEqual(expected);
|
|
493
|
+
for (let i = threshold; i < threshold + 100; i++) {
|
|
494
|
+
expect(data[i]).toBe(0);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
test.skipIf(config.inlineThreshold == null)("truncate chunked file to inline size demotes to inline", async () => {
|
|
498
|
+
const threshold = config.inlineThreshold;
|
|
499
|
+
// Start chunked.
|
|
500
|
+
await fs.writeFile("/demote.dat", makeData(threshold + 100));
|
|
501
|
+
// Shrink below threshold.
|
|
502
|
+
await fs.truncate("/demote.dat", threshold - 10);
|
|
503
|
+
const s = await fs.stat("/demote.dat");
|
|
504
|
+
expect(s.size).toBe(threshold - 10);
|
|
505
|
+
const data = await fs.readFile("/demote.dat");
|
|
506
|
+
const expected = makeData(threshold + 100).slice(0, threshold - 10);
|
|
507
|
+
expect(data).toEqual(expected);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
// ---------------------------------------------------------------
|
|
511
|
+
// Permission tests (gated)
|
|
512
|
+
// ---------------------------------------------------------------
|
|
513
|
+
describe.skipIf(!capabilities.permissions)("permissions", () => {
|
|
514
|
+
test("chmod changes mode bits", async () => {
|
|
515
|
+
await fs.writeFile("/perm.txt", "p");
|
|
516
|
+
await fs.chmod("/perm.txt", 0o644);
|
|
517
|
+
const s = await fs.stat("/perm.txt");
|
|
518
|
+
expect(s.mode & 0o777).toBe(0o644);
|
|
519
|
+
});
|
|
520
|
+
test("chmod preserves file type bits", async () => {
|
|
521
|
+
await fs.writeFile("/typebits.txt", "t");
|
|
522
|
+
const before = await fs.stat("/typebits.txt");
|
|
523
|
+
const typeBits = before.mode & 0o170000;
|
|
524
|
+
await fs.chmod("/typebits.txt", 0o755);
|
|
525
|
+
const after = await fs.stat("/typebits.txt");
|
|
526
|
+
if (typeBits !== 0) {
|
|
527
|
+
expect(after.mode & 0o170000).toBe(typeBits);
|
|
528
|
+
}
|
|
529
|
+
expect(after.mode & 0o777).toBe(0o755);
|
|
530
|
+
});
|
|
531
|
+
test("chown changes uid/gid", async () => {
|
|
532
|
+
await fs.writeFile("/own.txt", "o");
|
|
533
|
+
await fs.chown("/own.txt", 1000, 2000);
|
|
534
|
+
const s = await fs.stat("/own.txt");
|
|
535
|
+
expect(s.uid).toBe(1000);
|
|
536
|
+
expect(s.gid).toBe(2000);
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
// ---------------------------------------------------------------
|
|
540
|
+
// utimes tests (gated)
|
|
541
|
+
// ---------------------------------------------------------------
|
|
542
|
+
describe.skipIf(!capabilities.utimes)("utimes", () => {
|
|
543
|
+
test("utimes updates atime and mtime", async () => {
|
|
544
|
+
await fs.writeFile("/ut.txt", "t");
|
|
545
|
+
// utimes takes seconds (POSIX convention), stat returns milliseconds.
|
|
546
|
+
const atimeSec = 1700000000;
|
|
547
|
+
const mtimeSec = 1710000000;
|
|
548
|
+
await fs.utimes("/ut.txt", atimeSec, mtimeSec);
|
|
549
|
+
const s = await fs.stat("/ut.txt");
|
|
550
|
+
expect(s.atimeMs).toBe(atimeSec * 1000);
|
|
551
|
+
expect(s.mtimeMs).toBe(mtimeSec * 1000);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
// ---------------------------------------------------------------
|
|
555
|
+
// mkdir tests (gated)
|
|
556
|
+
// ---------------------------------------------------------------
|
|
557
|
+
describe.skipIf(!capabilities.mkdir)("mkdir", () => {
|
|
558
|
+
test("createDir creates a directory", async () => {
|
|
559
|
+
await fs.createDir("/newdir");
|
|
560
|
+
const s = await fs.stat("/newdir");
|
|
561
|
+
expect(s.isDirectory).toBe(true);
|
|
562
|
+
});
|
|
563
|
+
test("mkdir recursive creates nested directories", async () => {
|
|
564
|
+
await fs.mkdir("/p/q/r", { recursive: true });
|
|
565
|
+
const s = await fs.stat("/p/q/r");
|
|
566
|
+
expect(s.isDirectory).toBe(true);
|
|
567
|
+
});
|
|
568
|
+
test("createDir throws EEXIST for existing directory", async () => {
|
|
569
|
+
await fs.mkdir("/existing", { recursive: true });
|
|
570
|
+
const err = await fs.createDir("/existing").catch((e) => e);
|
|
571
|
+
expectErrorCode(err, "EEXIST");
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
// ---------------------------------------------------------------
|
|
575
|
+
// removeDir tests (gated)
|
|
576
|
+
// ---------------------------------------------------------------
|
|
577
|
+
describe.skipIf(!capabilities.removeDir)("removeDir", () => {
|
|
578
|
+
test("removeDir removes empty directory", async () => {
|
|
579
|
+
await fs.mkdir("/emptydir", { recursive: true });
|
|
580
|
+
await fs.removeDir("/emptydir");
|
|
581
|
+
expect(await fs.exists("/emptydir")).toBe(false);
|
|
582
|
+
});
|
|
583
|
+
test("removeDir on non-empty directory throws ENOTEMPTY", async () => {
|
|
584
|
+
await fs.writeFile("/nonempty/child.txt", "x");
|
|
585
|
+
const err = await fs.removeDir("/nonempty").catch((e) => e);
|
|
586
|
+
expect(err).toBeInstanceOf(Error);
|
|
587
|
+
expect(hasErrorCode(err, "ENOTEMPTY") ||
|
|
588
|
+
hasErrorCode(err, "EPERM")).toBe(true);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
// ---------------------------------------------------------------
|
|
592
|
+
// pread tests (gated)
|
|
593
|
+
// ---------------------------------------------------------------
|
|
594
|
+
describe.skipIf(!capabilities.pread)("pread", () => {
|
|
595
|
+
test("pread reads range from file", async () => {
|
|
596
|
+
await fs.writeFile("/pr.txt", "abcdefghij");
|
|
597
|
+
const data = await fs.pread("/pr.txt", 3, 4);
|
|
598
|
+
expect(new TextDecoder().decode(data)).toBe("defg");
|
|
599
|
+
});
|
|
600
|
+
test("pread at offset 0", async () => {
|
|
601
|
+
await fs.writeFile("/pr0.txt", "hello");
|
|
602
|
+
const data = await fs.pread("/pr0.txt", 0, 5);
|
|
603
|
+
expect(new TextDecoder().decode(data)).toBe("hello");
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
// ---------------------------------------------------------------
|
|
607
|
+
// fsync tests (gated)
|
|
608
|
+
// ---------------------------------------------------------------
|
|
609
|
+
describe.skipIf(!capabilities.fsync)("fsync", () => {
|
|
610
|
+
test("pwrite + fsync + readFile returns written data", async () => {
|
|
611
|
+
await fs.writeFile("/fsync.txt", "original");
|
|
612
|
+
await fs.pwrite("/fsync.txt", 0, new TextEncoder().encode("modified"));
|
|
613
|
+
const fsyncFn = fs.fsync;
|
|
614
|
+
if (fsyncFn)
|
|
615
|
+
await fsyncFn.call(fs, "/fsync.txt");
|
|
616
|
+
const text = await fs.readTextFile("/fsync.txt");
|
|
617
|
+
expect(text).toBe("modified");
|
|
618
|
+
});
|
|
619
|
+
test("pwrite without fsync still returns data via readFile", async () => {
|
|
620
|
+
await fs.writeFile("/nofsync.txt", "original");
|
|
621
|
+
await fs.pwrite("/nofsync.txt", 0, new TextEncoder().encode("buffered"));
|
|
622
|
+
const text = await fs.readTextFile("/nofsync.txt");
|
|
623
|
+
expect(text).toBe("buffered");
|
|
624
|
+
});
|
|
625
|
+
test("fsync on nonexistent path is silent no-op", async () => {
|
|
626
|
+
const fsyncFn = fs.fsync;
|
|
627
|
+
if (fsyncFn) {
|
|
628
|
+
await expect(fsyncFn.call(fs, "/no-such-file.txt")).resolves.toBeUndefined();
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
// ---------------------------------------------------------------
|
|
633
|
+
// copy tests (gated)
|
|
634
|
+
// ---------------------------------------------------------------
|
|
635
|
+
describe.skipIf(!capabilities.copy)("copy", () => {
|
|
636
|
+
test("copy file: content matches original", async () => {
|
|
637
|
+
const data = makeData(1024);
|
|
638
|
+
await fs.writeFile("/copysrc.dat", data);
|
|
639
|
+
const copyFn = fs.copy;
|
|
640
|
+
if (copyFn) {
|
|
641
|
+
await copyFn.call(fs, "/copysrc.dat", "/copydst.dat");
|
|
642
|
+
const result = await fs.readFile("/copydst.dat");
|
|
643
|
+
expect(result).toEqual(data);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
test("copy file: modifying copy does not affect original", async () => {
|
|
647
|
+
await fs.writeFile("/orig.txt", "original");
|
|
648
|
+
const copyFn = fs.copy;
|
|
649
|
+
if (copyFn) {
|
|
650
|
+
await copyFn.call(fs, "/orig.txt", "/dup.txt");
|
|
651
|
+
await fs.writeFile("/dup.txt", "modified");
|
|
652
|
+
const origText = await fs.readTextFile("/orig.txt");
|
|
653
|
+
expect(origText).toBe("original");
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
test("copy file: metadata matches original", async () => {
|
|
657
|
+
await fs.writeFile("/meta-src.txt", "meta");
|
|
658
|
+
await fs.chmod("/meta-src.txt", 0o644);
|
|
659
|
+
const copyFn = fs.copy;
|
|
660
|
+
if (copyFn) {
|
|
661
|
+
await copyFn.call(fs, "/meta-src.txt", "/meta-dst.txt");
|
|
662
|
+
const srcStat = await fs.stat("/meta-src.txt");
|
|
663
|
+
const dstStat = await fs.stat("/meta-dst.txt");
|
|
664
|
+
expect(dstStat.size).toBe(srcStat.size);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
// ---------------------------------------------------------------
|
|
669
|
+
// readDirStat tests (gated)
|
|
670
|
+
// ---------------------------------------------------------------
|
|
671
|
+
describe.skipIf(!capabilities.readDirStat)("readDirStat", () => {
|
|
672
|
+
test("readDirStat returns same entries as readDir", async () => {
|
|
673
|
+
await fs.writeFile("/rds/file.txt", "f");
|
|
674
|
+
await fs.writeFile("/rds/sub/nested.txt", "n");
|
|
675
|
+
const dirEntries = await fs.readDir("/rds");
|
|
676
|
+
const rdsFn = fs.readDirStat;
|
|
677
|
+
if (rdsFn) {
|
|
678
|
+
const statEntries = await rdsFn.call(fs, "/rds");
|
|
679
|
+
const names = statEntries.map((e) => e.name).sort();
|
|
680
|
+
expect(names).toEqual(dirEntries.sort());
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
test("readDirStat entries have valid stat fields", async () => {
|
|
684
|
+
await fs.writeFile("/rds2/a.txt", "aaa");
|
|
685
|
+
await fs.writeFile("/rds2/dir/b.txt", "b");
|
|
686
|
+
const rdsFn = fs.readDirStat;
|
|
687
|
+
if (rdsFn) {
|
|
688
|
+
const entries = await rdsFn.call(fs, "/rds2");
|
|
689
|
+
const fileEntry = entries.find((e) => e.name === "a.txt");
|
|
690
|
+
expect(fileEntry).toBeDefined();
|
|
691
|
+
expect(fileEntry.stat.size).toBe(3);
|
|
692
|
+
expect(fileEntry.isDirectory).toBe(false);
|
|
693
|
+
const dirEntry = entries.find((e) => e.name === "dir");
|
|
694
|
+
expect(dirEntry).toBeDefined();
|
|
695
|
+
expect(dirEntry.isDirectory).toBe(true);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
// ---------------------------------------------------------------
|
|
700
|
+
// Edge cases
|
|
701
|
+
// ---------------------------------------------------------------
|
|
702
|
+
describe("edge cases", () => {
|
|
703
|
+
test("empty file: stat.size == 0, readFile returns empty Uint8Array", async () => {
|
|
704
|
+
await fs.writeFile("/empty.txt", "");
|
|
705
|
+
const s = await fs.stat("/empty.txt");
|
|
706
|
+
expect(s.size).toBe(0);
|
|
707
|
+
const data = await fs.readFile("/empty.txt");
|
|
708
|
+
expect(data.length).toBe(0);
|
|
709
|
+
});
|
|
710
|
+
test.skipIf(config.inlineThreshold == null)("file at exactly inlineThreshold bytes", async () => {
|
|
711
|
+
const threshold = config.inlineThreshold;
|
|
712
|
+
const data = makeData(threshold);
|
|
713
|
+
await fs.writeFile("/at-threshold.dat", data);
|
|
714
|
+
const result = await fs.readFile("/at-threshold.dat");
|
|
715
|
+
expect(result).toEqual(data);
|
|
716
|
+
const s = await fs.stat("/at-threshold.dat");
|
|
717
|
+
expect(s.size).toBe(threshold);
|
|
718
|
+
});
|
|
719
|
+
test.skipIf(config.inlineThreshold == null)("file at inlineThreshold + 1 bytes", async () => {
|
|
720
|
+
const threshold = config.inlineThreshold;
|
|
721
|
+
const data = makeData(threshold + 1);
|
|
722
|
+
await fs.writeFile("/above-threshold.dat", data);
|
|
723
|
+
const result = await fs.readFile("/above-threshold.dat");
|
|
724
|
+
expect(result).toEqual(data);
|
|
725
|
+
const s = await fs.stat("/above-threshold.dat");
|
|
726
|
+
expect(s.size).toBe(threshold + 1);
|
|
727
|
+
});
|
|
728
|
+
test.skipIf(config.chunkSize == null)("file at exactly chunkSize bytes", async () => {
|
|
729
|
+
const cs = config.chunkSize;
|
|
730
|
+
const data = makeData(cs);
|
|
731
|
+
await fs.writeFile("/at-chunk.dat", data);
|
|
732
|
+
const result = await fs.readFile("/at-chunk.dat");
|
|
733
|
+
expect(result).toEqual(data);
|
|
734
|
+
});
|
|
735
|
+
test.skipIf(config.chunkSize == null)("file at chunkSize + 1 bytes", async () => {
|
|
736
|
+
const cs = config.chunkSize;
|
|
737
|
+
const data = makeData(cs + 1);
|
|
738
|
+
await fs.writeFile("/above-chunk.dat", data);
|
|
739
|
+
const result = await fs.readFile("/above-chunk.dat");
|
|
740
|
+
expect(result).toEqual(data);
|
|
741
|
+
});
|
|
742
|
+
test.skipIf(!capabilities.pread)("pread with length 0 returns empty Uint8Array", async () => {
|
|
743
|
+
await fs.writeFile("/pread0.txt", "hello");
|
|
744
|
+
const data = await fs.pread("/pread0.txt", 0, 0);
|
|
745
|
+
expect(data.length).toBe(0);
|
|
746
|
+
});
|
|
747
|
+
test.skipIf(!capabilities.pread)("pread at offset == file size returns empty Uint8Array", async () => {
|
|
748
|
+
await fs.writeFile("/preadeof.txt", "hello");
|
|
749
|
+
const data = await fs.pread("/preadeof.txt", 5, 10);
|
|
750
|
+
expect(data.length).toBe(0);
|
|
751
|
+
});
|
|
752
|
+
test.skipIf(!capabilities.pread)("pread at offset > file size returns empty Uint8Array", async () => {
|
|
753
|
+
await fs.writeFile("/preadbeyond.txt", "hello");
|
|
754
|
+
const data = await fs.pread("/preadbeyond.txt", 100, 10);
|
|
755
|
+
expect(data.length).toBe(0);
|
|
756
|
+
});
|
|
757
|
+
test("writeFile with empty content creates empty file", async () => {
|
|
758
|
+
await fs.writeFile("/empty-write.txt", new Uint8Array(0));
|
|
759
|
+
const s = await fs.stat("/empty-write.txt");
|
|
760
|
+
expect(s.size).toBe(0);
|
|
761
|
+
const data = await fs.readFile("/empty-write.txt");
|
|
762
|
+
expect(data.length).toBe(0);
|
|
763
|
+
});
|
|
764
|
+
test("long filename (255 chars)", async () => {
|
|
765
|
+
const longName = "a".repeat(255);
|
|
766
|
+
const path = `/${longName}`;
|
|
767
|
+
await fs.writeFile(path, "long name content");
|
|
768
|
+
const text = await fs.readTextFile(path);
|
|
769
|
+
expect(text).toBe("long name content");
|
|
770
|
+
});
|
|
771
|
+
test("deeply nested path (20 levels)", async () => {
|
|
772
|
+
const parts = Array.from({ length: 20 }, (_, i) => `d${i}`);
|
|
773
|
+
const path = "/" + parts.join("/") + "/deep.txt";
|
|
774
|
+
await fs.writeFile(path, "deep");
|
|
775
|
+
const text = await fs.readTextFile(path);
|
|
776
|
+
expect(text).toBe("deep");
|
|
777
|
+
});
|
|
778
|
+
test.skipIf(!capabilities.pwrite)("pwrite on nonexistent file throws ENOENT", async () => {
|
|
779
|
+
const err = await fs
|
|
780
|
+
.pwrite("/no-such-file.txt", 0, new TextEncoder().encode("x"))
|
|
781
|
+
.catch((e) => e);
|
|
782
|
+
expectErrorCode(err, "ENOENT");
|
|
783
|
+
});
|
|
784
|
+
test.skipIf(!capabilities.truncate)("truncate on nonexistent file throws ENOENT", async () => {
|
|
785
|
+
const err = await fs.truncate("/no-such-file.txt", 0).catch((e) => e);
|
|
786
|
+
expectErrorCode(err, "ENOENT");
|
|
787
|
+
});
|
|
788
|
+
test("stat on root directory '/' returns isDirectory: true", async () => {
|
|
789
|
+
const s = await fs.stat("/");
|
|
790
|
+
expect(s.isDirectory).toBe(true);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
// ---------------------------------------------------------------
|
|
794
|
+
// Relative symlink tests (gated)
|
|
795
|
+
// ---------------------------------------------------------------
|
|
796
|
+
describe.skipIf(!capabilities.symlinks)("relative symlinks", () => {
|
|
797
|
+
test("relative symlink resolution", async () => {
|
|
798
|
+
// Create /dir/real.txt, then /dir/link.txt -> real.txt (relative)
|
|
799
|
+
await fs.writeFile("/dir/real.txt", "real content");
|
|
800
|
+
await fs.symlink("real.txt", "/dir/link.txt");
|
|
801
|
+
const text = await fs.readTextFile("/dir/link.txt");
|
|
802
|
+
expect(text).toBe("real content");
|
|
803
|
+
});
|
|
804
|
+
test("symlink-to-directory traversal", async () => {
|
|
805
|
+
// Create /real-dir/file.txt, then /a -> /real-dir
|
|
806
|
+
// Reading /a/file.txt should resolve to /real-dir/file.txt
|
|
807
|
+
await fs.writeFile("/real-dir/file.txt", "traversed");
|
|
808
|
+
await fs.symlink("/real-dir", "/a");
|
|
809
|
+
const text = await fs.readTextFile("/a/file.txt");
|
|
810
|
+
expect(text).toBe("traversed");
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
// ---------------------------------------------------------------
|
|
814
|
+
// Concurrent rename + readFile (gated on pwrite for concurrency)
|
|
815
|
+
// ---------------------------------------------------------------
|
|
816
|
+
describe.skipIf(!capabilities.pwrite)("concurrent rename + readFile", () => {
|
|
817
|
+
test("concurrent rename + readFile does not crash or corrupt", async () => {
|
|
818
|
+
await fs.writeFile("/conc-rename.txt", "data before rename");
|
|
819
|
+
// Run rename and readFile concurrently. Neither should crash.
|
|
820
|
+
// readFile may see the file at old or new path depending on ordering.
|
|
821
|
+
const results = await Promise.allSettled([
|
|
822
|
+
fs.rename("/conc-rename.txt", "/conc-renamed.txt"),
|
|
823
|
+
fs.readFile("/conc-rename.txt"),
|
|
824
|
+
]);
|
|
825
|
+
// Rename should succeed.
|
|
826
|
+
expect(results[0].status).toBe("fulfilled");
|
|
827
|
+
// readFile may succeed (read before rename) or fail with ENOENT (read after rename).
|
|
828
|
+
if (results[1].status === "fulfilled") {
|
|
829
|
+
const data = results[1].value;
|
|
830
|
+
expect(new TextDecoder().decode(data)).toBe("data before rename");
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
const err = results[1].reason;
|
|
834
|
+
expectErrorCode(err, "ENOENT");
|
|
835
|
+
}
|
|
836
|
+
// The file should exist at the new path.
|
|
837
|
+
const text = await fs.readTextFile("/conc-renamed.txt");
|
|
838
|
+
expect(text).toBe("data before rename");
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
}
|