From c01046480509fb6015c6835bdc64de1ace0e0526 Mon Sep 17 00:00:00 2001 From: steven-mpawulo Date: Mon, 19 Jan 2026 10:49:53 +0300 Subject: [PATCH 1/2] Add max bytes limit and default chunk size --- sdk/typescript/package-lock.json | 9 +- sdk/typescript/src/errors.ts | 4 +- sdk/typescript/src/filesystem/agentfs.ts | 2300 +++++++++++--------- sdk/typescript/src/filesystem/interface.ts | 18 + sdk/typescript/tests/filesystem.test.ts | 2 + 5 files changed, 1281 insertions(+), 1052 deletions(-) diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index e52ecf16..82bf090e 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1047,7 +1047,6 @@ "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1081,7 +1080,6 @@ "integrity": "sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.0.16", "@vitest/mocker": "4.0.16", @@ -1203,7 +1201,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -1592,7 +1589,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1619,6 +1615,7 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.57.0" }, @@ -1638,6 +1635,7 @@ "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -1656,6 +1654,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -1941,7 +1940,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2017,7 +2015,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 9fdea885..d6edbb19 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -9,7 +9,8 @@ export type FsErrorCode = | 'ENOTEMPTY' // Directory not empty | 'EPERM' // Operation not permitted | 'EINVAL' // Invalid argument - | 'ENOSYS'; // Function not implemented (use for symlinks) + | 'ENOSPC' // Not enough space + | 'ENOSYS' // Function not implemented (use for symlinks) /** * Filesystem syscall names for error reporting. @@ -25,6 +26,7 @@ export type FsSyscall = | 'rename' | 'scandir' | 'copyfile' + | 'write' | 'access'; export interface ErrnoException extends Error { diff --git a/sdk/typescript/src/filesystem/agentfs.ts b/sdk/typescript/src/filesystem/agentfs.ts index df0a087d..d912bee9 100644 --- a/sdk/typescript/src/filesystem/agentfs.ts +++ b/sdk/typescript/src/filesystem/agentfs.ts @@ -1,30 +1,30 @@ -import type { DatabasePromise } from '@tursodatabase/database-common'; -import { createFsError, type FsSyscall } from '../errors.js'; +import type {DatabasePromise} from '@tursodatabase/database-common'; +import {createFsError, type FsSyscall} from '../errors.js'; import { - assertInodeIsDirectory, - assertNotRoot, - assertNotSymlinkMode, - assertReadableExistingInode, - assertReaddirTargetInode, - assertUnlinkTargetInode, - assertWritableExistingInode, - getInodeModeOrThrow, - normalizeRmOptions, - throwENOENTUnlessForce, + assertInodeIsDirectory, + assertNotRoot, + assertNotSymlinkMode, + assertReadableExistingInode, + assertReaddirTargetInode, + assertUnlinkTargetInode, + assertWritableExistingInode, + getInodeModeOrThrow, + normalizeRmOptions, + throwENOENTUnlessForce, } from '../guards.js'; import { - S_IFMT, - S_IFREG, - S_IFDIR, - S_IFLNK, - DEFAULT_FILE_MODE, - DEFAULT_DIR_MODE, - createStats, - type Stats, - type DirEntry, - type FilesystemStats, - type FileHandle, - type FileSystem, + S_IFMT, + S_IFREG, + S_IFDIR, + S_IFLNK, + DEFAULT_FILE_MODE, + DEFAULT_DIR_MODE, + createStats, + type Stats, + type DirEntry, + type FilesystemStats, + type FileHandle, + type FileSystem, } from './interface.js'; const DEFAULT_CHUNK_SIZE = 4096; @@ -33,1159 +33,1369 @@ const DEFAULT_CHUNK_SIZE = 4096; * An open file handle for AgentFS. */ class AgentFSFile implements FileHandle { - private db: DatabasePromise; - private bufferCtor: BufferConstructor; - private ino: number; - private chunkSize: number; - - constructor(db: DatabasePromise, bufferCtor: BufferConstructor, ino: number, chunkSize: number) { - this.db = db; - this.bufferCtor = bufferCtor; - this.ino = ino; - this.chunkSize = chunkSize; - } - - async pread(offset: number, size: number): Promise { - const startChunk = Math.floor(offset / this.chunkSize); - const endChunk = Math.floor((offset + size - 1) / this.chunkSize); - - const stmt = this.db.prepare(` - SELECT chunk_index, data FROM fs_data - WHERE ino = ? AND chunk_index >= ? AND chunk_index <= ? - ORDER BY chunk_index ASC - `); - const rows = await stmt.all(this.ino, startChunk, endChunk) as { chunk_index: number; data: Buffer }[]; - - const buffers: Buffer[] = []; - let bytesCollected = 0; - const startOffsetInChunk = offset % this.chunkSize; - - for (const row of rows) { - const skip = buffers.length === 0 ? startOffsetInChunk : 0; - if (skip >= row.data.length) { - continue; - } - const remaining = size - bytesCollected; - const take = Math.min(row.data.length - skip, remaining); - buffers.push(row.data.subarray(skip, skip + take)); - bytesCollected += take; + private db: DatabasePromise; + private bufferCtor: BufferConstructor; + private ino: number; + private chunkSize: number; + + constructor(db: DatabasePromise, bufferCtor: BufferConstructor, ino: number, chunkSize: number) { + this.db = db; + this.bufferCtor = bufferCtor; + this.ino = ino; + this.chunkSize = chunkSize; } - if (buffers.length === 0) { - return this.bufferCtor.alloc(0); - } - - return this.bufferCtor.concat(buffers); - } - - async pwrite(offset: number, data: Buffer): Promise { - if (data.length === 0) { - return; - } + async pread(offset: number, size: number): Promise { + const startChunk = Math.floor(offset / this.chunkSize); + const endChunk = Math.floor((offset + size - 1) / this.chunkSize); + + const stmt = this.db.prepare(` + SELECT chunk_index, data + FROM fs_data + WHERE ino = ? + AND chunk_index >= ? + AND chunk_index <= ? + ORDER BY chunk_index ASC + `); + const rows = await stmt.all(this.ino, startChunk, endChunk) as { chunk_index: number; data: Buffer }[]; + + const buffers: Buffer[] = []; + let bytesCollected = 0; + const startOffsetInChunk = offset % this.chunkSize; + + for (const row of rows) { + const skip = buffers.length === 0 ? startOffsetInChunk : 0; + if (skip >= row.data.length) { + continue; + } + const remaining = size - bytesCollected; + const take = Math.min(row.data.length - skip, remaining); + buffers.push(row.data.subarray(skip, skip + take)); + bytesCollected += take; + } - const sizeStmt = this.db.prepare('SELECT size FROM fs_inode WHERE ino = ?'); - const sizeRow = await sizeStmt.get(this.ino) as { size: number } | undefined; - const currentSize = sizeRow?.size ?? 0; + if (buffers.length === 0) { + return this.bufferCtor.alloc(0); + } - if (offset > currentSize) { - const zeros = this.bufferCtor.alloc(offset - currentSize); - await this.writeDataAtOffset(currentSize, zeros); + return this.bufferCtor.concat(buffers); } - await this.writeDataAtOffset(offset, data); - - const newSize = Math.max(currentSize, offset + data.length); - const now = Math.floor(Date.now() / 1000); - const updateStmt = this.db.prepare('UPDATE fs_inode SET size = ?, mtime = ? WHERE ino = ?'); - await updateStmt.run(newSize, now, this.ino); - } + async pwrite(offset: number, data: Buffer): Promise { + if (data.length === 0) { + return; + } - private async writeDataAtOffset(offset: number, data: Buffer): Promise { - const startChunk = Math.floor(offset / this.chunkSize); - const endChunk = Math.floor((offset + data.length - 1) / this.chunkSize); + const sizeStmt = this.db.prepare('SELECT size FROM fs_inode WHERE ino = ?'); + const sizeRow = await sizeStmt.get(this.ino) as { size: number } | undefined; + const currentSize = sizeRow?.size ?? 0; - for (let chunkIdx = startChunk; chunkIdx <= endChunk; chunkIdx++) { - const chunkStart = chunkIdx * this.chunkSize; - const chunkEnd = chunkStart + this.chunkSize; + if (offset > currentSize) { + const zeros = this.bufferCtor.alloc(offset - currentSize); + await this.writeDataAtOffset(currentSize, zeros); + } - const dataStart = Math.max(0, chunkStart - offset); - const dataEnd = Math.min(data.length, chunkEnd - offset); - const writeOffset = Math.max(0, offset - chunkStart); + await this.writeDataAtOffset(offset, data); - const selectStmt = this.db.prepare('SELECT data FROM fs_data WHERE ino = ? AND chunk_index = ?'); - const existingRow = await selectStmt.get(this.ino, chunkIdx) as { data: Buffer } | undefined; + const newSize = Math.max(currentSize, offset + data.length); + const now = Math.floor(Date.now() / 1000); + const updateStmt = this.db.prepare('UPDATE fs_inode SET size = ?, mtime = ? WHERE ino = ?'); + await updateStmt.run(newSize, now, this.ino); + } - let chunkData: Buffer; - if (existingRow) { - chunkData = this.bufferCtor.from(existingRow.data); - if (writeOffset + (dataEnd - dataStart) > chunkData.length) { - const newChunk = this.bufferCtor.alloc(writeOffset + (dataEnd - dataStart)); - chunkData.copy(newChunk); - chunkData = newChunk; + private async writeDataAtOffset(offset: number, data: Buffer): Promise { + const startChunk = Math.floor(offset / this.chunkSize); + const endChunk = Math.floor((offset + data.length - 1) / this.chunkSize); + + for (let chunkIdx = startChunk; chunkIdx <= endChunk; chunkIdx++) { + const chunkStart = chunkIdx * this.chunkSize; + const chunkEnd = chunkStart + this.chunkSize; + + const dataStart = Math.max(0, chunkStart - offset); + const dataEnd = Math.min(data.length, chunkEnd - offset); + const writeOffset = Math.max(0, offset - chunkStart); + + const selectStmt = this.db.prepare('SELECT data FROM fs_data WHERE ino = ? AND chunk_index = ?'); + const existingRow = await selectStmt.get(this.ino, chunkIdx) as { data: Buffer } | undefined; + + let chunkData: Buffer; + if (existingRow) { + chunkData = this.bufferCtor.from(existingRow.data); + if (writeOffset + (dataEnd - dataStart) > chunkData.length) { + const newChunk = this.bufferCtor.alloc(writeOffset + (dataEnd - dataStart)); + chunkData.copy(newChunk); + chunkData = newChunk; + } + } else { + chunkData = this.bufferCtor.alloc(writeOffset + (dataEnd - dataStart)); + } + + data.copy(chunkData, writeOffset, dataStart, dataEnd); + + const upsertStmt = this.db.prepare(` + INSERT INTO fs_data (ino, chunk_index, data) + VALUES (?, ?, ?) ON CONFLICT(ino, chunk_index) DO + UPDATE SET data = excluded.data + `); + await upsertStmt.run(this.ino, chunkIdx, chunkData); } - } else { - chunkData = this.bufferCtor.alloc(writeOffset + (dataEnd - dataStart)); - } - - data.copy(chunkData, writeOffset, dataStart, dataEnd); + } - const upsertStmt = this.db.prepare(` - INSERT INTO fs_data (ino, chunk_index, data) VALUES (?, ?, ?) - ON CONFLICT(ino, chunk_index) DO UPDATE SET data = excluded.data - `); - await upsertStmt.run(this.ino, chunkIdx, chunkData); + async truncate(newSize: number): Promise { + const sizeStmt = this.db.prepare('SELECT size FROM fs_inode WHERE ino = ?'); + const sizeRow = await sizeStmt.get(this.ino) as { size: number } | undefined; + const currentSize = sizeRow?.size ?? 0; + + await this.db.exec('BEGIN'); + try { + if (newSize === 0) { + const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?'); + await deleteStmt.run(this.ino); + } else if (newSize < currentSize) { + const lastChunkIdx = Math.floor((newSize - 1) / this.chunkSize); + + const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ? AND chunk_index > ?'); + await deleteStmt.run(this.ino, lastChunkIdx); + + const offsetInChunk = newSize % this.chunkSize; + if (offsetInChunk > 0) { + const selectStmt = this.db.prepare('SELECT data FROM fs_data WHERE ino = ? AND chunk_index = ?'); + const row = await selectStmt.get(this.ino, lastChunkIdx) as { data: Buffer } | undefined; + + if (row && row.data.length > offsetInChunk) { + const truncatedChunk = row.data.subarray(0, offsetInChunk); + const updateStmt = this.db.prepare('UPDATE fs_data SET data = ? WHERE ino = ? AND chunk_index = ?'); + await updateStmt.run(truncatedChunk, this.ino, lastChunkIdx); + } + } + } + + const now = Math.floor(Date.now() / 1000); + const updateStmt = this.db.prepare('UPDATE fs_inode SET size = ?, mtime = ? WHERE ino = ?'); + await updateStmt.run(newSize, now, this.ino); + + await this.db.exec('COMMIT'); + } catch (e) { + await this.db.exec('ROLLBACK'); + throw e; + } } - } - async truncate(newSize: number): Promise { - const sizeStmt = this.db.prepare('SELECT size FROM fs_inode WHERE ino = ?'); - const sizeRow = await sizeStmt.get(this.ino) as { size: number } | undefined; - const currentSize = sizeRow?.size ?? 0; + async fsync(): Promise { + await this.db.exec('PRAGMA synchronous = FULL'); + await this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + } - await this.db.exec('BEGIN'); - try { - if (newSize === 0) { - const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?'); - await deleteStmt.run(this.ino); - } else if (newSize < currentSize) { - const lastChunkIdx = Math.floor((newSize - 1) / this.chunkSize); - - const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ? AND chunk_index > ?'); - await deleteStmt.run(this.ino, lastChunkIdx); - - const offsetInChunk = newSize % this.chunkSize; - if (offsetInChunk > 0) { - const selectStmt = this.db.prepare('SELECT data FROM fs_data WHERE ino = ? AND chunk_index = ?'); - const row = await selectStmt.get(this.ino, lastChunkIdx) as { data: Buffer } | undefined; - - if (row && row.data.length > offsetInChunk) { - const truncatedChunk = row.data.subarray(0, offsetInChunk); - const updateStmt = this.db.prepare('UPDATE fs_data SET data = ? WHERE ino = ? AND chunk_index = ?'); - await updateStmt.run(truncatedChunk, this.ino, lastChunkIdx); - } + async fstat(): Promise { + const stmt = this.db.prepare(` + SELECT ino, mode, nlink, uid, gid, size, atime, mtime, ctime + FROM fs_inode + WHERE ino = ? + `); + const row = await stmt.get(this.ino) as { + ino: number; + mode: number; + nlink: number; + uid: number; + gid: number; + size: number; + atime: number; + mtime: number; + ctime: number; + } | undefined; + + if (!row) { + throw new Error('File handle refers to deleted inode'); } - } - const now = Math.floor(Date.now() / 1000); - const updateStmt = this.db.prepare('UPDATE fs_inode SET size = ?, mtime = ? WHERE ino = ?'); - await updateStmt.run(newSize, now, this.ino); - - await this.db.exec('COMMIT'); - } catch (e) { - await this.db.exec('ROLLBACK'); - throw e; - } - } - - async fsync(): Promise { - await this.db.exec('PRAGMA synchronous = FULL'); - await this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); - } - - async fstat(): Promise { - const stmt = this.db.prepare(` - SELECT ino, mode, nlink, uid, gid, size, atime, mtime, ctime - FROM fs_inode WHERE ino = ? - `); - const row = await stmt.get(this.ino) as { - ino: number; - mode: number; - nlink: number; - uid: number; - gid: number; - size: number; - atime: number; - mtime: number; - ctime: number; - } | undefined; - - if (!row) { - throw new Error('File handle refers to deleted inode'); + return createStats(row); } - - return createStats(row); - } } /** * A filesystem backed by SQLite, implementing the FileSystem interface. */ export class AgentFS implements FileSystem { - private db: DatabasePromise; - private bufferCtor: BufferConstructor; - private rootIno: number = 1; - private chunkSize: number = DEFAULT_CHUNK_SIZE; - - private constructor(db: DatabasePromise, b: BufferConstructor) { - this.db = db; - this.bufferCtor = b; - } - - static async fromDatabase(db: DatabasePromise, b?: BufferConstructor): Promise { - const fs = new AgentFS(db, b ?? Buffer); - await fs.initialize(); - return fs; - } - - getChunkSize(): number { - return this.chunkSize; - } - - private async initialize(): Promise { - await this.db.exec(` - CREATE TABLE IF NOT EXISTS fs_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - `); - - await this.db.exec(` - CREATE TABLE IF NOT EXISTS fs_inode ( - ino INTEGER PRIMARY KEY AUTOINCREMENT, - mode INTEGER NOT NULL, - nlink INTEGER NOT NULL DEFAULT 0, - uid INTEGER NOT NULL DEFAULT 0, - gid INTEGER NOT NULL DEFAULT 0, - size INTEGER NOT NULL DEFAULT 0, - atime INTEGER NOT NULL, - mtime INTEGER NOT NULL, - ctime INTEGER NOT NULL - ) - `); - - await this.db.exec(` - CREATE TABLE IF NOT EXISTS fs_dentry ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - parent_ino INTEGER NOT NULL, - ino INTEGER NOT NULL, - UNIQUE(parent_ino, name) - ) - `); - - await this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_fs_dentry_parent - ON fs_dentry(parent_ino, name) - `); - - await this.db.exec(` - CREATE TABLE IF NOT EXISTS fs_data ( - ino INTEGER NOT NULL, - chunk_index INTEGER NOT NULL, - data BLOB NOT NULL, - PRIMARY KEY (ino, chunk_index) - ) - `); - - await this.db.exec(` - CREATE TABLE IF NOT EXISTS fs_symlink ( - ino INTEGER PRIMARY KEY, - target TEXT NOT NULL - ) - `); - - this.chunkSize = await this.ensureRoot(); - } - - private async ensureRoot(): Promise { - const configStmt = this.db.prepare("SELECT value FROM fs_config WHERE key = 'chunk_size'"); - const config = await configStmt.get() as { value: string } | undefined; - - let chunkSize: number; - if (!config) { - const insertConfigStmt = this.db.prepare(` - INSERT INTO fs_config (key, value) VALUES ('chunk_size', ?) - `); - await insertConfigStmt.run(DEFAULT_CHUNK_SIZE.toString()); - chunkSize = DEFAULT_CHUNK_SIZE; - } else { - chunkSize = parseInt(config.value, 10) || DEFAULT_CHUNK_SIZE; + private db: DatabasePromise; + private bufferCtor: BufferConstructor; + private rootIno: number = 1; + private chunkSize: number = DEFAULT_CHUNK_SIZE; + + private constructor(db: DatabasePromise, b: BufferConstructor) { + this.db = db; + this.bufferCtor = b; } - const stmt = this.db.prepare('SELECT ino FROM fs_inode WHERE ino = ?'); - const root = await stmt.get(this.rootIno); - - if (!root) { - const now = Math.floor(Date.now() / 1000); - const insertStmt = this.db.prepare(` - INSERT INTO fs_inode (ino, mode, nlink, uid, gid, size, atime, mtime, ctime) - VALUES (?, ?, 1, 0, 0, 0, ?, ?, ?) - `); - await insertStmt.run(this.rootIno, DEFAULT_DIR_MODE, now, now, now); + static async fromDatabase(db: DatabasePromise, b?: BufferConstructor): Promise { + const fs = new AgentFS(db, b ?? Buffer); + await fs.initialize(); + return fs; } - return chunkSize; - } - - private normalizePath(path: string): string { - const normalized = path.replace(/\/+$/, '') || '/'; - return normalized.startsWith('/') ? normalized : '/' + normalized; - } - - private splitPath(path: string): string[] { - const normalized = this.normalizePath(path); - if (normalized === '/') return []; - return normalized.split('/').filter(p => p); - } - - private async resolvePathOrThrow( - path: string, - syscall: FsSyscall - ): Promise<{ normalizedPath: string; ino: number }> { - const normalizedPath = this.normalizePath(path); - const ino = await this.resolvePath(normalizedPath); - if (ino === null) { - throw createFsError({ - code: 'ENOENT', - syscall, - path: normalizedPath, - message: 'no such file or directory', - }); + getChunkSize(): number { + return this.chunkSize; } - return { normalizedPath, ino }; - } - private async resolvePath(path: string): Promise { - const normalized = this.normalizePath(path); + private async ensureMaxBytes(): Promise { + const stmt = this.db.prepare("SELECT value from fs_config WHERE key = 'max_bytes'") + const row = await stmt.get() as { value: string } | undefined + + if (!row) { + const insert = this.db.prepare(` + INSERT INTO fs_config(key, value) + VALUES ('max_bytes', '0') + `) - if (normalized === '/') { - return this.rootIno; + await insert.run() + } } - const parts = this.splitPath(normalized); - let currentIno = this.rootIno; + private async getMaxBytes(): Promise { + const stmt = this.db.prepare("SELECT value FROM fs_config WHERE key = 'max_bytes'") + const row = await stmt.get() as { value: string } | undefined - for (const name of parts) { - const stmt = this.db.prepare(` - SELECT ino FROM fs_dentry - WHERE parent_ino = ? AND name = ? - `); - const result = await stmt.get(currentIno, name) as { ino: number } | undefined; + if (!row) { + return null + } + + const bytes = Number.parseInt(row.value, 10) + if (!Number.isFinite(bytes) || bytes <= 0) { + return null + } - if (!result) { - return null; - } + return bytes - currentIno = result.ino; } - return currentIno; - } + private async getBytesUsed(): Promise { + const stmt = this.db.prepare("SELECT COALESCE(SUM(LENGTH(data)), 0) as total FROM fs_data") + const row = await stmt.get() as { total: number } - private async resolveParent(path: string): Promise<{ parentIno: number; name: string } | null> { - const normalized = this.normalizePath(path); + return row.total + } + + private async assertHasSpace(bytesToWrite: number): Promise { + if (bytesToWrite <= 0) return; + + const max = await this.getMaxBytes(); + + if (max === null) return; - if (normalized === '/') { - return null; + const used = await this.getBytesUsed(); + + if (used + bytesToWrite > max) { + throw createFsError({ + code: 'ENOSPC', + syscall: 'write', + path: '', + message: 'no space left on device', + }); + } } - const parts = this.splitPath(normalized); - const name = parts[parts.length - 1]; - const parentPath = parts.length === 1 ? '/' : '/' + parts.slice(0, -1).join('/'); - const parentIno = await this.resolvePath(parentPath); + private async initialize(): Promise { + await this.db.exec(` + CREATE TABLE IF NOT EXISTS fs_config + ( + key + TEXT + PRIMARY + KEY, + value + TEXT + NOT + NULL + ) + `); + + await this.db.exec(` + CREATE TABLE IF NOT EXISTS fs_inode + ( + ino + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + mode + INTEGER + NOT + NULL, + nlink + INTEGER + NOT + NULL + DEFAULT + 0, + uid + INTEGER + NOT + NULL + DEFAULT + 0, + gid + INTEGER + NOT + NULL + DEFAULT + 0, + size + INTEGER + NOT + NULL + DEFAULT + 0, + atime + INTEGER + NOT + NULL, + mtime + INTEGER + NOT + NULL, + ctime + INTEGER + NOT + NULL + ) + `); + + await this.db.exec(` + CREATE TABLE IF NOT EXISTS fs_dentry + ( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + name + TEXT + NOT + NULL, + parent_ino + INTEGER + NOT + NULL, + ino + INTEGER + NOT + NULL, + UNIQUE + ( + parent_ino, + name + ) + ) + `); - if (parentIno === null) { - return null; + await this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_fs_dentry_parent + ON fs_dentry(parent_ino, name) + `); + + await this.db.exec(` + CREATE TABLE IF NOT EXISTS fs_data + ( + ino + INTEGER + NOT + NULL, + chunk_index + INTEGER + NOT + NULL, + data + BLOB + NOT + NULL, + PRIMARY + KEY + ( + ino, + chunk_index + ) + ) + `); + + await this.db.exec(` + CREATE TABLE IF NOT EXISTS fs_symlink + ( + ino + INTEGER + PRIMARY + KEY, + target + TEXT + NOT + NULL + ) + `); + + this.chunkSize = await this.ensureRoot(); + await this.ensureMaxBytes(); } - return { parentIno, name }; - } - - private async createInode(mode: number, uid: number = 0, gid: number = 0): Promise { - const now = Math.floor(Date.now() / 1000); - const stmt = this.db.prepare(` - INSERT INTO fs_inode (mode, uid, gid, size, atime, mtime, ctime) - VALUES (?, ?, ?, 0, ?, ?, ?) - RETURNING ino - `); - const { ino } = await stmt.get(mode, uid, gid, now, now, now); - return Number(ino); - } - - private async createDentry(parentIno: number, name: string, ino: number): Promise { - const stmt = this.db.prepare(` - INSERT INTO fs_dentry (name, parent_ino, ino) - VALUES (?, ?, ?) - `); - await stmt.run(name, parentIno, ino); - - const updateStmt = this.db.prepare('UPDATE fs_inode SET nlink = nlink + 1 WHERE ino = ?'); - await updateStmt.run(ino); - } - - private async ensureParentDirs(path: string): Promise { - const parts = this.splitPath(path); - parts.pop(); - - let currentIno = this.rootIno; - - for (const name of parts) { - const stmt = this.db.prepare(` - SELECT ino FROM fs_dentry - WHERE parent_ino = ? AND name = ? - `); - const result = await stmt.get(currentIno, name) as { ino: number } | undefined; - - if (!result) { - const dirIno = await this.createInode(DEFAULT_DIR_MODE); - await this.createDentry(currentIno, name, dirIno); - currentIno = dirIno; - } else { - await assertInodeIsDirectory(this.db, result.ino, 'open', this.normalizePath(path)); - currentIno = result.ino; - } + private async ensureRoot(): Promise { + const configStmt = this.db.prepare("SELECT value FROM fs_config WHERE key = 'chunk_size'"); + const config = await configStmt.get() as { value: string } | undefined; + + let chunkSize: number; + if (!config) { + const insertConfigStmt = this.db.prepare(` + INSERT INTO fs_config (key, value) + VALUES ('chunk_size', ?) + `); + await insertConfigStmt.run(DEFAULT_CHUNK_SIZE.toString()); + chunkSize = DEFAULT_CHUNK_SIZE; + } else { + chunkSize = parseInt(config.value, 10) || DEFAULT_CHUNK_SIZE; + } + + const stmt = this.db.prepare('SELECT ino FROM fs_inode WHERE ino = ?'); + const root = await stmt.get(this.rootIno); + + if (!root) { + const now = Math.floor(Date.now() / 1000); + const insertStmt = this.db.prepare(` + INSERT INTO fs_inode (ino, mode, nlink, uid, gid, size, atime, mtime, ctime) + VALUES (?, ?, 1, 0, 0, 0, ?, ?, ?) + `); + await insertStmt.run(this.rootIno, DEFAULT_DIR_MODE, now, now, now); + } + + return chunkSize; } - } - - private async getLinkCount(ino: number): Promise { - const stmt = this.db.prepare('SELECT nlink FROM fs_inode WHERE ino = ?'); - const result = await stmt.get(ino) as { nlink: number } | undefined; - return result?.nlink ?? 0; - } - - private async getInodeMode(ino: number): Promise { - const stmt = this.db.prepare('SELECT mode FROM fs_inode WHERE ino = ?'); - const row = await stmt.get(ino) as { mode: number } | undefined; - return row?.mode ?? null; - } - - // ==================== FileSystem Interface Implementation ==================== - - async writeFile( - path: string, - content: string | Buffer, - options?: BufferEncoding | { encoding?: BufferEncoding } - ): Promise { - await this.ensureParentDirs(path); - - const ino = await this.resolvePath(path); - - const encoding = typeof options === 'string' - ? options - : options?.encoding; - - const normalizedPath = this.normalizePath(path); - if (ino !== null) { - await assertWritableExistingInode(this.db, ino, 'open', normalizedPath); - await this.updateFileContent(ino, content, encoding); - } else { - const parent = await this.resolveParent(path); - if (!parent) { - throw createFsError({ - code: 'ENOENT', - syscall: 'open', - path: normalizedPath, - message: 'no such file or directory', - }); - } - - await assertInodeIsDirectory(this.db, parent.parentIno, 'open', normalizedPath); - - const fileIno = await this.createInode(DEFAULT_FILE_MODE); - await this.createDentry(parent.parentIno, parent.name, fileIno); - await this.updateFileContent(fileIno, content, encoding); + + private normalizePath(path: string): string { + const normalized = path.replace(/\/+$/, '') || '/'; + return normalized.startsWith('/') ? normalized : '/' + normalized; } - } - - private async updateFileContent( - ino: number, - content: string | Buffer, - encoding?: BufferEncoding - ): Promise { - const buffer = typeof content === 'string' - ? this.bufferCtor.from(content, encoding ?? 'utf8') - : content; - const now = Math.floor(Date.now() / 1000); - - const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?'); - await deleteStmt.run(ino); - - if (buffer.length > 0) { - const stmt = this.db.prepare(` - INSERT INTO fs_data (ino, chunk_index, data) - VALUES (?, ?, ?) - `); - - let chunkIndex = 0; - for (let offset = 0; offset < buffer.length; offset += this.chunkSize) { - const chunk = buffer.subarray(offset, Math.min(offset + this.chunkSize, buffer.length)); - await stmt.run(ino, chunkIndex, chunk); - chunkIndex++; - } + + private splitPath(path: string): string[] { + const normalized = this.normalizePath(path); + if (normalized === '/') return []; + return normalized.split('/').filter(p => p); } - const updateStmt = this.db.prepare(` - UPDATE fs_inode - SET size = ?, mtime = ? - WHERE ino = ? - `); - await updateStmt.run(buffer.length, now, ino); - } - - async readFile(path: string): Promise; - async readFile(path: string, encoding: BufferEncoding): Promise; - async readFile(path: string, options: { encoding: BufferEncoding }): Promise; - async readFile( - path: string, - options?: BufferEncoding | { encoding?: BufferEncoding } - ): Promise { - const encoding = typeof options === 'string' - ? options - : options?.encoding; - - const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'open'); - - await assertReadableExistingInode(this.db, ino, 'open', normalizedPath); - - const stmt = this.db.prepare(` - SELECT data FROM fs_data - WHERE ino = ? - ORDER BY chunk_index ASC - `); - const rows = await stmt.all(ino) as { data: Buffer }[]; - - let combined: Buffer; - if (rows.length === 0) { - combined = this.bufferCtor.alloc(0); - } else { - const buffers = rows.map(row => row.data); - combined = this.bufferCtor.concat(buffers); + private async resolvePathOrThrow( + path: string, + syscall: FsSyscall + ): Promise<{ normalizedPath: string; ino: number }> { + const normalizedPath = this.normalizePath(path); + const ino = await this.resolvePath(normalizedPath); + if (ino === null) { + throw createFsError({ + code: 'ENOENT', + syscall, + path: normalizedPath, + message: 'no such file or directory', + }); + } + return {normalizedPath, ino}; } - const now = Math.floor(Date.now() / 1000); - const updateStmt = this.db.prepare('UPDATE fs_inode SET atime = ? WHERE ino = ?'); - await updateStmt.run(now, ino); + private async resolvePath(path: string): Promise { + const normalized = this.normalizePath(path); + + if (normalized === '/') { + return this.rootIno; + } - if (encoding) { - return combined.toString(encoding); + const parts = this.splitPath(normalized); + let currentIno = this.rootIno; + + for (const name of parts) { + const stmt = this.db.prepare(` + SELECT ino + FROM fs_dentry + WHERE parent_ino = ? + AND name = ? + `); + const result = await stmt.get(currentIno, name) as { ino: number } | undefined; + + if (!result) { + return null; + } + + currentIno = result.ino; + } + + return currentIno; } - return combined; - } - - async readdir(path: string): Promise { - const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'scandir'); - - await assertReaddirTargetInode(this.db, ino, normalizedPath); - - const stmt = this.db.prepare(` - SELECT name FROM fs_dentry - WHERE parent_ino = ? - ORDER BY name ASC - `); - const rows = await stmt.all(ino) as { name: string }[]; - - return rows.map(row => row.name); - } - - async readdirPlus(path: string): Promise { - const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'scandir'); - - await assertReaddirTargetInode(this.db, ino, normalizedPath); - - const stmt = this.db.prepare(` - SELECT d.name, i.ino, i.mode, i.nlink, i.uid, i.gid, i.size, i.atime, i.mtime, i.ctime - FROM fs_dentry d - JOIN fs_inode i ON d.ino = i.ino - WHERE d.parent_ino = ? - ORDER BY d.name ASC - `); - const rows = await stmt.all(ino) as { - name: string; - ino: number; - mode: number; - nlink: number; - uid: number; - gid: number; - size: number; - atime: number; - mtime: number; - ctime: number; - }[]; - - return rows.map(row => ({ - name: row.name, - stats: createStats({ - ino: row.ino, - mode: row.mode, - nlink: row.nlink, - uid: row.uid, - gid: row.gid, - size: row.size, - atime: row.atime, - mtime: row.mtime, - ctime: row.ctime, - }), - })); - } - - async stat(path: string): Promise { - const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'stat'); - - const stmt = this.db.prepare(` - SELECT ino, mode, nlink, uid, gid, size, atime, mtime, ctime - FROM fs_inode - WHERE ino = ? - `); - const row = await stmt.get(ino) as { - ino: number; - mode: number; - nlink: number; - uid: number; - gid: number; - size: number; - atime: number; - mtime: number; - ctime: number; - } | undefined; - - if (!row) { - throw createFsError({ - code: 'ENOENT', - syscall: 'stat', - path: normalizedPath, - message: 'no such file or directory', - }); + + private async resolveParent(path: string): Promise<{ parentIno: number; name: string } | null> { + const normalized = this.normalizePath(path); + + if (normalized === '/') { + return null; + } + + const parts = this.splitPath(normalized); + const name = parts[parts.length - 1]; + const parentPath = parts.length === 1 ? '/' : '/' + parts.slice(0, -1).join('/'); + + const parentIno = await this.resolvePath(parentPath); + + if (parentIno === null) { + return null; + } + + return {parentIno, name}; } - return createStats(row); - } - - async lstat(path: string): Promise { - // For now, lstat is the same as stat since we don't follow symlinks in stat yet - return this.stat(path); - } - - async mkdir(path: string): Promise { - const normalizedPath = this.normalizePath(path); - - const existing = await this.resolvePath(normalizedPath); - if (existing !== null) { - throw createFsError({ - code: 'EEXIST', - syscall: 'mkdir', - path: normalizedPath, - message: 'file already exists', - }); + private async createInode(mode: number, uid: number = 0, gid: number = 0): Promise { + const now = Math.floor(Date.now() / 1000); + const stmt = this.db.prepare(` + INSERT INTO fs_inode (mode, uid, gid, size, atime, mtime, ctime) + VALUES (?, ?, ?, 0, ?, ?, ?) RETURNING ino + `); + const {ino} = await stmt.get(mode, uid, gid, now, now, now); + return Number(ino); } - const parent = await this.resolveParent(normalizedPath); - if (!parent) { - throw createFsError({ - code: 'ENOENT', - syscall: 'mkdir', - path: normalizedPath, - message: 'no such file or directory', - }); + private async createDentry(parentIno: number, name: string, ino: number): Promise { + const stmt = this.db.prepare(` + INSERT INTO fs_dentry (name, parent_ino, ino) + VALUES (?, ?, ?) + `); + await stmt.run(name, parentIno, ino); + + const updateStmt = this.db.prepare('UPDATE fs_inode SET nlink = nlink + 1 WHERE ino = ?'); + await updateStmt.run(ino); } - await assertInodeIsDirectory(this.db, parent.parentIno, 'mkdir', normalizedPath); - - const dirIno = await this.createInode(DEFAULT_DIR_MODE); - try { - await this.createDentry(parent.parentIno, parent.name, dirIno); - } catch { - throw createFsError({ - code: 'EEXIST', - syscall: 'mkdir', - path: normalizedPath, - message: 'file already exists', - }); + private async ensureParentDirs(path: string): Promise { + const parts = this.splitPath(path); + parts.pop(); + + let currentIno = this.rootIno; + + for (const name of parts) { + const stmt = this.db.prepare(` + SELECT ino + FROM fs_dentry + WHERE parent_ino = ? + AND name = ? + `); + const result = await stmt.get(currentIno, name) as { ino: number } | undefined; + + if (!result) { + const dirIno = await this.createInode(DEFAULT_DIR_MODE); + await this.createDentry(currentIno, name, dirIno); + currentIno = dirIno; + } else { + await assertInodeIsDirectory(this.db, result.ino, 'open', this.normalizePath(path)); + currentIno = result.ino; + } + } } - } - - async rmdir(path: string): Promise { - const normalizedPath = this.normalizePath(path); - assertNotRoot(normalizedPath, 'rmdir'); - - const { ino } = await this.resolvePathOrThrow(normalizedPath, 'rmdir'); - - const mode = await getInodeModeOrThrow(this.db, ino, 'rmdir', normalizedPath); - assertNotSymlinkMode(mode, 'rmdir', normalizedPath); - if ((mode & S_IFMT) !== S_IFDIR) { - throw createFsError({ - code: 'ENOTDIR', - syscall: 'rmdir', - path: normalizedPath, - message: 'not a directory', - }); + + private async getLinkCount(ino: number): Promise { + const stmt = this.db.prepare('SELECT nlink FROM fs_inode WHERE ino = ?'); + const result = await stmt.get(ino) as { nlink: number } | undefined; + return result?.nlink ?? 0; } - const stmt = this.db.prepare(` - SELECT 1 as one FROM fs_dentry - WHERE parent_ino = ? - LIMIT 1 - `); - const child = await stmt.get(ino) as { one: number } | undefined; - if (child) { - throw createFsError({ - code: 'ENOTEMPTY', - syscall: 'rmdir', - path: normalizedPath, - message: 'directory not empty', - }); + private async getInodeMode(ino: number): Promise { + const stmt = this.db.prepare('SELECT mode FROM fs_inode WHERE ino = ?'); + const row = await stmt.get(ino) as { mode: number } | undefined; + return row?.mode ?? null; } - const parent = await this.resolveParent(normalizedPath); - if (!parent) { - throw createFsError({ - code: 'EPERM', - syscall: 'rmdir', - path: normalizedPath, - message: 'operation not permitted', - }); + // ==================== FileSystem Interface Implementation ==================== + + // we can add our check for the available disk size on host device + async writeFile( + path: string, + content: string | Buffer, + options?: BufferEncoding | { encoding?: BufferEncoding } + ): Promise { + await this.ensureParentDirs(path); + + const ino = await this.resolvePath(path); + + const encoding = typeof options === 'string' + ? options + : options?.encoding; + + const normalizedPath = this.normalizePath(path); + if (ino !== null) { + await assertWritableExistingInode(this.db, ino, 'open', normalizedPath); + await this.updateFileContent(ino, content, encoding); + } else { + const parent = await this.resolveParent(path); + if (!parent) { + throw createFsError({ + code: 'ENOENT', + syscall: 'open', + path: normalizedPath, + message: 'no such file or directory', + }); + } + + await assertInodeIsDirectory(this.db, parent.parentIno, 'open', normalizedPath); + + const fileIno = await this.createInode(DEFAULT_FILE_MODE); + await this.createDentry(parent.parentIno, parent.name, fileIno); + await this.updateFileContent(fileIno, content, encoding); + } } - await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino); - } + private async updateFileContent( + ino: number, + content: string | Buffer, + encoding?: BufferEncoding + ): Promise { + const buffer = typeof content === 'string' + ? this.bufferCtor.from(content, encoding ?? 'utf8') + : content; - async unlink(path: string): Promise { - const normalizedPath = this.normalizePath(path); - assertNotRoot(normalizedPath, 'unlink'); - const { ino } = await this.resolvePathOrThrow(normalizedPath, 'unlink'); + const sizeStmt = this.db.prepare( + "SELECT size FROM fs_inode WHERE ino = ?" + ); - await assertUnlinkTargetInode(this.db, ino, normalizedPath); + const row = await sizeStmt.get(ino) as { size: number } | undefined; - const parent = (await this.resolveParent(normalizedPath))!; + const currentSize = row?.size ?? 0; + const difference = buffer.length - currentSize; + + if (difference > 0) { + await this.assertHasSpace(difference); + } - const stmt = this.db.prepare(` - DELETE FROM fs_dentry - WHERE parent_ino = ? AND name = ? - `); - await stmt.run(parent.parentIno, parent.name); - const decrementStmt = this.db.prepare('UPDATE fs_inode SET nlink = nlink - 1 WHERE ino = ?'); - await decrementStmt.run(ino); + const now = Math.floor(Date.now() / 1000); - const linkCount = await this.getLinkCount(ino); - if (linkCount === 0) { - const deleteInodeStmt = this.db.prepare('DELETE FROM fs_inode WHERE ino = ?'); - await deleteInodeStmt.run(ino); + const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?'); + await deleteStmt.run(ino); + + if (buffer.length > 0) { + const stmt = this.db.prepare(` + INSERT INTO fs_data (ino, chunk_index, data) + VALUES (?, ?, ?) + `); + + let chunkIndex = 0; + for (let offset = 0; offset < buffer.length; offset += this.chunkSize) { + const chunk = buffer.subarray(offset, Math.min(offset + this.chunkSize, buffer.length)); + await stmt.run(ino, chunkIndex, chunk); + chunkIndex++; + } + } - const deleteDataStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?'); - await deleteDataStmt.run(ino); + const updateStmt = this.db.prepare(` + UPDATE fs_inode + SET size = ?, + mtime = ? + WHERE ino = ? + `); + await updateStmt.run(buffer.length, now, ino); } - } - - async rm( - path: string, - options?: { force?: boolean; recursive?: boolean } - ): Promise { - const normalizedPath = this.normalizePath(path); - const { force, recursive } = normalizeRmOptions(options); - assertNotRoot(normalizedPath, 'rm'); - - const ino = await this.resolvePath(normalizedPath); - if (ino === null) { - throwENOENTUnlessForce(normalizedPath, 'rm', force); - return; + + async readFile(path: string): Promise; + async readFile(path: string, encoding: BufferEncoding): Promise; + async readFile(path: string, options: { encoding: BufferEncoding }): Promise; + async readFile( + path: string, + options?: BufferEncoding | { encoding?: BufferEncoding } + ): Promise { + const encoding = typeof options === 'string' + ? options + : options?.encoding; + + const {normalizedPath, ino} = await this.resolvePathOrThrow(path, 'open'); + + await assertReadableExistingInode(this.db, ino, 'open', normalizedPath); + + const stmt = this.db.prepare(` + SELECT data + FROM fs_data + WHERE ino = ? + ORDER BY chunk_index ASC + `); + const rows = await stmt.all(ino) as { data: Buffer }[]; + + let combined: Buffer; + if (rows.length === 0) { + combined = this.bufferCtor.alloc(0); + } else { + const buffers = rows.map(row => row.data); + combined = this.bufferCtor.concat(buffers); + } + + const now = Math.floor(Date.now() / 1000); + const updateStmt = this.db.prepare('UPDATE fs_inode SET atime = ? WHERE ino = ?'); + await updateStmt.run(now, ino); + + if (encoding) { + return combined.toString(encoding); + } + return combined; } - const mode = await getInodeModeOrThrow(this.db, ino, 'rm', normalizedPath); - assertNotSymlinkMode(mode, 'rm', normalizedPath); - - const parent = await this.resolveParent(normalizedPath); - if (!parent) { - throw createFsError({ - code: 'EPERM', - syscall: 'rm', - path: normalizedPath, - message: 'operation not permitted', - }); + async readdir(path: string): Promise { + const {normalizedPath, ino} = await this.resolvePathOrThrow(path, 'scandir'); + + await assertReaddirTargetInode(this.db, ino, normalizedPath); + + const stmt = this.db.prepare(` + SELECT name + FROM fs_dentry + WHERE parent_ino = ? + ORDER BY name ASC + `); + const rows = await stmt.all(ino) as { name: string }[]; + + return rows.map(row => row.name); } - if ((mode & S_IFMT) === S_IFDIR) { - if (!recursive) { - throw createFsError({ - code: 'EISDIR', - syscall: 'rm', - path: normalizedPath, - message: 'illegal operation on a directory', - }); - } - - await this.rmDirContentsRecursive(ino); - await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino); - return; + async readdirPlus(path: string): Promise { + const {normalizedPath, ino} = await this.resolvePathOrThrow(path, 'scandir'); + + await assertReaddirTargetInode(this.db, ino, normalizedPath); + + const stmt = this.db.prepare(` + SELECT d.name, + i.ino, + i.mode, + i.nlink, + i.uid, + i.gid, + i.size, + i.atime, + i.mtime, + i.ctime + FROM fs_dentry d + JOIN fs_inode i ON d.ino = i.ino + WHERE d.parent_ino = ? + ORDER BY d.name ASC + `); + const rows = await stmt.all(ino) as { + name: string; + ino: number; + mode: number; + nlink: number; + uid: number; + gid: number; + size: number; + atime: number; + mtime: number; + ctime: number; + }[]; + + return rows.map(row => ({ + name: row.name, + stats: createStats({ + ino: row.ino, + mode: row.mode, + nlink: row.nlink, + uid: row.uid, + gid: row.gid, + size: row.size, + atime: row.atime, + mtime: row.mtime, + ctime: row.ctime, + }), + })); } - await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino); - } - - private async rmDirContentsRecursive(dirIno: number): Promise { - const stmt = this.db.prepare(` - SELECT name, ino FROM fs_dentry - WHERE parent_ino = ? - ORDER BY name ASC - `); - const children = await stmt.all(dirIno) as { name: string; ino: number }[]; - - for (const child of children) { - const mode = await this.getInodeMode(child.ino); - if (mode === null) { - continue; - } - - if ((mode & S_IFMT) === S_IFDIR) { - await this.rmDirContentsRecursive(child.ino); - await this.removeDentryAndMaybeInode(dirIno, child.name, child.ino); - } else { - assertNotSymlinkMode(mode, 'rm', ''); - await this.removeDentryAndMaybeInode(dirIno, child.name, child.ino); - } + async stat(path: string): Promise { + const {normalizedPath, ino} = await this.resolvePathOrThrow(path, 'stat'); + + const stmt = this.db.prepare(` + SELECT ino, mode, nlink, uid, gid, size, atime, mtime, ctime + FROM fs_inode + WHERE ino = ? + `); + const row = await stmt.get(ino) as { + ino: number; + mode: number; + nlink: number; + uid: number; + gid: number; + size: number; + atime: number; + mtime: number; + ctime: number; + } | undefined; + + if (!row) { + throw createFsError({ + code: 'ENOENT', + syscall: 'stat', + path: normalizedPath, + message: 'no such file or directory', + }); + } + + return createStats(row); } - } - private async removeDentryAndMaybeInode(parentIno: number, name: string, ino: number): Promise { - const stmt = this.db.prepare(` - DELETE FROM fs_dentry - WHERE parent_ino = ? AND name = ? - `); - await stmt.run(parentIno, name); + async lstat(path: string): Promise { + // For now, lstat is the same as stat since we don't follow symlinks in stat yet + return this.stat(path); + } - const decrementStmt = this.db.prepare('UPDATE fs_inode SET nlink = nlink - 1 WHERE ino = ?'); - await decrementStmt.run(ino); + async mkdir(path: string): Promise { + const normalizedPath = this.normalizePath(path); - const linkCount = await this.getLinkCount(ino); - if (linkCount === 0) { - const deleteInodeStmt = this.db.prepare('DELETE FROM fs_inode WHERE ino = ?'); - await deleteInodeStmt.run(ino); + const existing = await this.resolvePath(normalizedPath); + if (existing !== null) { + throw createFsError({ + code: 'EEXIST', + syscall: 'mkdir', + path: normalizedPath, + message: 'file already exists', + }); + } + + const parent = await this.resolveParent(normalizedPath); + if (!parent) { + throw createFsError({ + code: 'ENOENT', + syscall: 'mkdir', + path: normalizedPath, + message: 'no such file or directory', + }); + } - const deleteDataStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?'); - await deleteDataStmt.run(ino); + await assertInodeIsDirectory(this.db, parent.parentIno, 'mkdir', normalizedPath); - const deleteSymlinkStmt = this.db.prepare('DELETE FROM fs_symlink WHERE ino = ?'); - await deleteSymlinkStmt.run(ino); + const dirIno = await this.createInode(DEFAULT_DIR_MODE); + try { + await this.createDentry(parent.parentIno, parent.name, dirIno); + } catch { + throw createFsError({ + code: 'EEXIST', + syscall: 'mkdir', + path: normalizedPath, + message: 'file already exists', + }); + } } - } - async rename(oldPath: string, newPath: string): Promise { - const oldNormalized = this.normalizePath(oldPath); - const newNormalized = this.normalizePath(newPath); + async rmdir(path: string): Promise { + const normalizedPath = this.normalizePath(path); + assertNotRoot(normalizedPath, 'rmdir'); + + const {ino} = await this.resolvePathOrThrow(normalizedPath, 'rmdir'); + + const mode = await getInodeModeOrThrow(this.db, ino, 'rmdir', normalizedPath); + assertNotSymlinkMode(mode, 'rmdir', normalizedPath); + if ((mode & S_IFMT) !== S_IFDIR) { + throw createFsError({ + code: 'ENOTDIR', + syscall: 'rmdir', + path: normalizedPath, + message: 'not a directory', + }); + } - if (oldNormalized === newNormalized) return; + const stmt = this.db.prepare(` + SELECT 1 as one + FROM fs_dentry + WHERE parent_ino = ? LIMIT 1 + `); + const child = await stmt.get(ino) as { one: number } | undefined; + if (child) { + throw createFsError({ + code: 'ENOTEMPTY', + syscall: 'rmdir', + path: normalizedPath, + message: 'directory not empty', + }); + } - assertNotRoot(oldNormalized, 'rename'); - assertNotRoot(newNormalized, 'rename'); + const parent = await this.resolveParent(normalizedPath); + if (!parent) { + throw createFsError({ + code: 'EPERM', + syscall: 'rmdir', + path: normalizedPath, + message: 'operation not permitted', + }); + } - const oldParent = await this.resolveParent(oldNormalized); - if (!oldParent) { - throw createFsError({ - code: 'EPERM', - syscall: 'rename', - path: oldNormalized, - message: 'operation not permitted', - }); + await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino); } - const newParent = await this.resolveParent(newNormalized); - if (!newParent) { - throw createFsError({ - code: 'ENOENT', - syscall: 'rename', - path: newNormalized, - message: 'no such file or directory', - }); - } + async unlink(path: string): Promise { + const normalizedPath = this.normalizePath(path); + assertNotRoot(normalizedPath, 'unlink'); + const {ino} = await this.resolvePathOrThrow(normalizedPath, 'unlink'); + + await assertUnlinkTargetInode(this.db, ino, normalizedPath); + + const parent = (await this.resolveParent(normalizedPath))!; - await assertInodeIsDirectory(this.db, newParent.parentIno, 'rename', newNormalized); - - await this.db.exec('BEGIN'); - try { - const oldResolved = await this.resolvePathOrThrow(oldNormalized, 'rename'); - const oldIno = oldResolved.ino; - const oldMode = await getInodeModeOrThrow(this.db, oldIno, 'rename', oldNormalized); - assertNotSymlinkMode(oldMode, 'rename', oldNormalized); - const oldIsDir = (oldMode & S_IFMT) === S_IFDIR; - - if (oldIsDir && newNormalized.startsWith(oldNormalized + '/')) { - throw createFsError({ - code: 'EINVAL', - syscall: 'rename', - path: newNormalized, - message: 'invalid argument', - }); - } - - const newIno = await this.resolvePath(newNormalized); - if (newIno !== null) { - const newMode = await getInodeModeOrThrow(this.db, newIno, 'rename', newNormalized); - assertNotSymlinkMode(newMode, 'rename', newNormalized); - const newIsDir = (newMode & S_IFMT) === S_IFDIR; - - if (newIsDir && !oldIsDir) { - throw createFsError({ - code: 'EISDIR', - syscall: 'rename', - path: newNormalized, - message: 'illegal operation on a directory', - }); + const stmt = this.db.prepare(` + DELETE + FROM fs_dentry + WHERE parent_ino = ? + AND name = ? + `); + await stmt.run(parent.parentIno, parent.name); + + const decrementStmt = this.db.prepare('UPDATE fs_inode SET nlink = nlink - 1 WHERE ino = ?'); + await decrementStmt.run(ino); + + const linkCount = await this.getLinkCount(ino); + if (linkCount === 0) { + const deleteInodeStmt = this.db.prepare('DELETE FROM fs_inode WHERE ino = ?'); + await deleteInodeStmt.run(ino); + + const deleteDataStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?'); + await deleteDataStmt.run(ino); } - if (!newIsDir && oldIsDir) { - throw createFsError({ - code: 'ENOTDIR', - syscall: 'rename', - path: newNormalized, - message: 'not a directory', - }); + } + + async rm( + path: string, + options?: { force?: boolean; recursive?: boolean } + ): Promise { + const normalizedPath = this.normalizePath(path); + const {force, recursive} = normalizeRmOptions(options); + assertNotRoot(normalizedPath, 'rm'); + + const ino = await this.resolvePath(normalizedPath); + if (ino === null) { + throwENOENTUnlessForce(normalizedPath, 'rm', force); + return; } - if (newIsDir) { - const stmt = this.db.prepare(` - SELECT 1 as one FROM fs_dentry - WHERE parent_ino = ? - LIMIT 1 - `); - const child = await stmt.get(newIno) as { one: number } | undefined; - if (child) { + const mode = await getInodeModeOrThrow(this.db, ino, 'rm', normalizedPath); + assertNotSymlinkMode(mode, 'rm', normalizedPath); + + const parent = await this.resolveParent(normalizedPath); + if (!parent) { throw createFsError({ - code: 'ENOTEMPTY', - syscall: 'rename', - path: newNormalized, - message: 'directory not empty', + code: 'EPERM', + syscall: 'rm', + path: normalizedPath, + message: 'operation not permitted', }); - } } - await this.removeDentryAndMaybeInode(newParent.parentIno, newParent.name, newIno); - } - - const stmt = this.db.prepare(` - UPDATE fs_dentry - SET parent_ino = ?, name = ? - WHERE parent_ino = ? AND name = ? - `); - await stmt.run(newParent.parentIno, newParent.name, oldParent.parentIno, oldParent.name); - - const now = Math.floor(Date.now() / 1000); - const updateInodeCtimeStmt = this.db.prepare(` - UPDATE fs_inode - SET ctime = ? - WHERE ino = ? - `); - await updateInodeCtimeStmt.run(now, oldIno); - - const updateDirTimesStmt = this.db.prepare(` - UPDATE fs_inode - SET mtime = ?, ctime = ? - WHERE ino = ? - `); - await updateDirTimesStmt.run(now, now, oldParent.parentIno); - if (newParent.parentIno !== oldParent.parentIno) { - await updateDirTimesStmt.run(now, now, newParent.parentIno); - } - - await this.db.exec('COMMIT'); - } catch (e) { - await this.db.exec('ROLLBACK'); - throw e; + if ((mode & S_IFMT) === S_IFDIR) { + if (!recursive) { + throw createFsError({ + code: 'EISDIR', + syscall: 'rm', + path: normalizedPath, + message: 'illegal operation on a directory', + }); + } + + await this.rmDirContentsRecursive(ino); + await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino); + return; + } + + await this.removeDentryAndMaybeInode(parent.parentIno, parent.name, ino); } - } - - async copyFile(src: string, dest: string): Promise { - const srcNormalized = this.normalizePath(src); - const destNormalized = this.normalizePath(dest); - - if (srcNormalized === destNormalized) { - throw createFsError({ - code: 'EINVAL', - syscall: 'copyfile', - path: destNormalized, - message: 'invalid argument', - }); + + private async rmDirContentsRecursive(dirIno: number): Promise { + const stmt = this.db.prepare(` + SELECT name, ino + FROM fs_dentry + WHERE parent_ino = ? + ORDER BY name ASC + `); + const children = await stmt.all(dirIno) as { name: string; ino: number }[]; + + for (const child of children) { + const mode = await this.getInodeMode(child.ino); + if (mode === null) { + continue; + } + + if ((mode & S_IFMT) === S_IFDIR) { + await this.rmDirContentsRecursive(child.ino); + await this.removeDentryAndMaybeInode(dirIno, child.name, child.ino); + } else { + assertNotSymlinkMode(mode, 'rm', ''); + await this.removeDentryAndMaybeInode(dirIno, child.name, child.ino); + } + } } - const { ino: srcIno } = await this.resolvePathOrThrow(srcNormalized, 'copyfile'); - await assertReadableExistingInode(this.db, srcIno, 'copyfile', srcNormalized); - - const stmt = this.db.prepare(` - SELECT mode, uid, gid, size FROM fs_inode WHERE ino = ? - `); - const srcRow = await stmt.get(srcIno) as - | { mode: number; uid: number; gid: number; size: number } - | undefined; - if (!srcRow) { - throw createFsError({ - code: 'ENOENT', - syscall: 'copyfile', - path: srcNormalized, - message: 'no such file or directory', - }); + private async removeDentryAndMaybeInode(parentIno: number, name: string, ino: number): Promise { + const stmt = this.db.prepare(` + DELETE + FROM fs_dentry + WHERE parent_ino = ? + AND name = ? + `); + await stmt.run(parentIno, name); + + const decrementStmt = this.db.prepare('UPDATE fs_inode SET nlink = nlink - 1 WHERE ino = ?'); + await decrementStmt.run(ino); + + const linkCount = await this.getLinkCount(ino); + if (linkCount === 0) { + const deleteInodeStmt = this.db.prepare('DELETE FROM fs_inode WHERE ino = ?'); + await deleteInodeStmt.run(ino); + + const deleteDataStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?'); + await deleteDataStmt.run(ino); + + const deleteSymlinkStmt = this.db.prepare('DELETE FROM fs_symlink WHERE ino = ?'); + await deleteSymlinkStmt.run(ino); + } } - const destParent = await this.resolveParent(destNormalized); - if (!destParent) { - throw createFsError({ - code: 'ENOENT', - syscall: 'copyfile', - path: destNormalized, - message: 'no such file or directory', - }); + async rename(oldPath: string, newPath: string): Promise { + const oldNormalized = this.normalizePath(oldPath); + const newNormalized = this.normalizePath(newPath); + + if (oldNormalized === newNormalized) return; + + assertNotRoot(oldNormalized, 'rename'); + assertNotRoot(newNormalized, 'rename'); + + const oldParent = await this.resolveParent(oldNormalized); + if (!oldParent) { + throw createFsError({ + code: 'EPERM', + syscall: 'rename', + path: oldNormalized, + message: 'operation not permitted', + }); + } + + const newParent = await this.resolveParent(newNormalized); + if (!newParent) { + throw createFsError({ + code: 'ENOENT', + syscall: 'rename', + path: newNormalized, + message: 'no such file or directory', + }); + } + + await assertInodeIsDirectory(this.db, newParent.parentIno, 'rename', newNormalized); + + await this.db.exec('BEGIN'); + try { + const oldResolved = await this.resolvePathOrThrow(oldNormalized, 'rename'); + const oldIno = oldResolved.ino; + const oldMode = await getInodeModeOrThrow(this.db, oldIno, 'rename', oldNormalized); + assertNotSymlinkMode(oldMode, 'rename', oldNormalized); + const oldIsDir = (oldMode & S_IFMT) === S_IFDIR; + + if (oldIsDir && newNormalized.startsWith(oldNormalized + '/')) { + throw createFsError({ + code: 'EINVAL', + syscall: 'rename', + path: newNormalized, + message: 'invalid argument', + }); + } + + const newIno = await this.resolvePath(newNormalized); + if (newIno !== null) { + const newMode = await getInodeModeOrThrow(this.db, newIno, 'rename', newNormalized); + assertNotSymlinkMode(newMode, 'rename', newNormalized); + const newIsDir = (newMode & S_IFMT) === S_IFDIR; + + if (newIsDir && !oldIsDir) { + throw createFsError({ + code: 'EISDIR', + syscall: 'rename', + path: newNormalized, + message: 'illegal operation on a directory', + }); + } + if (!newIsDir && oldIsDir) { + throw createFsError({ + code: 'ENOTDIR', + syscall: 'rename', + path: newNormalized, + message: 'not a directory', + }); + } + + if (newIsDir) { + const stmt = this.db.prepare(` + SELECT 1 as one + FROM fs_dentry + WHERE parent_ino = ? LIMIT 1 + `); + const child = await stmt.get(newIno) as { one: number } | undefined; + if (child) { + throw createFsError({ + code: 'ENOTEMPTY', + syscall: 'rename', + path: newNormalized, + message: 'directory not empty', + }); + } + } + + await this.removeDentryAndMaybeInode(newParent.parentIno, newParent.name, newIno); + } + + const stmt = this.db.prepare(` + UPDATE fs_dentry + SET parent_ino = ?, + name = ? + WHERE parent_ino = ? + AND name = ? + `); + await stmt.run(newParent.parentIno, newParent.name, oldParent.parentIno, oldParent.name); + + const now = Math.floor(Date.now() / 1000); + const updateInodeCtimeStmt = this.db.prepare(` + UPDATE fs_inode + SET ctime = ? + WHERE ino = ? + `); + await updateInodeCtimeStmt.run(now, oldIno); + + const updateDirTimesStmt = this.db.prepare(` + UPDATE fs_inode + SET mtime = ?, + ctime = ? + WHERE ino = ? + `); + await updateDirTimesStmt.run(now, now, oldParent.parentIno); + if (newParent.parentIno !== oldParent.parentIno) { + await updateDirTimesStmt.run(now, now, newParent.parentIno); + } + + await this.db.exec('COMMIT'); + } catch (e) { + await this.db.exec('ROLLBACK'); + throw e; + } } - await assertInodeIsDirectory(this.db, destParent.parentIno, 'copyfile', destNormalized); - - await this.db.exec('BEGIN'); - try { - const now = Math.floor(Date.now() / 1000); - - const destIno = await this.resolvePath(destNormalized); - if (destIno !== null) { - const destMode = await getInodeModeOrThrow(this.db, destIno, 'copyfile', destNormalized); - assertNotSymlinkMode(destMode, 'copyfile', destNormalized); - if ((destMode & S_IFMT) === S_IFDIR) { - throw createFsError({ - code: 'EISDIR', - syscall: 'copyfile', - path: destNormalized, - message: 'illegal operation on a directory', - }); + + async copyFile(src: string, dest: string): Promise { + const srcNormalized = this.normalizePath(src); + const destNormalized = this.normalizePath(dest); + + if (srcNormalized === destNormalized) { + throw createFsError({ + code: 'EINVAL', + syscall: 'copyfile', + path: destNormalized, + message: 'invalid argument', + }); } - const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?'); - await deleteStmt.run(destIno); - - const copyStmt = this.db.prepare(` - INSERT INTO fs_data (ino, chunk_index, data) - SELECT ?, chunk_index, data - FROM fs_data - WHERE ino = ? - ORDER BY chunk_index ASC - `); - await copyStmt.run(destIno, srcIno); + const {ino: srcIno} = await this.resolvePathOrThrow(srcNormalized, 'copyfile'); + await assertReadableExistingInode(this.db, srcIno, 'copyfile', srcNormalized); - const updateStmt = this.db.prepare(` - UPDATE fs_inode - SET mode = ?, uid = ?, gid = ?, size = ?, mtime = ?, ctime = ? - WHERE ino = ? + const stmt = this.db.prepare(` + SELECT mode, uid, gid, size + FROM fs_inode + WHERE ino = ? `); - await updateStmt.run(srcRow.mode, srcRow.uid, srcRow.gid, srcRow.size, now, now, destIno); - } else { - const destInoCreated = await this.createInode(srcRow.mode, srcRow.uid, srcRow.gid); - await this.createDentry(destParent.parentIno, destParent.name, destInoCreated); - - const copyStmt = this.db.prepare(` - INSERT INTO fs_data (ino, chunk_index, data) - SELECT ?, chunk_index, data - FROM fs_data - WHERE ino = ? - ORDER BY chunk_index ASC - `); - await copyStmt.run(destInoCreated, srcIno); + const srcRow = await stmt.get(srcIno) as + | { mode: number; uid: number; gid: number; size: number } + | undefined; + if (!srcRow) { + throw createFsError({ + code: 'ENOENT', + syscall: 'copyfile', + path: srcNormalized, + message: 'no such file or directory', + }); + } - const updateStmt = this.db.prepare(` - UPDATE fs_inode - SET size = ?, mtime = ?, ctime = ? - WHERE ino = ? - `); - await updateStmt.run(srcRow.size, now, now, destInoCreated); - } + await this.assertHasSpace(srcRow.size); - await this.db.exec('COMMIT'); - } catch (e) { - await this.db.exec('ROLLBACK'); - throw e; - } - } - - async symlink(target: string, linkpath: string): Promise { - const normalizedLinkpath = this.normalizePath(linkpath); - - const existing = await this.resolvePath(normalizedLinkpath); - if (existing !== null) { - throw createFsError({ - code: 'EEXIST', - syscall: 'open', - path: normalizedLinkpath, - message: 'file already exists', - }); + const destParent = await this.resolveParent(destNormalized); + if (!destParent) { + throw createFsError({ + code: 'ENOENT', + syscall: 'copyfile', + path: destNormalized, + message: 'no such file or directory', + }); + } + await assertInodeIsDirectory(this.db, destParent.parentIno, 'copyfile', destNormalized); + + await this.db.exec('BEGIN'); + try { + const now = Math.floor(Date.now() / 1000); + + const destIno = await this.resolvePath(destNormalized); + if (destIno !== null) { + const destMode = await getInodeModeOrThrow(this.db, destIno, 'copyfile', destNormalized); + assertNotSymlinkMode(destMode, 'copyfile', destNormalized); + if ((destMode & S_IFMT) === S_IFDIR) { + throw createFsError({ + code: 'EISDIR', + syscall: 'copyfile', + path: destNormalized, + message: 'illegal operation on a directory', + }); + } + + const deleteStmt = this.db.prepare('DELETE FROM fs_data WHERE ino = ?'); + await deleteStmt.run(destIno); + + const copyStmt = this.db.prepare(` + INSERT INTO fs_data (ino, chunk_index, data) + SELECT ?, chunk_index, data + FROM fs_data + WHERE ino = ? + ORDER BY chunk_index ASC + `); + await copyStmt.run(destIno, srcIno); + + const updateStmt = this.db.prepare(` + UPDATE fs_inode + SET mode = ?, + uid = ?, + gid = ?, + size = ?, + mtime = ?, + ctime = ? + WHERE ino = ? + `); + await updateStmt.run(srcRow.mode, srcRow.uid, srcRow.gid, srcRow.size, now, now, destIno); + } else { + const destInoCreated = await this.createInode(srcRow.mode, srcRow.uid, srcRow.gid); + await this.createDentry(destParent.parentIno, destParent.name, destInoCreated); + + const copyStmt = this.db.prepare(` + INSERT INTO fs_data (ino, chunk_index, data) + SELECT ?, chunk_index, data + FROM fs_data + WHERE ino = ? + ORDER BY chunk_index ASC + `); + await copyStmt.run(destInoCreated, srcIno); + + const updateStmt = this.db.prepare(` + UPDATE fs_inode + SET size = ?, + mtime = ?, + ctime = ? + WHERE ino = ? + `); + await updateStmt.run(srcRow.size, now, now, destInoCreated); + } + + await this.db.exec('COMMIT'); + } catch (e) { + await this.db.exec('ROLLBACK'); + throw e; + } } - const parent = await this.resolveParent(normalizedLinkpath); - if (!parent) { - throw createFsError({ - code: 'ENOENT', - syscall: 'open', - path: normalizedLinkpath, - message: 'no such file or directory', - }); - } + async symlink(target: string, linkpath: string): Promise { + const normalizedLinkpath = this.normalizePath(linkpath); - await assertInodeIsDirectory(this.db, parent.parentIno, 'open', normalizedLinkpath); + const existing = await this.resolvePath(normalizedLinkpath); + if (existing !== null) { + throw createFsError({ + code: 'EEXIST', + syscall: 'open', + path: normalizedLinkpath, + message: 'file already exists', + }); + } - const mode = S_IFLNK | 0o777; - const symlinkIno = await this.createInode(mode); - await this.createDentry(parent.parentIno, parent.name, symlinkIno); + const parent = await this.resolveParent(normalizedLinkpath); + if (!parent) { + throw createFsError({ + code: 'ENOENT', + syscall: 'open', + path: normalizedLinkpath, + message: 'no such file or directory', + }); + } - const stmt = this.db.prepare('INSERT INTO fs_symlink (ino, target) VALUES (?, ?)'); - await stmt.run(symlinkIno, target); + await assertInodeIsDirectory(this.db, parent.parentIno, 'open', normalizedLinkpath); - const updateStmt = this.db.prepare('UPDATE fs_inode SET size = ? WHERE ino = ?'); - await updateStmt.run(target.length, symlinkIno); - } + const mode = S_IFLNK | 0o777; + const symlinkIno = await this.createInode(mode); + await this.createDentry(parent.parentIno, parent.name, symlinkIno); - async readlink(path: string): Promise { - const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'open'); + const stmt = this.db.prepare('INSERT INTO fs_symlink (ino, target) VALUES (?, ?)'); + await stmt.run(symlinkIno, target); - const mode = await this.getInodeMode(ino); - if (mode === null || (mode & S_IFMT) !== S_IFLNK) { - throw createFsError({ - code: 'EINVAL', - syscall: 'open', - path: normalizedPath, - message: 'invalid argument', - }); + const updateStmt = this.db.prepare('UPDATE fs_inode SET size = ? WHERE ino = ?'); + await updateStmt.run(target.length, symlinkIno); } - const stmt = this.db.prepare('SELECT target FROM fs_symlink WHERE ino = ?'); - const row = await stmt.get(ino) as { target: string } | undefined; + async readlink(path: string): Promise { + const {normalizedPath, ino} = await this.resolvePathOrThrow(path, 'open'); - if (!row) { - throw createFsError({ - code: 'ENOENT', - syscall: 'open', - path: normalizedPath, - message: 'no such file or directory', - }); - } + const mode = await this.getInodeMode(ino); + if (mode === null || (mode & S_IFMT) !== S_IFLNK) { + throw createFsError({ + code: 'EINVAL', + syscall: 'open', + path: normalizedPath, + message: 'invalid argument', + }); + } - return row.target; - } - - async access(path: string): Promise { - const normalizedPath = this.normalizePath(path); - const ino = await this.resolvePath(normalizedPath); - if (ino === null) { - throw createFsError({ - code: 'ENOENT', - syscall: 'access', - path: normalizedPath, - message: 'no such file or directory', - }); + const stmt = this.db.prepare('SELECT target FROM fs_symlink WHERE ino = ?'); + const row = await stmt.get(ino) as { target: string } | undefined; + + if (!row) { + throw createFsError({ + code: 'ENOENT', + syscall: 'open', + path: normalizedPath, + message: 'no such file or directory', + }); + } + + return row.target; } - } - async statfs(): Promise { - const inodeStmt = this.db.prepare('SELECT COUNT(*) as count FROM fs_inode'); - const inodeRow = await inodeStmt.get() as { count: number }; + async access(path: string): Promise { + const normalizedPath = this.normalizePath(path); + const ino = await this.resolvePath(normalizedPath); + if (ino === null) { + throw createFsError({ + code: 'ENOENT', + syscall: 'access', + path: normalizedPath, + message: 'no such file or directory', + }); + } + } - const bytesStmt = this.db.prepare('SELECT COALESCE(SUM(LENGTH(data)), 0) as total FROM fs_data'); - const bytesRow = await bytesStmt.get() as { total: number }; + async statfs(): Promise { + const inodeStmt = this.db.prepare( + 'SELECT COUNT(*) as count FROM fs_inode' + ); + const inodeRow = await inodeStmt.get() as { count: number }; + + const bytesStmt = this.db.prepare( + 'SELECT COALESCE(SUM(LENGTH(data)), 0) as total FROM fs_data' + ); + const bytesRow = await bytesStmt.get() as { total: number }; + + const max = await this.getMaxBytes(); + + return { + inodes: inodeRow.count, + bytesUsed: bytesRow.total, + bytesTotal: max, + bytesAvailable: max + ? Math.max(0, max - bytesRow.total) + : null, + }; + } - return { - inodes: inodeRow.count, - bytesUsed: bytesRow.total, - }; - } - async open(path: string): Promise { - const { normalizedPath, ino } = await this.resolvePathOrThrow(path, 'open'); - await assertReadableExistingInode(this.db, ino, 'open', normalizedPath); + async open(path: string): Promise { + const {normalizedPath, ino} = await this.resolvePathOrThrow(path, 'open'); + await assertReadableExistingInode(this.db, ino, 'open', normalizedPath); - return new AgentFSFile(this.db, this.bufferCtor, ino, this.chunkSize); - } + return new AgentFSFile(this.db, this.bufferCtor, ino, this.chunkSize); + } - // Legacy alias - async deleteFile(path: string): Promise { - return await this.unlink(path); - } + // Legacy alias + async deleteFile(path: string): Promise { + return await this.unlink(path); + } } diff --git a/sdk/typescript/src/filesystem/interface.ts b/sdk/typescript/src/filesystem/interface.ts index f3c52d04..8d7ff096 100644 --- a/sdk/typescript/src/filesystem/interface.ts +++ b/sdk/typescript/src/filesystem/interface.ts @@ -70,7 +70,25 @@ export interface FilesystemStats { /** Total number of inodes (files, directories, symlinks) */ inodes: number; /** Total bytes used by file contents */ + bytesUsed: number; + /** Total bytes used by file contents */ + /** + * Maximum number of bytes allowed in the filesystem. + * + * If no storage limit is configured, this will be `null`. + */ + bytesTotal: number | null; + + /** + * Number of bytes still available for storing new data. + * + * This is calculated as: + * bytesTotal - bytesUsed + * + * If no storage limit is configured, this will be `null`. + */ + bytesAvailable: number | null; } /** diff --git a/sdk/typescript/tests/filesystem.test.ts b/sdk/typescript/tests/filesystem.test.ts index 2dd6f7c1..a7c15dee 100644 --- a/sdk/typescript/tests/filesystem.test.ts +++ b/sdk/typescript/tests/filesystem.test.ts @@ -634,6 +634,8 @@ describe("Filesystem Integration Tests", () => { }); }); + + // ==================== Data Integrity Tests ==================== describe("Data Integrity Tests", () => { From ec11eef9dc7951524428a54317efb897e1ea2d26 Mon Sep 17 00:00:00 2001 From: steven-mpawulo Date: Mon, 19 Jan 2026 11:11:54 +0300 Subject: [PATCH 2/2] adds bytesTotal and bytesAvailable to cloud flare integration but initially makes them null --- sdk/typescript/src/integrations/cloudflare/agentfs.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/typescript/src/integrations/cloudflare/agentfs.ts b/sdk/typescript/src/integrations/cloudflare/agentfs.ts index 76e36bc0..753a0b22 100644 --- a/sdk/typescript/src/integrations/cloudflare/agentfs.ts +++ b/sdk/typescript/src/integrations/cloudflare/agentfs.ts @@ -1343,9 +1343,13 @@ export class AgentFS implements FileSystem { 'SELECT COALESCE(SUM(LENGTH(data)), 0) as total FROM fs_data' ).toArray(); + + return { inodes: inodeRows[0].count, bytesUsed: bytesRows[0].total, + bytesTotal: null, + bytesAvailable: null }; }