diff --git a/docs/CLI.md b/docs/CLI.md index 911d324a..59134652 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -2,6 +2,29 @@ GasGuard supports command-line autocompletion for `bash`, `zsh`, and `fish`. +## Scan resource limits + +Local scans check the number and combined size of supported source files before +starting analysis. The defaults are 10,000 files and 100 MiB of source. Files +under `node_modules`, `.git`, `target`, `dist`, and `build` are excluded by +default. Configure limits in `gasguard.config.json` or override them per run: + +```json +{ + "scan": { + "maxFiles": 10000, + "maxBytes": 104857600 + } +} +``` + +```sh +gasguard scan ./contracts --max-files 5000 --max-bytes 52428800 +``` + +Discovery stops as soon as a configured limit is exceeded. The CLI reports +`FILE_LIMIT_EXCEEDED` or `SIZE_LIMIT_EXCEEDED` and does not start analysis. + ## Installation To load completions in your current shell session, add the following to your profile (`~/.bashrc`, `~/.zshrc`, or your fish config): diff --git a/packages/cli/src/commands/scan.spec.ts b/packages/cli/src/commands/scan.spec.ts index 1f161014..0acc1f78 100644 --- a/packages/cli/src/commands/scan.spec.ts +++ b/packages/cli/src/commands/scan.spec.ts @@ -114,4 +114,42 @@ describe("runScan", () => { code: "SIZE_LIMIT_EXCEEDED", }); }); + + it("accepts repositories exactly at the file and source-byte limits", async () => { + const sourceFiles = ["contract A {}", "contract B {}"]; + await fs.writeFile(path.join(directory, "A.sol"), sourceFiles[0]); + await fs.writeFile(path.join(directory, "B.sol"), sourceFiles[1]); + const totalBytes = sourceFiles.reduce( + (total, source) => total + Buffer.byteLength(source), + 0, + ); + + await expect( + collectScannableFiles(directory, { + maxFiles: sourceFiles.length, + maxBytes: totalBytes, + }), + ).resolves.toHaveLength(sourceFiles.length); + }); + + it("rejects the first file or byte beyond the configured preflight limit", async () => { + const sourceFiles = ["contract A {}", "contract B {}"]; + await fs.writeFile(path.join(directory, "A.sol"), sourceFiles[0]); + await fs.writeFile(path.join(directory, "B.sol"), sourceFiles[1]); + const totalBytes = sourceFiles.reduce( + (total, source) => total + Buffer.byteLength(source), + 0, + ); + + await expect( + collectScannableFiles(directory, { maxFiles: sourceFiles.length - 1 }), + ).rejects.toMatchObject>({ + code: "FILE_LIMIT_EXCEEDED", + }); + await expect( + collectScannableFiles(directory, { maxBytes: totalBytes - 1 }), + ).rejects.toMatchObject>({ + code: "SIZE_LIMIT_EXCEEDED", + }); + }); }); diff --git a/packages/cli/src/commands/scan.ts b/packages/cli/src/commands/scan.ts index 4ac75eed..f391ad92 100644 --- a/packages/cli/src/commands/scan.ts +++ b/packages/cli/src/commands/scan.ts @@ -18,7 +18,7 @@ import type { Finding as EngineFinding, } from "../../../../libs/engine/core/analyzer-interface"; import { ScanWatcher } from "../../../../src/analysis/watch/watcher"; -import { glob } from "glob"; +import { globIterate } from "glob"; import { loadCliConfig } from "../config/config-loader"; import { finishProgress, @@ -132,7 +132,11 @@ export const scanCommand = new Command("scan") process.exitCode = 130; return; } - console.error(chalk.red(`Error during scan: ${error}`)); + const message = + error instanceof RepositoryAnalysisError + ? `${error.code}: ${error.message}` + : String(error); + console.error(chalk.red(`Error during scan: ${message}`)); process.exitCode = 1; } finally { process.removeListener("SIGINT", abort); @@ -298,16 +302,16 @@ export async function collectScannableFiles( const patterns = limits.include?.length ? limits.include : ["**/*.sol", "**/*.vy", "**/*.rs"]; - const discovered = await glob(patterns, { + const discovered = globIterate(patterns, { cwd: path.resolve(dirPath), absolute: true, nodir: true, ignore: [...defaultExcludes, ...(limits.exclude ?? [])], }); - for (const file of discovered - .filter((candidate) => extensions.includes(path.extname(candidate))) - .sort()) { + const seen = new Set(); + for await (const file of discovered) { checkCancelled(); + if (!extensions.includes(path.extname(file)) || seen.has(file)) continue; if (files.length >= maxFiles) { throw new RepositoryAnalysisError( `Repository exceeds the ${maxFiles} file limit.`, @@ -323,8 +327,9 @@ export async function collectScannableFiles( } totalBytes += fileStats.size; files.push(file); + seen.add(file); } - return files; + return files.sort(); } function asConfigObject(value: unknown): Record {