Files
my-pi/pi-permission-system/test/wildcard-matcher.test.ts
T

473 lines
16 KiB
TypeScript

import { join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
const mockHomedir = vi.hoisted(() => vi.fn(() => "/home/testuser"));
vi.mock("node:os", () => ({
homedir: mockHomedir,
default: { homedir: mockHomedir },
}));
const FAKE_HOME = "/home/testuser";
import {
compileWildcardPattern,
compileWildcardPatternEntries,
findCompiledWildcardMatch,
findCompiledWildcardMatchForNames,
wildcardMatch,
} from "#src/wildcard-matcher";
afterEach(() => {
mockHomedir.mockClear();
vi.restoreAllMocks();
});
describe("compileWildcardPatternEntries", () => {
test("returns empty array for empty iterable", () => {
const result = compileWildcardPatternEntries([]);
expect(result).toEqual([]);
});
test("compiles a single exact pattern", () => {
const result = compileWildcardPatternEntries([["read", "allow"]]);
expect(result).toHaveLength(1);
expect(result[0].pattern).toBe("read");
expect(result[0].state).toBe("allow");
});
test("compiles multiple patterns in order", () => {
const entries: [string, string][] = [
["read", "allow"],
["write", "deny"],
["bash *", "ask"],
];
const result = compileWildcardPatternEntries(entries);
expect(result).toHaveLength(3);
expect(result.map((r) => r.pattern)).toEqual(["read", "write", "bash *"]);
});
});
describe("findCompiledWildcardMatch", () => {
test("returns null for empty patterns array", () => {
const result = findCompiledWildcardMatch([], "read");
expect(result).toBeNull();
});
test("matches exact pattern", () => {
const patterns = compileWildcardPatternEntries([["read", "allow"]]);
const result = findCompiledWildcardMatch(patterns, "read");
expect(result).not.toBeNull();
expect(result?.state).toBe("allow");
expect(result?.matchedPattern).toBe("read");
expect(result?.matchedName).toBe("read");
});
test("returns null when no pattern matches", () => {
const patterns = compileWildcardPatternEntries([["read", "allow"]]);
const result = findCompiledWildcardMatch(patterns, "write");
expect(result).toBeNull();
});
test("matches glob * pattern", () => {
const patterns = compileWildcardPatternEntries([["git *", "allow"]]);
const result = findCompiledWildcardMatch(patterns, "git status");
expect(result).not.toBeNull();
expect(result?.state).toBe("allow");
expect(result?.matchedPattern).toBe("git *");
});
test("glob * matches zero or more characters", () => {
const patterns = compileWildcardPatternEntries([["git*", "allow"]]);
expect(findCompiledWildcardMatch(patterns, "git")).not.toBeNull();
expect(findCompiledWildcardMatch(patterns, "git status")).not.toBeNull();
expect(findCompiledWildcardMatch(patterns, "npm install")).toBeNull();
});
test("last-match-wins precedence: later pattern overrides earlier", () => {
const patterns = compileWildcardPatternEntries([
["git *", "allow"],
["git push *", "deny"],
]);
const result = findCompiledWildcardMatch(patterns, "git push origin main");
expect(result).not.toBeNull();
expect(result?.state).toBe("deny");
expect(result?.matchedPattern).toBe("git push *");
});
test("last-match-wins: specific deny before broad allow matches the later one", () => {
const patterns = compileWildcardPatternEntries([
["*", "deny"],
["git status", "allow"],
]);
const result = findCompiledWildcardMatch(patterns, "git status");
expect(result).not.toBeNull();
expect(result?.state).toBe("allow");
});
test("exact pattern does not match partial name", () => {
const patterns = compileWildcardPatternEntries([["read", "allow"]]);
expect(findCompiledWildcardMatch(patterns, "read ")).toBeNull();
expect(findCompiledWildcardMatch(patterns, "readonly")).toBeNull();
});
test("regex special characters in pattern are escaped", () => {
const patterns = compileWildcardPatternEntries([
["tool.name", "allow"],
["tool+extra", "deny"],
]);
// "tool.name" should not match "toolXname" (dot is escaped)
expect(findCompiledWildcardMatch(patterns, "toolXname")).toBeNull();
// Exact match works
expect(findCompiledWildcardMatch(patterns, "tool.name")).not.toBeNull();
expect(findCompiledWildcardMatch(patterns, "tool+extra")).not.toBeNull();
});
});
describe("findCompiledWildcardMatchForNames", () => {
test("returns null for empty names array", () => {
const patterns = compileWildcardPatternEntries([["read", "allow"]]);
const result = findCompiledWildcardMatchForNames(patterns, []);
expect(result).toBeNull();
});
test("returns null when all names are whitespace", () => {
const patterns = compileWildcardPatternEntries([[" ", "allow"]]);
const result = findCompiledWildcardMatchForNames(patterns, [" ", "\t"]);
expect(result).toBeNull();
});
test("matches first name that has a pattern match", () => {
const patterns = compileWildcardPatternEntries([
["read", "allow"],
["write", "deny"],
]);
const result = findCompiledWildcardMatchForNames(patterns, [
"grep",
"write",
]);
expect(result).not.toBeNull();
expect(result?.matchedName).toBe("write");
expect(result?.state).toBe("deny");
});
test("trims whitespace from names before matching", () => {
const patterns = compileWildcardPatternEntries([["read", "allow"]]);
const result = findCompiledWildcardMatchForNames(patterns, [" read "]);
expect(result).not.toBeNull();
expect(result?.state).toBe("allow");
});
test("returns null when no name matches any pattern", () => {
const patterns = compileWildcardPatternEntries([["read", "allow"]]);
const result = findCompiledWildcardMatchForNames(patterns, [
"write",
"grep",
]);
expect(result).toBeNull();
});
test("multi-name lookup: returns match for first matching name in order", () => {
const patterns = compileWildcardPatternEntries([
["read", "allow"],
["write", "deny"],
]);
// "read" comes before "write" in names array, so "read" should match first
const result = findCompiledWildcardMatchForNames(patterns, [
"read",
"write",
]);
expect(result).not.toBeNull();
expect(result?.matchedName).toBe("read");
expect(result?.state).toBe("allow");
});
test("compileWildcardPattern produces correct pattern metadata", () => {
const compiled = compileWildcardPattern("bash *", "ask");
expect(compiled.pattern).toBe("bash *");
expect(compiled.state).toBe("ask");
expect(compiled.matches("bash ls -la")).toBe(true);
expect(compiled.matches("echo hello")).toBe(false);
});
test("a compiled pattern folds the value it is handed (#653)", () => {
const compiled = compileWildcardPattern("/dev/*", "allow", {
windowsSeparators: true,
});
expect(compiled.matches("/dev/null")).toBe(true);
expect(compiled.matches("\\dev\\null")).toBe(true);
});
});
describe("wildcardMatch", () => {
test("'*' pattern matches any value", () => {
expect(wildcardMatch("*", "anything")).toBe(true);
expect(wildcardMatch("*", "")).toBe(true);
expect(wildcardMatch("*", "bash")).toBe(true);
});
test("'*' pattern matches values containing newlines", () => {
expect(wildcardMatch("*", "line1\nline2")).toBe(true);
expect(wildcardMatch("*", "a\nb\nc")).toBe(true);
});
test("prefix-wildcard pattern matches value with embedded newlines", () => {
const command =
"node -e \"\nimport('x').then(() => {\n console.log('done');\n});\n\"";
expect(wildcardMatch("node *", command)).toBe(true);
});
test("compileWildcardPattern matches a multiline string", () => {
const compiled = compileWildcardPattern("*", "allow");
expect(compiled.matches("a\nb")).toBe(true);
});
test("exact pattern matches identical value", () => {
expect(wildcardMatch("read", "read")).toBe(true);
expect(wildcardMatch("external_directory", "external_directory")).toBe(
true,
);
});
test("exact pattern does not match a different value", () => {
expect(wildcardMatch("read", "write")).toBe(false);
expect(wildcardMatch("read", "readonly")).toBe(false);
expect(wildcardMatch("read", "read ")).toBe(false);
});
test("glob pattern matches with wildcard", () => {
expect(wildcardMatch("git *", "git status")).toBe(true);
expect(wildcardMatch("git *", "git push origin main")).toBe(true);
expect(wildcardMatch("git *", "npm install")).toBe(false);
});
test("glob with no trailing space matches longer string", () => {
expect(wildcardMatch("git*", "git")).toBe(true);
expect(wildcardMatch("git*", "git status")).toBe(true);
expect(wildcardMatch("git*", "npm")).toBe(false);
});
test("regex special characters in pattern are treated as literals", () => {
expect(wildcardMatch("tool.name", "tool.name")).toBe(true);
expect(wildcardMatch("tool.name", "toolXname")).toBe(false);
});
describe("trailing wildcard optionality", () => {
test("'git *' matches bare 'git' (trailing space+wildcard is optional)", () => {
expect(wildcardMatch("git *", "git")).toBe(true);
});
test("'git *' still matches 'git status' (existing behaviour preserved)", () => {
expect(wildcardMatch("git *", "git status")).toBe(true);
});
test("'git *' still matches 'git status --short'", () => {
expect(wildcardMatch("git *", "git status --short")).toBe(true);
});
test("'git *' does not match an unrelated command", () => {
expect(wildcardMatch("git *", "npm install")).toBe(false);
});
test("'git status *' matches bare 'git status'", () => {
expect(wildcardMatch("git status *", "git status")).toBe(true);
});
test("'git status *' matches 'git status --short'", () => {
expect(wildcardMatch("git status *", "git status --short")).toBe(true);
});
test("non-trailing '*' is unaffected: 'g*t' does not match 'g' or 't'", () => {
expect(wildcardMatch("g*t", "g")).toBe(false);
expect(wildcardMatch("g*t", "t")).toBe(false);
});
test("non-trailing '*' still matches when content is present: 'g*t' matches 'git'", () => {
expect(wildcardMatch("g*t", "git")).toBe(true);
});
test("'git*' (no space) still matches bare 'git' — unchanged behaviour", () => {
expect(wildcardMatch("git*", "git")).toBe(true);
});
test("'*' alone still matches everything", () => {
expect(wildcardMatch("*", "git")).toBe(true);
expect(wildcardMatch("*", "")).toBe(true);
});
});
describe("match options (Windows path folding)", () => {
test("caseInsensitive matches a value differing only in case", () => {
expect(
wildcardMatch("C:\\Users\\Foo\\*", "c:\\users\\foo\\bar.md", {
caseInsensitive: true,
}),
).toBe(true);
});
test("case folding is off by default", () => {
expect(wildcardMatch("C:\\Users\\Foo\\*", "c:\\users\\foo\\bar.md")).toBe(
false,
);
});
test("windowsSeparators matches a backslash value against a forward-slash pattern", () => {
expect(
wildcardMatch("C:/Users/Foo/*", "C:\\Users\\Foo\\bar.md", {
windowsSeparators: true,
}),
).toBe(true);
});
test("separator normalization is off by default", () => {
expect(wildcardMatch("C:/Users/Foo/*", "C:\\Users\\Foo\\bar.md")).toBe(
false,
);
});
test("both options fold a mixed-case forward-slash pattern onto a lowercased backslash value", () => {
expect(
wildcardMatch(
"C:/Users/Foo/AppData/Roaming/*",
"c:\\users\\foo\\appdata\\roaming\\npm\\x.md",
{ caseInsensitive: true, windowsSeparators: true },
),
).toBe(true);
});
});
describe("windowsSeparators folds both operands (#653)", () => {
test("a forward-slash pattern matches a forward-slash value", () => {
expect(
wildcardMatch("/dev/null", "/dev/null", { windowsSeparators: true }),
).toBe(true);
});
test("a forward-slash glob matches a forward-slash device value", () => {
expect(
wildcardMatch("/dev/*", "/dev/null", {
caseInsensitive: true,
windowsSeparators: true,
}),
).toBe(true);
});
test("a forward-slash relative pattern matches a forward-slash value", () => {
expect(
wildcardMatch("src/*", "src/foo.ts", { windowsSeparators: true }),
).toBe(true);
});
test("a backslash pattern matches a forward-slash value", () => {
expect(
wildcardMatch("src\\*", "src/foo.ts", { windowsSeparators: true }),
).toBe(true);
});
test("the value fold is off by default", () => {
expect(wildcardMatch("src\\*", "src/foo.ts")).toBe(false);
expect(wildcardMatch("/dev/null", "\\dev\\null")).toBe(false);
});
test("folding separators does not make unrelated values match", () => {
expect(
wildcardMatch("/dev/null", "/dev/stdout", { windowsSeparators: true }),
).toBe(false);
});
});
});
describe("? single-character wildcard", () => {
test("'?' matches exactly one character", () => {
expect(wildcardMatch("?", "a")).toBe(true);
expect(wildcardMatch("?", "Z")).toBe(true);
expect(wildcardMatch("?", "5")).toBe(true);
});
test("'?' does not match zero characters", () => {
expect(wildcardMatch("?", "")).toBe(false);
expect(wildcardMatch("a?", "a")).toBe(false);
});
test("'?' does not match two or more characters", () => {
expect(wildcardMatch("?", "ab")).toBe(false);
expect(wildcardMatch("?", "abc")).toBe(false);
});
test("multiple '?' match exactly that many characters", () => {
expect(wildcardMatch("f??", "foo")).toBe(true);
expect(wildcardMatch("f??", "fo")).toBe(false);
expect(wildcardMatch("f??", "fooo")).toBe(false);
});
test("'?' combined with '*'", () => {
// git + one char + anything
expect(wildcardMatch("git?*", "git status")).toBe(true);
// git + zero chars — '?' requires one
expect(wildcardMatch("git?*", "git")).toBe(false);
});
test("'?' matches path separators and special characters", () => {
expect(wildcardMatch("a?b", "a/b")).toBe(true);
expect(wildcardMatch("a?b", "a.b")).toBe(true);
expect(wildcardMatch("a?b", "a\nb")).toBe(true);
});
test("'?' in pattern matches literal '?' in value", () => {
expect(wildcardMatch("a?c", "a?c")).toBe(true);
});
test("'?' in bash-style patterns", () => {
expect(wildcardMatch("git statu?", "git status")).toBe(true);
expect(wildcardMatch("git statu?", "git statux")).toBe(true);
expect(wildcardMatch("git statu?", "git statu")).toBe(false);
});
});
describe("home path expansion in patterns", () => {
test("wildcardMatch expands ~ prefix in pattern before matching", () => {
const expandedPath = join(FAKE_HOME, "dev/project");
expect(wildcardMatch("~/dev/project", expandedPath)).toBe(true);
});
test("wildcardMatch expands ~/glob in pattern", () => {
const expandedFile = join(FAKE_HOME, "dev/project/file.ts");
expect(wildcardMatch("~/dev/*", expandedFile)).toBe(true);
});
test("wildcardMatch ~/glob does not match a different home directory", () => {
expect(wildcardMatch("~/dev/*", "/other/user/dev/file.ts")).toBe(false);
});
test("wildcardMatch expands $HOME prefix in pattern before matching", () => {
const expandedPath = join(FAKE_HOME, "dev/project");
expect(wildcardMatch("$HOME/dev/project", expandedPath)).toBe(true);
});
test("wildcardMatch expands $HOME/glob in pattern", () => {
const expandedFile = join(FAKE_HOME, "work/file.ts");
expect(wildcardMatch("$HOME/work/*", expandedFile)).toBe(true);
});
test("compileWildcardPattern retains original ~ pattern in .pattern field", () => {
const compiled = compileWildcardPattern("~/dev/*", "allow");
expect(compiled.pattern).toBe("~/dev/*");
});
test("compileWildcardPattern retains original $HOME pattern in .pattern field", () => {
const compiled = compileWildcardPattern("$HOME/dev/*", "allow");
expect(compiled.pattern).toBe("$HOME/dev/*");
});
test("compileWildcardPattern expanded pattern matches the expanded path", () => {
const compiled = compileWildcardPattern("~/dev/*", "allow");
const expandedFile = join(FAKE_HOME, "dev/file.ts");
expect(compiled.matches(expandedFile)).toBe(true);
});
test("non-home pattern is unaffected", () => {
expect(wildcardMatch("/absolute/path/*", "/absolute/path/file")).toBe(true);
expect(wildcardMatch("/absolute/path/*", "/other/file")).toBe(false);
});
});