diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3eeaf6..e3eec3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ShortCircuitCondition` detector: PR [#202](https://github.com/nowarp/misti/pull/202) - `PreferredStdlibApi` detector now suggest some preferred replacements for cell methods - Add Callgraph: PR [#185](https://github.com/nowarp/misti/pull/185) +- Support for browser environment: PR [#231](https://github.com/nowarp/misti/pull/231) +- `souffleEnabled` option to disable Souffle check execution: PR [#231](https://github.com/nowarp/misti/pull/231) - Add function effects to Callgraph: PR [#227](https://github.com/nowarp/misti/pull/227) ### Changed diff --git a/examples/implicit-init/test/implicitInit.spec.ts b/examples/implicit-init/test/implicitInit.spec.ts index 3ed9aad9..c8d309aa 100644 --- a/examples/implicit-init/test/implicitInit.spec.ts +++ b/examples/implicit-init/test/implicitInit.spec.ts @@ -1,7 +1,9 @@ import { Driver, MistiResultWarnings } from "../../../src/cli"; import path from "path"; +import { createNodeFileSystem } from "../../../src/vfs/createNodeFileSystem"; describe("ImplicitInit Detector Tests", () => { + const fs = createNodeFileSystem(process.cwd()); it("should detect an issue in the sample contract", async () => { const tactConfigPath = path.resolve( __dirname, @@ -9,7 +11,7 @@ describe("ImplicitInit Detector Tests", () => { "tactConfig.json", ); const config = path.resolve(__dirname, "project", "mistiConfig.json"); - const driver = await Driver.create([tactConfigPath], { config }); + const driver = await Driver.create([tactConfigPath], { config, fs }); expect(driver.detectors.length).toBe(1); expect(driver.detectors[0].id).toBe("ImplicitInit"); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 33b9f5bb..242bcbcd 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -13,6 +13,7 @@ import { ResultReport, } from "./result"; import { Logger } from "../internals/logger"; +import { createNodeFileSystem } from "../vfs/createNodeFileSystem"; import { Command } from "commander"; /** @@ -56,7 +57,10 @@ export async function runMistiCommand( command: Command = createMistiCommand(), ): Promise<[Driver, MistiResult]> { await command.parseAsync(args, { from: "user" }); - const driver = await Driver.create(command.args, command.opts()); + const driver = await Driver.create(command.args, { + ...command.opts(), + fs: createNodeFileSystem(process.cwd()), + }); const result = await driver.execute(); return [driver, result]; } diff --git a/src/cli/driver.ts b/src/cli/driver.ts index 5efeb0b9..d4536a09 100644 --- a/src/cli/driver.ts +++ b/src/cli/driver.ts @@ -17,11 +17,10 @@ import { Severity, } from "../internals/warnings"; import { Tool, findBuiltInTool } from "../tools/tool"; -import fs from "fs"; +import { VirtualFileSystem } from "../vfs/virtualFileSystem"; import ignore from "ignore"; import JSONbig from "json-bigint"; import path from "path"; -import { setTimeout } from "timers/promises"; /** * Manages the initialization and execution of detectors for analyzing compilation units. @@ -33,6 +32,7 @@ export class Driver { outputPath: string; disabledDetectors: Set; colorizeOutput: boolean; + fs: VirtualFileSystem; /** * Compilation units representing the actual entrypoints of the analysis targets * based on user's input. Might be empty if no paths are specified. @@ -43,6 +43,7 @@ export class Driver { outputFormat: OutputFormat; private constructor(tactPaths: string[], options: CLIOptions) { + this.fs = options.fs; this.ctx = new MistiContext(options); this.cus = this.createCUs(tactPaths); this.disabledDetectors = new Set(options.disabledDetectors ?? []); @@ -81,7 +82,7 @@ export class Driver { private createCUs(tactPaths: string[]): Map { return [...new Set(tactPaths)] .reduce((acc, tactPath) => { - if (fs.statSync(tactPath).isDirectory()) { + if (this.fs.stat(tactPath).isDirectory()) { const tactFiles = this.collectTactFiles(tactPath); this.ctx.logger.debug( `Collected Tact files from ${tactPath}:\n${tactFiles.map((tactFile) => "- " + tactFile).join("\n")}`, @@ -94,7 +95,7 @@ export class Driver { }, [] as string[]) .filter( (tactPath) => - fs.existsSync(tactPath) || + this.fs.exists(tactPath) || (this.ctx.logger.error(`${tactPath} is not available`), false), ) .reduce((acc, tactPath) => { @@ -103,7 +104,7 @@ export class Driver { let configManager: TactConfigManager; if (tactPath.endsWith(".tact")) { importGraph = ImportGraphBuilder.make(this.ctx, [tactPath]).build(); - let projectRoot = importGraph.resolveProjectRoot(); + let projectRoot = importGraph.resolveProjectRoot(this.fs); if (projectRoot === undefined) { projectRoot = path.dirname(tactPath); this.ctx.logger.warn( @@ -116,6 +117,7 @@ export class Driver { projectRoot, tactPath, projectName, + this.fs, ); const projectConfig = configManager.findProjectByName(projectName); if (projectConfig === undefined) { @@ -126,7 +128,12 @@ export class Driver { ].join("\n"), ); } - const ast = parseTactProject(this.ctx, projectConfig, projectRoot); + const ast = parseTactProject( + this.ctx, + projectConfig, + projectRoot, + this.fs, + ); const cu = createIR(this.ctx, projectName, ast, importGraph); acc.set(projectName, cu); } else { @@ -141,6 +148,7 @@ export class Driver { this.ctx, configProject, configManager.getProjectRoot(), + this.fs, ); const projectName = configProject.name as ProjectName; const cu = createIR(this.ctx, projectName, ast, importGraph); @@ -158,20 +166,20 @@ export class Driver { */ private collectTactFiles(dir: string): string[] { let results: string[] = []; - const files = fs.readdirSync(dir); + const files = this.fs.readdir(dir); // If .gitignore exists, use it to ignore files - const gitignorePath = findGitignore(dir); + const gitignorePath = findGitignore(dir, this.fs); let ig = ignore(); if (gitignorePath) { - ig = ignore().add(fs.readFileSync(gitignorePath, "utf8")); + ig = ignore().add(this.fs.readFile(gitignorePath).toString("utf8")); } files.forEach((file) => { const fullPath = path.join(dir, file); const relativePath = path.relative(dir, fullPath); if (!ig.ignores(relativePath) && !fullPath.includes("node_modules")) { - if (fs.statSync(fullPath).isDirectory()) { + if (this.fs.stat(fullPath).isDirectory()) { results = results.concat(this.collectTactFiles(fullPath)); } else if (file.endsWith(".tact")) { results.push(fullPath); @@ -622,9 +630,18 @@ export class Driver { `${cu.projectName}: Running ${detector.id} for ${cu.projectName}`, ); try { + // Conditional import for setTimeout to support both Node.js and browser environments + let setTimeoutPromise: (ms: number, value?: any) => Promise; + + if (typeof window !== "undefined") { + setTimeoutPromise = (ms) => + new Promise((resolve) => setTimeout(resolve, ms)); + } else { + setTimeoutPromise = (await import("timers/promises")).setTimeout; + } const warnings = await Promise.race([ detector.check(cu), - setTimeout(MistiEnv.MISTI_TIMEOUT, []).then(() => { + setTimeoutPromise(MistiEnv.MISTI_TIMEOUT, []).then(() => { throw new Error( `Detector ${detector.id} timed out after ${MistiEnv.MISTI_TIMEOUT}ms`, ); @@ -668,11 +685,11 @@ export class Driver { * @param startDir The directory to start searching from. * @returns The path to the .gitignore file or null if not found. */ -function findGitignore(startDir: string): string | null { +function findGitignore(startDir: string, fs: VirtualFileSystem): string | null { let currentDir = startDir; while (currentDir !== path.parse(currentDir).root) { const gitignorePath = path.join(currentDir, ".gitignore"); - if (fs.existsSync(gitignorePath)) { + if (fs.exists(gitignorePath)) { return gitignorePath; } currentDir = path.dirname(currentDir); diff --git a/src/cli/options.ts b/src/cli/options.ts index a982c77d..3277116b 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -1,5 +1,7 @@ import { ToolConfig, OutputFormat } from "./types"; import { Severity, parseSeverity } from "../internals/warnings"; +import { createVirtualFileSystem } from "../vfs/createVirtualFileSystem"; +import { VirtualFileSystem } from "../vfs/virtualFileSystem"; import { Option } from "commander"; export const STDOUT_PATH = "-"; @@ -13,6 +15,7 @@ export interface CLIOptions { soufflePath: string; souffleBinary: string; souffleVerbose: boolean; + souffleEnabled: boolean; tactStdlibPath: string | undefined; verbose: boolean; quiet: boolean; @@ -23,6 +26,7 @@ export interface CLIOptions { config: string | undefined; newDetector: string | undefined; listDetectors: boolean; + fs: VirtualFileSystem; } export const cliOptionDefaults: Required = { @@ -34,6 +38,7 @@ export const cliOptionDefaults: Required = { soufflePath: "/tmp/misti/souffle", souffleBinary: "souffle", souffleVerbose: false, + souffleEnabled: true, tactStdlibPath: undefined, verbose: false, quiet: false, @@ -44,6 +49,7 @@ export const cliOptionDefaults: Required = { config: undefined, newDetector: undefined, listDetectors: false, + fs: createVirtualFileSystem("/", {}), }; export const cliOptions = [ diff --git a/src/createDetector.ts b/src/createDetector.ts index 057ea836..f038a886 100644 --- a/src/createDetector.ts +++ b/src/createDetector.ts @@ -3,7 +3,7 @@ * * @packageDocumentation */ -import fs from "fs-extra"; +import { createNodeFileSystem } from "./vfs/createNodeFileSystem"; import path from "path"; const TEMPLATE_PATH = path.join( @@ -49,6 +49,7 @@ function getFileInfo(str: string): [string, string] { * @return true if the detector was successfully created, false otherwise. */ export async function createDetector(nameOrPath: string): Promise { + const fs = createNodeFileSystem(process.cwd()); const [dir, detectorName] = getFileInfo(nameOrPath); if (!isCamelCase(detectorName)) { console.error( @@ -57,17 +58,17 @@ export async function createDetector(nameOrPath: string): Promise { return false; } const filepath = path.join(dir, `${detectorName}.ts`); - if (await fs.pathExists(filepath)) { + if (fs.exists(filepath)) { console.error(`File already exists at ${filepath}`); return false; } try { - const templateContent = await fs.readFile(TEMPLATE_PATH, "utf8"); + const templateContent = fs.readFile(TEMPLATE_PATH).toString("utf8"); const content = templateContent.replace( /__ClassName__/g, capitalize(detectorName), ); - await fs.outputFile(filepath, content); + fs.writeFile(filepath, content); console.log( [ `Created ${filepath}\n`, diff --git a/src/internals/config.ts b/src/internals/config.ts index d08b3277..288ccbf7 100644 --- a/src/internals/config.ts +++ b/src/internals/config.ts @@ -5,7 +5,8 @@ import { getEnabledDetectors, DetectorName, } from "../detectors/detector"; -import * as fs from "fs"; +import { createNodeFileSystem } from "../vfs/createNodeFileSystem"; +import { VirtualFileSystem } from "../vfs/virtualFileSystem"; import { z } from "zod"; const DetectorConfigSchema = z.object({ @@ -57,22 +58,26 @@ export class MistiConfig { public tactStdlibPath?: string; public unusedPrefix: string; public verbosity: "quiet" | "debug" | "default"; + public fs: VirtualFileSystem; constructor({ configPath = undefined, detectors = undefined, tools = undefined, allDetectors = false, + fs = createNodeFileSystem(process.cwd()), }: Partial<{ configPath?: string; detectors?: string[]; tools?: ToolConfig[]; allDetectors: boolean; + fs: VirtualFileSystem; }> = {}) { let configData; + this.fs = fs; if (configPath) { try { - const configFileContents = fs.readFileSync(configPath, "utf8"); + const configFileContents = fs.readFile(configPath).toString("utf8"); configData = JSON.parse(configFileContents); } catch (err) { if (err instanceof Error) { diff --git a/src/internals/context.ts b/src/internals/context.ts index 108e72d9..82afca21 100644 --- a/src/internals/context.ts +++ b/src/internals/context.ts @@ -20,15 +20,17 @@ export class MistiContext { * Initializes the context for Misti, setting up configuration and appropriate logger. */ constructor(options: CLIOptions = cliOptionDefaults) { - this.souffleAvailable = this.checkSouffleInstallation( - options.souffleBinary, - ); + this.souffleAvailable = options.souffleEnabled + ? this.checkSouffleInstallation(options.souffleBinary) + : false; + try { this.config = new MistiConfig({ detectors: options.enabledDetectors, tools: options.tools, allDetectors: options.allDetectors, configPath: options.config, + fs: options.fs, }); } catch (err) { throwZodError(err, { diff --git a/src/internals/ir/builders/imports.ts b/src/internals/ir/builders/imports.ts index 1cc41fb0..390a1ceb 100644 --- a/src/internals/ir/builders/imports.ts +++ b/src/internals/ir/builders/imports.ts @@ -22,7 +22,6 @@ import { } from "@tact-lang/compiler/dist/grammar/ast"; import { ItemOrigin } from "@tact-lang/compiler/dist/grammar/grammar"; import tactGrammar from "@tact-lang/compiler/dist/grammar/grammar.ohm-bundle"; -import fs from "fs"; import { Node, NonterminalNode } from "ohm-js"; import path from "path"; @@ -66,7 +65,7 @@ export class ImportGraphBuilder { let fileContent = ""; try { - fileContent = fs.readFileSync(filePath, "utf8"); + fileContent = this.ctx.config.fs.readFile(filePath).toString("utf8"); } catch { this.ctx.logger.warn( `Cannot find imported file: ${filePath}. The analysis might not work.`, diff --git a/src/internals/ir/imports.ts b/src/internals/ir/imports.ts index 59525cca..79c76ad1 100644 --- a/src/internals/ir/imports.ts +++ b/src/internals/ir/imports.ts @@ -1,4 +1,5 @@ import { IdxGenerator } from "./indices"; +import { VirtualFileSystem } from "../../vfs/virtualFileSystem"; import { SrcInfo } from "@tact-lang/compiler/dist/grammar/ast"; import { ItemOrigin } from "@tact-lang/compiler/dist/grammar/grammar"; import path from "path"; @@ -95,9 +96,10 @@ export class ImportGraph { * Resolves project root based on the import directives. * The project root is a directory including all the imported files. * + * @param fs The virtual file system used to manage and resolve file paths during the operation. * @returns Project root directory or undefined if there are no user imports. */ - public resolveProjectRoot(): string | undefined { + public resolveProjectRoot(fs: VirtualFileSystem): string | undefined { let projectRoot: string | undefined; this.nodes.forEach((node) => { if (node.origin === "user") { @@ -113,7 +115,7 @@ export class ImportGraph { } } }); - return projectRoot ? path.resolve(projectRoot) : undefined; + return projectRoot ? fs.resolve(projectRoot) : undefined; } /** diff --git a/src/internals/tact/config.ts b/src/internals/tact/config.ts index 1fe4a499..87de8c8c 100644 --- a/src/internals/tact/config.ts +++ b/src/internals/tact/config.ts @@ -1,3 +1,4 @@ +import { VirtualFileSystem } from "../../vfs/virtualFileSystem"; import { ExecutionException, throwZodError } from "../exceptions"; import { ProjectName } from "../ir"; import { @@ -45,6 +46,7 @@ export class TactConfigManager { * @param ctx Misti context. * @param projectName Name of the project. * @param contractPath Path to the Tact contract. + * @param vfs Virtual file system to manage interactions with the project files. */ public static fromContract( projectRoot: string, @@ -53,12 +55,16 @@ export class TactConfigManager { contractPath, ".tact", ) as ProjectName, + vfs: VirtualFileSystem, ): TactConfigManager { + const absoluteProjectRoot = vfs.resolve(projectRoot); + const absoluteContractPath = path.resolve(projectRoot, contractPath); + const tactConfig: TactConfig = { projects: [ { name: projectName, - path: path.relative(projectRoot, contractPath), + path: path.relative(absoluteProjectRoot, absoluteContractPath), output: "/tmp/misti/output", // never used options: { debug: false, diff --git a/src/internals/tact/parser.ts b/src/internals/tact/parser.ts index 1b60bcde..6c065854 100644 --- a/src/internals/tact/parser.ts +++ b/src/internals/tact/parser.ts @@ -1,13 +1,19 @@ -import { getStdlibPath } from "./stdlib"; +import { VirtualFileSystem } from "../../vfs/virtualFileSystem"; import { MistiContext } from "../context"; import { TactException } from "../exceptions"; +import { getStdlibPath } from "./stdlib"; +import { createNodeFileSystem } from "../../vfs/createNodeFileSystem"; +import { + createVirtualFileSystem, + VirtualFileSystem as TactVirtualFileSystem, +} from "@tact-lang/compiler"; import { ConfigProject } from "@tact-lang/compiler/dist/config/parseConfig"; import { CompilerContext } from "@tact-lang/compiler/dist/context"; import { getRawAST } from "@tact-lang/compiler/dist/grammar/store"; import { AstStore } from "@tact-lang/compiler/dist/grammar/store"; +import stdLibFiles from "@tact-lang/compiler/dist/imports/stdlib"; import { enableFeatures } from "@tact-lang/compiler/dist/pipeline/build"; import { precompile } from "@tact-lang/compiler/dist/pipeline/precompile"; -import { createNodeFileSystem } from "@tact-lang/compiler/dist/vfs/createNodeFileSystem"; /** * Parses the project defined in the Tact configuration file, generating its AST. @@ -15,21 +21,30 @@ import { createNodeFileSystem } from "@tact-lang/compiler/dist/vfs/createNodeFil * @param mistiCtx Misti context * @param projectRoot Absolute path to the root the project * @param config The Tact configuration object: contents of the existing file or a generated object + * @param vfs Virtual file system to manage file interactions during parsing * @returns A mapping of project names to their corresponding ASTs. */ export function parseTactProject( mistiCtx: MistiContext, projectConfig: ConfigProject, projectRoot: string, + vfs: VirtualFileSystem, ): AstStore | never { - const project = createNodeFileSystem(projectRoot, false); const stdlibPath = mistiCtx.config.tactStdlibPath ?? getStdlibPath(); - const stdlib = createNodeFileSystem(stdlibPath, false); + let stdlib: VirtualFileSystem | TactVirtualFileSystem; + + if (vfs.type === "local") { + stdlib = createNodeFileSystem(stdlibPath); + vfs = createNodeFileSystem(projectRoot); + } else { + stdlib = createVirtualFileSystem("@stdlib", stdLibFiles); + } + mistiCtx.logger.debug(`Parsing project ${projectConfig.name} ...`); try { let ctx = new CompilerContext(); ctx = enableFeatures(ctx, mistiCtx.logger, projectConfig); - ctx = precompile(ctx, project, stdlib, projectConfig.path); + ctx = precompile(ctx, vfs, stdlib, projectConfig.path); return getRawAST(ctx); } catch (error: unknown) { throw TactException.make(error); diff --git a/src/internals/tact/stdlib.ts b/src/internals/tact/stdlib.ts index c31e307f..98e2814f 100644 --- a/src/internals/tact/stdlib.ts +++ b/src/internals/tact/stdlib.ts @@ -42,5 +42,9 @@ export function definedInStdlib( ? DEFAULT_STDLIB_PATH_ELEMENTS : stdlibPath.split("/").filter((part) => part !== ""); const filePath = typeof locOrPath === "string" ? locOrPath : locOrPath.file; - return filePath !== null && hasSubdirs(filePath, pathElements); + + return ( + filePath !== null && + (filePath.startsWith("@stdlib") || hasSubdirs(filePath, pathElements)) + ); } diff --git a/src/vfs/createNodeFileSystem.ts b/src/vfs/createNodeFileSystem.ts new file mode 100644 index 00000000..8c25d848 --- /dev/null +++ b/src/vfs/createNodeFileSystem.ts @@ -0,0 +1,125 @@ +import { VirtualFileSystem, FileStat } from "./virtualFileSystem"; +import { InternalException } from "../internals/exceptions"; +import fs from "fs"; +import path from "path"; + +/** + * Creates a Virtual File System backed by the local file system. + * This file system interacts directly with the host's disk storage. + * + * @param root - The root directory for the virtual file system. + * @param readonly - If true, prevents write operations. Default is true. + * @returns A VirtualFileSystem instance with local file system operations. + */ +export function createNodeFileSystem( + root: string, + readonly: boolean = true, +): VirtualFileSystem { + let normalizedRoot = path.normalize(root); + if (!normalizedRoot.endsWith(path.sep)) { + normalizedRoot += path.sep; + } + + return { + /** + * The normalized root directory for the virtual file system. + */ + root: normalizedRoot, + + /** + * The type of the virtual file system. In this case, it is "local". + */ + type: "local", + + /** + * Checks if a file or directory exists at the specified path. + * + * @param filePath - The path to check existence for. + * @returns True if the file or directory exists, otherwise false. + */ + exists(filePath: string): boolean { + const resolvedPath = this.resolve(filePath); + return fs.existsSync(resolvedPath); + }, + + /** + * Resolves a given path to an absolute path within the virtual file system's root. + * + * @param filePath - One or more path segments to resolve. + * @returns The resolved absolute path. + */ + resolve(...filePath: string[]): string { + return path.normalize(path.resolve(normalizedRoot, ...filePath)); + }, + + /** + * Reads a file from the virtual file system. + * + * @param filePath - The path of the file to read. + * @returns A Buffer containing the file's content. + */ + readFile(filePath: string): Buffer { + const resolvedPath = this.resolve(filePath); + return fs.readFileSync(resolvedPath); + }, + + /** + * Writes content to a file in the virtual file system. + * Creates necessary directories if they do not exist. + * + * @param filePath - The path of the file to write to. + * @param content - The content to write, as a Buffer or string. + * @throws An exception if the file system is in readonly mode. + */ + writeFile(filePath: string, content: Buffer | string): void { + if (readonly) { + throw InternalException.make( + `Cannot write to file "${filePath}": The file system is in readonly mode.`, + ); + } + const resolvedPath = this.resolve(filePath); + // Ensure the directory exists + const dir = path.dirname(resolvedPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(resolvedPath, content); + }, + + /** + * Reads the contents of a directory in the virtual file system. + * + * @param dirPath - The path of the directory to read. + * @returns An array of filenames in the directory. + * @throws An error if the specified path is not a directory. + */ + readdir(dirPath: string): string[] { + const resolvedPath = this.resolve(dirPath); + if (!fs.statSync(resolvedPath).isDirectory()) { + throw InternalException.make( + `Path '${resolvedPath}' is not a directory`, + ); + } + return fs.readdirSync(resolvedPath); + }, + + /** + * Retrieves the statistics of a file or directory. + * + * @param filePath - The path of the file or directory. + * @returns An object containing file/directory metadata. + */ + stat(filePath: string): FileStat { + const resolvedPath = this.resolve(filePath); + const stats = fs.statSync(resolvedPath); + + return { + isFile: () => stats.isFile(), + isDirectory: () => stats.isDirectory(), + size: stats.size, + createdAt: stats.birthtime, + updatedAt: stats.mtime, + }; + }, + }; +} diff --git a/src/vfs/createVirtualFileSystem.ts b/src/vfs/createVirtualFileSystem.ts new file mode 100644 index 00000000..84bed926 --- /dev/null +++ b/src/vfs/createVirtualFileSystem.ts @@ -0,0 +1,146 @@ +import { + FileStat, + FileSystemTree, + VirtualFileSystem, +} from "./virtualFileSystem"; +import { InternalException } from "../internals/exceptions"; +import path from "path"; + +/** + * Creates a virtual file system for managing files only (no directories). + * This file system is entirely in-memory and does not interact with the host file system. + * + * @param root - The root directory for the virtual file system. + * @param fileSystemTree - The initial structure of the in-memory file system. Default is an empty object. + * @param readonly - If true, prevents write operations. Default is true. + * @returns A VirtualFileSystem instance for managing in-memory files. + */ +export function createVirtualFileSystem( + root: string, + fileSystemTree: FileSystemTree = {}, + readonly: boolean = true, +): VirtualFileSystem { + let normalizedRoot = path.normalize(root); + if (!normalizedRoot.endsWith(path.sep)) { + normalizedRoot += path.sep; + } + + const memoryFS = fileSystemTree; + + return { + /** + * The normalized root directory for the virtual file system. + */ + root: normalizedRoot, + + /** + * The type of the virtual file system. In this case, it is "inMemory". + */ + type: "inMemory", + + /** + * Checks if a file exists at the specified path. + * + * @param filePath - The path to check existence for. + * @returns True if the file exists, otherwise false. + */ + exists(filePath: string): boolean { + const resolvedPath = this.resolve(filePath); + return resolvedPath in memoryFS; + }, + + /** + * Resolves a given path to an absolute path within the virtual file system's root. + * + * @param filePath - One or more path segments to resolve. + * @returns The resolved absolute path. + */ + resolve(...filePath: string[]): string { + return path.normalize(path.resolve(normalizedRoot, ...filePath)); + }, + + /** + * Reads a file from the virtual file system. + * + * @param filePath - The path of the file to read. + * @returns A Buffer containing the file's content. + * @throws An error if the file does not exist or is not a file. + */ + readFile(filePath: string): Buffer { + const resolvedPath = this.resolve(filePath); + const file = memoryFS[resolvedPath]; + + if (!file || file.type !== "file") { + throw InternalException.make( + `File '${resolvedPath}' does not exist or is not a file`, + ); + } + + return Buffer.from(file.content, "utf-8"); + }, + + /** + * Writes content to a file in the virtual file system. + * + * @param filePath - The path of the file to write to. + * @param content - The content to write, as a Buffer or string. + * @throws An error if the file system is in readonly mode. + */ + writeFile(filePath: string, content: Buffer | string): void { + if (readonly) { + throw InternalException.make( + `Cannot write to file "${filePath}": The file system is in readonly mode.`, + ); + } + const resolvedPath = this.resolve(filePath); + + // Write the file + memoryFS[resolvedPath] = { + type: "file", + content: content.toString(), + size: + typeof content === "string" + ? Buffer.byteLength(content) + : content.length, + createdAt: memoryFS[resolvedPath]?.createdAt || new Date(), + updatedAt: new Date(), + }; + }, + + /** + * Lists all file names in the virtual file system. + * + * @returns An array of file names. + */ + readdir(): string[] { + // List all file names in the memory file system + return Object.keys(memoryFS).filter( + (key) => memoryFS[key].type === "file", + ); + }, + + /** + * Retrieves the statistics of a file. + * + * @param filePath - The path of the file. + * @returns An object containing file metadata such as size, creation date, and update date. + * @throws An error if the file does not exist. + */ + stat(filePath: string): FileStat { + const resolvedPath = this.resolve(filePath); + const node = memoryFS[resolvedPath]; + + if (!node) { + throw InternalException.make(`File '${resolvedPath}' does not exist`); + } + + return { + isFile: () => node.type === "file", + isDirectory: () => false, // No directories in this implementation + size: node.size ?? 0, + createdAt: node.createdAt ?? new Date(), + updatedAt: node.updatedAt ?? new Date(), + }; + }, + }; +} diff --git a/src/vfs/virtualFileSystem.ts b/src/vfs/virtualFileSystem.ts new file mode 100644 index 00000000..0083bf6d --- /dev/null +++ b/src/vfs/virtualFileSystem.ts @@ -0,0 +1,39 @@ +type FileNode = { + type: "file"; + content: string; + size?: number; + createdAt?: Date; + updatedAt?: Date; +}; + +type DirectoryNode = { + type: "directory"; + size?: number; + createdAt?: Date; + updatedAt?: Date; +}; + +type FileSystemNode = FileNode | DirectoryNode; + +export type FileSystemTree = Record; + +export type FileStat = { + isFile: () => boolean; + isDirectory: () => boolean; + size: number; + createdAt: Date; + updatedAt: Date; +}; + +type FileSystemBackend = "local" | "inMemory"; + +export type VirtualFileSystem = { + root: string; + type: FileSystemBackend; + resolve(...path: string[]): string; + exists(path: string): boolean; + readFile(path: string): Buffer; + writeFile(path: string, content: Buffer | string): void; + readdir: (path: string) => string[]; + stat: (path: string) => FileStat; +}; diff --git a/test/config.spec.ts b/test/config.spec.ts index 9291166a..333b0e5f 100644 --- a/test/config.spec.ts +++ b/test/config.spec.ts @@ -1,7 +1,5 @@ import { MistiConfig } from "../src/internals/config"; -import * as fs from "fs"; - -jest.mock("fs"); +import { createVirtualFileSystem } from "../src/vfs/createVirtualFileSystem"; describe("Config class", () => { const MOCK_CONFIG_PATH = "./mistiConfig_mock.json"; @@ -13,13 +11,20 @@ describe("Config class", () => { ignoredProjects: ["ignoredProject"], }); + const fs = createVirtualFileSystem(process.cwd(), {}, false); + beforeEach(() => { jest.clearAllMocks(); }); it("should load and parse config file correctly", () => { - (fs.readFileSync as jest.Mock).mockReturnValue(MOCK_CONFIG_CONTENT); - const configInstance = new MistiConfig({ configPath: MOCK_CONFIG_PATH }); + fs.readFile = jest + .fn() + .mockReturnValue(Buffer.from(MOCK_CONFIG_CONTENT, "utf8")); + const configInstance = new MistiConfig({ + configPath: MOCK_CONFIG_PATH, + fs, + }); expect(configInstance.detectors).toEqual([ { className: "ReadOnlyVariables" }, { className: "ZeroAddress" }, @@ -28,10 +33,10 @@ describe("Config class", () => { }); it("throws an error when the config file cannot be read", () => { - (fs.readFileSync as jest.Mock).mockImplementation(() => { + fs.readFile = jest.fn().mockImplementation(() => { throw new Error("Failed to read file"); }); - expect(() => new MistiConfig({ configPath: MOCK_CONFIG_PATH })).toThrow( + expect(() => new MistiConfig({ configPath: MOCK_CONFIG_PATH, fs })).toThrow( "Failed to read file", ); }); @@ -43,8 +48,14 @@ describe("Config class", () => { { detector: "ReadOnlyVariables", position: "file.tact:10:5" }, ], }); - (fs.readFileSync as jest.Mock).mockReturnValue(configWithSuppressions); - const configInstance = new MistiConfig({ configPath: MOCK_CONFIG_PATH }); + fs.readFile = jest + .fn() + .mockReturnValue(Buffer.from(configWithSuppressions, "utf8")); + + const configInstance = new MistiConfig({ + configPath: MOCK_CONFIG_PATH, + fs, + }); expect(configInstance.suppressions).toEqual([ { detector: "ReadOnlyVariables", file: "file.tact", line: 10, col: 5 }, ]);