agentfs-sdk 0.4.0-pre.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.
@@ -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
@@ -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 writeFile(path, content) {
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 new Error(`ENOENT: parent directory does not exist: ${path}`);
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' ? this.bufferCtor.from(content, 'utf-8') : content;
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.resolvePath(path);
294
- if (ino === null) {
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.resolvePath(path);
324
- if (ino === null) {
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 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
- }
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.resolvePath(path);
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 new Error(`Inode not found: ${ino}`);
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
  }
@@ -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.2",
4
4
  "description": "AgentFS SDK",
5
5
  "main": "dist/index_node.js",
6
6
  "types": "dist/index_node.d.ts",