UNPKG

@firesystem/s3

Version:
1,700 lines (1,501 loc) 51.9 kB
import type { IFileSystem, IReactiveFileSystem, FileEntry, FileStat, FileMetadata, FSEvent, Disposable, FileSystemEventPayloads, IFileSystemCapabilities, } from "@firesystem/core"; import { normalizePath, dirname, basename, join, TypedEventEmitter, FileSystemEvents, BaseFileSystem, } from "@firesystem/core"; import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadObjectCommand, CopyObjectCommand, DeleteObjectsCommand, type _Object, } from "@aws-sdk/client-s3"; import type { S3FileSystemConfig } from "./types"; interface WatchListener { id: string; pattern: string; callback: (event: FSEvent) => void; } /** * S3FileSystem - Adaptação do Amazon S3 para interface de FileSystem * * CARACTERÍSTICAS DO S3: * - Object storage, não file system hierárquico tradicional * - Não tem diretórios reais, apenas prefixos (keys com "/") * - Todas as operações são eventuais (eventual consistency) * - Não tem rename atômico - sempre copy + delete * - Permissões via IAM, bucket policies e ACLs (não Unix-style) * * MODOS DE OPERAÇÃO: * - "strict": Simula diretórios com markers vazios (compatível com ferramentas) * - "lenient": Diretórios são virtuais, existem apenas quando têm conteúdo * * LIMITAÇÕES: * - rename de diretórios é caro (copia todos os objetos) * - watch é simulado (não há notificações nativas) * - Permissões só podem ser verificadas parcialmente * - Tamanho máximo: 5GB (single upload) ou 5TB (multipart) * * CONSIDERAÇÕES DE PERFORMANCE: * - Liste apenas o necessário (ListObjectsV2 é paginado) * - Use prefixos para limitar buscas * - Evite rename de diretórios grandes * - Cache pode ajudar, mas cuidado com consistência */ export class S3FileSystem extends BaseFileSystem { private client: S3Client; private config: S3FileSystemConfig & { prefix: string; mode: "strict" | "lenient"; }; private watchers: WatchListener[] = []; public readonly events = new TypedEventEmitter<FileSystemEventPayloads>(); // Capabilities do S3 - Reflete as características reais do S3 readonly capabilities: IFileSystemCapabilities = { // Operações básicas readonly: false, // Depende das permissões IAM/bucket policy caseSensitive: true, // S3 keys são case-sensitive atomicRename: false, // S3 não tem rename - é sempre copy + delete // Limites maxFileSize: 5 * 1024 * 1024 * 1024 * 1024, // 5TB com multipart upload maxPathLength: 1024, // S3 key length limit // Features básicas supportsWatch: false, // S3 não tem watch nativo (apenas via polling) supportsMetadata: true, // S3 suporta user metadata supportsGlob: false, // S3 não tem glob nativo (implementado via list + filter) // Features avançadas - específicas do S3 supportsTrueDirectories: false, // S3 usa apenas prefixos, não diretórios reais supportsAtomicOperations: false, // Nenhuma operação é verdadeiramente atômica supportsMultipartUpload: true, // Para arquivos > 5GB supportsPermissions: false, // Sem permissões no estilo Unix metadataLimit: 2048, // 2KB total para user metadata description: "S3-compatible object storage with eventual consistency", }; constructor(config: S3FileSystemConfig) { super(); this.config = { ...config, prefix: config.prefix || "/", mode: config.mode || "strict", }; // Normalize prefix if (this.config.prefix !== "/") { this.config.prefix = normalizePath(this.config.prefix); if (!this.config.prefix.endsWith("/")) { this.config.prefix += "/"; } // Remove leading slash for S3 this.config.prefix = this.config.prefix.substring(1); } this.client = new S3Client({ region: config.region, credentials: config.credentials, ...config.clientOptions, }); } async initialize(): Promise<void> { const startTime = Date.now(); this.events.emit(FileSystemEvents.INITIALIZING, undefined as any); try { // Only create root directory marker in strict mode if ( this.config.mode === "strict" && this.config.prefix !== "/" && this.config.prefix !== "" ) { await this.ensureDirectoryMarker(this.config.prefix); } this.events.emit(FileSystemEvents.INITIALIZED, { duration: Date.now() - startTime, }); } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "initialize", path: "/", error: error as Error, }); throw error; } } private toS3Key(path: string): string { const normalized = normalizePath(path); if (normalized === "/") { return this.config.prefix === "/" ? "" : this.config.prefix; } const withoutLeadingSlash = normalized.substring(1); if (this.config.prefix === "/" || this.config.prefix === "") { return withoutLeadingSlash; } return this.config.prefix + withoutLeadingSlash; } private fromS3Key(key: string): string { if (this.config.prefix === "/" || this.config.prefix === "") { return "/" + key; } if (key.startsWith(this.config.prefix)) { const withoutPrefix = key.substring(this.config.prefix.length); return "/" + withoutPrefix; } return "/" + key; } private async streamToString(stream: any): Promise<string> { const chunks: any[] = []; for await (const chunk of stream) { chunks.push(chunk); } return Buffer.concat(chunks).toString("utf-8"); } private async ensureDirectoryMarker(s3Key: string): Promise<void> { const key = s3Key.endsWith("/") ? s3Key : s3Key + "/"; try { await this.client.send( new PutObjectCommand({ Bucket: this.config.bucket, Key: key, Body: "", ContentType: "application/x-directory", Metadata: { "x-amz-meta-type": "directory", "x-amz-meta-created": new Date().toISOString(), }, }), ); } catch (error) { throw new Error(`Failed to create directory marker: ${error}`); } } private async ensureParentExists(path: string): Promise<void> { // Only ensure parent exists in strict mode if (this.config.mode !== "strict") return; const parent = dirname(path); if (parent === "/" || parent === ".") return; const s3Key = this.toS3Key(parent); await this.ensureDirectoryMarker(s3Key); } private parseS3Metadata(metadata?: Record<string, string>): { created?: Date; modified?: Date; fileMetadata?: FileMetadata; type?: "file" | "directory"; } { const result: any = {}; if (metadata) { if (metadata["x-amz-meta-created"]) { result.created = new Date(metadata["x-amz-meta-created"]); } if (metadata["x-amz-meta-modified"]) { result.modified = new Date(metadata["x-amz-meta-modified"]); } if (metadata["x-amz-meta-type"]) { result.type = metadata["x-amz-meta-type"] as "file" | "directory"; } if (metadata["x-amz-meta-custom"]) { try { result.fileMetadata = JSON.parse(metadata["x-amz-meta-custom"]); } catch { // Ignore parse errors } } } return result; } private createS3Metadata( type: "file" | "directory", metadata?: FileMetadata, created?: Date, ): Record<string, string> { const now = new Date(); const s3Metadata: Record<string, string> = { "x-amz-meta-type": type, "x-amz-meta-created": (created || now).toISOString(), "x-amz-meta-modified": now.toISOString(), }; if (metadata) { s3Metadata["x-amz-meta-custom"] = JSON.stringify(metadata); } return s3Metadata; } async readFile(path: string): Promise<FileEntry> { const normalized = normalizePath(path); const startTime = Date.now(); const operationId = `read-${normalized}-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "readFile", path: normalized, id: operationId, }); try { const s3Key = this.toS3Key(normalized); // First check if it's a directory (only if path doesn't already end with /) if (!s3Key.endsWith("/")) { const dirKey = s3Key + "/"; try { const dirCheck = await this.client.send( new HeadObjectCommand({ Bucket: this.config.bucket, Key: dirKey, }), ); // If we get here, it means the directory marker exists if ( dirCheck && dirCheck.Metadata?.["x-amz-meta-type"] === "directory" ) { throw new Error( `EISDIR: illegal operation on a directory, read '${normalized}'`, ); } } catch (error: any) { // Ignore not found errors - just means it's not a directory if ( error.name !== "NoSuchKey" && error.name !== "NotFound" && !error.message?.includes("EISDIR") ) { // For other errors, don't throw - just continue trying to read as file } if (error.message?.includes("EISDIR")) { throw error; } } } const response = await this.client.send( new GetObjectCommand({ Bucket: this.config.bucket, Key: s3Key, }), ); if (!response.Body) { throw new Error(`File not found: ${normalized}`); } const parsedMetadata = this.parseS3Metadata(response.Metadata); // In lenient mode, infer type from key let type = parsedMetadata.type; if (!type && this.config.mode === "lenient") { type = s3Key.endsWith("/") ? "directory" : "file"; } // Check if trying to read a directory if (type === "directory" || s3Key.endsWith("/")) { throw new Error( `EISDIR: illegal operation on a directory, read '${normalized}'`, ); } // Read content based on metadata let content: any; if (parsedMetadata.fileMetadata?.isBinary) { // Decode from base64 and return as ArrayBuffer const base64Content = await this.streamToString(response.Body); const buffer = Buffer.from(base64Content, "base64"); content = buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength, ); } else { content = await this.streamToString(response.Body); // Try to parse JSON if it looks like JSON // This handles objects that were stringified during write if ( content && typeof content === "string" && (content.startsWith("{") || content.startsWith("[")) ) { try { content = JSON.parse(content); } catch { // If parse fails, keep as string } } } const result: FileEntry = { path: normalized, name: basename(normalized), type: type || "file", size: response.ContentLength || 0, created: parsedMetadata.created || new Date(response.LastModified || Date.now()), modified: parsedMetadata.modified || new Date(response.LastModified || Date.now()), metadata: parsedMetadata.fileMetadata, content: content, }; this.events.emit(FileSystemEvents.FILE_READ, { path: normalized, size: result.size || 0, }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "readFile", path: normalized, id: operationId, duration: Date.now() - startTime, }); return result; } catch (error: any) { if (error.name === "NoSuchKey") { const notFoundError = new Error( `ENOENT: no such file or directory, open '${normalized}'`, ); this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "readFile", path: normalized, error: notFoundError, }); throw notFoundError; } this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "readFile", path: normalized, error: error as Error, }); throw error; } } async writeFile( path: string, content: any, metadata?: FileMetadata, ): Promise<FileEntry> { const normalized = normalizePath(path); const startTime = Date.now(); const operationId = `write-${normalized}-${startTime}`; let contentStr: string; let isBinary = false; let originalSize: number; if (content === null || content === undefined) { contentStr = ""; originalSize = 0; } else if (typeof content === "string") { contentStr = content; originalSize = Buffer.byteLength(contentStr); } else if (content instanceof ArrayBuffer) { contentStr = Buffer.from(content).toString("base64"); isBinary = true; originalSize = content.byteLength; // Use original ArrayBuffer size } else { contentStr = JSON.stringify(content); originalSize = Buffer.byteLength(contentStr); } const size = originalSize; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "writeFile", path: normalized, id: operationId, }); this.events.emit(FileSystemEvents.FILE_WRITING, { path: normalized, size }); try { // In strict mode, check if parent exists if (this.config.mode === "strict") { const parent = dirname(normalized); if (parent !== "/" && parent !== ".") { const parentExists = await this.exists(parent); if (!parentExists) { throw new Error( `ENOENT: no such file or directory, open '${normalized}'`, ); } } } await this.ensureParentExists(normalized); const s3Key = this.toS3Key(normalized); // Check if file already exists to preserve created date let existingCreated: Date | undefined; try { const existing = await this.client.send( new HeadObjectCommand({ Bucket: this.config.bucket, Key: s3Key, }), ); if (existing.Metadata?.["x-amz-meta-created"]) { existingCreated = new Date(existing.Metadata["x-amz-meta-created"]); } } catch { // File doesn't exist, which is fine } const finalMetadata = isBinary ? { ...metadata, isBinary: true } : metadata; const s3Metadata = this.createS3Metadata( "file", finalMetadata, existingCreated, ); await this.client.send( new PutObjectCommand({ Bucket: this.config.bucket, Key: s3Key, Body: contentStr, ContentType: "text/plain", Metadata: s3Metadata, }), ); const now = new Date(); const result: FileEntry = { path: normalized, name: basename(normalized), type: "file", size, created: existingCreated || new Date(s3Metadata["x-amz-meta-created"]), modified: now, metadata: finalMetadata, content, }; // Notify watchers this.notifyWatchers({ type: existingCreated ? "updated" : "created", path: normalized, timestamp: now, }); this.events.emit(FileSystemEvents.FILE_WRITTEN, { path: normalized, size, }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "writeFile", path: normalized, id: operationId, duration: Date.now() - startTime, }); return result; } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "writeFile", path: normalized, error: error as Error, }); throw error; } } async deleteFile(path: string): Promise<void> { const normalized = normalizePath(path); const startTime = Date.now(); const operationId = `delete-${normalized}-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "deleteFile", path: normalized, id: operationId, }); this.events.emit(FileSystemEvents.FILE_DELETING, { path: normalized }); try { // Check if file exists first const exists = await this.exists(normalized); if (!exists) { throw new Error( `ENOENT: no such file or directory, unlink '${normalized}'`, ); } const s3Key = this.toS3Key(normalized); // Check if it's a directory with contents if (await this.isDirectory(normalized)) { const entries = await this.readDir(normalized); if (entries.length > 0) { throw new Error( `ENOTEMPTY: directory not empty, rmdir '${normalized}'`, ); } } await this.client.send( new DeleteObjectCommand({ Bucket: this.config.bucket, Key: s3Key, }), ); // Notify watchers this.notifyWatchers({ type: "deleted", path: normalized, timestamp: new Date(), }); this.events.emit(FileSystemEvents.FILE_DELETED, { path: normalized }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "deleteFile", path: normalized, id: operationId, duration: Date.now() - startTime, }); } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "deleteFile", path: normalized, error: error as Error, }); throw error; } } async exists(path: string): Promise<boolean> { const normalized = normalizePath(path); try { const s3Key = this.toS3Key(normalized); // Check as file first try { await this.client.send( new HeadObjectCommand({ Bucket: this.config.bucket, Key: s3Key, }), ); return true; } catch { // In lenient mode, also check if it's a prefix with contents if (this.config.mode === "lenient") { const prefix = s3Key.endsWith("/") ? s3Key : s3Key + "/"; const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: prefix, MaxKeys: 1, }), ); return (response.Contents?.length || 0) > 0; } // In strict mode, check for directory marker const dirKey = s3Key.endsWith("/") ? s3Key : s3Key + "/"; try { await this.client.send( new HeadObjectCommand({ Bucket: this.config.bucket, Key: dirKey, }), ); return true; } catch { return false; } } } catch { return false; } } async readDir(path: string): Promise<FileEntry[]> { const normalized = normalizePath(path); const startTime = Date.now(); const operationId = `readdir-${normalized}-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "readDir", path: normalized, id: operationId, }); try { // Check if directory exists first (except root) if (normalized !== "/") { const exists = await this.exists(normalized); if (!exists) { throw new Error( `ENOENT: no such file or directory, scandir '${normalized}'`, ); } } const s3Prefix = this.toS3Key(normalized); const prefix = s3Prefix === "" ? "" : s3Prefix.endsWith("/") ? s3Prefix : s3Prefix + "/"; const entries: FileEntry[] = []; const seenPaths = new Set<string>(); let continuationToken: string | undefined; do { const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: prefix, Delimiter: "/", ContinuationToken: continuationToken, }), ); // Add directories (CommonPrefixes) if (response.CommonPrefixes) { for (const commonPrefix of response.CommonPrefixes) { if (commonPrefix.Prefix) { const path = this.fromS3Key(commonPrefix.Prefix); if (!seenPaths.has(path)) { seenPaths.add(path); entries.push({ path, name: basename(path.endsWith("/") ? path.slice(0, -1) : path), type: "directory", size: 0, created: new Date(), modified: new Date(), }); } } } } // Add files (Contents) if (response.Contents) { for (const object of response.Contents) { if (object.Key) { // Skip directory markers if (object.Key.endsWith("/")) continue; // Skip objects not directly in this directory const relativePath = object.Key.substring(prefix.length); if (relativePath.includes("/")) continue; const path = this.fromS3Key(object.Key); if (!seenPaths.has(path)) { seenPaths.add(path); entries.push({ path, name: basename(path), type: "file", size: object.Size || 0, created: object.LastModified || new Date(), modified: object.LastModified || new Date(), }); } } } } continuationToken = response.NextContinuationToken; } while (continuationToken); this.events.emit(FileSystemEvents.DIR_READ, { path: normalized, count: entries.length, }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "readDir", path: normalized, id: operationId, duration: Date.now() - startTime, }); return entries; } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "readDir", path: normalized, error: error as Error, }); throw error; } } async mkdir(path: string, recursive?: boolean): Promise<FileEntry> { const normalized = normalizePath(path); const startTime = Date.now(); const operationId = `mkdir-${normalized}-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "mkdir", path: normalized, id: operationId, }); this.events.emit(FileSystemEvents.DIR_CREATING, { path: normalized, recursive: !!recursive, }); try { // Check if trying to create root if (normalized === "/") { throw new Error(`EEXIST: file already exists, mkdir '${normalized}'`); } // Check if directory already exists if (await this.exists(normalized)) { throw new Error(`EEXIST: file already exists, mkdir '${normalized}'`); } // In lenient mode, directories are virtual - just return success if (this.config.mode === "lenient") { const now = new Date(); const result: FileEntry = { path: normalized, name: basename(normalized), type: "directory", size: 0, created: now, modified: now, }; this.notifyWatchers({ type: "created", path: normalized, timestamp: now, }); this.events.emit(FileSystemEvents.DIR_CREATED, { path: normalized }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "mkdir", path: normalized, id: operationId, duration: Date.now() - startTime, }); return result; } // Strict mode - create directory markers if (recursive) { // Create all parent directories const parts = normalized.split("/").filter(Boolean); let currentPath = ""; for (const part of parts) { currentPath += "/" + part; const s3Key = this.toS3Key(currentPath); await this.ensureDirectoryMarker(s3Key); } } else { // Check if parent exists const parent = dirname(normalized); if (parent !== "/" && parent !== ".") { const parentExists = await this.exists(parent); if (!parentExists) { throw new Error( `ENOENT: no such file or directory, mkdir '${normalized}'`, ); } } // Ensure parent exists await this.ensureParentExists(normalized); // Create directory marker const s3Key = this.toS3Key(normalized); await this.ensureDirectoryMarker(s3Key); } const now = new Date(); const result: FileEntry = { path: normalized, name: basename(normalized), type: "directory", size: 0, created: now, modified: now, }; // Notify watchers this.notifyWatchers({ type: "created", path: normalized, timestamp: now, }); this.events.emit(FileSystemEvents.DIR_CREATED, { path: normalized }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "mkdir", path: normalized, id: operationId, duration: Date.now() - startTime, }); return result; } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "mkdir", path: normalized, error: error as Error, }); throw error; } } async rmdir(path: string, recursive?: boolean): Promise<void> { const normalized = normalizePath(path); const startTime = Date.now(); const operationId = `rmdir-${normalized}-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "rmdir", path: normalized, id: operationId, }); this.events.emit(FileSystemEvents.DIR_DELETING, { path: normalized, recursive: !!recursive, }); try { // Check if trying to remove root if (normalized === "/") { throw new Error( `EBUSY: resource busy or locked, rmdir '${normalized}'`, ); } // Check if directory exists const exists = await this.exists(normalized); if (!exists) { throw new Error( `ENOENT: no such file or directory, rmdir '${normalized}'`, ); } // Check if it's actually a file if (!(await this.isDirectory(normalized))) { throw new Error(`ENOTDIR: not a directory, rmdir '${normalized}'`); } const s3Prefix = this.toS3Key(normalized); const prefix = s3Prefix.endsWith("/") ? s3Prefix : s3Prefix + "/"; if (recursive) { // Delete all objects with this prefix const objectsToDelete: { Key: string }[] = []; let continuationToken: string | undefined; do { const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: prefix, ContinuationToken: continuationToken, }), ); if (response.Contents) { for (const object of response.Contents) { if (object.Key) { objectsToDelete.push({ Key: object.Key }); } } } continuationToken = response.NextContinuationToken; } while (continuationToken); // Delete in batches (S3 allows max 1000 per request) const batchSize = 1000; for (let i = 0; i < objectsToDelete.length; i += batchSize) { const batch = objectsToDelete.slice(i, i + batchSize); await this.client.send( new DeleteObjectsCommand({ Bucket: this.config.bucket, Delete: { Objects: batch }, }), ); } } else { // Check if directory is empty const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: prefix, MaxKeys: 2, // We only need to know if there's more than the dir marker }), ); const hasContents = (response.Contents?.length || 0) > 1 || (response.Contents?.length === 1 && !response.Contents[0].Key?.endsWith("/")); if (hasContents) { throw new Error( `ENOTEMPTY: directory not empty, rmdir '${normalized}'`, ); } // Delete directory marker await this.client.send( new DeleteObjectCommand({ Bucket: this.config.bucket, Key: prefix, }), ); } // Notify watchers this.notifyWatchers({ type: "deleted", path: normalized, timestamp: new Date(), }); this.events.emit(FileSystemEvents.DIR_DELETED, { path: normalized }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "rmdir", path: normalized, id: operationId, duration: Date.now() - startTime, }); } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "rmdir", path: normalized, error: error as Error, }); throw error; } } async rename(oldPath: string, newPath: string): Promise<FileEntry> { const normalizedOld = normalizePath(oldPath); const normalizedNew = normalizePath(newPath); const startTime = Date.now(); const operationId = `rename-${normalizedOld}-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "rename", path: normalizedOld, id: operationId, }); try { // Check if source exists if (!(await this.exists(normalizedOld))) { throw new Error( `ENOENT: no such file or directory, rename '${normalizedOld}' -> '${normalizedNew}'`, ); } // Check if target already exists if (await this.exists(normalizedNew)) { throw new Error( `EEXIST: file already exists, rename '${normalizedOld}' -> '${normalizedNew}'`, ); } // Check if new parent exists const newParent = dirname(normalizedNew); if ( newParent !== "/" && newParent !== "." && !(await this.exists(newParent)) ) { throw new Error( `ENOENT: no such file or directory, rename '${normalizedOld}' -> '${normalizedNew}'`, ); } // Determine if source is a directory const isDirectory = await this.isDirectory(normalizedOld); if (!isDirectory) { // For files: S3 doesn't have rename, so we copy + delete // This is NOT atomic - the file exists in both places temporarily const oldEntry = await this.readFile(normalizedOld); await this.writeFile( normalizedNew, oldEntry.content, oldEntry.metadata, ); await this.deleteFile(normalizedOld); const result = await this.readFile(normalizedNew); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "rename", path: normalizedOld, id: operationId, duration: Date.now() - startTime, }); return result; } else { // For directories: This is expensive in S3! // We need to list all objects with the old prefix and copy them one by one const s3OldPrefix = this.toS3Key(normalizedOld); const s3NewPrefix = this.toS3Key(normalizedNew); const oldPrefix = s3OldPrefix.endsWith("/") ? s3OldPrefix : s3OldPrefix + "/"; const newPrefix = s3NewPrefix.endsWith("/") ? s3NewPrefix : s3NewPrefix + "/"; // List all objects with old prefix const objectsToRename: string[] = []; let continuationToken: string | undefined; do { const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: oldPrefix, ContinuationToken: continuationToken, }), ); if (response.Contents) { for (const object of response.Contents) { if (object.Key) { objectsToRename.push(object.Key); } } } continuationToken = response.NextContinuationToken; } while (continuationToken); // Copy each object to new location for (const oldKey of objectsToRename) { const newKey = oldKey.replace(oldPrefix, newPrefix); await this.client.send( new CopyObjectCommand({ Bucket: this.config.bucket, CopySource: `${this.config.bucket}/${oldKey}`, Key: newKey, MetadataDirective: "COPY", }), ); } // Delete old objects if (objectsToRename.length > 0) { const batchSize = 1000; for (let i = 0; i < objectsToRename.length; i += batchSize) { const batch = objectsToRename.slice(i, i + batchSize); await this.client.send( new DeleteObjectsCommand({ Bucket: this.config.bucket, Delete: { Objects: batch.map((Key) => ({ Key })) }, }), ); } } // Create directory marker for new directory if in strict mode if (this.config.mode === "strict") { await this.ensureDirectoryMarker(newPrefix); } // Return directory entry const result: FileEntry = { path: normalizedNew, name: basename(normalizedNew), type: "directory", size: 0, created: new Date(), modified: new Date(), }; this.events.emit(FileSystemEvents.OPERATION_END, { operation: "rename", path: normalizedOld, id: operationId, duration: Date.now() - startTime, }); return result; } } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "rename", path: normalizedOld, error: error as Error, }); throw error; } } async move(sourcePaths: string[], targetPath: string): Promise<void> { const normalizedTarget = normalizePath(targetPath); const startTime = Date.now(); const operationId = `move-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "move", path: sourcePaths[0], id: operationId, }); try { // Check if target exists and is a directory if (!(await this.exists(normalizedTarget))) { throw new Error( `ENOENT: no such file or directory, open '${normalizedTarget}'`, ); } if (!(await this.isDirectory(normalizedTarget))) { throw new Error(`ENOTDIR: not a directory, open '${normalizedTarget}'`); } for (const sourcePath of sourcePaths) { const normalizedSource = normalizePath(sourcePath); const targetFilePath = join( normalizedTarget, basename(normalizedSource), ); // Read source const sourceEntry = await this.readFile(normalizedSource); // Write to target await this.writeFile( targetFilePath, sourceEntry.content, sourceEntry.metadata, ); // Delete source await this.deleteFile(normalizedSource); } this.events.emit(FileSystemEvents.OPERATION_END, { operation: "move", path: sourcePaths[0], id: operationId, duration: Date.now() - startTime, }); } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "move", path: sourcePaths[0], error: error as Error, }); throw error; } } async copy(sourcePath: string, targetPath: string): Promise<FileEntry> { const normalizedSource = normalizePath(sourcePath); const normalizedTarget = normalizePath(targetPath); const startTime = Date.now(); const operationId = `copy-${normalizedSource}-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "copy", path: normalizedSource, id: operationId, }); try { // Check if source is a directory if (await this.isDirectory(normalizedSource)) { throw new Error( `EISDIR: illegal operation on a directory, open '${normalizedSource}'`, ); } // Read source const sourceEntry = await this.readFile(normalizedSource); // Write to target const result = await this.writeFile( normalizedTarget, sourceEntry.content, sourceEntry.metadata, ); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "copy", path: normalizedSource, id: operationId, duration: Date.now() - startTime, }); return result; } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "copy", path: normalizedSource, error: error as Error, }); throw error; } } watch(pattern: string, callback: (event: FSEvent) => void): Disposable { const id = Math.random().toString(36).substr(2, 9); const listener: WatchListener = { id, pattern, callback }; this.watchers.push(listener); return { dispose: () => { const index = this.watchers.findIndex((w) => w.id === id); if (index !== -1) { this.watchers.splice(index, 1); } }, }; } private notifyWatchers(event: FSEvent): void { for (const watcher of this.watchers) { // Simple pattern matching (could be enhanced with glob) if ( watcher.pattern === "*" || event.path.includes(watcher.pattern) || this.matchGlob(event.path, watcher.pattern) ) { watcher.callback(event); } } } private matchGlob(path: string, pattern: string): boolean { // Enhanced glob matching if (pattern === "*") { // Single * matches files and directories in root return path.split("/").length === 2; } if (pattern === "**") { // ** matches everything return true; } // Convert glob to regex let regex = pattern .replace(/\*\*/g, "___DOUBLE_STAR___") .replace(/\*/g, "[^/]*") .replace(/___DOUBLE_STAR___/g, ".*") .replace(/\?/g, ".") .replace(/\//g, "\\/"); return new RegExp("^" + regex + "$").test(path); } private async isDirectory(path: string): Promise<boolean> { const normalized = normalizePath(path); const s3Key = this.toS3Key(normalized); const dirKey = s3Key.endsWith("/") ? s3Key : s3Key + "/"; try { const response = await this.client.send( new HeadObjectCommand({ Bucket: this.config.bucket, Key: dirKey, }), ); return response.Metadata?.["x-amz-meta-type"] === "directory"; } catch { // Check if any objects exist with this prefix const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: dirKey, MaxKeys: 1, }), ); return (response.Contents?.length || 0) > 0; } } async stat(path: string): Promise<FileStat> { const normalized = normalizePath(path); try { // Check if it's a directory first if (await this.isDirectory(normalized)) { const s3Key = this.toS3Key(normalized); const dirKey = s3Key.endsWith("/") ? s3Key : s3Key + "/"; try { const response = await this.client.send( new HeadObjectCommand({ Bucket: this.config.bucket, Key: dirKey, }), ); const metadata = this.parseS3Metadata(response.Metadata); return { path: normalized, size: 0, type: "directory", created: metadata.created || new Date(response.LastModified || Date.now()), modified: metadata.modified || new Date(response.LastModified || Date.now()), readonly: false, // TODO: Check S3 bucket/object permissions }; } catch { // Directory exists but no marker return { path: normalized, size: 0, type: "directory", created: new Date(), modified: new Date(), readonly: false, // TODO: Check S3 bucket/object permissions }; } } // It's a file const entry = await this.readFile(normalized); return { path: entry.path, size: entry.size || 0, type: entry.type, created: entry.created || new Date(), modified: entry.modified || new Date(), readonly: false, // TODO: Check S3 bucket/object permissions }; } catch (error: any) { if (error.message?.includes("EISDIR")) { // Handle the case where readFile throws EISDIR return { path: normalized, size: 0, type: "directory", created: new Date(), modified: new Date(), readonly: false, // TODO: Check S3 bucket/object permissions }; } throw new Error(`Failed to stat ${normalized}: ${error}`); } } async glob(pattern: string): Promise<string[]> { const results: string[] = []; const seenPaths = new Set<string>(); // Convert glob pattern to prefix for S3 const prefix = pattern.split("*")[0].replace(/^\//, ""); const s3Prefix = this.config.prefix === "/" ? prefix : this.config.prefix + prefix; let continuationToken: string | undefined; do { const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: s3Prefix, ContinuationToken: continuationToken, }), ); // Add files if (response.Contents) { for (const object of response.Contents) { if (object.Key) { const path = this.fromS3Key(object.Key); // Add the file itself if it matches (skip directory markers) if (!object.Key.endsWith("/") && this.matchGlob(path, pattern)) { seenPaths.add(path); } // Also check parent directories for patterns like "*" if (pattern === "*" || pattern === "**") { const parts = path.split("/").filter(Boolean); for (let i = 1; i <= parts.length - 1; i++) { const dirPath = "/" + parts.slice(0, i).join("/"); if (this.matchGlob(dirPath, pattern)) { seenPaths.add(dirPath); } } } } } } continuationToken = response.NextContinuationToken; } while (continuationToken); return Array.from(seenPaths).sort(); } async clear(): Promise<void> { const startTime = Date.now(); const operationId = `clear-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "clear", path: "/", id: operationId, }); try { // List and delete all objects const objectsToDelete: { Key: string }[] = []; let continuationToken: string | undefined; do { const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: this.config.prefix === "/" ? "" : this.config.prefix, ContinuationToken: continuationToken, }), ); if (response.Contents) { for (const object of response.Contents) { if (object.Key) { objectsToDelete.push({ Key: object.Key }); } } } continuationToken = response.NextContinuationToken; } while (continuationToken); // Delete in batches const batchSize = 1000; for (let i = 0; i < objectsToDelete.length; i += batchSize) { const batch = objectsToDelete.slice(i, i + batchSize); await this.client.send( new DeleteObjectsCommand({ Bucket: this.config.bucket, Delete: { Objects: batch }, }), ); } this.events.emit(FileSystemEvents.OPERATION_END, { operation: "clear", path: "/", id: operationId, duration: Date.now() - startTime, }); } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "clear", path: "/", error: error as Error, }); throw error; } } async size(): Promise<number> { let totalSize = 0; let continuationToken: string | undefined; do { const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: this.config.prefix === "/" ? "" : this.config.prefix, ContinuationToken: continuationToken, }), ); if (response.Contents) { for (const object of response.Contents) { totalSize += object.Size || 0; } } continuationToken = response.NextContinuationToken; } while (continuationToken); return totalSize; } /** * Verifica se pode modificar um arquivo/objeto no S3. * * LIMITAÇÕES DO S3: * - S3 tem um modelo de permissões complexo (IAM, bucket policies, ACLs) * - Muitas vezes só sabemos se temos permissão quando tentamos a operação * - Este método faz uma estimativa baseada em: * 1. Se conseguimos acessar o objeto (HEAD request) * 2. Se recebemos erro 403 em qualquer operação * * @returns true se provavelmente pode modificar, false se definitivamente não pode */ async canModify(path: string): Promise<boolean> { const normalized = normalizePath(path); try { const s3Key = this.toS3Key(normalized); // Tenta fazer um HEAD request no objeto try { await this.client.send( new HeadObjectCommand({ Bucket: this.config.bucket, Key: s3Key, }), ); // Se conseguimos ler metadata, provavelmente podemos modificar // MAS isso não é garantia - bucket policy pode permitir GET mas não PUT/DELETE return true; } catch (error: any) { if (error.name === "NoSuchKey") { // Arquivo não existe - verifica se podemos criar no diretório pai const parent = dirname(normalized); return this.canCreateIn(parent); } // Erro 403 = sem permissão if ( error.name === "AccessDenied" || error.$metadata?.httpStatusCode === 403 ) { return false; } // Outros erros (rede, etc) - assumir que não pode return false; } } catch { return false; } } /** * Verifica se pode criar objetos em um "diretório" (prefixo) no S3. * * LIMITAÇÕES DO S3: * - S3 não tem diretórios reais, apenas prefixos * - Em modo "lenient", qualquer prefixo é válido * - Permissões são verificadas no nível do bucket, não do "diretório" * * @returns true se provavelmente pode criar, false se definitivamente não pode */ async canCreateIn(parentPath: string): Promise<boolean> { const normalized = normalizePath(parentPath); try { // Para root, verifica se temos acesso de escrita ao bucket if (normalized === "/") { try { // Tenta listar o bucket - se conseguir, provavelmente pode escrever // MAS não é garantia - pode ter ListBucket mas não PutObject await this.client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, MaxKeys: 1, }), ); return true; } catch (error: any) { if ( error.name === "AccessDenied" || error.$metadata?.httpStatusCode === 403 ) { return false; } // Outros erros - assumir que não pode return false; } } // Em modo lenient, qualquer prefixo é válido para criar if (this.config.mode === "lenient") { // Ainda precisamos verificar acesso ao bucket return this.canCreateIn("/"); } // Em modo strict, verifica se o "diretório" existe const exists = await this.exists(normalized); if (!exists) { return false; } // Verifica se é realmente um "diretório" (tem objetos com esse prefixo) const isDir = await this.isDirectory(normalized); if (!isDir) { return false; // É um arquivo, não pode criar "dentro" dele } // Se chegou aqui, provavelmente pode criar // (mas ainda depende das permissões reais do bucket) return true; } catch { return false; } } /** * Tenta uma escrita "mais segura" no S3, mas NÃO é verdadeiramente atômica. * * LIMITAÇÕES DO S3: * - S3 não tem escrita atômica real * - Este