import fs from "fs" import os from "os" import path from "path" import { FileSystemEntry, FileSystemListResponse, FileSystemListingMetadata, WINDOWS_DRIVES_ROOT, } from "../api-types" interface FileSystemBrowserOptions { rootDir: string unrestricted?: boolean } interface DirectoryReadOptions { includeFiles: boolean formatPath: (entryName: string) => string formatAbsolutePath: (entryName: string) => string } const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i)) export class FileSystemBrowser { private readonly root: string private readonly unrestricted: boolean private readonly homeDir: string private readonly isWindows: boolean constructor(options: FileSystemBrowserOptions) { this.root = path.resolve(options.rootDir) this.unrestricted = Boolean(options.unrestricted) this.homeDir = os.homedir() this.isWindows = process.platform === "win32" } list(relativePath = ".", options: { includeFiles?: boolean } = {}): FileSystemEntry[] { if (this.unrestricted) { throw new Error("Relative listing is unavailable when running with unrestricted root") } const includeFiles = options.includeFiles ?? true const normalizedPath = this.normalizeRelativePath(relativePath) const absolutePath = this.toRestrictedAbsolute(normalizedPath) return this.readDirectoryEntries(absolutePath, { includeFiles, formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName), formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName), }) } browse(targetPath?: string, options: { includeFiles?: boolean } = {}): FileSystemListResponse { const includeFiles = options.includeFiles ?? true if (this.unrestricted) { return this.listUnrestricted(targetPath, includeFiles) } return this.listRestrictedWithMetadata(targetPath, includeFiles) } readFile(relativePath: string): string { if (this.unrestricted) { throw new Error("readFile is not available in unrestricted mode") } const resolved = this.toRestrictedAbsolute(relativePath) return fs.readFileSync(resolved, "utf-8") } private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse { const normalizedPath = this.normalizeRelativePath(relativePath) const absolutePath = this.toRestrictedAbsolute(normalizedPath) const entries = this.readDirectoryEntries(absolutePath, { includeFiles, formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName), formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName), }) const metadata: FileSystemListingMetadata = { scope: "restricted", currentPath: normalizedPath, parentPath: normalizedPath === "." ? undefined : this.getRestrictedParent(normalizedPath), rootPath: this.root, homePath: this.homeDir, displayPath: this.resolveRestrictedAbsolute(normalizedPath), pathKind: "relative", } return { entries, metadata } } private listUnrestricted(targetPath: string | undefined, includeFiles: boolean): FileSystemListResponse { const resolvedPath = this.resolveUnrestrictedPath(targetPath) if (this.isWindows && resolvedPath === WINDOWS_DRIVES_ROOT) { return this.listWindowsDrives() } const entries = this.readDirectoryEntries(resolvedPath, { includeFiles, formatPath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName), formatAbsolutePath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName), }) const parentPath = this.getUnrestrictedParent(resolvedPath) const metadata: FileSystemListingMetadata = { scope: "unrestricted", currentPath: resolvedPath, parentPath, rootPath: this.homeDir, homePath: this.homeDir, displayPath: resolvedPath, pathKind: "absolute", } return { entries, metadata } } private listWindowsDrives(): FileSystemListResponse { if (!this.isWindows) { throw new Error("Drive listing is only supported on Windows hosts") } const entries: FileSystemEntry[] = [] for (const letter of WINDOWS_DRIVE_LETTERS) { const drivePath = `${letter}:\\` try { if (fs.existsSync(drivePath)) { entries.push({ name: `${letter}:`, path: drivePath, absolutePath: drivePath, type: "directory", }) } } catch { // Ignore inaccessible drives } } // Provide a generic UNC root entry so users can navigate to network shares manually. entries.push({ name: "UNC Network", path: "\\\\", absolutePath: "\\\\", type: "directory", }) const metadata: FileSystemListingMetadata = { scope: "unrestricted", currentPath: WINDOWS_DRIVES_ROOT, parentPath: undefined, rootPath: this.homeDir, homePath: this.homeDir, displayPath: "Drives", pathKind: "drives", } return { entries, metadata } } private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] { const dirents = fs.readdirSync(directory, { withFileTypes: true }) const results: FileSystemEntry[] = [] for (const entry of dirents) { if (!options.includeFiles && !entry.isDirectory()) { continue } const absoluteEntryPath = path.join(directory, entry.name) let stats: fs.Stats try { stats = fs.statSync(absoluteEntryPath) } catch { // Skip entries we cannot stat (insufficient permissions, etc.) continue } const isDirectory = entry.isDirectory() if (!options.includeFiles && !isDirectory) { continue } results.push({ name: entry.name, path: options.formatPath(entry.name), absolutePath: options.formatAbsolutePath(entry.name), type: isDirectory ? "directory" : "file", size: isDirectory ? undefined : stats.size, modifiedAt: stats.mtime.toISOString(), }) } return results.sort((a, b) => a.name.localeCompare(b.name)) } private normalizeRelativePath(input: string | undefined) { if (!input || input === "." || input === "./" || input === "/") { return "." } let normalized = input.replace(/\\+/g, "/") if (normalized.startsWith("./")) { normalized = normalized.replace(/^\.\/+/, "") } if (normalized.startsWith("/")) { normalized = normalized.replace(/^\/+/g, "") } return normalized === "" ? "." : normalized } private buildRelativePath(parent: string, child: string) { if (!parent || parent === ".") { return this.normalizeRelativePath(child) } return this.normalizeRelativePath(`${parent}/${child}`) } private resolveRestrictedAbsolute(relativePath: string) { return this.toRestrictedAbsolute(relativePath) } private resolveRestrictedAbsoluteChild(parent: string, child: string) { const normalized = this.buildRelativePath(parent, child) return this.toRestrictedAbsolute(normalized) } private toRestrictedAbsolute(relativePath: string) { const normalized = this.normalizeRelativePath(relativePath) const target = path.resolve(this.root, normalized) const relativeToRoot = path.relative(this.root, target) if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) && relativeToRoot !== "") { throw new Error("Access outside of root is not allowed") } return target } private resolveUnrestrictedPath(input: string | undefined): string { if (!input || input === "." || input === "./") { return this.homeDir } if (this.isWindows) { if (input === WINDOWS_DRIVES_ROOT) { return WINDOWS_DRIVES_ROOT } const normalized = path.win32.normalize(input) if (/^[a-zA-Z]:/.test(normalized) || normalized.startsWith("\\\\")) { return normalized } return path.win32.resolve(this.homeDir, normalized) } if (input.startsWith("/")) { return path.posix.normalize(input) } return path.posix.resolve(this.homeDir, input) } private resolveAbsoluteChild(parent: string, child: string) { if (this.isWindows) { return path.win32.normalize(path.win32.join(parent, child)) } return path.posix.normalize(path.posix.join(parent, child)) } private getRestrictedParent(relativePath: string) { const normalized = this.normalizeRelativePath(relativePath) if (normalized === ".") { return undefined } const segments = normalized.split("/") segments.pop() return segments.length === 0 ? "." : segments.join("/") } private getUnrestrictedParent(currentPath: string) { if (this.isWindows) { const normalized = path.win32.normalize(currentPath) const parsed = path.win32.parse(normalized) if (normalized === WINDOWS_DRIVES_ROOT) { return undefined } if (normalized === parsed.root) { return WINDOWS_DRIVES_ROOT } return path.win32.dirname(normalized) } const normalized = path.posix.normalize(currentPath) if (normalized === "/") { return undefined } return path.posix.dirname(normalized) } }