agentfs-sdk 0.3.1 → 0.4.0-pre.2
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/errors.d.ts +20 -0
- package/dist/errors.js +13 -0
- package/dist/filesystem.d.ts +40 -1
- package/dist/filesystem.js +460 -34
- package/dist/guards.d.ts +18 -0
- package/dist/guards.js +145 -0
- package/package.json +1 -1
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX-style error codes for filesystem operations.
|
|
3
|
+
*/
|
|
4
|
+
export type FsErrorCode = 'ENOENT' | 'EEXIST' | 'EISDIR' | 'ENOTDIR' | 'ENOTEMPTY' | 'EPERM' | 'EINVAL' | 'ENOSYS';
|
|
5
|
+
/**
|
|
6
|
+
* Filesystem syscall names for error reporting.
|
|
7
|
+
* rm, scandir and copyFile are not actual syscall but used for convenience
|
|
8
|
+
*/
|
|
9
|
+
export type FsSyscall = 'open' | 'stat' | 'mkdir' | 'rmdir' | 'rm' | 'unlink' | 'rename' | 'scandir' | 'copyfile' | 'access';
|
|
10
|
+
export interface ErrnoException extends Error {
|
|
11
|
+
code?: FsErrorCode;
|
|
12
|
+
syscall?: FsSyscall;
|
|
13
|
+
path?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function createFsError(params: {
|
|
16
|
+
code: FsErrorCode;
|
|
17
|
+
syscall: FsSyscall;
|
|
18
|
+
path?: string;
|
|
19
|
+
message?: string;
|
|
20
|
+
}): ErrnoException;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function createFsError(params) {
|
|
2
|
+
const { code, syscall, path, message } = params;
|
|
3
|
+
const base = message ?? code;
|
|
4
|
+
const suffix = path !== undefined
|
|
5
|
+
? ` '${path}'`
|
|
6
|
+
: '';
|
|
7
|
+
const err = new Error(`${code}: ${base}, ${syscall}${suffix}`);
|
|
8
|
+
err.code = code;
|
|
9
|
+
err.syscall = syscall;
|
|
10
|
+
if (path !== undefined)
|
|
11
|
+
err.path = path;
|
|
12
|
+
return err;
|
|
13
|
+
}
|
package/dist/filesystem.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { DatabasePromise } from '@tursodatabase/database-common';
|
|
2
|
+
export declare const S_IFMT = 61440;
|
|
3
|
+
export declare const S_IFREG = 32768;
|
|
4
|
+
export declare const S_IFDIR = 16384;
|
|
5
|
+
export declare const S_IFLNK = 40960;
|
|
2
6
|
export interface Stats {
|
|
3
7
|
ino: number;
|
|
4
8
|
mode: number;
|
|
@@ -40,6 +44,7 @@ export declare class Filesystem {
|
|
|
40
44
|
* Split path into components
|
|
41
45
|
*/
|
|
42
46
|
private splitPath;
|
|
47
|
+
private resolvePathOrThrow;
|
|
43
48
|
/**
|
|
44
49
|
* Resolve a path to an inode number
|
|
45
50
|
*/
|
|
@@ -64,12 +69,46 @@ export declare class Filesystem {
|
|
|
64
69
|
* Get link count for an inode
|
|
65
70
|
*/
|
|
66
71
|
private getLinkCount;
|
|
67
|
-
|
|
72
|
+
private getInodeMode;
|
|
73
|
+
writeFile(path: string, content: string | Buffer, options?: BufferEncoding | {
|
|
74
|
+
encoding?: BufferEncoding;
|
|
75
|
+
}): Promise<void>;
|
|
68
76
|
private updateFileContent;
|
|
69
77
|
readFile(path: string, options?: BufferEncoding | {
|
|
70
78
|
encoding?: BufferEncoding;
|
|
71
79
|
}): Promise<Buffer | string>;
|
|
72
80
|
readdir(path: string): Promise<string[]>;
|
|
81
|
+
unlink(path: string): Promise<void>;
|
|
73
82
|
deleteFile(path: string): Promise<void>;
|
|
74
83
|
stat(path: string): Promise<Stats>;
|
|
84
|
+
/**
|
|
85
|
+
* Create a directory (non-recursive, no options yet)
|
|
86
|
+
*/
|
|
87
|
+
mkdir(path: string): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Remove a file or directory
|
|
90
|
+
*/
|
|
91
|
+
rm(path: string, options?: {
|
|
92
|
+
force?: boolean;
|
|
93
|
+
recursive?: boolean;
|
|
94
|
+
}): Promise<void>;
|
|
95
|
+
private rmDirContentsRecursive;
|
|
96
|
+
private removeDentryAndMaybeInode;
|
|
97
|
+
/**
|
|
98
|
+
* Remove an empty directory
|
|
99
|
+
*/
|
|
100
|
+
rmdir(path: string): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Rename (move) a file or directory
|
|
103
|
+
*/
|
|
104
|
+
rename(oldPath: string, newPath: string): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* Copy a file. Overwrites destination if it exists.
|
|
107
|
+
*/
|
|
108
|
+
copyFile(src: string, dest: string): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Test a user's permissions for the file or directory specified by path.
|
|
111
|
+
* Currently supports existence checks only (F_OK semantics).
|
|
112
|
+
*/
|
|
113
|
+
access(path: string): Promise<void>;
|
|
75
114
|
}
|
package/dist/filesystem.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { createFsError } from './errors.js';
|
|
2
|
+
import { assertInodeIsDirectory, assertNotRoot, assertNotSymlinkMode, assertReadableExistingInode, assertReaddirTargetInode, assertUnlinkTargetInode, assertWritableExistingInode, getInodeModeOrThrow, normalizeRmOptions, throwENOENTUnlessForce, } from './guards.js';
|
|
1
3
|
// File types for mode field
|
|
2
|
-
const S_IFMT = 0o170000; // File type mask
|
|
3
|
-
const S_IFREG = 0o100000; // Regular file
|
|
4
|
-
const S_IFDIR = 0o040000; // Directory
|
|
5
|
-
const S_IFLNK = 0o120000; // Symbolic link
|
|
4
|
+
export const S_IFMT = 0o170000; // File type mask
|
|
5
|
+
export const S_IFREG = 0o100000; // Regular file
|
|
6
|
+
export const S_IFDIR = 0o040000; // Directory
|
|
7
|
+
export const S_IFLNK = 0o120000; // Symbolic link
|
|
6
8
|
// Default permissions
|
|
7
9
|
const DEFAULT_FILE_MODE = S_IFREG | 0o644; // Regular file, rw-r--r--
|
|
8
10
|
const DEFAULT_DIR_MODE = S_IFDIR | 0o755; // Directory, rwxr-xr-x
|
|
@@ -134,6 +136,19 @@ export class Filesystem {
|
|
|
134
136
|
return [];
|
|
135
137
|
return normalized.split('/').filter(p => p);
|
|
136
138
|
}
|
|
139
|
+
async resolvePathOrThrow(path, syscall) {
|
|
140
|
+
const normalizedPath = this.normalizePath(path);
|
|
141
|
+
const ino = await this.resolvePath(normalizedPath);
|
|
142
|
+
if (ino === null) {
|
|
143
|
+
throw createFsError({
|
|
144
|
+
code: 'ENOENT',
|
|
145
|
+
syscall,
|
|
146
|
+
path: normalizedPath,
|
|
147
|
+
message: 'no such file or directory',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return { normalizedPath, ino };
|
|
151
|
+
}
|
|
137
152
|
/**
|
|
138
153
|
* Resolve a path to an inode number
|
|
139
154
|
*/
|
|
@@ -207,9 +222,7 @@ export class Filesystem {
|
|
|
207
222
|
// Remove the filename, keep only directory parts
|
|
208
223
|
parts.pop();
|
|
209
224
|
let currentIno = this.rootIno;
|
|
210
|
-
let currentPath = '';
|
|
211
225
|
for (const name of parts) {
|
|
212
|
-
currentPath += '/' + name;
|
|
213
226
|
// Check if this directory exists
|
|
214
227
|
const stmt = this.db.prepare(`
|
|
215
228
|
SELECT ino FROM fs_dentry
|
|
@@ -223,6 +236,8 @@ export class Filesystem {
|
|
|
223
236
|
currentIno = dirIno;
|
|
224
237
|
}
|
|
225
238
|
else {
|
|
239
|
+
// Ensure existing path component is actually a directory
|
|
240
|
+
await assertInodeIsDirectory(this.db, result.ino, 'open', this.normalizePath(path));
|
|
226
241
|
currentIno = result.ino;
|
|
227
242
|
}
|
|
228
243
|
}
|
|
@@ -235,31 +250,50 @@ export class Filesystem {
|
|
|
235
250
|
const result = await stmt.get(ino);
|
|
236
251
|
return result.count;
|
|
237
252
|
}
|
|
238
|
-
async
|
|
253
|
+
async getInodeMode(ino) {
|
|
254
|
+
const stmt = this.db.prepare('SELECT mode FROM fs_inode WHERE ino = ?');
|
|
255
|
+
const row = await stmt.get(ino);
|
|
256
|
+
return row?.mode ?? null;
|
|
257
|
+
}
|
|
258
|
+
async writeFile(path, content, options) {
|
|
239
259
|
// Ensure parent directories exist
|
|
240
260
|
await this.ensureParentDirs(path);
|
|
241
261
|
// Check if file already exists
|
|
242
262
|
const ino = await this.resolvePath(path);
|
|
263
|
+
const encoding = typeof options === 'string'
|
|
264
|
+
? options
|
|
265
|
+
: options?.encoding;
|
|
266
|
+
const normalizedPath = this.normalizePath(path);
|
|
243
267
|
if (ino !== null) {
|
|
268
|
+
await assertWritableExistingInode(this.db, ino, 'open', normalizedPath);
|
|
244
269
|
// Update existing file
|
|
245
|
-
await this.updateFileContent(ino, content);
|
|
270
|
+
await this.updateFileContent(ino, content, encoding);
|
|
246
271
|
}
|
|
247
272
|
else {
|
|
248
273
|
// Create new file
|
|
249
274
|
const parent = await this.resolveParent(path);
|
|
250
275
|
if (!parent) {
|
|
251
|
-
throw
|
|
276
|
+
throw createFsError({
|
|
277
|
+
code: 'ENOENT',
|
|
278
|
+
syscall: 'open',
|
|
279
|
+
path: normalizedPath,
|
|
280
|
+
message: 'no such file or directory',
|
|
281
|
+
});
|
|
252
282
|
}
|
|
283
|
+
// Ensure parent is a directory
|
|
284
|
+
await assertInodeIsDirectory(this.db, parent.parentIno, 'open', normalizedPath);
|
|
253
285
|
// Create inode
|
|
254
286
|
const fileIno = await this.createInode(DEFAULT_FILE_MODE);
|
|
255
287
|
// Create directory entry
|
|
256
288
|
await this.createDentry(parent.parentIno, parent.name, fileIno);
|
|
257
289
|
// Write content
|
|
258
|
-
await this.updateFileContent(fileIno, content);
|
|
290
|
+
await this.updateFileContent(fileIno, content, encoding);
|
|
259
291
|
}
|
|
260
292
|
}
|
|
261
|
-
async updateFileContent(ino, content) {
|
|
262
|
-
const buffer = typeof content === 'string'
|
|
293
|
+
async updateFileContent(ino, content, encoding) {
|
|
294
|
+
const buffer = typeof content === 'string'
|
|
295
|
+
? this.bufferCtor.from(content, encoding ?? 'utf8')
|
|
296
|
+
: content;
|
|
263
297
|
const now = Math.floor(Date.now() / 1000);
|
|
264
298
|
// Delete existing data chunks
|
|
265
299
|
const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?');
|
|
@@ -290,10 +324,8 @@ export class Filesystem {
|
|
|
290
324
|
const encoding = typeof options === 'string'
|
|
291
325
|
? options
|
|
292
326
|
: options?.encoding;
|
|
293
|
-
const ino = await this.
|
|
294
|
-
|
|
295
|
-
throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
296
|
-
}
|
|
327
|
+
const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'open');
|
|
328
|
+
await assertReadableExistingInode(this.db, ino, 'open', normalizedPath);
|
|
297
329
|
// Get all data chunks
|
|
298
330
|
const stmt = this.db.prepare(`
|
|
299
331
|
SELECT data FROM fs_data
|
|
@@ -320,10 +352,8 @@ export class Filesystem {
|
|
|
320
352
|
return combined;
|
|
321
353
|
}
|
|
322
354
|
async readdir(path) {
|
|
323
|
-
const ino = await this.
|
|
324
|
-
|
|
325
|
-
throw new Error(`ENOENT: no such file or directory, scandir '${path}'`);
|
|
326
|
-
}
|
|
355
|
+
const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'scandir');
|
|
356
|
+
await assertReaddirTargetInode(this.db, ino, normalizedPath);
|
|
327
357
|
// Get all directory entries
|
|
328
358
|
const stmt = this.db.prepare(`
|
|
329
359
|
SELECT name FROM fs_dentry
|
|
@@ -333,15 +363,13 @@ export class Filesystem {
|
|
|
333
363
|
const rows = await stmt.all(ino);
|
|
334
364
|
return rows.map(row => row.name);
|
|
335
365
|
}
|
|
336
|
-
async
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const parent = await this.resolveParent(
|
|
342
|
-
|
|
343
|
-
throw new Error(`Cannot delete root directory`);
|
|
344
|
-
}
|
|
366
|
+
async unlink(path) {
|
|
367
|
+
const normalizedPath = this.normalizePath(path);
|
|
368
|
+
assertNotRoot(normalizedPath, 'unlink');
|
|
369
|
+
const { ino } = await this.resolvePathOrThrow(normalizedPath, 'unlink');
|
|
370
|
+
await assertUnlinkTargetInode(this.db, ino, normalizedPath);
|
|
371
|
+
const parent = (await this.resolveParent(normalizedPath));
|
|
372
|
+
// parent is guaranteed to exist here since normalizedPath !== '/'
|
|
345
373
|
// Delete the directory entry
|
|
346
374
|
const stmt = this.db.prepare(`
|
|
347
375
|
DELETE FROM fs_dentry
|
|
@@ -359,11 +387,12 @@ export class Filesystem {
|
|
|
359
387
|
await deleteDataStmt.run(ino);
|
|
360
388
|
}
|
|
361
389
|
}
|
|
390
|
+
// Backwards-compatible alias
|
|
391
|
+
async deleteFile(path) {
|
|
392
|
+
return await this.unlink(path);
|
|
393
|
+
}
|
|
362
394
|
async stat(path) {
|
|
363
|
-
const ino = await this.
|
|
364
|
-
if (ino === null) {
|
|
365
|
-
throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
|
|
366
|
-
}
|
|
395
|
+
const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'stat');
|
|
367
396
|
const stmt = this.db.prepare(`
|
|
368
397
|
SELECT ino, mode, uid, gid, size, atime, mtime, ctime
|
|
369
398
|
FROM fs_inode
|
|
@@ -371,7 +400,12 @@ export class Filesystem {
|
|
|
371
400
|
`);
|
|
372
401
|
const row = await stmt.get(ino);
|
|
373
402
|
if (!row) {
|
|
374
|
-
throw
|
|
403
|
+
throw createFsError({
|
|
404
|
+
code: 'ENOENT',
|
|
405
|
+
syscall: 'stat',
|
|
406
|
+
path: normalizedPath,
|
|
407
|
+
message: 'no such file or directory',
|
|
408
|
+
});
|
|
375
409
|
}
|
|
376
410
|
const nlink = await this.getLinkCount(ino);
|
|
377
411
|
return {
|
|
@@ -389,4 +423,396 @@ export class Filesystem {
|
|
|
389
423
|
isSymbolicLink: () => (row.mode & S_IFMT) === S_IFLNK,
|
|
390
424
|
};
|
|
391
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* Create a directory (non-recursive, no options yet)
|
|
428
|
+
*/
|
|
429
|
+
async mkdir(path) {
|
|
430
|
+
const normalizedPath = this.normalizePath(path);
|
|
431
|
+
const existing = await this.resolvePath(normalizedPath);
|
|
432
|
+
if (existing !== null) {
|
|
433
|
+
throw createFsError({
|
|
434
|
+
code: 'EEXIST',
|
|
435
|
+
syscall: 'mkdir',
|
|
436
|
+
path: normalizedPath,
|
|
437
|
+
message: 'file already exists',
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
const parent = await this.resolveParent(normalizedPath);
|
|
441
|
+
if (!parent) {
|
|
442
|
+
throw createFsError({
|
|
443
|
+
code: 'ENOENT',
|
|
444
|
+
syscall: 'mkdir',
|
|
445
|
+
path: normalizedPath,
|
|
446
|
+
message: 'no such file or directory',
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
await assertInodeIsDirectory(this.db, parent.parentIno, 'mkdir', normalizedPath);
|
|
450
|
+
const dirIno = await this.createInode(DEFAULT_DIR_MODE);
|
|
451
|
+
try {
|
|
452
|
+
await this.createDentry(parent.parentIno, parent.name, dirIno);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
throw createFsError({
|
|
456
|
+
code: 'EEXIST',
|
|
457
|
+
syscall: 'mkdir',
|
|
458
|
+
path: normalizedPath,
|
|
459
|
+
message: 'file already exists',
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Remove a file or directory
|
|
465
|
+
*/
|
|
466
|
+
async rm(path, options) {
|
|
467
|
+
const normalizedPath = this.normalizePath(path);
|
|
468
|
+
const { force, recursive } = normalizeRmOptions(options);
|
|
469
|
+
assertNotRoot(normalizedPath, 'rm');
|
|
470
|
+
const ino = await this.resolvePath(normalizedPath);
|
|
471
|
+
if (ino === null) {
|
|
472
|
+
throwENOENTUnlessForce(normalizedPath, 'rm', force);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const mode = await getInodeModeOrThrow(this.db, ino, 'rm', normalizedPath);
|
|
476
|
+
assertNotSymlinkMode(mode, 'rm', normalizedPath);
|
|
477
|
+
const parent = await this.resolveParent(normalizedPath);
|
|
478
|
+
if (!parent) {
|
|
479
|
+
throw createFsError({
|
|
480
|
+
code: 'EPERM',
|
|
481
|
+
syscall: 'rm',
|
|
482
|
+
path: normalizedPath,
|
|
483
|
+
message: 'operation not permitted',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
if ((mode & S_IFMT) === S_IFDIR) {
|
|
487
|
+
if (!recursive) {
|
|
488
|
+
throw createFsError({
|
|
489
|
+
code: 'EISDIR',
|
|
490
|
+
syscall: 'rm',
|
|
491
|
+
path: normalizedPath,
|
|
492
|
+
message: 'illegal operation on a directory',
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
await this.rmDirContentsRecursive(ino);
|
|
496
|
+
await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// Regular file
|
|
500
|
+
await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino);
|
|
501
|
+
}
|
|
502
|
+
async rmDirContentsRecursive(dirIno) {
|
|
503
|
+
const stmt = this.db.prepare(`
|
|
504
|
+
SELECT name, ino FROM fs_dentry
|
|
505
|
+
WHERE parent_ino = ?
|
|
506
|
+
ORDER BY name ASC
|
|
507
|
+
`);
|
|
508
|
+
const children = await stmt.all(dirIno);
|
|
509
|
+
for (const child of children) {
|
|
510
|
+
const mode = await this.getInodeMode(child.ino);
|
|
511
|
+
if (mode === null) {
|
|
512
|
+
// DB inconsistency; treat as already gone
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if ((mode & S_IFMT) === S_IFDIR) {
|
|
516
|
+
await this.rmDirContentsRecursive(child.ino);
|
|
517
|
+
await this.removeDentryAndMaybeInode(dirIno, child.name, child.ino);
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
// Not supported yet (symlinks)
|
|
521
|
+
assertNotSymlinkMode(mode, 'rm', '<symlink>');
|
|
522
|
+
await this.removeDentryAndMaybeInode(dirIno, child.name, child.ino);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async removeDentryAndMaybeInode(parentIno, name, ino) {
|
|
527
|
+
const stmt = this.db.prepare(`
|
|
528
|
+
DELETE FROM fs_dentry
|
|
529
|
+
WHERE parent_ino = ? AND name = ?
|
|
530
|
+
`);
|
|
531
|
+
await stmt.run(parentIno, name);
|
|
532
|
+
const linkCount = await this.getLinkCount(ino);
|
|
533
|
+
if (linkCount === 0) {
|
|
534
|
+
const deleteInodeStmt = this.db.prepare('DELETE FROM fs_inode WHERE ino = ?');
|
|
535
|
+
await deleteInodeStmt.run(ino);
|
|
536
|
+
const deleteDataStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?');
|
|
537
|
+
await deleteDataStmt.run(ino);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Remove an empty directory
|
|
542
|
+
*/
|
|
543
|
+
async rmdir(path) {
|
|
544
|
+
const normalizedPath = this.normalizePath(path);
|
|
545
|
+
assertNotRoot(normalizedPath, 'rmdir');
|
|
546
|
+
const { ino } = await this.resolvePathOrThrow(normalizedPath, 'rmdir');
|
|
547
|
+
const mode = await getInodeModeOrThrow(this.db, ino, 'rmdir', normalizedPath);
|
|
548
|
+
assertNotSymlinkMode(mode, 'rmdir', normalizedPath);
|
|
549
|
+
if ((mode & S_IFMT) !== S_IFDIR) {
|
|
550
|
+
throw createFsError({
|
|
551
|
+
code: 'ENOTDIR',
|
|
552
|
+
syscall: 'rmdir',
|
|
553
|
+
path: normalizedPath,
|
|
554
|
+
message: 'not a directory',
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
const stmt = this.db.prepare(`
|
|
558
|
+
SELECT 1 as one FROM fs_dentry
|
|
559
|
+
WHERE parent_ino = ?
|
|
560
|
+
LIMIT 1
|
|
561
|
+
`);
|
|
562
|
+
const child = await stmt.get(ino);
|
|
563
|
+
if (child) {
|
|
564
|
+
throw createFsError({
|
|
565
|
+
code: 'ENOTEMPTY',
|
|
566
|
+
syscall: 'rmdir',
|
|
567
|
+
path: normalizedPath,
|
|
568
|
+
message: 'directory not empty',
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
const parent = await this.resolveParent(normalizedPath);
|
|
572
|
+
if (!parent) {
|
|
573
|
+
throw createFsError({
|
|
574
|
+
code: 'EPERM',
|
|
575
|
+
syscall: 'rmdir',
|
|
576
|
+
path: normalizedPath,
|
|
577
|
+
message: 'operation not permitted',
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino);
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Rename (move) a file or directory
|
|
584
|
+
*/
|
|
585
|
+
async rename(oldPath, newPath) {
|
|
586
|
+
const oldNormalized = this.normalizePath(oldPath);
|
|
587
|
+
const newNormalized = this.normalizePath(newPath);
|
|
588
|
+
// No-op
|
|
589
|
+
if (oldNormalized === newNormalized)
|
|
590
|
+
return;
|
|
591
|
+
assertNotRoot(oldNormalized, 'rename');
|
|
592
|
+
assertNotRoot(newNormalized, 'rename');
|
|
593
|
+
const oldParent = await this.resolveParent(oldNormalized);
|
|
594
|
+
if (!oldParent) {
|
|
595
|
+
throw createFsError({
|
|
596
|
+
code: 'EPERM',
|
|
597
|
+
syscall: 'rename',
|
|
598
|
+
path: oldNormalized,
|
|
599
|
+
message: 'operation not permitted',
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
const newParent = await this.resolveParent(newNormalized);
|
|
603
|
+
if (!newParent) {
|
|
604
|
+
throw createFsError({
|
|
605
|
+
code: 'ENOENT',
|
|
606
|
+
syscall: 'rename',
|
|
607
|
+
path: newNormalized,
|
|
608
|
+
message: 'no such file or directory',
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
// Ensure destination parent exists and is a directory
|
|
612
|
+
await assertInodeIsDirectory(this.db, newParent.parentIno, 'rename', newNormalized);
|
|
613
|
+
await this.db.exec('BEGIN');
|
|
614
|
+
try {
|
|
615
|
+
const oldResolved = await this.resolvePathOrThrow(oldNormalized, 'rename');
|
|
616
|
+
const oldIno = oldResolved.ino;
|
|
617
|
+
const oldMode = await getInodeModeOrThrow(this.db, oldIno, 'rename', oldNormalized);
|
|
618
|
+
assertNotSymlinkMode(oldMode, 'rename', oldNormalized);
|
|
619
|
+
const oldIsDir = (oldMode & S_IFMT) === S_IFDIR;
|
|
620
|
+
// Prevent renaming a directory into its own subtree (would create cycles).
|
|
621
|
+
if (oldIsDir && newNormalized.startsWith(oldNormalized + '/')) {
|
|
622
|
+
throw createFsError({
|
|
623
|
+
code: 'EINVAL',
|
|
624
|
+
syscall: 'rename',
|
|
625
|
+
path: newNormalized,
|
|
626
|
+
message: 'invalid argument',
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
const newIno = await this.resolvePath(newNormalized);
|
|
630
|
+
if (newIno !== null) {
|
|
631
|
+
const newMode = await getInodeModeOrThrow(this.db, newIno, 'rename', newNormalized);
|
|
632
|
+
assertNotSymlinkMode(newMode, 'rename', newNormalized);
|
|
633
|
+
const newIsDir = (newMode & S_IFMT) === S_IFDIR;
|
|
634
|
+
if (newIsDir && !oldIsDir) {
|
|
635
|
+
throw createFsError({
|
|
636
|
+
code: 'EISDIR',
|
|
637
|
+
syscall: 'rename',
|
|
638
|
+
path: newNormalized,
|
|
639
|
+
message: 'illegal operation on a directory',
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
if (!newIsDir && oldIsDir) {
|
|
643
|
+
throw createFsError({
|
|
644
|
+
code: 'ENOTDIR',
|
|
645
|
+
syscall: 'rename',
|
|
646
|
+
path: newNormalized,
|
|
647
|
+
message: 'not a directory',
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
// If replacing a directory, it must be empty.
|
|
651
|
+
if (newIsDir) {
|
|
652
|
+
const stmt = this.db.prepare(`
|
|
653
|
+
SELECT 1 as one FROM fs_dentry
|
|
654
|
+
WHERE parent_ino = ?
|
|
655
|
+
LIMIT 1
|
|
656
|
+
`);
|
|
657
|
+
const child = await stmt.get(newIno);
|
|
658
|
+
if (child) {
|
|
659
|
+
throw createFsError({
|
|
660
|
+
code: 'ENOTEMPTY',
|
|
661
|
+
syscall: 'rename',
|
|
662
|
+
path: newNormalized,
|
|
663
|
+
message: 'directory not empty',
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// Remove the destination entry (and inode if this was the last link)
|
|
668
|
+
await this.removeDentryAndMaybeInode(newParent.parentIno, newParent.name, newIno);
|
|
669
|
+
}
|
|
670
|
+
// Move the directory entry
|
|
671
|
+
const stmt = this.db.prepare(`
|
|
672
|
+
UPDATE fs_dentry
|
|
673
|
+
SET parent_ino = ?, name = ?
|
|
674
|
+
WHERE parent_ino = ? AND name = ?
|
|
675
|
+
`);
|
|
676
|
+
await stmt.run(newParent.parentIno, newParent.name, oldParent.parentIno, oldParent.name);
|
|
677
|
+
// Update timestamps
|
|
678
|
+
const now = Math.floor(Date.now() / 1000);
|
|
679
|
+
const updateInodeCtimeStmt = this.db.prepare(`
|
|
680
|
+
UPDATE fs_inode
|
|
681
|
+
SET ctime = ?
|
|
682
|
+
WHERE ino = ?
|
|
683
|
+
`);
|
|
684
|
+
await updateInodeCtimeStmt.run(now, oldIno);
|
|
685
|
+
const updateDirTimesStmt = this.db.prepare(`
|
|
686
|
+
UPDATE fs_inode
|
|
687
|
+
SET mtime = ?, ctime = ?
|
|
688
|
+
WHERE ino = ?
|
|
689
|
+
`);
|
|
690
|
+
await updateDirTimesStmt.run(now, now, oldParent.parentIno);
|
|
691
|
+
if (newParent.parentIno !== oldParent.parentIno) {
|
|
692
|
+
await updateDirTimesStmt.run(now, now, newParent.parentIno);
|
|
693
|
+
}
|
|
694
|
+
await this.db.exec('COMMIT');
|
|
695
|
+
}
|
|
696
|
+
catch (e) {
|
|
697
|
+
await this.db.exec('ROLLBACK');
|
|
698
|
+
throw e;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Copy a file. Overwrites destination if it exists.
|
|
703
|
+
*/
|
|
704
|
+
async copyFile(src, dest) {
|
|
705
|
+
const srcNormalized = this.normalizePath(src);
|
|
706
|
+
const destNormalized = this.normalizePath(dest);
|
|
707
|
+
if (srcNormalized === destNormalized) {
|
|
708
|
+
throw createFsError({
|
|
709
|
+
code: 'EINVAL',
|
|
710
|
+
syscall: 'copyfile',
|
|
711
|
+
path: destNormalized,
|
|
712
|
+
message: 'invalid argument',
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
// Resolve and validate source
|
|
716
|
+
// node uses copyfile as syscall name even though it's not a syscall
|
|
717
|
+
const { ino: srcIno } = await this.resolvePathOrThrow(srcNormalized, 'copyfile');
|
|
718
|
+
await assertReadableExistingInode(this.db, srcIno, 'copyfile', srcNormalized);
|
|
719
|
+
const stmt = this.db.prepare(`
|
|
720
|
+
SELECT mode, uid, gid, size FROM fs_inode WHERE ino = ?
|
|
721
|
+
`);
|
|
722
|
+
const srcRow = await stmt.get(srcIno);
|
|
723
|
+
if (!srcRow) {
|
|
724
|
+
throw createFsError({
|
|
725
|
+
code: 'ENOENT',
|
|
726
|
+
syscall: 'copyfile',
|
|
727
|
+
path: srcNormalized,
|
|
728
|
+
message: 'no such file or directory',
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
// Destination parent must exist and be a directory (Node does not create parents)
|
|
732
|
+
const destParent = await this.resolveParent(destNormalized);
|
|
733
|
+
if (!destParent) {
|
|
734
|
+
throw createFsError({
|
|
735
|
+
code: 'ENOENT',
|
|
736
|
+
syscall: 'copyfile',
|
|
737
|
+
path: destNormalized,
|
|
738
|
+
message: 'no such file or directory',
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
await assertInodeIsDirectory(this.db, destParent.parentIno, 'copyfile', destNormalized);
|
|
742
|
+
await this.db.exec('BEGIN');
|
|
743
|
+
try {
|
|
744
|
+
const now = Math.floor(Date.now() / 1000);
|
|
745
|
+
// If destination exists, it must be a file (overwrite semantics).
|
|
746
|
+
const destIno = await this.resolvePath(destNormalized);
|
|
747
|
+
if (destIno !== null) {
|
|
748
|
+
const destMode = await getInodeModeOrThrow(this.db, destIno, 'copyfile', destNormalized);
|
|
749
|
+
assertNotSymlinkMode(destMode, 'copyfile', destNormalized);
|
|
750
|
+
if ((destMode & S_IFMT) === S_IFDIR) {
|
|
751
|
+
throw createFsError({
|
|
752
|
+
code: 'EISDIR',
|
|
753
|
+
syscall: 'copyfile',
|
|
754
|
+
path: destNormalized,
|
|
755
|
+
message: 'illegal operation on a directory',
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
// Replace destination contents
|
|
759
|
+
const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?');
|
|
760
|
+
await deleteStmt.run(destIno);
|
|
761
|
+
const copyStmt = this.db.prepare(`
|
|
762
|
+
INSERT INTO fs_data (ino, chunk_index, data)
|
|
763
|
+
SELECT ?, chunk_index, data
|
|
764
|
+
FROM fs_data
|
|
765
|
+
WHERE ino = ?
|
|
766
|
+
ORDER BY chunk_index ASC
|
|
767
|
+
`);
|
|
768
|
+
await copyStmt.run(destIno, srcIno);
|
|
769
|
+
const updateStmt = this.db.prepare(`
|
|
770
|
+
UPDATE fs_inode
|
|
771
|
+
SET mode = ?, uid = ?, gid = ?, size = ?, mtime = ?, ctime = ?
|
|
772
|
+
WHERE ino = ?
|
|
773
|
+
`);
|
|
774
|
+
await updateStmt.run(srcRow.mode, srcRow.uid, srcRow.gid, srcRow.size, now, now, destIno);
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
// Create new destination inode + dentry
|
|
778
|
+
const destInoCreated = await this.createInode(srcRow.mode, srcRow.uid, srcRow.gid);
|
|
779
|
+
await this.createDentry(destParent.parentIno, destParent.name, destInoCreated);
|
|
780
|
+
const copyStmt = this.db.prepare(`
|
|
781
|
+
INSERT INTO fs_data (ino, chunk_index, data)
|
|
782
|
+
SELECT ?, chunk_index, data
|
|
783
|
+
FROM fs_data
|
|
784
|
+
WHERE ino = ?
|
|
785
|
+
ORDER BY chunk_index ASC
|
|
786
|
+
`);
|
|
787
|
+
await copyStmt.run(destInoCreated, srcIno);
|
|
788
|
+
const updateStmt = this.db.prepare(`
|
|
789
|
+
UPDATE fs_inode
|
|
790
|
+
SET size = ?, mtime = ?, ctime = ?
|
|
791
|
+
WHERE ino = ?
|
|
792
|
+
`);
|
|
793
|
+
await updateStmt.run(srcRow.size, now, now, destInoCreated);
|
|
794
|
+
}
|
|
795
|
+
await this.db.exec('COMMIT');
|
|
796
|
+
}
|
|
797
|
+
catch (e) {
|
|
798
|
+
await this.db.exec('ROLLBACK');
|
|
799
|
+
throw e;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Test a user's permissions for the file or directory specified by path.
|
|
804
|
+
* Currently supports existence checks only (F_OK semantics).
|
|
805
|
+
*/
|
|
806
|
+
async access(path) {
|
|
807
|
+
const normalizedPath = this.normalizePath(path);
|
|
808
|
+
const ino = await this.resolvePath(normalizedPath);
|
|
809
|
+
if (ino === null) {
|
|
810
|
+
throw createFsError({
|
|
811
|
+
code: 'ENOENT',
|
|
812
|
+
syscall: 'access',
|
|
813
|
+
path: normalizedPath,
|
|
814
|
+
message: 'no such file or directory',
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
392
818
|
}
|
package/dist/guards.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DatabasePromise } from '@tursodatabase/database-common';
|
|
2
|
+
import { type FsSyscall } from './errors.js';
|
|
3
|
+
export declare function getInodeModeOrThrow(db: DatabasePromise, ino: number, syscall: FsSyscall, path: string): Promise<number>;
|
|
4
|
+
export declare function assertNotRoot(path: string, syscall: FsSyscall): void;
|
|
5
|
+
export declare function normalizeRmOptions(options?: {
|
|
6
|
+
force?: boolean;
|
|
7
|
+
recursive?: boolean;
|
|
8
|
+
}): {
|
|
9
|
+
force: boolean;
|
|
10
|
+
recursive: boolean;
|
|
11
|
+
};
|
|
12
|
+
export declare function throwENOENTUnlessForce(path: string, syscall: FsSyscall, force: boolean): void;
|
|
13
|
+
export declare function assertNotSymlinkMode(mode: number, syscall: FsSyscall, path: string): void;
|
|
14
|
+
export declare function assertInodeIsDirectory(db: DatabasePromise, ino: number, syscall: FsSyscall, fullPathForError: string): Promise<void>;
|
|
15
|
+
export declare function assertWritableExistingInode(db: DatabasePromise, ino: number, syscall: FsSyscall, fullPathForError: string): Promise<void>;
|
|
16
|
+
export declare function assertReadableExistingInode(db: DatabasePromise, ino: number, syscall: FsSyscall, fullPathForError: string): Promise<void>;
|
|
17
|
+
export declare function assertReaddirTargetInode(db: DatabasePromise, ino: number, fullPathForError: string): Promise<void>;
|
|
18
|
+
export declare function assertUnlinkTargetInode(db: DatabasePromise, ino: number, fullPathForError: string): Promise<void>;
|
package/dist/guards.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createFsError } from './errors.js';
|
|
2
|
+
import { S_IFDIR, S_IFLNK, S_IFMT } from './filesystem.js';
|
|
3
|
+
async function getInodeMode(db, ino) {
|
|
4
|
+
const stmt = db.prepare('SELECT mode FROM fs_inode WHERE ino = ?');
|
|
5
|
+
const row = await stmt.get(ino);
|
|
6
|
+
return row?.mode ?? null;
|
|
7
|
+
}
|
|
8
|
+
function isDirMode(mode) {
|
|
9
|
+
return (mode & S_IFMT) === S_IFDIR;
|
|
10
|
+
}
|
|
11
|
+
export async function getInodeModeOrThrow(db, ino, syscall, path) {
|
|
12
|
+
const mode = await getInodeMode(db, ino);
|
|
13
|
+
if (mode === null) {
|
|
14
|
+
throw createFsError({
|
|
15
|
+
code: 'ENOENT',
|
|
16
|
+
syscall,
|
|
17
|
+
path,
|
|
18
|
+
message: 'no such file or directory',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return mode;
|
|
22
|
+
}
|
|
23
|
+
export function assertNotRoot(path, syscall) {
|
|
24
|
+
if (path === '/') {
|
|
25
|
+
throw createFsError({
|
|
26
|
+
code: 'EPERM',
|
|
27
|
+
syscall,
|
|
28
|
+
path,
|
|
29
|
+
message: 'operation not permitted on root directory',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function normalizeRmOptions(options) {
|
|
34
|
+
return {
|
|
35
|
+
force: options?.force === true,
|
|
36
|
+
recursive: options?.recursive === true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function throwENOENTUnlessForce(path, syscall, force) {
|
|
40
|
+
if (force)
|
|
41
|
+
return;
|
|
42
|
+
throw createFsError({
|
|
43
|
+
code: 'ENOENT',
|
|
44
|
+
syscall,
|
|
45
|
+
path,
|
|
46
|
+
message: 'no such file or directory',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function assertNotSymlinkMode(mode, syscall, path) {
|
|
50
|
+
if ((mode & S_IFMT) === S_IFLNK) {
|
|
51
|
+
throw createFsError({
|
|
52
|
+
code: 'ENOSYS',
|
|
53
|
+
syscall,
|
|
54
|
+
path,
|
|
55
|
+
message: 'symbolic links not supported yet',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function assertExistingNonDirNonSymlinkInode(db, ino, syscall, fullPathForError) {
|
|
60
|
+
const mode = await getInodeMode(db, ino);
|
|
61
|
+
if (mode === null) {
|
|
62
|
+
throw createFsError({
|
|
63
|
+
code: 'ENOENT',
|
|
64
|
+
syscall,
|
|
65
|
+
path: fullPathForError,
|
|
66
|
+
message: 'no such file or directory',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (isDirMode(mode)) {
|
|
70
|
+
throw createFsError({
|
|
71
|
+
code: 'EISDIR',
|
|
72
|
+
syscall,
|
|
73
|
+
path: fullPathForError,
|
|
74
|
+
message: 'illegal operation on a directory',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
assertNotSymlinkMode(mode, syscall, fullPathForError);
|
|
78
|
+
}
|
|
79
|
+
export async function assertInodeIsDirectory(db, ino, syscall, fullPathForError) {
|
|
80
|
+
const mode = await getInodeMode(db, ino);
|
|
81
|
+
if (mode === null) {
|
|
82
|
+
throw createFsError({
|
|
83
|
+
code: 'ENOENT',
|
|
84
|
+
syscall,
|
|
85
|
+
path: fullPathForError,
|
|
86
|
+
message: 'no such file or directory',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (!isDirMode(mode)) {
|
|
90
|
+
throw createFsError({
|
|
91
|
+
code: 'ENOTDIR',
|
|
92
|
+
syscall,
|
|
93
|
+
path: fullPathForError,
|
|
94
|
+
message: 'not a directory',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function assertWritableExistingInode(db, ino, syscall, fullPathForError) {
|
|
99
|
+
await assertExistingNonDirNonSymlinkInode(db, ino, syscall, fullPathForError);
|
|
100
|
+
}
|
|
101
|
+
export async function assertReadableExistingInode(db, ino, syscall, fullPathForError) {
|
|
102
|
+
await assertExistingNonDirNonSymlinkInode(db, ino, syscall, fullPathForError);
|
|
103
|
+
}
|
|
104
|
+
export async function assertReaddirTargetInode(db, ino, fullPathForError) {
|
|
105
|
+
const syscall = 'scandir';
|
|
106
|
+
const mode = await getInodeMode(db, ino);
|
|
107
|
+
if (mode === null) {
|
|
108
|
+
throw createFsError({
|
|
109
|
+
code: 'ENOENT',
|
|
110
|
+
syscall,
|
|
111
|
+
path: fullPathForError,
|
|
112
|
+
message: 'no such file or directory',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
assertNotSymlinkMode(mode, syscall, fullPathForError);
|
|
116
|
+
if (!isDirMode(mode)) {
|
|
117
|
+
throw createFsError({
|
|
118
|
+
code: 'ENOTDIR',
|
|
119
|
+
syscall,
|
|
120
|
+
path: fullPathForError,
|
|
121
|
+
message: 'not a directory',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function assertUnlinkTargetInode(db, ino, fullPathForError) {
|
|
126
|
+
const syscall = 'unlink';
|
|
127
|
+
const mode = await getInodeMode(db, ino);
|
|
128
|
+
if (mode === null) {
|
|
129
|
+
throw createFsError({
|
|
130
|
+
code: 'ENOENT',
|
|
131
|
+
syscall,
|
|
132
|
+
path: fullPathForError,
|
|
133
|
+
message: 'no such file or directory',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (isDirMode(mode)) {
|
|
137
|
+
throw createFsError({
|
|
138
|
+
code: 'EISDIR',
|
|
139
|
+
syscall,
|
|
140
|
+
path: fullPathForError,
|
|
141
|
+
message: 'illegal operation on a directory',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
assertNotSymlinkMode(mode, syscall, fullPathForError);
|
|
145
|
+
}
|