/** * SMB2/3 File System Provider * Handles file system operations for SMB shares */ import { promises as fs, constants as fsConstants, Stats } from 'fs'; import { join, resolve, dirname, basename } from 'path'; import type { SMB2ServerConfig, SMB2ShareConfig, SMB2FileId, SMB2OpenFile, SMB2Connection, SMB2Session, SMB2TreeConnect, FileBasicInformation, FileStandardInformation, FileDirectoryInformation, } from '../protocol/types.js'; import { ACCESS_MASK, SHARE_ACCESS, CREATE_DISPOSITION, CREATE_OPTIONS, FILE_ATTRIBUTES, OPLOCK_LEVEL, } from '../protocol/constants.js'; import { Logger } from '../utils/logger.js'; import { generateFileId, fileIdToString, validateSmbPath, smbPathToLocal, localPathToSmb, getCurrentFileTime, dateToFileTime, fileTimeToDate, } from '../utils/helpers.js'; export interface FileHandle { readonly fileId: SMB2FileId; readonly localPath: string; readonly smbPath: string; readonly desiredAccess: ACCESS_MASK; readonly shareAccess: SHARE_ACCESS; readonly createOptions: CREATE_OPTIONS; readonly fileAttributes: FILE_ATTRIBUTES; readonly oplockLevel: OPLOCK_LEVEL; readonly creationTime: Date; readonly lastAccessTime: Date; readonly lastWriteTime: Date; readonly changeTime: Date; readonly isDirectory: boolean; readonly fileSize: bigint; readonly allocationSize: bigint; fdHandle?: number; } export interface DirectoryEntry { readonly name: string; readonly isDirectory: boolean; readonly fileSize: bigint; readonly creationTime: Date; readonly lastAccessTime: Date; readonly lastWriteTime: Date; readonly changeTime: Date; readonly fileAttributes: FILE_ATTRIBUTES; } export class SMB2FileSystemProvider { private readonly config: SMB2ServerConfig; private readonly logger: Logger; private readonly openFiles: Map; private readonly shares: Map; constructor(config: SMB2ServerConfig) { this.config = config; this.logger = new Logger('SMB2FileSystemProvider'); this.openFiles = new Map(); this.shares = new Map(); // Load share configurations this.loadShares(); } /** * Open or create a file */ public async openFile( shareName: string, path: string, desiredAccess: ACCESS_MASK, shareAccess: SHARE_ACCESS, createDisposition: CREATE_DISPOSITION, createOptions: CREATE_OPTIONS, fileAttributes: FILE_ATTRIBUTES = FILE_ATTRIBUTES.NORMAL, session: SMB2Session, treeConnect: SMB2TreeConnect ): Promise { try { this.logger.debug(`Opening file: ${shareName}\\${path}`); // Validate path if (!validateSmbPath(path)) { throw new Error('Invalid path'); } // Get share configuration const share = this.shares.get(shareName); if (!share) { throw new Error(`Share not found: ${shareName}`); } // Convert to local path const localPath = smbPathToLocal(path, share.path); this.logger.debug(`Local path: ${localPath}`); // Check if path exists let stats: Stats | null = null; let exists = false; try { stats = await fs.stat(localPath); exists = true; } catch { exists = false; } // Handle create disposition const shouldCreate = await this.handleCreateDisposition( localPath, exists, createDisposition, createOptions ); if (shouldCreate && !exists) { await this.createFileOrDirectory(localPath, createOptions, fileAttributes); stats = await fs.stat(localPath); } if (!stats) { throw new Error('File not found and cannot be created'); } // Generate file ID const fileId = generateFileId(); const fileIdStr = fileIdToString(fileId); // Create file handle const handle: FileHandle = { fileId, localPath, smbPath: path, desiredAccess, shareAccess, createOptions, fileAttributes: this.getFileAttributes(stats), oplockLevel: OPLOCK_LEVEL.NONE, creationTime: stats.birthtime, lastAccessTime: stats.atime, lastWriteTime: stats.mtime, changeTime: stats.ctime, isDirectory: stats.isDirectory(), fileSize: BigInt(stats.size), allocationSize: BigInt(Math.ceil(stats.size / 4096) * 4096), // Align to 4KB }; // Open file descriptor if needed if (!handle.isDirectory && this.needsFileDescriptor(desiredAccess)) { const flags = this.getFileFlags(desiredAccess, createDisposition); handle.fdHandle = await fs.open(localPath, flags).then(fd => fd.fd); } this.openFiles.set(fileIdStr, handle); this.logger.info(`File opened: ${path} (ID: ${fileIdStr})`); return handle; } catch (error) { this.logger.error(`Error opening file ${path}:`, error); throw error; } } /** * Close a file */ public async closeFile(fileId: SMB2FileId): Promise { try { const fileIdStr = fileIdToString(fileId); const handle = this.openFiles.get(fileIdStr); if (!handle) { this.logger.warn(`Attempted to close non-existent file: ${fileIdStr}`); return; } // Close file descriptor if open if (handle.fdHandle !== undefined) { await fs.close(handle.fdHandle); } this.openFiles.delete(fileIdStr); this.logger.debug(`File closed: ${handle.smbPath} (ID: ${fileIdStr})`); } catch (error) { this.logger.error(`Error closing file:`, error); throw error; } } /** * Read data from file */ public async readFile( fileId: SMB2FileId, offset: bigint, length: number ): Promise { try { const fileIdStr = fileIdToString(fileId); const handle = this.openFiles.get(fileIdStr); if (!handle) { throw new Error('File not open'); } if (handle.isDirectory) { throw new Error('Cannot read from directory'); } // Check access rights if (!(handle.desiredAccess & ACCESS_MASK.FILE_READ_DATA)) { throw new Error('Read access denied'); } let data: Buffer; if (handle.fdHandle !== undefined) { // Read using file descriptor const buffer = Buffer.alloc(length); const result = await fs.read(handle.fdHandle, buffer, 0, length, Number(offset)); data = buffer.subarray(0, result.bytesRead); } else { // Read entire file and slice const fileData = await fs.readFile(handle.localPath); const start = Number(offset); const end = Math.min(start + length, fileData.length); data = fileData.subarray(start, end); } // Update access time await this.updateAccessTime(handle.localPath); this.logger.debug(`Read ${data.length} bytes from ${handle.smbPath} at offset ${offset}`); return data; } catch (error) { this.logger.error(`Error reading file:`, error); throw error; } } /** * Write data to file */ public async writeFile( fileId: SMB2FileId, offset: bigint, data: Buffer ): Promise { try { const fileIdStr = fileIdToString(fileId); const handle = this.openFiles.get(fileIdStr); if (!handle) { throw new Error('File not open'); } if (handle.isDirectory) { throw new Error('Cannot write to directory'); } // Check access rights if (!(handle.desiredAccess & ACCESS_MASK.FILE_WRITE_DATA)) { throw new Error('Write access denied'); } let bytesWritten: number; if (handle.fdHandle !== undefined) { // Write using file descriptor const result = await fs.write(handle.fdHandle, data, 0, data.length, Number(offset)); bytesWritten = result.bytesWritten; } else { // For now, just write the entire data (not ideal for large files) await fs.writeFile(handle.localPath, data); bytesWritten = data.length; } this.logger.debug(`Wrote ${bytesWritten} bytes to ${handle.smbPath} at offset ${offset}`); return bytesWritten; } catch (error) { this.logger.error(`Error writing file:`, error); throw error; } } /** * Query directory contents */ public async queryDirectory( fileId: SMB2FileId, pattern: string = '*' ): Promise { try { const fileIdStr = fileIdToString(fileId); const handle = this.openFiles.get(fileIdStr); if (!handle) { throw new Error('Directory not open'); } if (!handle.isDirectory) { throw new Error('Not a directory'); } const entries: DirectoryEntry[] = []; const files = await fs.readdir(handle.localPath, { withFileTypes: true }); for (const file of files) { // Simple pattern matching (only * wildcard) if (pattern !== '*' && !this.matchPattern(file.name, pattern)) { continue; } try { const filePath = join(handle.localPath, file.name); const stats = await fs.stat(filePath); const entry: DirectoryEntry = { name: file.name, isDirectory: file.isDirectory(), fileSize: BigInt(stats.size), creationTime: stats.birthtime, lastAccessTime: stats.atime, lastWriteTime: stats.mtime, changeTime: stats.ctime, fileAttributes: this.getFileAttributes(stats), }; entries.push(entry); } catch (error) { this.logger.warn(`Error getting stats for ${file.name}:`, error); continue; } } this.logger.debug(`Listed ${entries.length} entries from ${handle.smbPath}`); return entries; } catch (error) { this.logger.error(`Error querying directory:`, error); throw error; } } /** * Get file information */ public async getFileInfo(fileId: SMB2FileId): Promise<{ basic: FileBasicInformation; standard: FileStandardInformation; }> { try { const fileIdStr = fileIdToString(fileId); const handle = this.openFiles.get(fileIdStr); if (!handle) { throw new Error('File not open'); } // Get current stats const stats = await fs.stat(handle.localPath); const basic: FileBasicInformation = { creationTime: dateToFileTime(stats.birthtime), lastAccessTime: dateToFileTime(stats.atime), lastWriteTime: dateToFileTime(stats.mtime), changeTime: dateToFileTime(stats.ctime), fileAttributes: this.getFileAttributes(stats), reserved: 0, }; const standard: FileStandardInformation = { allocationSize: BigInt(Math.ceil(stats.size / 4096) * 4096), endOfFile: BigInt(stats.size), numberOfLinks: stats.nlink, deletePending: false, directory: stats.isDirectory(), reserved: 0, }; return { basic, standard }; } catch (error) { this.logger.error(`Error getting file info:`, error); throw error; } } /** * Set file information */ public async setFileInfo( fileId: SMB2FileId, info: Partial ): Promise { try { const fileIdStr = fileIdToString(fileId); const handle = this.openFiles.get(fileIdStr); if (!handle) { throw new Error('File not open'); } // Check access rights if (!(handle.desiredAccess & ACCESS_MASK.FILE_WRITE_ATTRIBUTES)) { throw new Error('Write attributes access denied'); } // Update timestamps if provided if (info.lastAccessTime || info.lastWriteTime) { const atime = info.lastAccessTime ? fileTimeToDate(info.lastAccessTime) : undefined; const mtime = info.lastWriteTime ? fileTimeToDate(info.lastWriteTime) : undefined; if (atime || mtime) { await fs.utimes(handle.localPath, atime || new Date(), mtime || new Date()); } } // Note: File attributes setting would require platform-specific implementation this.logger.debug(`File info updated: ${handle.smbPath}`); } catch (error) { this.logger.error(`Error setting file info:`, error); throw error; } } /** * Delete a file or directory */ public async deleteFile(shareName: string, path: string): Promise { try { if (!validateSmbPath(path)) { throw new Error('Invalid path'); } const share = this.shares.get(shareName); if (!share) { throw new Error(`Share not found: ${shareName}`); } const localPath = smbPathToLocal(path, share.path); const stats = await fs.stat(localPath); if (stats.isDirectory()) { await fs.rmdir(localPath); } else { await fs.unlink(localPath); } this.logger.info(`Deleted: ${path}`); } catch (error) { this.logger.error(`Error deleting ${path}:`, error); throw error; } } /** * Create directory */ public async createDirectory(shareName: string, path: string): Promise { try { if (!validateSmbPath(path)) { throw new Error('Invalid path'); } const share = this.shares.get(shareName); if (!share) { throw new Error(`Share not found: ${shareName}`); } const localPath = smbPathToLocal(path, share.path); await fs.mkdir(localPath, { recursive: true }); this.logger.info(`Directory created: ${path}`); } catch (error) { this.logger.error(`Error creating directory ${path}:`, error); throw error; } } /** * Get file handle by ID */ public getFileHandle(fileId: SMB2FileId): FileHandle | undefined { return this.openFiles.get(fileIdToString(fileId)); } /** * Get all open files */ public getOpenFiles(): FileHandle[] { return Array.from(this.openFiles.values()); } /** * Load share configurations */ private loadShares(): void { for (const share of this.config.shares) { this.shares.set(share.name, share); this.logger.debug(`Loaded share: ${share.name} -> ${share.path}`); } this.logger.info(`Loaded ${this.shares.size} shares`); } /** * Handle create disposition logic */ private async handleCreateDisposition( localPath: string, exists: boolean, disposition: CREATE_DISPOSITION, createOptions: CREATE_OPTIONS ): Promise { switch (disposition) { case CREATE_DISPOSITION.SUPERSEDE: if (exists) { await fs.unlink(localPath); } return true; case CREATE_DISPOSITION.OPEN: if (!exists) { throw new Error('File does not exist'); } return false; case CREATE_DISPOSITION.CREATE: if (exists) { throw new Error('File already exists'); } return true; case CREATE_DISPOSITION.OPEN_IF: return !exists; case CREATE_DISPOSITION.OVERWRITE: if (!exists) { throw new Error('File does not exist'); } return true; case CREATE_DISPOSITION.OVERWRITE_IF: return true; default: throw new Error(`Unsupported create disposition: ${disposition}`); } } /** * Create file or directory based on options */ private async createFileOrDirectory( localPath: string, createOptions: CREATE_OPTIONS, fileAttributes: FILE_ATTRIBUTES ): Promise { // Ensure parent directory exists const parentDir = dirname(localPath); await fs.mkdir(parentDir, { recursive: true }); if (createOptions & CREATE_OPTIONS.DIRECTORY_FILE) { await fs.mkdir(localPath); } else { // Create empty file await fs.writeFile(localPath, Buffer.alloc(0)); } } /** * Get file attributes from stats */ private getFileAttributes(stats: Stats): FILE_ATTRIBUTES { let attributes = FILE_ATTRIBUTES.NORMAL; if (stats.isDirectory()) { attributes |= FILE_ATTRIBUTES.DIRECTORY; } // Add other attributes based on file properties // This is a simplified implementation return attributes; } /** * Check if file descriptor is needed */ private needsFileDescriptor(access: ACCESS_MASK): boolean { return !!(access & (ACCESS_MASK.FILE_READ_DATA | ACCESS_MASK.FILE_WRITE_DATA)); } /** * Get file flags for fs.open */ private getFileFlags(access: ACCESS_MASK, disposition: CREATE_DISPOSITION): string { let flags = ''; const canRead = !!(access & ACCESS_MASK.FILE_READ_DATA); const canWrite = !!(access & ACCESS_MASK.FILE_WRITE_DATA); if (canRead && canWrite) { flags = 'r+'; } else if (canWrite) { flags = 'w'; } else { flags = 'r'; } return flags; } /** * Update file access time */ private async updateAccessTime(localPath: string): Promise { try { const now = new Date(); const stats = await fs.stat(localPath); await fs.utimes(localPath, now, stats.mtime); } catch { // Ignore errors when updating access time } } /** * Simple pattern matching (supports * wildcard only) */ private matchPattern(filename: string, pattern: string): boolean { if (pattern === '*') { return true; } // Convert pattern to regex const regexPattern = pattern .replace(/\./g, '\\.') .replace(/\*/g, '.*') .replace(/\?/g, '.'); const regex = new RegExp(`^${regexPattern}$`, 'i'); return regex.test(filename); } /** * Cleanup resources */ public async cleanup(): Promise { // Close all open file descriptors const closePromises = Array.from(this.openFiles.values()) .filter(handle => handle.fdHandle !== undefined) .map(handle => fs.close(handle.fdHandle!).catch(() => {})); await Promise.all(closePromises); this.openFiles.clear(); this.logger.info('File system provider cleanup completed'); } }