Files
openclaw/src/shared/ignore-rules.test.ts
cxbAsDev 593423ecc0 fix(shared): bound ignore-file reads during workspace scans (#101429)
* fix(shared): bound ignore-file reads during workspace scans

* fix(shared): import bounded reader through infra policy wrapper

* fix(shared): preserve chained-symlink ignore files while bounding reads

* fix(shared): correct ignore-rules test calls and remove dead return

* fix(shared): restore ignore matcher return value from addIgnoreRules

* fix(shared): fail closed on oversized ignore files during scans

* style(shared): format ignore-rules signature

* test(shared): rename misleading under-cap ignore-file test

* chore: retrigger CI for unrelated flaky run-with-env shutdown test

* fix(shared): make oversized ignore-file exclusion terminal

* test(shared): remove loadSkillsFromDir dependency for main compat

* chore: retrigger dead-code scan after Periphery/Homebrew flake

* fix(scans): fail closed on ignore read races

* fix(scans): treat fail-closed ignore paths literally

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(scans): preserve native ignore path semantics

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(scans): bound ignore rule amplification

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(scans): preserve case-insensitive fail-closed matching

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(scans): preserve bounded ignore composition

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(scans): satisfy matcher method binding lint

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(scans): preserve ignore matcher case mode

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(scans): keep matcher options internal

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

* fix(scans): require matcher case contract

Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.me>
2026-07-18 15:31:42 +01:00

245 lines
9.2 KiB
TypeScript

// Shared ignore-rules tests cover workspace ignore-file scanning.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import ignore from "ignore";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { addIgnoreRules } from "./ignore-rules.js";
function oversizedIgnoreFileContent(): string {
return `#${"x".repeat(4 * 1024 * 1024)}`;
}
describe("addIgnoreRules", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ignore-rules-"));
});
afterEach(() => {
fs.rmSync(tempDir, { force: true, recursive: true });
});
it("loads patterns from a .gitignore file", () => {
fs.writeFileSync(path.join(tempDir, ".gitignore"), "node_modules/\ndist/\n", "utf-8");
const ig = addIgnoreRules(tempDir, tempDir);
expect(ig.ignores("node_modules/foo")).toBe(true);
expect(ig.ignores("dist/bar.js")).toBe(true);
expect(ig.ignores("src/main.ts")).toBe(false);
});
it("parses a large ignore file under the byte cap", () => {
const huge = `#${"x".repeat(2 * 1024 * 1024)}\nignored-file\n`;
fs.writeFileSync(path.join(tempDir, ".gitignore"), huge, "utf-8");
const ig = addIgnoreRules(tempDir, tempDir);
expect(ig.ignores("ignored-file")).toBe(true);
expect(ig.ignores("unrelated-file")).toBe(false);
});
it("fails closed and excludes the subtree when an ignore file exceeds the byte cap", () => {
fs.writeFileSync(path.join(tempDir, ".gitignore"), oversizedIgnoreFileContent(), "utf-8");
const ig = addIgnoreRules(tempDir, tempDir);
expect(ig.ignores("unrelated-file")).toBe(true);
});
it("fails closed before an under-cap file can amplify into too many rules", () => {
fs.writeFileSync(path.join(tempDir, ".gitignore"), "a\n".repeat(20_001), "utf-8");
const ig = addIgnoreRules(tempDir, tempDir);
expect(ig.ignores("unrelated-file")).toBe(true);
});
it("enforces the rule-count budget across ignore files", () => {
fs.writeFileSync(path.join(tempDir, ".gitignore"), "a\n".repeat(10_000), "utf-8");
fs.writeFileSync(path.join(tempDir, ".ignore"), "b\n".repeat(10_001), "utf-8");
const ig = addIgnoreRules(tempDir, tempDir);
expect(ig.ignores("unrelated-file")).toBe(true);
});
it("fails closed before compiling an excessively long pattern", () => {
fs.writeFileSync(path.join(tempDir, ".gitignore"), "a".repeat(16 * 1024 + 1), "utf-8");
const ig = addIgnoreRules(tempDir, tempDir);
expect(ig.ignores("unrelated-file")).toBe(true);
});
it("counts directory prefixes toward the compiled-pattern budget", () => {
const first = "a".repeat(150);
const second = "b".repeat(150);
const nestedDir = path.join(tempDir, first, second);
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(nestedDir, ".gitignore"), "x\n".repeat(20_000), "utf-8");
const ig = addIgnoreRules(nestedDir, tempDir);
expect(ig.ignores(`${first}/${second}`)).toBe(true);
expect(ig.ignores(`${first}/${second}/secret.txt`)).toBe(true);
});
it("keeps the subtree excluded when a later ignore file negates it", () => {
fs.writeFileSync(path.join(tempDir, ".gitignore"), oversizedIgnoreFileContent(), "utf-8");
// A later .ignore could reopen the subtree if the oversized-file exclusion
// were not terminal for this directory.
fs.writeFileSync(path.join(tempDir, ".ignore"), "!ignored-file\n!secret.txt\n", "utf-8");
const ig = addIgnoreRules(tempDir, tempDir);
expect(ig.ignores("ignored-file")).toBe(true);
expect(ig.ignores("secret.txt")).toBe(true);
});
it("treats fail-closed subtree paths literally", () => {
const oversized = oversizedIgnoreFileContent();
const unusualNames = [
"Private",
"#private",
"!private",
"[private]",
...(process.platform === "win32" ? [] : ["private?docs", "private*docs"]),
];
let ig: ReturnType<typeof addIgnoreRules> | undefined;
for (const name of unusualNames) {
const nestedDir = path.join(tempDir, name);
fs.mkdirSync(nestedDir);
fs.writeFileSync(path.join(nestedDir, ".gitignore"), oversized, "utf-8");
ig = ig
? addIgnoreRules(nestedDir, tempDir, ig, { ignoreCase: true })
: addIgnoreRules(nestedDir, tempDir);
expect(ig.ignores(name)).toBe(true);
expect(ig.ignores(`${name}/secret.txt`)).toBe(true);
expect(ig.ignores([name, "nested", "secret.txt"].join(path.sep))).toBe(true);
}
if (!ig) {
throw new Error("expected ignore matcher");
}
expect(ig.ignores("public/secret.txt")).toBe(false);
expect(ig.ignores("private/secret.txt")).toBe(true);
if (process.platform !== "win32") {
expect(ig.ignores("#private\\secret.txt")).toBe(false);
}
});
it("preserves case-sensitive semantics for a supplied matcher", () => {
const nestedDir = path.join(tempDir, "Private");
fs.mkdirSync(nestedDir);
fs.writeFileSync(path.join(nestedDir, ".gitignore"), oversizedIgnoreFileContent(), "utf-8");
const ig = addIgnoreRules(nestedDir, tempDir, ignore({ ignorecase: false }), {
ignoreCase: false,
});
expect(ig.ignores("Private/secret.txt")).toBe(true);
expect(ig.ignores("private/secret.txt")).toBe(false);
const otherDir = path.join(tempDir, "Other");
fs.mkdirSync(otherDir);
fs.writeFileSync(path.join(otherDir, ".gitignore"), oversizedIgnoreFileContent(), "utf-8");
addIgnoreRules(otherDir, tempDir, ig, { ignoreCase: false });
expect(ig.ignores("Other/secret.txt")).toBe(true);
expect(ig.ignores("other/secret.txt")).toBe(false);
});
it("preserves case-insensitive semantics for a supplied default matcher", () => {
const nestedDir = path.join(tempDir, "Private");
fs.mkdirSync(nestedDir);
fs.writeFileSync(path.join(nestedDir, ".gitignore"), oversizedIgnoreFileContent(), "utf-8");
const ig = addIgnoreRules(nestedDir, tempDir, ignore(), { ignoreCase: true });
expect(ig.ignores("Private/secret.txt")).toBe(true);
expect(ig.ignores("private/secret.txt")).toBe(true);
});
it("adopts the explicit case mode after matcher composition", () => {
const source = addIgnoreRules(tempDir, tempDir);
const inherited = ignore().add(source);
const nestedDir = path.join(tempDir, "Private");
fs.mkdirSync(nestedDir);
fs.writeFileSync(path.join(nestedDir, ".gitignore"), oversizedIgnoreFileContent(), "utf-8");
addIgnoreRules(nestedDir, tempDir, inherited, { ignoreCase: true });
expect(inherited.ignores("Private/secret.txt")).toBe(true);
expect(inherited.ignores("private/secret.txt")).toBe(true);
});
it("keeps fail-closed metadata when the matcher is extended", () => {
const nestedDir = path.join(tempDir, "locked");
fs.mkdirSync(nestedDir);
fs.writeFileSync(path.join(nestedDir, ".gitignore"), oversizedIgnoreFileContent(), "utf-8");
const ig = addIgnoreRules(nestedDir, tempDir);
fs.writeFileSync(path.join(tempDir, ".gitignore"), "!locked/\n!locked/secret.txt\n", "utf-8");
addIgnoreRules(tempDir, tempDir, ig, { ignoreCase: true });
expect(ig.ignores("locked")).toBe(true);
expect(ig.ignores("locked/secret.txt")).toBe(true);
const inherited = ignore().add(ig);
inherited.add("!locked/\n!locked/secret.txt");
expect(inherited.ignores("locked")).toBe(true);
expect(inherited.ignores("locked/secret.txt")).toBe(true);
});
it("preserves the configured ignore matcher surface", () => {
fs.writeFileSync(path.join(tempDir, ".gitignore"), "from-file\n", "utf-8");
const configured = ignore().add("preconfigured");
const ig = addIgnoreRules(tempDir, tempDir, configured, { ignoreCase: true });
expect(ig).toBe(configured);
expect(ig.ignores("preconfigured")).toBe(true);
expect(ig.test("from-file").ignored).toBe(true);
expect(ig.filter(["from-file", "visible"])).toEqual(["visible"]);
expect(["from-file", "visible"].filter(ig.createFilter())).toEqual(["visible"]);
ig.add("added-later");
expect(ig.ignores("added-later")).toBe(true);
});
it("follows a symlinked .gitignore to a regular file", () => {
const realDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ignore-rules-real-"));
try {
fs.writeFileSync(path.join(realDir, "real.gitignore"), "node_modules/\n", "utf-8");
fs.symlinkSync(path.join(realDir, "real.gitignore"), path.join(tempDir, ".gitignore"));
const ig = addIgnoreRules(tempDir, tempDir);
expect(ig.ignores("node_modules/foo")).toBe(true);
} finally {
fs.rmSync(realDir, { force: true, recursive: true });
}
});
it("follows a chain of symlinks to the final regular .gitignore", () => {
const realDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ignore-rules-real-"));
try {
fs.writeFileSync(path.join(realDir, "real.gitignore"), "node_modules/\n", "utf-8");
const linkA = path.join(realDir, "link-a");
const linkB = path.join(realDir, "link-b");
fs.symlinkSync(path.join(realDir, "real.gitignore"), linkA);
fs.symlinkSync(linkA, linkB);
fs.symlinkSync(linkB, path.join(tempDir, ".gitignore"));
const ig = addIgnoreRules(tempDir, tempDir);
expect(ig.ignores("node_modules/foo")).toBe(true);
} finally {
fs.rmSync(realDir, { force: true, recursive: true });
}
});
});