@firesystem/s3
Version:
AWS S3 implementation of Virtual File System
1,700 lines (1,501 loc) • 51.9 kB
text/typescript
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