@secure-exec/core 0.2.0-rc.1 → 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 +2 -2
- package/dist/generated/isolate-runtime.js +2 -2
- package/dist/index.d.ts +17 -4
- package/dist/index.js +10 -2
- package/dist/isolate-runtime/require-setup.js +1489 -239
- package/dist/isolate-runtime/setup-dynamic-import.js +31 -0
- 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/file-lock.js +2 -3
- package/dist/kernel/index.d.ts +4 -4
- package/dist/kernel/index.js +3 -3
- package/dist/kernel/kernel.js +141 -122
- 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/socket-table.d.ts +7 -0
- package/dist/kernel/socket-table.js +99 -35
- 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/bridge-contract.d.ts +21 -3
- package/dist/shared/bridge-contract.js +2 -0
- package/dist/shared/console-formatter.js +8 -8
- package/dist/shared/global-exposure.js +95 -0
- package/dist/shared/in-memory-fs.d.ts +14 -59
- package/dist/shared/in-memory-fs.js +97 -597
- 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,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared FsMetadataStore conformance test suite.
|
|
3
|
+
*
|
|
4
|
+
* Every FsMetadataStore implementation must pass the 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 { defineMetadataStoreTests } from "@secure-exec/core/test/metadata-store-conformance";
|
|
11
|
+
*
|
|
12
|
+
* defineMetadataStoreTests({
|
|
13
|
+
* name: "InMemoryMetadataStore",
|
|
14
|
+
* createStore: () => new InMemoryMetadataStore(),
|
|
15
|
+
* capabilities: { versioning: false },
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The `versioning` capability flag gates tests for the optional
|
|
20
|
+
* FsMetadataStoreVersioning interface (deferred to US-013).
|
|
21
|
+
*/
|
|
22
|
+
import { describe, beforeEach, afterEach, expect, test } from "vitest";
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Error code helper
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
function hasErrorCode(err, code) {
|
|
27
|
+
if (typeof err !== "object" || err === null)
|
|
28
|
+
return false;
|
|
29
|
+
const e = err;
|
|
30
|
+
if (e.code === code)
|
|
31
|
+
return true;
|
|
32
|
+
if (typeof e.message === "string" && e.message.startsWith(`${code}:`))
|
|
33
|
+
return true;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
function expectErrorCode(err, code) {
|
|
37
|
+
expect(err).toBeInstanceOf(Error);
|
|
38
|
+
expect(hasErrorCode(err, code)).toBe(true);
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
function fileAttrs(mode = 0o644) {
|
|
44
|
+
return { type: "file", mode, uid: 1000, gid: 1000 };
|
|
45
|
+
}
|
|
46
|
+
function dirAttrs(mode = 0o755) {
|
|
47
|
+
return { type: "directory", mode, uid: 1000, gid: 1000 };
|
|
48
|
+
}
|
|
49
|
+
function symlinkAttrs(target) {
|
|
50
|
+
return { type: "symlink", mode: 0o777, uid: 1000, gid: 1000, symlinkTarget: target };
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Test suite
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
export function defineMetadataStoreTests(config) {
|
|
56
|
+
const { name } = config;
|
|
57
|
+
describe(name, () => {
|
|
58
|
+
let store;
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
store = await config.createStore();
|
|
61
|
+
});
|
|
62
|
+
afterEach(async () => {
|
|
63
|
+
if (config.cleanup)
|
|
64
|
+
await config.cleanup();
|
|
65
|
+
});
|
|
66
|
+
// ---------------------------------------------------------------
|
|
67
|
+
// Inode lifecycle
|
|
68
|
+
// ---------------------------------------------------------------
|
|
69
|
+
describe("inode lifecycle", () => {
|
|
70
|
+
test("createInode returns unique ino numbers", async () => {
|
|
71
|
+
const ino1 = await store.createInode(fileAttrs());
|
|
72
|
+
const ino2 = await store.createInode(fileAttrs());
|
|
73
|
+
const ino3 = await store.createInode(fileAttrs());
|
|
74
|
+
expect(ino1).not.toBe(ino2);
|
|
75
|
+
expect(ino2).not.toBe(ino3);
|
|
76
|
+
expect(ino1).not.toBe(ino3);
|
|
77
|
+
// Root is ino 1, so all should be > 1.
|
|
78
|
+
expect(ino1).toBeGreaterThan(1);
|
|
79
|
+
expect(ino2).toBeGreaterThan(1);
|
|
80
|
+
expect(ino3).toBeGreaterThan(1);
|
|
81
|
+
});
|
|
82
|
+
test("getInode returns correct data", async () => {
|
|
83
|
+
const ino = await store.createInode(fileAttrs(0o600));
|
|
84
|
+
const meta = await store.getInode(ino);
|
|
85
|
+
expect(meta).not.toBeNull();
|
|
86
|
+
expect(meta.ino).toBe(ino);
|
|
87
|
+
expect(meta.type).toBe("file");
|
|
88
|
+
expect(meta.uid).toBe(1000);
|
|
89
|
+
expect(meta.gid).toBe(1000);
|
|
90
|
+
expect(meta.size).toBe(0);
|
|
91
|
+
expect(meta.nlink).toBe(0);
|
|
92
|
+
expect(meta.storageMode).toBe("inline");
|
|
93
|
+
expect(meta.inlineContent).toBeNull();
|
|
94
|
+
expect(meta.atimeMs).toBeGreaterThan(0);
|
|
95
|
+
expect(meta.mtimeMs).toBeGreaterThan(0);
|
|
96
|
+
expect(meta.ctimeMs).toBeGreaterThan(0);
|
|
97
|
+
expect(meta.birthtimeMs).toBeGreaterThan(0);
|
|
98
|
+
});
|
|
99
|
+
test("updateInode partial update", async () => {
|
|
100
|
+
const ino = await store.createInode(fileAttrs());
|
|
101
|
+
await store.updateInode(ino, { size: 100, nlink: 2 });
|
|
102
|
+
const meta = await store.getInode(ino);
|
|
103
|
+
expect(meta.size).toBe(100);
|
|
104
|
+
expect(meta.nlink).toBe(2);
|
|
105
|
+
// Other fields unchanged.
|
|
106
|
+
expect(meta.type).toBe("file");
|
|
107
|
+
expect(meta.uid).toBe(1000);
|
|
108
|
+
});
|
|
109
|
+
test("deleteInode makes getInode return null", async () => {
|
|
110
|
+
const ino = await store.createInode(fileAttrs());
|
|
111
|
+
await store.deleteInode(ino);
|
|
112
|
+
const meta = await store.getInode(ino);
|
|
113
|
+
expect(meta).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
test("getInode for never-created ino returns null", async () => {
|
|
116
|
+
const meta = await store.getInode(99999);
|
|
117
|
+
expect(meta).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
test("deleteInode cleans up associated chunk mappings and symlink targets", async () => {
|
|
120
|
+
// Create a file inode with chunk mappings.
|
|
121
|
+
const fileIno = await store.createInode(fileAttrs());
|
|
122
|
+
await store.setChunkKey(fileIno, 0, "chunk-0");
|
|
123
|
+
await store.setChunkKey(fileIno, 1, "chunk-1");
|
|
124
|
+
// Create a symlink inode.
|
|
125
|
+
const symlinkIno = await store.createInode(symlinkAttrs("/some/target"));
|
|
126
|
+
// Delete the file inode.
|
|
127
|
+
await store.deleteInode(fileIno);
|
|
128
|
+
expect(await store.getInode(fileIno)).toBeNull();
|
|
129
|
+
// Chunk mappings should also be cleaned up.
|
|
130
|
+
expect(await store.getChunkKey(fileIno, 0)).toBeNull();
|
|
131
|
+
expect(await store.getChunkKey(fileIno, 1)).toBeNull();
|
|
132
|
+
// Delete the symlink inode.
|
|
133
|
+
await store.deleteInode(symlinkIno);
|
|
134
|
+
expect(await store.getInode(symlinkIno)).toBeNull();
|
|
135
|
+
// readSymlink should fail or return null.
|
|
136
|
+
try {
|
|
137
|
+
const target = await store.readSymlink(symlinkIno);
|
|
138
|
+
// If it doesn't throw, it should return null or undefined.
|
|
139
|
+
expect(target == null).toBe(true);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Expected: inode no longer exists.
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
// ---------------------------------------------------------------
|
|
147
|
+
// Directory entries
|
|
148
|
+
// ---------------------------------------------------------------
|
|
149
|
+
describe("directory entries", () => {
|
|
150
|
+
test("createDentry + lookup round-trip", async () => {
|
|
151
|
+
const childIno = await store.createInode(fileAttrs());
|
|
152
|
+
await store.createDentry(1, "hello.txt", childIno, "file");
|
|
153
|
+
const result = await store.lookup(1, "hello.txt");
|
|
154
|
+
expect(result).toBe(childIno);
|
|
155
|
+
});
|
|
156
|
+
test("lookup nonexistent name returns null", async () => {
|
|
157
|
+
const result = await store.lookup(1, "nonexistent");
|
|
158
|
+
expect(result).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
test("listDir returns all children", async () => {
|
|
161
|
+
const ino1 = await store.createInode(fileAttrs());
|
|
162
|
+
const ino2 = await store.createInode(fileAttrs());
|
|
163
|
+
const ino3 = await store.createInode(dirAttrs());
|
|
164
|
+
await store.createDentry(1, "a.txt", ino1, "file");
|
|
165
|
+
await store.createDentry(1, "b.txt", ino2, "file");
|
|
166
|
+
await store.createDentry(1, "subdir", ino3, "directory");
|
|
167
|
+
const entries = await store.listDir(1);
|
|
168
|
+
expect(entries.length).toBe(3);
|
|
169
|
+
const names = entries.map((e) => e.name).sort();
|
|
170
|
+
expect(names).toEqual(["a.txt", "b.txt", "subdir"]);
|
|
171
|
+
const subdirEntry = entries.find((e) => e.name === "subdir");
|
|
172
|
+
expect(subdirEntry.type).toBe("directory");
|
|
173
|
+
expect(subdirEntry.ino).toBe(ino3);
|
|
174
|
+
});
|
|
175
|
+
test("listDir on empty directory returns empty", async () => {
|
|
176
|
+
const dirIno = await store.createInode(dirAttrs());
|
|
177
|
+
const entries = await store.listDir(dirIno);
|
|
178
|
+
expect(entries).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
test("listDirWithStats returns full metadata", async () => {
|
|
181
|
+
const childIno = await store.createInode(fileAttrs(0o644));
|
|
182
|
+
await store.updateInode(childIno, { size: 42, nlink: 1 });
|
|
183
|
+
await store.createDentry(1, "file.txt", childIno, "file");
|
|
184
|
+
const entries = await store.listDirWithStats(1);
|
|
185
|
+
expect(entries.length).toBe(1);
|
|
186
|
+
const entry = entries[0];
|
|
187
|
+
expect(entry.name).toBe("file.txt");
|
|
188
|
+
expect(entry.ino).toBe(childIno);
|
|
189
|
+
expect(entry.type).toBe("file");
|
|
190
|
+
expect(entry.stat).toBeDefined();
|
|
191
|
+
expect(entry.stat.ino).toBe(childIno);
|
|
192
|
+
expect(entry.stat.size).toBe(42);
|
|
193
|
+
expect(entry.stat.nlink).toBe(1);
|
|
194
|
+
});
|
|
195
|
+
test("removeDentry makes lookup return null", async () => {
|
|
196
|
+
const childIno = await store.createInode(fileAttrs());
|
|
197
|
+
await store.createDentry(1, "file.txt", childIno, "file");
|
|
198
|
+
await store.removeDentry(1, "file.txt");
|
|
199
|
+
const result = await store.lookup(1, "file.txt");
|
|
200
|
+
expect(result).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
test("removeDentry does NOT delete child inode", async () => {
|
|
203
|
+
const childIno = await store.createInode(fileAttrs());
|
|
204
|
+
await store.createDentry(1, "file.txt", childIno, "file");
|
|
205
|
+
await store.removeDentry(1, "file.txt");
|
|
206
|
+
// Inode should still exist.
|
|
207
|
+
const meta = await store.getInode(childIno);
|
|
208
|
+
expect(meta).not.toBeNull();
|
|
209
|
+
expect(meta.ino).toBe(childIno);
|
|
210
|
+
});
|
|
211
|
+
test("createDentry duplicate name throws EEXIST", async () => {
|
|
212
|
+
const ino1 = await store.createInode(fileAttrs());
|
|
213
|
+
const ino2 = await store.createInode(fileAttrs());
|
|
214
|
+
await store.createDentry(1, "file.txt", ino1, "file");
|
|
215
|
+
try {
|
|
216
|
+
await store.createDentry(1, "file.txt", ino2, "file");
|
|
217
|
+
expect.fail("should have thrown");
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
expectErrorCode(err, "EEXIST");
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
test("renameDentry same parent", async () => {
|
|
224
|
+
const childIno = await store.createInode(fileAttrs());
|
|
225
|
+
await store.createDentry(1, "old.txt", childIno, "file");
|
|
226
|
+
await store.renameDentry(1, "old.txt", 1, "new.txt");
|
|
227
|
+
expect(await store.lookup(1, "old.txt")).toBeNull();
|
|
228
|
+
expect(await store.lookup(1, "new.txt")).toBe(childIno);
|
|
229
|
+
});
|
|
230
|
+
test("renameDentry across parents", async () => {
|
|
231
|
+
const dirIno = await store.createInode(dirAttrs());
|
|
232
|
+
await store.createDentry(1, "subdir", dirIno, "directory");
|
|
233
|
+
const childIno = await store.createInode(fileAttrs());
|
|
234
|
+
await store.createDentry(1, "file.txt", childIno, "file");
|
|
235
|
+
await store.renameDentry(1, "file.txt", dirIno, "moved.txt");
|
|
236
|
+
expect(await store.lookup(1, "file.txt")).toBeNull();
|
|
237
|
+
expect(await store.lookup(dirIno, "moved.txt")).toBe(childIno);
|
|
238
|
+
});
|
|
239
|
+
test("renameDentry overwrites existing destination", async () => {
|
|
240
|
+
const ino1 = await store.createInode(fileAttrs());
|
|
241
|
+
const ino2 = await store.createInode(fileAttrs());
|
|
242
|
+
await store.createDentry(1, "src.txt", ino1, "file");
|
|
243
|
+
await store.createDentry(1, "dst.txt", ino2, "file");
|
|
244
|
+
await store.renameDentry(1, "src.txt", 1, "dst.txt");
|
|
245
|
+
expect(await store.lookup(1, "src.txt")).toBeNull();
|
|
246
|
+
expect(await store.lookup(1, "dst.txt")).toBe(ino1);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
// ---------------------------------------------------------------
|
|
250
|
+
// Path resolution
|
|
251
|
+
// ---------------------------------------------------------------
|
|
252
|
+
describe("path resolution", () => {
|
|
253
|
+
test("resolvePath root returns ino 1", async () => {
|
|
254
|
+
const ino = await store.resolvePath("/");
|
|
255
|
+
expect(ino).toBe(1);
|
|
256
|
+
});
|
|
257
|
+
test("resolvePath single component", async () => {
|
|
258
|
+
const fileIno = await store.createInode(fileAttrs());
|
|
259
|
+
await store.createDentry(1, "hello.txt", fileIno, "file");
|
|
260
|
+
const ino = await store.resolvePath("/hello.txt");
|
|
261
|
+
expect(ino).toBe(fileIno);
|
|
262
|
+
});
|
|
263
|
+
test("resolvePath multi-component", async () => {
|
|
264
|
+
const dirIno = await store.createInode(dirAttrs());
|
|
265
|
+
await store.createDentry(1, "a", dirIno, "directory");
|
|
266
|
+
const fileIno = await store.createInode(fileAttrs());
|
|
267
|
+
await store.createDentry(dirIno, "b.txt", fileIno, "file");
|
|
268
|
+
const ino = await store.resolvePath("/a/b.txt");
|
|
269
|
+
expect(ino).toBe(fileIno);
|
|
270
|
+
});
|
|
271
|
+
test("resolvePath follows symlinks", async () => {
|
|
272
|
+
const fileIno = await store.createInode(fileAttrs());
|
|
273
|
+
await store.createDentry(1, "real.txt", fileIno, "file");
|
|
274
|
+
const linkIno = await store.createInode(symlinkAttrs("/real.txt"));
|
|
275
|
+
await store.createDentry(1, "link.txt", linkIno, "symlink");
|
|
276
|
+
const ino = await store.resolvePath("/link.txt");
|
|
277
|
+
expect(ino).toBe(fileIno);
|
|
278
|
+
});
|
|
279
|
+
test("resolvePath ENOENT for missing intermediate", async () => {
|
|
280
|
+
try {
|
|
281
|
+
await store.resolvePath("/nonexistent/file.txt");
|
|
282
|
+
expect.fail("should have thrown");
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
expectErrorCode(err, "ENOENT");
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
test("resolvePath ENOENT for missing final component", async () => {
|
|
289
|
+
const dirIno = await store.createInode(dirAttrs());
|
|
290
|
+
await store.createDentry(1, "dir", dirIno, "directory");
|
|
291
|
+
try {
|
|
292
|
+
await store.resolvePath("/dir/missing.txt");
|
|
293
|
+
expect.fail("should have thrown");
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
expectErrorCode(err, "ENOENT");
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
test("resolvePath ELOOP on circular symlinks", async () => {
|
|
300
|
+
const linkA = await store.createInode(symlinkAttrs("/b"));
|
|
301
|
+
await store.createDentry(1, "a", linkA, "symlink");
|
|
302
|
+
const linkB = await store.createInode(symlinkAttrs("/a"));
|
|
303
|
+
await store.createDentry(1, "b", linkB, "symlink");
|
|
304
|
+
try {
|
|
305
|
+
await store.resolvePath("/a");
|
|
306
|
+
expect.fail("should have thrown");
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
expectErrorCode(err, "ELOOP");
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
test("resolvePath ELOOP at depth 41", async () => {
|
|
313
|
+
// Create a chain of 41 symlinks: link0 -> link1 -> ... -> link40.
|
|
314
|
+
// link40 points to a real file, but depth > 40 should ELOOP.
|
|
315
|
+
const fileIno = await store.createInode(fileAttrs());
|
|
316
|
+
await store.createDentry(1, "target", fileIno, "file");
|
|
317
|
+
let prevName = "target";
|
|
318
|
+
for (let i = 40; i >= 0; i--) {
|
|
319
|
+
const name = `link${i}`;
|
|
320
|
+
const linkIno = await store.createInode(symlinkAttrs(`/${prevName}`));
|
|
321
|
+
await store.createDentry(1, name, linkIno, "symlink");
|
|
322
|
+
prevName = name;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
await store.resolvePath("/link0");
|
|
326
|
+
expect.fail("should have thrown");
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
expectErrorCode(err, "ELOOP");
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
test("resolveParentPath returns parent and name", async () => {
|
|
333
|
+
const dirIno = await store.createInode(dirAttrs());
|
|
334
|
+
await store.createDentry(1, "dir", dirIno, "directory");
|
|
335
|
+
const fileIno = await store.createInode(fileAttrs());
|
|
336
|
+
await store.createDentry(dirIno, "file.txt", fileIno, "file");
|
|
337
|
+
const result = await store.resolveParentPath("/dir/file.txt");
|
|
338
|
+
expect(result.parentIno).toBe(dirIno);
|
|
339
|
+
expect(result.name).toBe("file.txt");
|
|
340
|
+
});
|
|
341
|
+
test("resolveParentPath does not follow final symlink", async () => {
|
|
342
|
+
const fileIno = await store.createInode(fileAttrs());
|
|
343
|
+
await store.createDentry(1, "real.txt", fileIno, "file");
|
|
344
|
+
const linkIno = await store.createInode(symlinkAttrs("/real.txt"));
|
|
345
|
+
await store.createDentry(1, "link.txt", linkIno, "symlink");
|
|
346
|
+
// resolveParentPath should return the parent dir and "link.txt",
|
|
347
|
+
// not follow the symlink.
|
|
348
|
+
const result = await store.resolveParentPath("/link.txt");
|
|
349
|
+
expect(result.parentIno).toBe(1);
|
|
350
|
+
expect(result.name).toBe("link.txt");
|
|
351
|
+
});
|
|
352
|
+
test("resolveParentPath ENOENT for missing intermediate", async () => {
|
|
353
|
+
try {
|
|
354
|
+
await store.resolveParentPath("/nonexistent/file.txt");
|
|
355
|
+
expect.fail("should have thrown");
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
expectErrorCode(err, "ENOENT");
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
test("resolvePath with relative symlink targets", async () => {
|
|
362
|
+
// Create /dir/ containing real.txt and a relative symlink link.txt -> real.txt.
|
|
363
|
+
const dirIno = await store.createInode(dirAttrs());
|
|
364
|
+
await store.createDentry(1, "dir", dirIno, "directory");
|
|
365
|
+
const fileIno = await store.createInode(fileAttrs());
|
|
366
|
+
await store.createDentry(dirIno, "real.txt", fileIno, "file");
|
|
367
|
+
// Relative symlink: target is "real.txt" (no leading /).
|
|
368
|
+
const linkIno = await store.createInode(symlinkAttrs("real.txt"));
|
|
369
|
+
await store.createDentry(dirIno, "link.txt", linkIno, "symlink");
|
|
370
|
+
const resolved = await store.resolvePath("/dir/link.txt");
|
|
371
|
+
expect(resolved).toBe(fileIno);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
// ---------------------------------------------------------------
|
|
375
|
+
// Chunk mapping
|
|
376
|
+
// ---------------------------------------------------------------
|
|
377
|
+
describe("chunk mapping", () => {
|
|
378
|
+
test("set + get round-trip", async () => {
|
|
379
|
+
const ino = await store.createInode(fileAttrs());
|
|
380
|
+
await store.setChunkKey(ino, 0, "block-key-0");
|
|
381
|
+
const key = await store.getChunkKey(ino, 0);
|
|
382
|
+
expect(key).toBe("block-key-0");
|
|
383
|
+
});
|
|
384
|
+
test("get missing chunk returns null", async () => {
|
|
385
|
+
const ino = await store.createInode(fileAttrs());
|
|
386
|
+
const key = await store.getChunkKey(ino, 5);
|
|
387
|
+
expect(key).toBeNull();
|
|
388
|
+
});
|
|
389
|
+
test("getAllChunkKeys returns ordered entries", async () => {
|
|
390
|
+
const ino = await store.createInode(fileAttrs());
|
|
391
|
+
await store.setChunkKey(ino, 2, "key-2");
|
|
392
|
+
await store.setChunkKey(ino, 0, "key-0");
|
|
393
|
+
await store.setChunkKey(ino, 1, "key-1");
|
|
394
|
+
const keys = await store.getAllChunkKeys(ino);
|
|
395
|
+
expect(keys).toEqual([
|
|
396
|
+
{ chunkIndex: 0, key: "key-0" },
|
|
397
|
+
{ chunkIndex: 1, key: "key-1" },
|
|
398
|
+
{ chunkIndex: 2, key: "key-2" },
|
|
399
|
+
]);
|
|
400
|
+
});
|
|
401
|
+
test("getAllChunkKeys for inode with no chunks returns empty", async () => {
|
|
402
|
+
const ino = await store.createInode(fileAttrs());
|
|
403
|
+
const keys = await store.getAllChunkKeys(ino);
|
|
404
|
+
expect(keys).toEqual([]);
|
|
405
|
+
});
|
|
406
|
+
test("setChunkKey overwrites existing key", async () => {
|
|
407
|
+
const ino = await store.createInode(fileAttrs());
|
|
408
|
+
await store.setChunkKey(ino, 0, "old-key");
|
|
409
|
+
await store.setChunkKey(ino, 0, "new-key");
|
|
410
|
+
const key = await store.getChunkKey(ino, 0);
|
|
411
|
+
expect(key).toBe("new-key");
|
|
412
|
+
});
|
|
413
|
+
test("deleteAllChunks returns all keys", async () => {
|
|
414
|
+
const ino = await store.createInode(fileAttrs());
|
|
415
|
+
await store.setChunkKey(ino, 0, "k0");
|
|
416
|
+
await store.setChunkKey(ino, 1, "k1");
|
|
417
|
+
await store.setChunkKey(ino, 2, "k2");
|
|
418
|
+
const deleted = await store.deleteAllChunks(ino);
|
|
419
|
+
expect(deleted.sort()).toEqual(["k0", "k1", "k2"]);
|
|
420
|
+
// All chunks should be gone.
|
|
421
|
+
expect(await store.getChunkKey(ino, 0)).toBeNull();
|
|
422
|
+
expect(await store.getChunkKey(ino, 1)).toBeNull();
|
|
423
|
+
expect(await store.getChunkKey(ino, 2)).toBeNull();
|
|
424
|
+
});
|
|
425
|
+
test("deleteChunksFrom returns deleted keys", async () => {
|
|
426
|
+
const ino = await store.createInode(fileAttrs());
|
|
427
|
+
await store.setChunkKey(ino, 0, "k0");
|
|
428
|
+
await store.setChunkKey(ino, 1, "k1");
|
|
429
|
+
await store.setChunkKey(ino, 2, "k2");
|
|
430
|
+
await store.setChunkKey(ino, 3, "k3");
|
|
431
|
+
const deleted = await store.deleteChunksFrom(ino, 2);
|
|
432
|
+
expect(deleted.sort()).toEqual(["k2", "k3"]);
|
|
433
|
+
// Chunks 0 and 1 should still exist.
|
|
434
|
+
expect(await store.getChunkKey(ino, 0)).toBe("k0");
|
|
435
|
+
expect(await store.getChunkKey(ino, 1)).toBe("k1");
|
|
436
|
+
// Chunks 2 and 3 should be gone.
|
|
437
|
+
expect(await store.getChunkKey(ino, 2)).toBeNull();
|
|
438
|
+
expect(await store.getChunkKey(ino, 3)).toBeNull();
|
|
439
|
+
});
|
|
440
|
+
test("deleteChunksFrom beyond last chunk is no-op", async () => {
|
|
441
|
+
const ino = await store.createInode(fileAttrs());
|
|
442
|
+
await store.setChunkKey(ino, 0, "k0");
|
|
443
|
+
const deleted = await store.deleteChunksFrom(ino, 10);
|
|
444
|
+
expect(deleted).toEqual([]);
|
|
445
|
+
// Existing chunk should still be there.
|
|
446
|
+
expect(await store.getChunkKey(ino, 0)).toBe("k0");
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
// ---------------------------------------------------------------
|
|
450
|
+
// Transactions
|
|
451
|
+
// ---------------------------------------------------------------
|
|
452
|
+
describe("transactions", () => {
|
|
453
|
+
test("commits on success", async () => {
|
|
454
|
+
const ino = await store.transaction(async () => {
|
|
455
|
+
const i = await store.createInode(fileAttrs());
|
|
456
|
+
await store.createDentry(1, "txn-file.txt", i, "file");
|
|
457
|
+
return i;
|
|
458
|
+
});
|
|
459
|
+
// Changes should be visible.
|
|
460
|
+
const meta = await store.getInode(ino);
|
|
461
|
+
expect(meta).not.toBeNull();
|
|
462
|
+
expect(await store.lookup(1, "txn-file.txt")).toBe(ino);
|
|
463
|
+
});
|
|
464
|
+
test("rolls back on error", async () => {
|
|
465
|
+
// For InMemoryMetadataStore, transaction() just calls the callback
|
|
466
|
+
// directly so there's no real rollback. But we verify the error
|
|
467
|
+
// propagates and any partial side effects from the InMemory store
|
|
468
|
+
// may or may not be visible (implementation-defined for in-memory).
|
|
469
|
+
// For SQLite, this should truly roll back.
|
|
470
|
+
const error = new Error("test rollback");
|
|
471
|
+
try {
|
|
472
|
+
await store.transaction(async () => {
|
|
473
|
+
await store.createInode(fileAttrs());
|
|
474
|
+
throw error;
|
|
475
|
+
});
|
|
476
|
+
expect.fail("should have thrown");
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
expect(err).toBe(error);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
// ---------------------------------------------------------------
|
|
484
|
+
// Symlinks
|
|
485
|
+
// ---------------------------------------------------------------
|
|
486
|
+
describe("symlinks", () => {
|
|
487
|
+
test("createInode with target + readSymlink round-trip", async () => {
|
|
488
|
+
const ino = await store.createInode(symlinkAttrs("/some/target"));
|
|
489
|
+
const target = await store.readSymlink(ino);
|
|
490
|
+
expect(target).toBe("/some/target");
|
|
491
|
+
});
|
|
492
|
+
test("readSymlink on non-symlink inode throws error", async () => {
|
|
493
|
+
const fileIno = await store.createInode(fileAttrs());
|
|
494
|
+
try {
|
|
495
|
+
await store.readSymlink(fileIno);
|
|
496
|
+
expect.fail("should have thrown");
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
// Any error is acceptable: EINVAL, ENOENT, or a generic Error.
|
|
500
|
+
expect(err).toBeInstanceOf(Error);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
// ---------------------------------------------------------------
|
|
505
|
+
// Versioning (gated)
|
|
506
|
+
// ---------------------------------------------------------------
|
|
507
|
+
describe.skipIf(!config.capabilities.versioning)("versioning", () => {
|
|
508
|
+
function getVersioningStore() {
|
|
509
|
+
return store;
|
|
510
|
+
}
|
|
511
|
+
test("createVersion returns incrementing version numbers", async () => {
|
|
512
|
+
const ino = await store.createInode(fileAttrs());
|
|
513
|
+
await store.updateInode(ino, { size: 100, storageMode: "inline", inlineContent: new Uint8Array(100) });
|
|
514
|
+
const vs = getVersioningStore();
|
|
515
|
+
const v1 = await vs.createVersion(ino);
|
|
516
|
+
const v2 = await vs.createVersion(ino);
|
|
517
|
+
const v3 = await vs.createVersion(ino);
|
|
518
|
+
expect(v1).toBe(1);
|
|
519
|
+
expect(v2).toBe(2);
|
|
520
|
+
expect(v3).toBe(3);
|
|
521
|
+
});
|
|
522
|
+
test("listVersions returns all versions newest first", async () => {
|
|
523
|
+
const ino = await store.createInode(fileAttrs());
|
|
524
|
+
await store.updateInode(ino, { size: 10, storageMode: "inline", inlineContent: new Uint8Array(10) });
|
|
525
|
+
const vs = getVersioningStore();
|
|
526
|
+
await vs.createVersion(ino);
|
|
527
|
+
await store.updateInode(ino, { size: 20 });
|
|
528
|
+
await vs.createVersion(ino);
|
|
529
|
+
await store.updateInode(ino, { size: 30 });
|
|
530
|
+
await vs.createVersion(ino);
|
|
531
|
+
const versions = await vs.listVersions(ino);
|
|
532
|
+
expect(versions.length).toBe(3);
|
|
533
|
+
expect(versions[0].version).toBe(3);
|
|
534
|
+
expect(versions[1].version).toBe(2);
|
|
535
|
+
expect(versions[2].version).toBe(1);
|
|
536
|
+
expect(versions[0].size).toBe(30);
|
|
537
|
+
expect(versions[1].size).toBe(20);
|
|
538
|
+
expect(versions[2].size).toBe(10);
|
|
539
|
+
});
|
|
540
|
+
test("getVersion returns correct metadata", async () => {
|
|
541
|
+
const ino = await store.createInode(fileAttrs());
|
|
542
|
+
const content = new Uint8Array([1, 2, 3, 4, 5]);
|
|
543
|
+
await store.updateInode(ino, { size: 5, storageMode: "inline", inlineContent: content });
|
|
544
|
+
const vs = getVersioningStore();
|
|
545
|
+
const v = await vs.createVersion(ino);
|
|
546
|
+
const meta = await vs.getVersion(ino, v);
|
|
547
|
+
expect(meta).not.toBeNull();
|
|
548
|
+
expect(meta.version).toBe(v);
|
|
549
|
+
expect(meta.size).toBe(5);
|
|
550
|
+
expect(meta.storageMode).toBe("inline");
|
|
551
|
+
expect(meta.inlineContent).toEqual(content);
|
|
552
|
+
expect(meta.createdAt).toBeGreaterThan(0);
|
|
553
|
+
});
|
|
554
|
+
test("getVersion returns null for nonexistent version", async () => {
|
|
555
|
+
const ino = await store.createInode(fileAttrs());
|
|
556
|
+
const vs = getVersioningStore();
|
|
557
|
+
const meta = await vs.getVersion(ino, 999);
|
|
558
|
+
expect(meta).toBeNull();
|
|
559
|
+
});
|
|
560
|
+
test("getVersionChunkMap returns chunk keys at snapshot time", async () => {
|
|
561
|
+
const ino = await store.createInode(fileAttrs());
|
|
562
|
+
await store.updateInode(ino, { storageMode: "chunked", inlineContent: null, size: 2048 });
|
|
563
|
+
await store.setChunkKey(ino, 0, "ino/0/abc");
|
|
564
|
+
await store.setChunkKey(ino, 1, "ino/1/def");
|
|
565
|
+
const vs = getVersioningStore();
|
|
566
|
+
const v = await vs.createVersion(ino);
|
|
567
|
+
const chunkMap = await vs.getVersionChunkMap(ino, v);
|
|
568
|
+
expect(chunkMap.length).toBe(2);
|
|
569
|
+
expect(chunkMap[0].chunkIndex).toBe(0);
|
|
570
|
+
expect(chunkMap[0].key).toBe("ino/0/abc");
|
|
571
|
+
expect(chunkMap[1].chunkIndex).toBe(1);
|
|
572
|
+
expect(chunkMap[1].key).toBe("ino/1/def");
|
|
573
|
+
});
|
|
574
|
+
test("restoreVersion reverts current chunk map", async () => {
|
|
575
|
+
const ino = await store.createInode(fileAttrs());
|
|
576
|
+
await store.updateInode(ino, { storageMode: "chunked", inlineContent: null, size: 1024 });
|
|
577
|
+
await store.setChunkKey(ino, 0, "ino/0/v1key");
|
|
578
|
+
const vs = getVersioningStore();
|
|
579
|
+
const v1 = await vs.createVersion(ino);
|
|
580
|
+
// Write new data.
|
|
581
|
+
await store.setChunkKey(ino, 0, "ino/0/v2key");
|
|
582
|
+
await store.setChunkKey(ino, 1, "ino/1/v2key");
|
|
583
|
+
await store.updateInode(ino, { size: 2048 });
|
|
584
|
+
// Restore to v1.
|
|
585
|
+
await vs.restoreVersion(ino, v1);
|
|
586
|
+
// Verify chunk map is restored.
|
|
587
|
+
const chunks = await store.getAllChunkKeys(ino);
|
|
588
|
+
expect(chunks.length).toBe(1);
|
|
589
|
+
expect(chunks[0].key).toBe("ino/0/v1key");
|
|
590
|
+
// Verify inode size is restored.
|
|
591
|
+
const meta = await store.getInode(ino);
|
|
592
|
+
expect(meta.size).toBe(1024);
|
|
593
|
+
});
|
|
594
|
+
test("deleteVersions removes specified versions", async () => {
|
|
595
|
+
const ino = await store.createInode(fileAttrs());
|
|
596
|
+
await store.updateInode(ino, { size: 10, storageMode: "inline", inlineContent: new Uint8Array(10) });
|
|
597
|
+
const vs = getVersioningStore();
|
|
598
|
+
const v1 = await vs.createVersion(ino);
|
|
599
|
+
const v2 = await vs.createVersion(ino);
|
|
600
|
+
const v3 = await vs.createVersion(ino);
|
|
601
|
+
await vs.deleteVersions(ino, [v1, v2]);
|
|
602
|
+
const remaining = await vs.listVersions(ino);
|
|
603
|
+
expect(remaining.length).toBe(1);
|
|
604
|
+
expect(remaining[0].version).toBe(v3);
|
|
605
|
+
});
|
|
606
|
+
test("deleteVersions returns orphaned block keys", async () => {
|
|
607
|
+
const ino = await store.createInode(fileAttrs());
|
|
608
|
+
await store.updateInode(ino, { storageMode: "chunked", inlineContent: null, size: 1024 });
|
|
609
|
+
await store.setChunkKey(ino, 0, "ino/0/v1key");
|
|
610
|
+
const vs = getVersioningStore();
|
|
611
|
+
const v1 = await vs.createVersion(ino);
|
|
612
|
+
// Write new chunk keys.
|
|
613
|
+
await store.setChunkKey(ino, 0, "ino/0/v2key");
|
|
614
|
+
// Delete v1. "ino/0/v1key" is no longer referenced by any version or current state.
|
|
615
|
+
const orphaned = await vs.deleteVersions(ino, [v1]);
|
|
616
|
+
expect(orphaned).toContain("ino/0/v1key");
|
|
617
|
+
expect(orphaned).not.toContain("ino/0/v2key");
|
|
618
|
+
});
|
|
619
|
+
test("deleteVersions does not return keys still referenced by remaining versions", async () => {
|
|
620
|
+
const ino = await store.createInode(fileAttrs());
|
|
621
|
+
await store.updateInode(ino, { storageMode: "chunked", inlineContent: null, size: 1024 });
|
|
622
|
+
await store.setChunkKey(ino, 0, "ino/0/sharedkey");
|
|
623
|
+
const vs = getVersioningStore();
|
|
624
|
+
const v1 = await vs.createVersion(ino);
|
|
625
|
+
const v2 = await vs.createVersion(ino);
|
|
626
|
+
// Both v1 and v2 reference "ino/0/sharedkey". Delete v1.
|
|
627
|
+
const orphaned = await vs.deleteVersions(ino, [v1]);
|
|
628
|
+
expect(orphaned).not.toContain("ino/0/sharedkey");
|
|
629
|
+
});
|
|
630
|
+
test("createVersion + write new data: old version has old size", async () => {
|
|
631
|
+
const ino = await store.createInode(fileAttrs());
|
|
632
|
+
await store.updateInode(ino, { size: 10, storageMode: "inline", inlineContent: new Uint8Array(10) });
|
|
633
|
+
const vs = getVersioningStore();
|
|
634
|
+
const v1 = await vs.createVersion(ino);
|
|
635
|
+
// Write new data (larger).
|
|
636
|
+
await store.updateInode(ino, { size: 50, inlineContent: new Uint8Array(50) });
|
|
637
|
+
// Old version still has old size.
|
|
638
|
+
const meta = await vs.getVersion(ino, v1);
|
|
639
|
+
expect(meta.size).toBe(10);
|
|
640
|
+
// No new version was created automatically.
|
|
641
|
+
const versions = await vs.listVersions(ino);
|
|
642
|
+
expect(versions.length).toBe(1);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
}
|