@zenfs/core 0.9.2 → 0.9.3
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/package.json +2 -9
- package/src/ApiError.ts +310 -0
- package/src/backends/AsyncStore.ts +635 -0
- package/src/backends/InMemory.ts +56 -0
- package/src/backends/Index.ts +500 -0
- package/src/backends/Locked.ts +181 -0
- package/src/backends/Overlay.ts +591 -0
- package/src/backends/SyncStore.ts +589 -0
- package/src/backends/backend.ts +152 -0
- package/src/config.ts +101 -0
- package/src/cred.ts +21 -0
- package/src/emulation/async.ts +910 -0
- package/src/emulation/constants.ts +176 -0
- package/src/emulation/dir.ts +139 -0
- package/src/emulation/index.ts +8 -0
- package/src/emulation/path.ts +468 -0
- package/src/emulation/promises.ts +1071 -0
- package/src/emulation/shared.ts +128 -0
- package/src/emulation/streams.ts +33 -0
- package/src/emulation/sync.ts +898 -0
- package/src/file.ts +721 -0
- package/src/filesystem.ts +546 -0
- package/src/index.ts +21 -0
- package/src/inode.ts +229 -0
- package/src/mutex.ts +52 -0
- package/src/stats.ts +385 -0
- package/src/utils.ts +287 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import { dirname, basename, join, resolve } from '../emulation/path.js';
|
|
2
|
+
import { ApiError, ErrorCode } from '../ApiError.js';
|
|
3
|
+
import type { Cred } from '../cred.js';
|
|
4
|
+
import { W_OK, R_OK } from '../emulation/constants.js';
|
|
5
|
+
import { PreloadFile, flagToMode } from '../file.js';
|
|
6
|
+
import { Async, FileSystem, type FileSystemMetadata } from '../filesystem.js';
|
|
7
|
+
import { randomIno, type Ino, Inode } from '../inode.js';
|
|
8
|
+
import { type Stats, FileType } from '../stats.js';
|
|
9
|
+
import { encode, decodeDirListing, encodeDirListing } from '../utils.js';
|
|
10
|
+
import { rootIno } from '../inode.js';
|
|
11
|
+
import { InMemory } from './InMemory.js';
|
|
12
|
+
|
|
13
|
+
interface LRUNode<K, V> {
|
|
14
|
+
key: K;
|
|
15
|
+
value: V;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Last Recently Used cache
|
|
20
|
+
*/
|
|
21
|
+
class LRUCache<K, V> {
|
|
22
|
+
private cache: LRUNode<K, V>[] = [];
|
|
23
|
+
|
|
24
|
+
constructor(public readonly limit: number) {}
|
|
25
|
+
|
|
26
|
+
public set(key: K, value: V): void {
|
|
27
|
+
const existingIndex = this.cache.findIndex(node => node.key === key);
|
|
28
|
+
if (existingIndex != -1) {
|
|
29
|
+
this.cache.splice(existingIndex, 1);
|
|
30
|
+
} else if (this.cache.length >= this.limit) {
|
|
31
|
+
this.cache.shift();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.cache.push({ key, value });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public get(key: K): V | null {
|
|
38
|
+
const node = this.cache.find(n => n.key === key);
|
|
39
|
+
if (!node) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Move the accessed item to the end of the cache (most recently used)
|
|
44
|
+
this.set(key, node.value);
|
|
45
|
+
return node.value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public remove(key: K): void {
|
|
49
|
+
const index = this.cache.findIndex(node => node.key === key);
|
|
50
|
+
if (index !== -1) {
|
|
51
|
+
this.cache.splice(index, 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public reset(): void {
|
|
56
|
+
this.cache = [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Represents an asynchronous key-value store.
|
|
62
|
+
*/
|
|
63
|
+
export interface AsyncStore {
|
|
64
|
+
/**
|
|
65
|
+
* The name of the store.
|
|
66
|
+
*/
|
|
67
|
+
name: string;
|
|
68
|
+
/**
|
|
69
|
+
* Empties the store completely.
|
|
70
|
+
*/
|
|
71
|
+
clear(): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Begins a transaction.
|
|
74
|
+
*/
|
|
75
|
+
beginTransaction(): AsyncTransaction;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Represents an asynchronous transaction.
|
|
80
|
+
*/
|
|
81
|
+
export interface AsyncTransaction {
|
|
82
|
+
/**
|
|
83
|
+
* Retrieves the data at the given key.
|
|
84
|
+
* @param key The key to look under for data.
|
|
85
|
+
*/
|
|
86
|
+
get(key: Ino): Promise<Uint8Array>;
|
|
87
|
+
/**
|
|
88
|
+
* Adds the data to the store under the given key. Overwrites any existing
|
|
89
|
+
* data.
|
|
90
|
+
* @param key The key to add the data under.
|
|
91
|
+
* @param data The data to add to the store.
|
|
92
|
+
* @param overwrite If 'true', overwrite any existing data. If 'false',
|
|
93
|
+
* avoids writing the data if the key exists.
|
|
94
|
+
*/
|
|
95
|
+
put(key: Ino, data: Uint8Array, overwrite: boolean): Promise<boolean>;
|
|
96
|
+
/**
|
|
97
|
+
* Deletes the data at the given key.
|
|
98
|
+
* @param key The key to delete from the store.
|
|
99
|
+
*/
|
|
100
|
+
remove(key: Ino): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Commits the transaction.
|
|
103
|
+
*/
|
|
104
|
+
commit(): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* Aborts and rolls back the transaction.
|
|
107
|
+
*/
|
|
108
|
+
abort(): Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface AsyncStoreOptions {
|
|
112
|
+
/**
|
|
113
|
+
* Promise that resolves to the store
|
|
114
|
+
*/
|
|
115
|
+
store: Promise<AsyncStore> | AsyncStore;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The size of the cache. If not provided, no cache will be used
|
|
119
|
+
*/
|
|
120
|
+
lruCacheSize?: number;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The file system to use for synchronous methods. Defaults to InMemory
|
|
124
|
+
*/
|
|
125
|
+
sync?: FileSystem;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* An asynchronous file system which uses an async store to store its data.
|
|
130
|
+
* @see AsyncStore
|
|
131
|
+
* @internal
|
|
132
|
+
*/
|
|
133
|
+
export class AsyncStoreFS extends Async(FileSystem) {
|
|
134
|
+
protected store: AsyncStore;
|
|
135
|
+
private _cache?: LRUCache<string, Ino>;
|
|
136
|
+
_sync: FileSystem;
|
|
137
|
+
|
|
138
|
+
public async ready() {
|
|
139
|
+
if (this._options.lruCacheSize > 0) {
|
|
140
|
+
this._cache = new LRUCache(this._options.lruCacheSize);
|
|
141
|
+
}
|
|
142
|
+
this.store = await this._options.store;
|
|
143
|
+
this._sync = this._options.sync || InMemory.create({ name: 'test' });
|
|
144
|
+
await this.makeRootDirectory();
|
|
145
|
+
await super.ready();
|
|
146
|
+
return this;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public metadata(): FileSystemMetadata {
|
|
150
|
+
return {
|
|
151
|
+
...super.metadata(),
|
|
152
|
+
name: this.store.name,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
constructor(protected _options: AsyncStoreOptions) {
|
|
157
|
+
super();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Delete all contents stored in the file system.
|
|
162
|
+
*/
|
|
163
|
+
public async empty(): Promise<void> {
|
|
164
|
+
if (this._cache) {
|
|
165
|
+
this._cache.reset();
|
|
166
|
+
}
|
|
167
|
+
await this.store.clear();
|
|
168
|
+
// INVARIANT: Root always exists.
|
|
169
|
+
await this.makeRootDirectory();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @todo Make rename compatible with the cache.
|
|
174
|
+
*/
|
|
175
|
+
public async rename(oldPath: string, newPath: string, cred: Cred): Promise<void> {
|
|
176
|
+
const c = this._cache;
|
|
177
|
+
if (this._cache) {
|
|
178
|
+
// Clear and disable cache during renaming process.
|
|
179
|
+
this._cache = null;
|
|
180
|
+
c.reset();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const tx = this.store.beginTransaction(),
|
|
185
|
+
oldParent = dirname(oldPath),
|
|
186
|
+
oldName = basename(oldPath),
|
|
187
|
+
newParent = dirname(newPath),
|
|
188
|
+
newName = basename(newPath),
|
|
189
|
+
// Remove oldPath from parent's directory listing.
|
|
190
|
+
oldDirNode = await this.findINode(tx, oldParent),
|
|
191
|
+
oldDirList = await this.getDirListing(tx, oldDirNode, oldParent);
|
|
192
|
+
|
|
193
|
+
if (!oldDirNode.toStats().hasAccess(W_OK, cred)) {
|
|
194
|
+
throw ApiError.With('EACCES', oldPath, 'rename');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!oldDirList[oldName]) {
|
|
198
|
+
throw ApiError.With('ENOENT', oldPath, 'rename');
|
|
199
|
+
}
|
|
200
|
+
const nodeId: Ino = oldDirList[oldName];
|
|
201
|
+
delete oldDirList[oldName];
|
|
202
|
+
|
|
203
|
+
// Invariant: Can't move a folder inside itself.
|
|
204
|
+
// This funny little hack ensures that the check passes only if oldPath
|
|
205
|
+
// is a subpath of newParent. We append '/' to avoid matching folders that
|
|
206
|
+
// are a substring of the bottom-most folder in the path.
|
|
207
|
+
if ((newParent + '/').indexOf(oldPath + '/') === 0) {
|
|
208
|
+
throw new ApiError(ErrorCode.EBUSY, oldParent);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Add newPath to parent's directory listing.
|
|
212
|
+
let newDirNode: Inode, newDirList: typeof oldDirList;
|
|
213
|
+
if (newParent === oldParent) {
|
|
214
|
+
// Prevent us from re-grabbing the same directory listing, which still
|
|
215
|
+
// contains oldName.
|
|
216
|
+
newDirNode = oldDirNode;
|
|
217
|
+
newDirList = oldDirList;
|
|
218
|
+
} else {
|
|
219
|
+
newDirNode = await this.findINode(tx, newParent);
|
|
220
|
+
newDirList = await this.getDirListing(tx, newDirNode, newParent);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (newDirList[newName]) {
|
|
224
|
+
// If it's a file, delete it.
|
|
225
|
+
const newNameNode = await this.getINode(tx, newDirList[newName], newPath);
|
|
226
|
+
if (newNameNode.toStats().isFile()) {
|
|
227
|
+
try {
|
|
228
|
+
await tx.remove(newNameNode.ino);
|
|
229
|
+
await tx.remove(newDirList[newName]);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
await tx.abort();
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
// If it's a directory, throw a permissions error.
|
|
236
|
+
throw ApiError.With('EPERM', newPath, 'rename');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
newDirList[newName] = nodeId;
|
|
240
|
+
// Commit the two changed directory listings.
|
|
241
|
+
try {
|
|
242
|
+
await tx.put(oldDirNode.ino, encodeDirListing(oldDirList), true);
|
|
243
|
+
await tx.put(newDirNode.ino, encodeDirListing(newDirList), true);
|
|
244
|
+
} catch (e) {
|
|
245
|
+
await tx.abort();
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await tx.commit();
|
|
250
|
+
} finally {
|
|
251
|
+
if (c) {
|
|
252
|
+
this._cache = c;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
public async stat(p: string, cred: Cred): Promise<Stats> {
|
|
258
|
+
const tx = this.store.beginTransaction();
|
|
259
|
+
const inode = await this.findINode(tx, p);
|
|
260
|
+
if (!inode) {
|
|
261
|
+
throw ApiError.With('ENOENT', p, 'stat');
|
|
262
|
+
}
|
|
263
|
+
const stats = inode.toStats();
|
|
264
|
+
if (!stats.hasAccess(R_OK, cred)) {
|
|
265
|
+
throw ApiError.With('EACCES', p, 'stat');
|
|
266
|
+
}
|
|
267
|
+
return stats;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
public async createFile(p: string, flag: string, mode: number, cred: Cred): Promise<PreloadFile<this>> {
|
|
271
|
+
const tx = this.store.beginTransaction(),
|
|
272
|
+
data = new Uint8Array(0),
|
|
273
|
+
newFile = await this.commitNewFile(tx, p, FileType.FILE, mode, cred, data);
|
|
274
|
+
// Open the file.
|
|
275
|
+
return new PreloadFile(this, p, flag, newFile.toStats(), data);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
public async openFile(p: string, flag: string, cred: Cred): Promise<PreloadFile<this>> {
|
|
279
|
+
const tx = this.store.beginTransaction(),
|
|
280
|
+
node = await this.findINode(tx, p),
|
|
281
|
+
data = await tx.get(node.ino);
|
|
282
|
+
if (!node.toStats().hasAccess(flagToMode(flag), cred)) {
|
|
283
|
+
throw ApiError.With('EACCES', p, 'openFile');
|
|
284
|
+
}
|
|
285
|
+
if (!data) {
|
|
286
|
+
throw ApiError.With('ENOENT', p, 'openFile');
|
|
287
|
+
}
|
|
288
|
+
return new PreloadFile(this, p, flag, node.toStats(), data);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
public async unlink(p: string, cred: Cred): Promise<void> {
|
|
292
|
+
return this.removeEntry(p, false, cred);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
public async rmdir(p: string, cred: Cred): Promise<void> {
|
|
296
|
+
// Check first if directory is empty.
|
|
297
|
+
const list = await this.readdir(p, cred);
|
|
298
|
+
if (list.length > 0) {
|
|
299
|
+
throw ApiError.With('ENOTEMPTY', p, 'rmdir');
|
|
300
|
+
}
|
|
301
|
+
await this.removeEntry(p, true, cred);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
public async mkdir(p: string, mode: number, cred: Cred): Promise<void> {
|
|
305
|
+
const tx = this.store.beginTransaction(),
|
|
306
|
+
data = encode('{}');
|
|
307
|
+
await this.commitNewFile(tx, p, FileType.DIRECTORY, mode, cred, data);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
public async readdir(p: string, cred: Cred): Promise<string[]> {
|
|
311
|
+
const tx = this.store.beginTransaction();
|
|
312
|
+
const node = await this.findINode(tx, p);
|
|
313
|
+
if (!node.toStats().hasAccess(R_OK, cred)) {
|
|
314
|
+
throw ApiError.With('EACCES', p, 'readdur');
|
|
315
|
+
}
|
|
316
|
+
return Object.keys(await this.getDirListing(tx, node, p));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Updated the inode and data node at the given path
|
|
321
|
+
* @todo Ensure mtime updates properly, and use that to determine if a data update is required.
|
|
322
|
+
*/
|
|
323
|
+
public async sync(p: string, data: Uint8Array, stats: Readonly<Stats>): Promise<void> {
|
|
324
|
+
const tx = this.store.beginTransaction(),
|
|
325
|
+
// We use the _findInode helper because we actually need the INode id.
|
|
326
|
+
fileInodeId = await this._findINode(tx, dirname(p), basename(p)),
|
|
327
|
+
fileInode = await this.getINode(tx, fileInodeId, p),
|
|
328
|
+
inodeChanged = fileInode.update(stats);
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
// Sync data.
|
|
332
|
+
await tx.put(fileInode.ino, data, true);
|
|
333
|
+
// Sync metadata.
|
|
334
|
+
if (inodeChanged) {
|
|
335
|
+
await tx.put(fileInodeId, fileInode.data, true);
|
|
336
|
+
}
|
|
337
|
+
} catch (e) {
|
|
338
|
+
await tx.abort();
|
|
339
|
+
throw e;
|
|
340
|
+
}
|
|
341
|
+
await tx.commit();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
public async link(existing: string, newpath: string, cred: Cred): Promise<void> {
|
|
345
|
+
const tx = this.store.beginTransaction(),
|
|
346
|
+
existingDir: string = dirname(existing),
|
|
347
|
+
existingDirNode = await this.findINode(tx, existingDir);
|
|
348
|
+
|
|
349
|
+
if (!existingDirNode.toStats().hasAccess(R_OK, cred)) {
|
|
350
|
+
throw ApiError.With('EACCES', existingDir, 'link');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const newDir: string = dirname(newpath),
|
|
354
|
+
newDirNode = await this.findINode(tx, newDir),
|
|
355
|
+
newListing = await this.getDirListing(tx, newDirNode, newDir);
|
|
356
|
+
|
|
357
|
+
if (!newDirNode.toStats().hasAccess(W_OK, cred)) {
|
|
358
|
+
throw ApiError.With('EACCES', newDir, 'link');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const ino = await this._findINode(tx, existingDir, basename(existing));
|
|
362
|
+
const node = await this.getINode(tx, ino, existing);
|
|
363
|
+
|
|
364
|
+
if (!node.toStats().hasAccess(W_OK, cred)) {
|
|
365
|
+
throw ApiError.With('EACCES', newpath, 'link');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
node.nlink++;
|
|
369
|
+
newListing[basename(newpath)] = ino;
|
|
370
|
+
try {
|
|
371
|
+
tx.put(ino, node.data, true);
|
|
372
|
+
tx.put(newDirNode.ino, encodeDirListing(newListing), true);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
tx.abort();
|
|
375
|
+
throw e;
|
|
376
|
+
}
|
|
377
|
+
tx.commit();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Checks if the root directory exists. Creates it if it doesn't.
|
|
382
|
+
*/
|
|
383
|
+
private async makeRootDirectory(): Promise<void> {
|
|
384
|
+
const tx = this.store.beginTransaction();
|
|
385
|
+
if ((await tx.get(rootIno)) === undefined) {
|
|
386
|
+
// Create new inode. o777, owned by root:root
|
|
387
|
+
const dirInode = new Inode();
|
|
388
|
+
dirInode.mode = 0o777 | FileType.DIRECTORY;
|
|
389
|
+
// If the root doesn't exist, the first random ID shouldn't exist,
|
|
390
|
+
// either.
|
|
391
|
+
await tx.put(dirInode.ino, encode('{}'), false);
|
|
392
|
+
await tx.put(rootIno, dirInode.data, false);
|
|
393
|
+
await tx.commit();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Helper function for findINode.
|
|
399
|
+
* @param parent The parent directory of the file we are attempting to find.
|
|
400
|
+
* @param filename The filename of the inode we are attempting to find, minus
|
|
401
|
+
* the parent.
|
|
402
|
+
*/
|
|
403
|
+
private async _findINode(tx: AsyncTransaction, parent: string, filename: string, visited: Set<string> = new Set<string>()): Promise<Ino> {
|
|
404
|
+
const currentPath = join(parent, filename);
|
|
405
|
+
if (visited.has(currentPath)) {
|
|
406
|
+
throw new ApiError(ErrorCode.EIO, 'Infinite loop detected while finding inode', currentPath);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
visited.add(currentPath);
|
|
410
|
+
if (this._cache) {
|
|
411
|
+
const id = this._cache.get(currentPath);
|
|
412
|
+
if (id) {
|
|
413
|
+
return id;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (parent === '/') {
|
|
418
|
+
if (filename === '') {
|
|
419
|
+
// BASE CASE #1: Return the root's ID.
|
|
420
|
+
if (this._cache) {
|
|
421
|
+
this._cache.set(currentPath, rootIno);
|
|
422
|
+
}
|
|
423
|
+
return rootIno;
|
|
424
|
+
} else {
|
|
425
|
+
// BASE CASE #2: Find the item in the root node.
|
|
426
|
+
const inode = await this.getINode(tx, rootIno, parent);
|
|
427
|
+
const dirList = await this.getDirListing(tx, inode!, parent);
|
|
428
|
+
if (dirList![filename]) {
|
|
429
|
+
const id = dirList![filename];
|
|
430
|
+
if (this._cache) {
|
|
431
|
+
this._cache.set(currentPath, id);
|
|
432
|
+
}
|
|
433
|
+
return id;
|
|
434
|
+
} else {
|
|
435
|
+
throw ApiError.With('ENOENT', resolve(parent, filename), '_findINode');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
// Get the parent directory's INode, and find the file in its directory
|
|
440
|
+
// listing.
|
|
441
|
+
const inode = await this.findINode(tx, parent, visited);
|
|
442
|
+
const dirList = await this.getDirListing(tx, inode!, parent);
|
|
443
|
+
if (dirList![filename]) {
|
|
444
|
+
const id = dirList![filename];
|
|
445
|
+
if (this._cache) {
|
|
446
|
+
this._cache.set(currentPath, id);
|
|
447
|
+
}
|
|
448
|
+
return id;
|
|
449
|
+
} else {
|
|
450
|
+
throw ApiError.With('ENOENT', resolve(parent, filename), '_findINode');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Finds the Inode of the given path.
|
|
457
|
+
* @param p The path to look up.
|
|
458
|
+
* @todo memoize/cache
|
|
459
|
+
*/
|
|
460
|
+
private async findINode(tx: AsyncTransaction, p: string, visited: Set<string> = new Set<string>()): Promise<Inode> {
|
|
461
|
+
const id = await this._findINode(tx, dirname(p), basename(p), visited);
|
|
462
|
+
return this.getINode(tx, id!, p);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Given the ID of a node, retrieves the corresponding Inode.
|
|
467
|
+
* @param tx The transaction to use.
|
|
468
|
+
* @param p The corresponding path to the file (used for error messages).
|
|
469
|
+
* @param id The ID to look up.
|
|
470
|
+
*/
|
|
471
|
+
private async getINode(tx: AsyncTransaction, id: Ino, p: string): Promise<Inode> {
|
|
472
|
+
const data = await tx.get(id);
|
|
473
|
+
if (!data) {
|
|
474
|
+
throw ApiError.With('ENOENT', p, 'getINode');
|
|
475
|
+
}
|
|
476
|
+
return new Inode(data.buffer);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Given the Inode of a directory, retrieves the corresponding directory
|
|
481
|
+
* listing.
|
|
482
|
+
*/
|
|
483
|
+
private async getDirListing(tx: AsyncTransaction, inode: Inode, p: string): Promise<{ [fileName: string]: Ino }> {
|
|
484
|
+
if (!inode.toStats().isDirectory()) {
|
|
485
|
+
throw ApiError.With('ENOTDIR', p, 'getDirListing');
|
|
486
|
+
}
|
|
487
|
+
const data = await tx.get(inode.ino);
|
|
488
|
+
if (!data) {
|
|
489
|
+
/*
|
|
490
|
+
Occurs when data is undefined, or corresponds to something other
|
|
491
|
+
than a directory listing. The latter should never occur unless
|
|
492
|
+
the file system is corrupted.
|
|
493
|
+
*/
|
|
494
|
+
throw ApiError.With('ENOENT', p, 'getDirListing');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return decodeDirListing(data);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Adds a new node under a random ID. Retries 5 times before giving up in
|
|
502
|
+
* the exceedingly unlikely chance that we try to reuse a random ino.
|
|
503
|
+
*/
|
|
504
|
+
private async addNewNode(tx: AsyncTransaction, data: Uint8Array, _maxAttempts: number = 5): Promise<Ino> {
|
|
505
|
+
if (_maxAttempts <= 0) {
|
|
506
|
+
// Max retries hit. Return with an error.
|
|
507
|
+
throw new ApiError(ErrorCode.EIO, 'Unable to commit data to key-value store.');
|
|
508
|
+
}
|
|
509
|
+
// Make an attempt
|
|
510
|
+
const ino = randomIno();
|
|
511
|
+
const isCommited = await tx.put(ino, data, false);
|
|
512
|
+
if (!isCommited) {
|
|
513
|
+
return await this.addNewNode(tx, data, --_maxAttempts);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return ino;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Commits a new file (well, a FILE or a DIRECTORY) to the file system with
|
|
521
|
+
* the given mode.
|
|
522
|
+
* Note: This will commit the transaction.
|
|
523
|
+
* @param p The path to the new file.
|
|
524
|
+
* @param type The type of the new file.
|
|
525
|
+
* @param mode The mode to create the new file with.
|
|
526
|
+
* @param cred The UID/GID to create the file with
|
|
527
|
+
* @param data The data to store at the file's data node.
|
|
528
|
+
*/
|
|
529
|
+
private async commitNewFile(tx: AsyncTransaction, p: string, type: FileType, mode: number, cred: Cred, data: Uint8Array): Promise<Inode> {
|
|
530
|
+
const parentDir = dirname(p),
|
|
531
|
+
fname = basename(p),
|
|
532
|
+
parentNode = await this.findINode(tx, parentDir),
|
|
533
|
+
dirListing = await this.getDirListing(tx, parentNode, parentDir);
|
|
534
|
+
|
|
535
|
+
//Check that the creater has correct access
|
|
536
|
+
if (!parentNode.toStats().hasAccess(W_OK, cred)) {
|
|
537
|
+
throw ApiError.With('EACCES', p, 'commitNewFile');
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Invariant: The root always exists.
|
|
541
|
+
// If we don't check this prior to taking steps below, we will create a
|
|
542
|
+
// file with name '' in root should p == '/'.
|
|
543
|
+
if (p === '/') {
|
|
544
|
+
throw ApiError.With('EEXIST', p, 'commitNewFile');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Check if file already exists.
|
|
548
|
+
if (dirListing[fname]) {
|
|
549
|
+
await tx.abort();
|
|
550
|
+
throw ApiError.With('EEXIST', p, 'commitNewFile');
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
// Commit data.
|
|
554
|
+
|
|
555
|
+
const inode = new Inode();
|
|
556
|
+
inode.ino = await this.addNewNode(tx, data);
|
|
557
|
+
inode.mode = mode | type;
|
|
558
|
+
inode.uid = cred.uid;
|
|
559
|
+
inode.gid = cred.gid;
|
|
560
|
+
inode.size = data.length;
|
|
561
|
+
|
|
562
|
+
// Update and commit parent directory listing.
|
|
563
|
+
dirListing[fname] = await this.addNewNode(tx, inode.data);
|
|
564
|
+
await tx.put(parentNode.ino, encodeDirListing(dirListing), true);
|
|
565
|
+
await tx.commit();
|
|
566
|
+
return inode;
|
|
567
|
+
} catch (e) {
|
|
568
|
+
tx.abort();
|
|
569
|
+
throw e;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Remove all traces of the given path from the file system.
|
|
575
|
+
* @param p The path to remove from the file system.
|
|
576
|
+
* @param isDir Does the path belong to a directory, or a file?
|
|
577
|
+
* @todo Update mtime.
|
|
578
|
+
*/
|
|
579
|
+
/**
|
|
580
|
+
* Remove all traces of the given path from the file system.
|
|
581
|
+
* @param p The path to remove from the file system.
|
|
582
|
+
* @param isDir Does the path belong to a directory, or a file?
|
|
583
|
+
* @todo Update mtime.
|
|
584
|
+
*/
|
|
585
|
+
private async removeEntry(p: string, isDir: boolean, cred: Cred): Promise<void> {
|
|
586
|
+
if (this._cache) {
|
|
587
|
+
this._cache.remove(p);
|
|
588
|
+
}
|
|
589
|
+
const tx = this.store.beginTransaction(),
|
|
590
|
+
parent: string = dirname(p),
|
|
591
|
+
parentNode = await this.findINode(tx, parent),
|
|
592
|
+
parentListing = await this.getDirListing(tx, parentNode, parent),
|
|
593
|
+
fileName: string = basename(p);
|
|
594
|
+
|
|
595
|
+
if (!parentListing[fileName]) {
|
|
596
|
+
throw ApiError.With('ENOENT', p, 'removeEntry');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const fileIno = parentListing[fileName];
|
|
600
|
+
|
|
601
|
+
// Get file inode.
|
|
602
|
+
const fileNode = await this.getINode(tx, fileIno, p);
|
|
603
|
+
|
|
604
|
+
if (!fileNode.toStats().hasAccess(W_OK, cred)) {
|
|
605
|
+
throw ApiError.With('EACCES', p, 'removeEntry');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Remove from directory listing of parent.
|
|
609
|
+
delete parentListing[fileName];
|
|
610
|
+
|
|
611
|
+
if (!isDir && fileNode.toStats().isDirectory()) {
|
|
612
|
+
throw ApiError.With('EISDIR', p, 'removeEntry');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (isDir && !fileNode.toStats().isDirectory()) {
|
|
616
|
+
throw ApiError.With('ENOTDIR', p, 'removeEntry');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
await tx.put(parentNode.ino, encodeDirListing(parentListing), true);
|
|
621
|
+
|
|
622
|
+
if (--fileNode.nlink < 1) {
|
|
623
|
+
// remove file
|
|
624
|
+
await tx.remove(fileNode.ino);
|
|
625
|
+
await tx.remove(fileIno);
|
|
626
|
+
}
|
|
627
|
+
} catch (e) {
|
|
628
|
+
await tx.abort();
|
|
629
|
+
throw e;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Success.
|
|
633
|
+
await tx.commit();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Ino } from '../inode.js';
|
|
2
|
+
import type { Backend } from './backend.js';
|
|
3
|
+
import { SimpleSyncStore, SimpleSyncTransaction, SyncStore, SyncStoreFS, SyncTransaction } from './SyncStore.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A simple in-memory store
|
|
7
|
+
*/
|
|
8
|
+
export class InMemoryStore implements SyncStore, SimpleSyncStore {
|
|
9
|
+
private store: Map<Ino, Uint8Array> = new Map();
|
|
10
|
+
|
|
11
|
+
constructor(public name: string = 'tmp') {}
|
|
12
|
+
public clear() {
|
|
13
|
+
this.store.clear();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public beginTransaction(): SyncTransaction {
|
|
17
|
+
return new SimpleSyncTransaction(this);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public get(key: Ino) {
|
|
21
|
+
return this.store.get(key);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public put(key: Ino, data: Uint8Array, overwrite: boolean): boolean {
|
|
25
|
+
if (!overwrite && this.store.has(key)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
this.store.set(key, data);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public remove(key: Ino): void {
|
|
33
|
+
this.store.delete(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A simple in-memory file system backed by an InMemoryStore.
|
|
39
|
+
* Files are not persisted across page loads.
|
|
40
|
+
*/
|
|
41
|
+
export const InMemory = {
|
|
42
|
+
name: 'InMemory',
|
|
43
|
+
isAvailable(): boolean {
|
|
44
|
+
return true;
|
|
45
|
+
},
|
|
46
|
+
options: {
|
|
47
|
+
name: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
required: false,
|
|
50
|
+
description: 'The name of the store',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
create({ name }: { name?: string }) {
|
|
54
|
+
return new SyncStoreFS({ store: new InMemoryStore(name) });
|
|
55
|
+
},
|
|
56
|
+
} as const satisfies Backend<SyncStoreFS, { name?: string }>;
|