agentfs-sdk 0.4.0-pre.1 → 0.4.0-pre.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.
@@ -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
+ }
@@ -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
- writeFile(path: string, content: string | Buffer): Promise<void>;
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
  }
@@ -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
@@ -43,6 +45,7 @@ export class Filesystem {
43
45
  CREATE TABLE IF NOT EXISTS fs_inode (
44
46
  ino INTEGER PRIMARY KEY AUTOINCREMENT,
45
47
  mode INTEGER NOT NULL,
48
+ nlink INTEGER NOT NULL DEFAULT 0,
46
49
  uid INTEGER NOT NULL DEFAULT 0,
47
50
  gid INTEGER NOT NULL DEFAULT 0,
48
51
  size INTEGER NOT NULL DEFAULT 0,
@@ -109,8 +112,8 @@ export class Filesystem {
109
112
  if (!root) {
110
113
  const now = Math.floor(Date.now() / 1000);
111
114
  const insertStmt = this.db.prepare(`
112
- INSERT INTO fs_inode (ino, mode, uid, gid, size, atime, mtime, ctime)
113
- VALUES (?, ?, 0, 0, 0, ?, ?, ?)
115
+ INSERT INTO fs_inode (ino, mode, nlink, uid, gid, size, atime, mtime, ctime)
116
+ VALUES (?, ?, 1, 0, 0, 0, ?, ?, ?)
114
117
  `);
115
118
  await insertStmt.run(this.rootIno, DEFAULT_DIR_MODE, now, now, now);
116
119
  }
@@ -134,6 +137,19 @@ export class Filesystem {
134
137
  return [];
135
138
  return normalized.split('/').filter(p => p);
136
139
  }
140
+ async resolvePathOrThrow(path, syscall) {
141
+ const normalizedPath = this.normalizePath(path);
142
+ const ino = await this.resolvePath(normalizedPath);
143
+ if (ino === null) {
144
+ throw createFsError({
145
+ code: 'ENOENT',
146
+ syscall,
147
+ path: normalizedPath,
148
+ message: 'no such file or directory',
149
+ });
150
+ }
151
+ return { normalizedPath, ino };
152
+ }
137
153
  /**
138
154
  * Resolve a path to an inode number
139
155
  */
@@ -198,6 +214,9 @@ export class Filesystem {
198
214
  VALUES (?, ?, ?)
199
215
  `);
200
216
  await stmt.run(name, parentIno, ino);
217
+ // Increment link count
218
+ const updateStmt = this.db.prepare('UPDATE fs_inode SET nlink = nlink + 1 WHERE ino = ?');
219
+ await updateStmt.run(ino);
201
220
  }
202
221
  /**
203
222
  * Ensure parent directories exist
@@ -207,9 +226,7 @@ export class Filesystem {
207
226
  // Remove the filename, keep only directory parts
208
227
  parts.pop();
209
228
  let currentIno = this.rootIno;
210
- let currentPath = '';
211
229
  for (const name of parts) {
212
- currentPath += '/' + name;
213
230
  // Check if this directory exists
214
231
  const stmt = this.db.prepare(`
215
232
  SELECT ino FROM fs_dentry
@@ -223,6 +240,8 @@ export class Filesystem {
223
240
  currentIno = dirIno;
224
241
  }
225
242
  else {
243
+ // Ensure existing path component is actually a directory
244
+ await assertInodeIsDirectory(this.db, result.ino, 'open', this.normalizePath(path));
226
245
  currentIno = result.ino;
227
246
  }
228
247
  }
@@ -231,35 +250,54 @@ export class Filesystem {
231
250
  * Get link count for an inode
232
251
  */
233
252
  async getLinkCount(ino) {
234
- const stmt = this.db.prepare('SELECT COUNT(*) as count FROM fs_dentry WHERE ino = ?');
253
+ const stmt = this.db.prepare('SELECT nlink FROM fs_inode WHERE ino = ?');
235
254
  const result = await stmt.get(ino);
236
- return result.count;
255
+ return result?.nlink ?? 0;
237
256
  }
238
- async writeFile(path, content) {
257
+ async getInodeMode(ino) {
258
+ const stmt = this.db.prepare('SELECT mode FROM fs_inode WHERE ino = ?');
259
+ const row = await stmt.get(ino);
260
+ return row?.mode ?? null;
261
+ }
262
+ async writeFile(path, content, options) {
239
263
  // Ensure parent directories exist
240
264
  await this.ensureParentDirs(path);
241
265
  // Check if file already exists
242
266
  const ino = await this.resolvePath(path);
267
+ const encoding = typeof options === 'string'
268
+ ? options
269
+ : options?.encoding;
270
+ const normalizedPath = this.normalizePath(path);
243
271
  if (ino !== null) {
272
+ await assertWritableExistingInode(this.db, ino, 'open', normalizedPath);
244
273
  // Update existing file
245
- await this.updateFileContent(ino, content);
274
+ await this.updateFileContent(ino, content, encoding);
246
275
  }
247
276
  else {
248
277
  // Create new file
249
278
  const parent = await this.resolveParent(path);
250
279
  if (!parent) {
251
- throw new Error(`ENOENT: parent directory does not exist: ${path}`);
280
+ throw createFsError({
281
+ code: 'ENOENT',
282
+ syscall: 'open',
283
+ path: normalizedPath,
284
+ message: 'no such file or directory',
285
+ });
252
286
  }
287
+ // Ensure parent is a directory
288
+ await assertInodeIsDirectory(this.db, parent.parentIno, 'open', normalizedPath);
253
289
  // Create inode
254
290
  const fileIno = await this.createInode(DEFAULT_FILE_MODE);
255
291
  // Create directory entry
256
292
  await this.createDentry(parent.parentIno, parent.name, fileIno);
257
293
  // Write content
258
- await this.updateFileContent(fileIno, content);
294
+ await this.updateFileContent(fileIno, content, encoding);
259
295
  }
260
296
  }
261
- async updateFileContent(ino, content) {
262
- const buffer = typeof content === 'string' ? this.bufferCtor.from(content, 'utf-8') : content;
297
+ async updateFileContent(ino, content, encoding) {
298
+ const buffer = typeof content === 'string'
299
+ ? this.bufferCtor.from(content, encoding ?? 'utf8')
300
+ : content;
263
301
  const now = Math.floor(Date.now() / 1000);
264
302
  // Delete existing data chunks
265
303
  const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?');
@@ -290,10 +328,8 @@ export class Filesystem {
290
328
  const encoding = typeof options === 'string'
291
329
  ? options
292
330
  : options?.encoding;
293
- const ino = await this.resolvePath(path);
294
- if (ino === null) {
295
- throw new Error(`ENOENT: no such file or directory, open '${path}'`);
296
- }
331
+ const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'open');
332
+ await assertReadableExistingInode(this.db, ino, 'open', normalizedPath);
297
333
  // Get all data chunks
298
334
  const stmt = this.db.prepare(`
299
335
  SELECT data FROM fs_data
@@ -320,10 +356,8 @@ export class Filesystem {
320
356
  return combined;
321
357
  }
322
358
  async readdir(path) {
323
- const ino = await this.resolvePath(path);
324
- if (ino === null) {
325
- throw new Error(`ENOENT: no such file or directory, scandir '${path}'`);
326
- }
359
+ const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'scandir');
360
+ await assertReaddirTargetInode(this.db, ino, normalizedPath);
327
361
  // Get all directory entries
328
362
  const stmt = this.db.prepare(`
329
363
  SELECT name FROM fs_dentry
@@ -333,21 +367,22 @@ export class Filesystem {
333
367
  const rows = await stmt.all(ino);
334
368
  return rows.map(row => row.name);
335
369
  }
336
- async deleteFile(path) {
337
- const ino = await this.resolvePath(path);
338
- if (ino === null) {
339
- throw new Error(`ENOENT: no such file or directory, unlink '${path}'`);
340
- }
341
- const parent = await this.resolveParent(path);
342
- if (!parent) {
343
- throw new Error(`Cannot delete root directory`);
344
- }
370
+ async unlink(path) {
371
+ const normalizedPath = this.normalizePath(path);
372
+ assertNotRoot(normalizedPath, 'unlink');
373
+ const { ino } = await this.resolvePathOrThrow(normalizedPath, 'unlink');
374
+ await assertUnlinkTargetInode(this.db, ino, normalizedPath);
375
+ const parent = (await this.resolveParent(normalizedPath));
376
+ // parent is guaranteed to exist here since normalizedPath !== '/'
345
377
  // Delete the directory entry
346
378
  const stmt = this.db.prepare(`
347
379
  DELETE FROM fs_dentry
348
380
  WHERE parent_ino = ? AND name = ?
349
381
  `);
350
382
  await stmt.run(parent.parentIno, parent.name);
383
+ // Decrement link count
384
+ const decrementStmt = this.db.prepare('UPDATE fs_inode SET nlink = nlink - 1 WHERE ino = ?');
385
+ await decrementStmt.run(ino);
351
386
  // Check if this was the last link to the inode
352
387
  const linkCount = await this.getLinkCount(ino);
353
388
  if (linkCount === 0) {
@@ -359,25 +394,30 @@ export class Filesystem {
359
394
  await deleteDataStmt.run(ino);
360
395
  }
361
396
  }
397
+ // Backwards-compatible alias
398
+ async deleteFile(path) {
399
+ return await this.unlink(path);
400
+ }
362
401
  async stat(path) {
363
- const ino = await this.resolvePath(path);
364
- if (ino === null) {
365
- throw new Error(`ENOENT: no such file or directory, stat '${path}'`);
366
- }
402
+ const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'stat');
367
403
  const stmt = this.db.prepare(`
368
- SELECT ino, mode, uid, gid, size, atime, mtime, ctime
404
+ SELECT ino, mode, nlink, uid, gid, size, atime, mtime, ctime
369
405
  FROM fs_inode
370
406
  WHERE ino = ?
371
407
  `);
372
408
  const row = await stmt.get(ino);
373
409
  if (!row) {
374
- throw new Error(`Inode not found: ${ino}`);
410
+ throw createFsError({
411
+ code: 'ENOENT',
412
+ syscall: 'stat',
413
+ path: normalizedPath,
414
+ message: 'no such file or directory',
415
+ });
375
416
  }
376
- const nlink = await this.getLinkCount(ino);
377
417
  return {
378
418
  ino: row.ino,
379
419
  mode: row.mode,
380
- nlink: nlink,
420
+ nlink: row.nlink,
381
421
  uid: row.uid,
382
422
  gid: row.gid,
383
423
  size: row.size,
@@ -389,4 +429,399 @@ export class Filesystem {
389
429
  isSymbolicLink: () => (row.mode & S_IFMT) === S_IFLNK,
390
430
  };
391
431
  }
432
+ /**
433
+ * Create a directory (non-recursive, no options yet)
434
+ */
435
+ async mkdir(path) {
436
+ const normalizedPath = this.normalizePath(path);
437
+ const existing = await this.resolvePath(normalizedPath);
438
+ if (existing !== null) {
439
+ throw createFsError({
440
+ code: 'EEXIST',
441
+ syscall: 'mkdir',
442
+ path: normalizedPath,
443
+ message: 'file already exists',
444
+ });
445
+ }
446
+ const parent = await this.resolveParent(normalizedPath);
447
+ if (!parent) {
448
+ throw createFsError({
449
+ code: 'ENOENT',
450
+ syscall: 'mkdir',
451
+ path: normalizedPath,
452
+ message: 'no such file or directory',
453
+ });
454
+ }
455
+ await assertInodeIsDirectory(this.db, parent.parentIno, 'mkdir', normalizedPath);
456
+ const dirIno = await this.createInode(DEFAULT_DIR_MODE);
457
+ try {
458
+ await this.createDentry(parent.parentIno, parent.name, dirIno);
459
+ }
460
+ catch {
461
+ throw createFsError({
462
+ code: 'EEXIST',
463
+ syscall: 'mkdir',
464
+ path: normalizedPath,
465
+ message: 'file already exists',
466
+ });
467
+ }
468
+ }
469
+ /**
470
+ * Remove a file or directory
471
+ */
472
+ async rm(path, options) {
473
+ const normalizedPath = this.normalizePath(path);
474
+ const { force, recursive } = normalizeRmOptions(options);
475
+ assertNotRoot(normalizedPath, 'rm');
476
+ const ino = await this.resolvePath(normalizedPath);
477
+ if (ino === null) {
478
+ throwENOENTUnlessForce(normalizedPath, 'rm', force);
479
+ return;
480
+ }
481
+ const mode = await getInodeModeOrThrow(this.db, ino, 'rm', normalizedPath);
482
+ assertNotSymlinkMode(mode, 'rm', normalizedPath);
483
+ const parent = await this.resolveParent(normalizedPath);
484
+ if (!parent) {
485
+ throw createFsError({
486
+ code: 'EPERM',
487
+ syscall: 'rm',
488
+ path: normalizedPath,
489
+ message: 'operation not permitted',
490
+ });
491
+ }
492
+ if ((mode & S_IFMT) === S_IFDIR) {
493
+ if (!recursive) {
494
+ throw createFsError({
495
+ code: 'EISDIR',
496
+ syscall: 'rm',
497
+ path: normalizedPath,
498
+ message: 'illegal operation on a directory',
499
+ });
500
+ }
501
+ await this.rmDirContentsRecursive(ino);
502
+ await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino);
503
+ return;
504
+ }
505
+ // Regular file
506
+ await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino);
507
+ }
508
+ async rmDirContentsRecursive(dirIno) {
509
+ const stmt = this.db.prepare(`
510
+ SELECT name, ino FROM fs_dentry
511
+ WHERE parent_ino = ?
512
+ ORDER BY name ASC
513
+ `);
514
+ const children = await stmt.all(dirIno);
515
+ for (const child of children) {
516
+ const mode = await this.getInodeMode(child.ino);
517
+ if (mode === null) {
518
+ // DB inconsistency; treat as already gone
519
+ continue;
520
+ }
521
+ if ((mode & S_IFMT) === S_IFDIR) {
522
+ await this.rmDirContentsRecursive(child.ino);
523
+ await this.removeDentryAndMaybeInode(dirIno, child.name, child.ino);
524
+ }
525
+ else {
526
+ // Not supported yet (symlinks)
527
+ assertNotSymlinkMode(mode, 'rm', '<symlink>');
528
+ await this.removeDentryAndMaybeInode(dirIno, child.name, child.ino);
529
+ }
530
+ }
531
+ }
532
+ async removeDentryAndMaybeInode(parentIno, name, ino) {
533
+ const stmt = this.db.prepare(`
534
+ DELETE FROM fs_dentry
535
+ WHERE parent_ino = ? AND name = ?
536
+ `);
537
+ await stmt.run(parentIno, name);
538
+ // Decrement link count
539
+ const decrementStmt = this.db.prepare('UPDATE fs_inode SET nlink = nlink - 1 WHERE ino = ?');
540
+ await decrementStmt.run(ino);
541
+ const linkCount = await this.getLinkCount(ino);
542
+ if (linkCount === 0) {
543
+ const deleteInodeStmt = this.db.prepare('DELETE FROM fs_inode WHERE ino = ?');
544
+ await deleteInodeStmt.run(ino);
545
+ const deleteDataStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?');
546
+ await deleteDataStmt.run(ino);
547
+ }
548
+ }
549
+ /**
550
+ * Remove an empty directory
551
+ */
552
+ async rmdir(path) {
553
+ const normalizedPath = this.normalizePath(path);
554
+ assertNotRoot(normalizedPath, 'rmdir');
555
+ const { ino } = await this.resolvePathOrThrow(normalizedPath, 'rmdir');
556
+ const mode = await getInodeModeOrThrow(this.db, ino, 'rmdir', normalizedPath);
557
+ assertNotSymlinkMode(mode, 'rmdir', normalizedPath);
558
+ if ((mode & S_IFMT) !== S_IFDIR) {
559
+ throw createFsError({
560
+ code: 'ENOTDIR',
561
+ syscall: 'rmdir',
562
+ path: normalizedPath,
563
+ message: 'not a directory',
564
+ });
565
+ }
566
+ const stmt = this.db.prepare(`
567
+ SELECT 1 as one FROM fs_dentry
568
+ WHERE parent_ino = ?
569
+ LIMIT 1
570
+ `);
571
+ const child = await stmt.get(ino);
572
+ if (child) {
573
+ throw createFsError({
574
+ code: 'ENOTEMPTY',
575
+ syscall: 'rmdir',
576
+ path: normalizedPath,
577
+ message: 'directory not empty',
578
+ });
579
+ }
580
+ const parent = await this.resolveParent(normalizedPath);
581
+ if (!parent) {
582
+ throw createFsError({
583
+ code: 'EPERM',
584
+ syscall: 'rmdir',
585
+ path: normalizedPath,
586
+ message: 'operation not permitted',
587
+ });
588
+ }
589
+ await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino);
590
+ }
591
+ /**
592
+ * Rename (move) a file or directory
593
+ */
594
+ async rename(oldPath, newPath) {
595
+ const oldNormalized = this.normalizePath(oldPath);
596
+ const newNormalized = this.normalizePath(newPath);
597
+ // No-op
598
+ if (oldNormalized === newNormalized)
599
+ return;
600
+ assertNotRoot(oldNormalized, 'rename');
601
+ assertNotRoot(newNormalized, 'rename');
602
+ const oldParent = await this.resolveParent(oldNormalized);
603
+ if (!oldParent) {
604
+ throw createFsError({
605
+ code: 'EPERM',
606
+ syscall: 'rename',
607
+ path: oldNormalized,
608
+ message: 'operation not permitted',
609
+ });
610
+ }
611
+ const newParent = await this.resolveParent(newNormalized);
612
+ if (!newParent) {
613
+ throw createFsError({
614
+ code: 'ENOENT',
615
+ syscall: 'rename',
616
+ path: newNormalized,
617
+ message: 'no such file or directory',
618
+ });
619
+ }
620
+ // Ensure destination parent exists and is a directory
621
+ await assertInodeIsDirectory(this.db, newParent.parentIno, 'rename', newNormalized);
622
+ await this.db.exec('BEGIN');
623
+ try {
624
+ const oldResolved = await this.resolvePathOrThrow(oldNormalized, 'rename');
625
+ const oldIno = oldResolved.ino;
626
+ const oldMode = await getInodeModeOrThrow(this.db, oldIno, 'rename', oldNormalized);
627
+ assertNotSymlinkMode(oldMode, 'rename', oldNormalized);
628
+ const oldIsDir = (oldMode & S_IFMT) === S_IFDIR;
629
+ // Prevent renaming a directory into its own subtree (would create cycles).
630
+ if (oldIsDir && newNormalized.startsWith(oldNormalized + '/')) {
631
+ throw createFsError({
632
+ code: 'EINVAL',
633
+ syscall: 'rename',
634
+ path: newNormalized,
635
+ message: 'invalid argument',
636
+ });
637
+ }
638
+ const newIno = await this.resolvePath(newNormalized);
639
+ if (newIno !== null) {
640
+ const newMode = await getInodeModeOrThrow(this.db, newIno, 'rename', newNormalized);
641
+ assertNotSymlinkMode(newMode, 'rename', newNormalized);
642
+ const newIsDir = (newMode & S_IFMT) === S_IFDIR;
643
+ if (newIsDir && !oldIsDir) {
644
+ throw createFsError({
645
+ code: 'EISDIR',
646
+ syscall: 'rename',
647
+ path: newNormalized,
648
+ message: 'illegal operation on a directory',
649
+ });
650
+ }
651
+ if (!newIsDir && oldIsDir) {
652
+ throw createFsError({
653
+ code: 'ENOTDIR',
654
+ syscall: 'rename',
655
+ path: newNormalized,
656
+ message: 'not a directory',
657
+ });
658
+ }
659
+ // If replacing a directory, it must be empty.
660
+ if (newIsDir) {
661
+ const stmt = this.db.prepare(`
662
+ SELECT 1 as one FROM fs_dentry
663
+ WHERE parent_ino = ?
664
+ LIMIT 1
665
+ `);
666
+ const child = await stmt.get(newIno);
667
+ if (child) {
668
+ throw createFsError({
669
+ code: 'ENOTEMPTY',
670
+ syscall: 'rename',
671
+ path: newNormalized,
672
+ message: 'directory not empty',
673
+ });
674
+ }
675
+ }
676
+ // Remove the destination entry (and inode if this was the last link)
677
+ await this.removeDentryAndMaybeInode(newParent.parentIno, newParent.name, newIno);
678
+ }
679
+ // Move the directory entry
680
+ const stmt = this.db.prepare(`
681
+ UPDATE fs_dentry
682
+ SET parent_ino = ?, name = ?
683
+ WHERE parent_ino = ? AND name = ?
684
+ `);
685
+ await stmt.run(newParent.parentIno, newParent.name, oldParent.parentIno, oldParent.name);
686
+ // Update timestamps
687
+ const now = Math.floor(Date.now() / 1000);
688
+ const updateInodeCtimeStmt = this.db.prepare(`
689
+ UPDATE fs_inode
690
+ SET ctime = ?
691
+ WHERE ino = ?
692
+ `);
693
+ await updateInodeCtimeStmt.run(now, oldIno);
694
+ const updateDirTimesStmt = this.db.prepare(`
695
+ UPDATE fs_inode
696
+ SET mtime = ?, ctime = ?
697
+ WHERE ino = ?
698
+ `);
699
+ await updateDirTimesStmt.run(now, now, oldParent.parentIno);
700
+ if (newParent.parentIno !== oldParent.parentIno) {
701
+ await updateDirTimesStmt.run(now, now, newParent.parentIno);
702
+ }
703
+ await this.db.exec('COMMIT');
704
+ }
705
+ catch (e) {
706
+ await this.db.exec('ROLLBACK');
707
+ throw e;
708
+ }
709
+ }
710
+ /**
711
+ * Copy a file. Overwrites destination if it exists.
712
+ */
713
+ async copyFile(src, dest) {
714
+ const srcNormalized = this.normalizePath(src);
715
+ const destNormalized = this.normalizePath(dest);
716
+ if (srcNormalized === destNormalized) {
717
+ throw createFsError({
718
+ code: 'EINVAL',
719
+ syscall: 'copyfile',
720
+ path: destNormalized,
721
+ message: 'invalid argument',
722
+ });
723
+ }
724
+ // Resolve and validate source
725
+ // node uses copyfile as syscall name even though it's not a syscall
726
+ const { ino: srcIno } = await this.resolvePathOrThrow(srcNormalized, 'copyfile');
727
+ await assertReadableExistingInode(this.db, srcIno, 'copyfile', srcNormalized);
728
+ const stmt = this.db.prepare(`
729
+ SELECT mode, uid, gid, size FROM fs_inode WHERE ino = ?
730
+ `);
731
+ const srcRow = await stmt.get(srcIno);
732
+ if (!srcRow) {
733
+ throw createFsError({
734
+ code: 'ENOENT',
735
+ syscall: 'copyfile',
736
+ path: srcNormalized,
737
+ message: 'no such file or directory',
738
+ });
739
+ }
740
+ // Destination parent must exist and be a directory (Node does not create parents)
741
+ const destParent = await this.resolveParent(destNormalized);
742
+ if (!destParent) {
743
+ throw createFsError({
744
+ code: 'ENOENT',
745
+ syscall: 'copyfile',
746
+ path: destNormalized,
747
+ message: 'no such file or directory',
748
+ });
749
+ }
750
+ await assertInodeIsDirectory(this.db, destParent.parentIno, 'copyfile', destNormalized);
751
+ await this.db.exec('BEGIN');
752
+ try {
753
+ const now = Math.floor(Date.now() / 1000);
754
+ // If destination exists, it must be a file (overwrite semantics).
755
+ const destIno = await this.resolvePath(destNormalized);
756
+ if (destIno !== null) {
757
+ const destMode = await getInodeModeOrThrow(this.db, destIno, 'copyfile', destNormalized);
758
+ assertNotSymlinkMode(destMode, 'copyfile', destNormalized);
759
+ if ((destMode & S_IFMT) === S_IFDIR) {
760
+ throw createFsError({
761
+ code: 'EISDIR',
762
+ syscall: 'copyfile',
763
+ path: destNormalized,
764
+ message: 'illegal operation on a directory',
765
+ });
766
+ }
767
+ // Replace destination contents
768
+ const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?');
769
+ await deleteStmt.run(destIno);
770
+ const copyStmt = this.db.prepare(`
771
+ INSERT INTO fs_data (ino, chunk_index, data)
772
+ SELECT ?, chunk_index, data
773
+ FROM fs_data
774
+ WHERE ino = ?
775
+ ORDER BY chunk_index ASC
776
+ `);
777
+ await copyStmt.run(destIno, srcIno);
778
+ const updateStmt = this.db.prepare(`
779
+ UPDATE fs_inode
780
+ SET mode = ?, uid = ?, gid = ?, size = ?, mtime = ?, ctime = ?
781
+ WHERE ino = ?
782
+ `);
783
+ await updateStmt.run(srcRow.mode, srcRow.uid, srcRow.gid, srcRow.size, now, now, destIno);
784
+ }
785
+ else {
786
+ // Create new destination inode + dentry
787
+ const destInoCreated = await this.createInode(srcRow.mode, srcRow.uid, srcRow.gid);
788
+ await this.createDentry(destParent.parentIno, destParent.name, destInoCreated);
789
+ const copyStmt = this.db.prepare(`
790
+ INSERT INTO fs_data (ino, chunk_index, data)
791
+ SELECT ?, chunk_index, data
792
+ FROM fs_data
793
+ WHERE ino = ?
794
+ ORDER BY chunk_index ASC
795
+ `);
796
+ await copyStmt.run(destInoCreated, srcIno);
797
+ const updateStmt = this.db.prepare(`
798
+ UPDATE fs_inode
799
+ SET size = ?, mtime = ?, ctime = ?
800
+ WHERE ino = ?
801
+ `);
802
+ await updateStmt.run(srcRow.size, now, now, destInoCreated);
803
+ }
804
+ await this.db.exec('COMMIT');
805
+ }
806
+ catch (e) {
807
+ await this.db.exec('ROLLBACK');
808
+ throw e;
809
+ }
810
+ }
811
+ /**
812
+ * Test a user's permissions for the file or directory specified by path.
813
+ * Currently supports existence checks only (F_OK semantics).
814
+ */
815
+ async access(path) {
816
+ const normalizedPath = this.normalizePath(path);
817
+ const ino = await this.resolvePath(normalizedPath);
818
+ if (ino === null) {
819
+ throw createFsError({
820
+ code: 'ENOENT',
821
+ syscall: 'access',
822
+ path: normalizedPath,
823
+ message: 'no such file or directory',
824
+ });
825
+ }
826
+ }
392
827
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentfs-sdk",
3
- "version": "0.4.0-pre.1",
3
+ "version": "0.4.0-pre.3",
4
4
  "description": "AgentFS SDK",
5
5
  "main": "dist/index_node.js",
6
6
  "types": "dist/index_node.d.ts",