From d2d69a8bd1332cd15105e1b4b4e0efe7bcc103fc Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:53:04 +0800 Subject: [PATCH] fix: validate CLI configuration before scanning --- cli/cli.ts | 73 +++++++++++++++++++++++++++++-------------- docs/cli.md | 6 ++++ src/tests/cli.test.ts | 47 ++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 24 deletions(-) diff --git a/cli/cli.ts b/cli/cli.ts index e26046d..086c64a 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -347,9 +347,10 @@ async function loadConfig(args: ParsedArgs): Promise 0 ? 1 : 0; } -function validateConfig(config: CliConfig): string | null { - if (config.scanners) { - const validation = validateScanners(config.scanners); - if (validation) return validation; +function validateConfig(value: unknown): string | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return "Config must be a JSON object"; } - if (config.severity) { - const invalid = config.severity.find((item) => !isSeverity(item)); - if (invalid) return `Unknown severity: ${String(invalid)}`; + const config = value as Record; + const arrayKeys = [ + "scanners", "severity", "include", "exclude", "ignoredFolders", + "ignoredLargeMarkdownFrontmatterKeys", "ignoredLargeMarkdownPathPatterns", + "watchedTags", "ignoredProperties", + ]; + for (const key of arrayKeys) { + const field = config[key]; + if (field !== undefined && + (!Array.isArray(field) || field.some((item) => typeof item !== "string"))) { + return `${key} must be an array of strings`; + } + } + const numberKeys = [ + "largeMarkdownBytes", "largeAttachmentBytes", "duplicateHashMaxBytes", + "lowUsageTagThreshold", "emptyNoteWordThreshold", + ]; + for (const key of numberKeys) { + const field = config[key]; + if (field !== undefined && (typeof field !== "number" || + !Number.isFinite(field) || !Number.isInteger(field) || field < 0)) { + return `${key} must be a finite non-negative integer`; + } + } + if (config.baselinePath !== undefined && typeof config.baselinePath !== "string") { + return "baselinePath must be a string"; + } + if (config.scanners !== undefined) { + const error = validateScanners(config.scanners as string[]); + if (error) return error; + } + if (config.severity !== undefined) { + for (const severity of config.severity as string[]) { + if (!isSeverity(severity)) return `Unknown severity: ${severity}`; + } } - if (config.failOn && !isFailOn(config.failOn)) { + if (config.failOn !== undefined && !isFailOn(config.failOn)) { return `Unsupported failOn value: ${String(config.failOn)}`; } - if ( - config.ignoreUnresolvedNoteLinks !== undefined && - typeof config.ignoreUnresolvedNoteLinks !== "boolean" - ) { + if (config.ignoreUnresolvedNoteLinks !== undefined && + typeof config.ignoreUnresolvedNoteLinks !== "boolean") { return "ignoreUnresolvedNoteLinks must be a boolean"; } if (config.ignoredFoldersByScanner !== undefined) { - if ( - typeof config.ignoredFoldersByScanner !== "object" || - config.ignoredFoldersByScanner === null || - Array.isArray(config.ignoredFoldersByScanner) - ) { + const foldersByScanner = config.ignoredFoldersByScanner; + if (typeof foldersByScanner !== "object" || foldersByScanner === null || + Array.isArray(foldersByScanner)) { return "ignoredFoldersByScanner must be an object of scanner IDs to folder arrays"; } - for (const [scannerId, folders] of Object.entries(config.ignoredFoldersByScanner)) { + for (const [scannerId, folders] of Object.entries(foldersByScanner)) { if (!SCANNER_IDS.includes(scannerId as ScannerId)) { return `Unknown scanner in ignoredFoldersByScanner: ${scannerId}`; } - if ( - !Array.isArray(folders) || - folders.some((folder) => typeof folder !== "string") - ) { + if (!Array.isArray(folders) || folders.some((folder) => typeof folder !== "string")) { return `ignoredFoldersByScanner.${scannerId} must be an array of folder paths`; } } diff --git a/docs/cli.md b/docs/cli.md index 19cf4fe..b7112cd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -106,6 +106,12 @@ vinspect . --baseline .vault-inspector-baseline.json --fail-on new ## Configuration +Configuration must be a JSON object. Thresholds and the duplicate hash cap must +be finite non-negative integers; numeric strings are not accepted. List options +must be arrays of strings. Invalid configuration exits with code 2 before the +vault is scanned, even when a command-line option would override that field. +Unknown configuration keys are ignored. + Config files are JSON and use the same option names: ```json diff --git a/src/tests/cli.test.ts b/src/tests/cli.test.ts index 69a132a..8361fba 100644 --- a/src/tests/cli.test.ts +++ b/src/tests/cli.test.ts @@ -109,6 +109,53 @@ describe("runCli", () => { }); }); + it.each([ + ["[]", "Config must be a JSON object"], + ["null", "Config must be a JSON object"], + ["true", "Config must be a JSON object"], + ['"config"', "Config must be a JSON object"], + ['{"largeMarkdownBytes":"garbage"}', "largeMarkdownBytes must be a finite non-negative integer"], + ['{"largeMarkdownBytes":"10"}', "largeMarkdownBytes must be a finite non-negative integer"], + ['{"largeMarkdownBytes":1e999}', "largeMarkdownBytes must be a finite non-negative integer"], + ['{"largeAttachmentBytes":-1}', "largeAttachmentBytes must be a finite non-negative integer"], + ['{"duplicateHashMaxBytes":null}', "duplicateHashMaxBytes must be a finite non-negative integer"], + ['{"lowUsageTagThreshold":1.5}', "lowUsageTagThreshold must be a finite non-negative integer"], + ['{"emptyNoteWordThreshold":false}', "emptyNoteWordThreshold must be a finite non-negative integer"], + ['{"scanners":"large-files"}', "scanners must be an array of strings"], + ['{"severity":[0]}', "severity must be an array of strings"], + ['{"include":null}', "include must be an array of strings"], + ['{"exclude":[1]}', "exclude must be an array of strings"], + ['{"watchedTags":{}}', "watchedTags must be an array of strings"], + ['{"baselinePath":false}', "baselinePath must be a string"], + ['{"failOn":""}', "Unsupported failOn value: "], + ])("rejects invalid config %s before scanning", async (raw, message) => { + const dir = await mkdtemp(join(tmpdir(), "vi-invalid-config-")); + try { + const config = join(dir, "config.json"); + await writeFile(config, raw, "utf8"); + // This vault deliberately does not exist. Config validation must win. + const result = await runCli([join(dir, "missing-vault"), "--config", config]); + expect(result).toEqual({ exitCode: 2, stdout: "", stderr: `${message}\n` }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it.each([0, 10])("accepts integer config threshold %s without losing findings", async (threshold) => { + await withVault({ "Note.md": "x".repeat(30) }, async (vaultPath) => { + const config = join(vaultPath, "config.json"); + await writeFile(config, JSON.stringify({ + scanners: ["large-files"], largeMarkdownBytes: threshold, failOn: "none", + }), "utf8"); + const result = await runCli([vaultPath, "--config", config, "--format", "json"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout).issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ scannerId: "large-files", primaryPath: "Note.md" }), + ])); + }); + }); + it("preserves valid block links in the actual CLI bundle", async () => { await withVault({ "Source.md": "[[Target#^KNOWN-ID|Alias]]\n![[Target#^Known-id]]\n[[Target#^missing]]\n",