diff --git a/scripts/gittensory-config-lint.ts b/scripts/gittensory-config-lint.ts index 1a708a2df2..3b83627cf6 100644 --- a/scripts/gittensory-config-lint.ts +++ b/scripts/gittensory-config-lint.ts @@ -2,9 +2,10 @@ // Wires up the previously-unwired src/selfhost/config-lint.ts validator (#2906): a self-hoster (or the // maintainer, dogfooding on JSONbored/gittensory) can now actually run it against a real .gittensory.yml or // private-config file and get actionable feedback, instead of the validator existing only in its own test suite. -import { existsSync, readFileSync } from "node:fs"; +import { lstatSync, readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { lintManifestText, type SelfHostConfigLintResult } from "../src/selfhost/config-lint"; +import { MAX_FOCUS_MANIFEST_BYTES } from "../src/signals/focus-manifest"; function usage(): string { return `Usage: npm run selfhost:config-lint -- [path] @@ -17,6 +18,24 @@ Options: path Manifest file to lint. Defaults to ".gittensory.yml" in the current directory.`; } +export function readManifestTextForLint(path: string): string { + let stat; + try { + stat = lstatSync(path); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + throw new Error(`no such file: ${path}`); + } + throw error; + } + if (stat.isSymbolicLink()) throw new Error(`refusing to read symlink: ${path}`); + if (!stat.isFile()) throw new Error(`not a regular file: ${path}`); + if (stat.size > MAX_FOCUS_MANIFEST_BYTES) { + throw new Error(`file exceeds ${MAX_FOCUS_MANIFEST_BYTES} bytes: ${path}`); + } + return readFileSync(path, "utf8"); +} + export function formatLintReport(path: string, result: SelfHostConfigLintResult): string { const lines = [`${path}: ${result.summary}`]; if (result.recognizedFields.length > 0) lines.push(` recognized fields: ${result.recognizedFields.join(", ")}`); @@ -32,11 +51,14 @@ function main(): void { return; } const path = args[0] ?? ".gittensory.yml"; - if (!existsSync(path)) { - console.error(`gittensory-config-lint: no such file: ${path}\n\n${usage()}`); + let text; + try { + text = readManifestTextForLint(path); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`gittensory-config-lint: ${message}\n\n${usage()}`); process.exit(1); } - const text = readFileSync(path, "utf8"); const result = lintManifestText(text); console.log(formatLintReport(path, result)); if (!result.ok) process.exit(1); diff --git a/test/unit/gittensory-config-lint-script.test.ts b/test/unit/gittensory-config-lint-script.test.ts index 366065284b..2e58d227d4 100644 --- a/test/unit/gittensory-config-lint-script.test.ts +++ b/test/unit/gittensory-config-lint-script.test.ts @@ -1,6 +1,10 @@ +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { formatLintReport } from "../../scripts/gittensory-config-lint"; +import { formatLintReport, readManifestTextForLint } from "../../scripts/gittensory-config-lint"; import { lintManifestText } from "../../src/selfhost/config-lint"; +import { MAX_FOCUS_MANIFEST_BYTES } from "../../src/signals/focus-manifest"; describe("formatLintReport (#2906)", () => { it("reports a valid manifest's summary and recognized fields, no warnings", () => { @@ -34,3 +38,60 @@ describe("formatLintReport (#2906)", () => { ); }); }); + +describe("readManifestTextForLint (#2923 regression)", () => { + function withTempDir(run: (dir: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "gittensory-config-lint-")); + try { + run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it("reads a regular manifest file at or below the parser byte limit", () => { + withTempDir((dir) => { + const path = join(dir, "manifest.yml"); + writeFileSync(path, "wantedPaths:\n - src/\n"); + + expect(readManifestTextForLint(path)).toBe("wantedPaths:\n - src/\n"); + }); + }); + + it("rejects missing paths before attempting to read", () => { + withTempDir((dir) => { + const path = join(dir, "missing.yml"); + + expect(() => readManifestTextForLint(path)).toThrow(`no such file: ${path}`); + }); + }); + + it("rejects symlinks so repository-controlled manifests cannot target special files", () => { + withTempDir((dir) => { + const target = join(dir, "target.yml"); + const link = join(dir, "manifest.yml"); + writeFileSync(target, "wantedPaths:\n - src/\n"); + symlinkSync(target, link); + + expect(() => readManifestTextForLint(link)).toThrow(`refusing to read symlink: ${link}`); + }); + }); + + it("rejects non-regular files before reading", () => { + withTempDir((dir) => { + const manifestDir = join(dir, "manifest.yml"); + mkdirSync(manifestDir); + + expect(() => readManifestTextForLint(manifestDir)).toThrow(`not a regular file: ${manifestDir}`); + }); + }); + + it("rejects oversized regular files before loading their contents", () => { + withTempDir((dir) => { + const path = join(dir, "manifest.yml"); + writeFileSync(path, "a".repeat(MAX_FOCUS_MANIFEST_BYTES + 1)); + + expect(() => readManifestTextForLint(path)).toThrow(`file exceeds ${MAX_FOCUS_MANIFEST_BYTES} bytes: ${path}`); + }); + }); +});