diff --git a/tests/web-model-scope.test.ts b/tests/web-model-scope.test.ts index 0f819c5..cada37b 100644 --- a/tests/web-model-scope.test.ts +++ b/tests/web-model-scope.test.ts @@ -93,6 +93,68 @@ test("non-glob patterns fall back to partial id and name containment", () => { ).toBe(true); }); +test("character classes match one member, like minimatch", () => { + expect( + modelMatchesScopePattern("zai/glm-5.[23]", "zai", { id: "glm-5.2" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[23]", "zai", { id: "glm-5.3" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[23]", "zai", { id: "glm-5.4" }), + ).toBe(false); + expect( + modelMatchesScopePattern("zai/glm-5.[2-3]", "zai", { id: "glm-5.3" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[2-3]", "zai", { id: "glm-5.4" }), + ).toBe(false); +}); + +test("negated character classes exclude members and the path separator", () => { + expect( + modelMatchesScopePattern("zai/glm-5.[!23]", "zai", { id: "glm-5.4" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[!23]", "zai", { id: "glm-5.2" }), + ).toBe(false); + expect( + modelMatchesScopePattern("zai/glm-5.[^23]", "zai", { id: "glm-5.x" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[^23]", "zai", { id: "glm-5.2" }), + ).toBe(false); + // A negated class never matches "/", so it cannot span the provider boundary. + expect(modelMatchesScopePattern("zai[!z]*", "zai", { id: "glm-5.4" })).toBe( + false, + ); + // A class whose only member is "/" can never match anything. + expect(modelMatchesScopePattern("zai[/]*", "zai", { id: "glm-5.4" })).toBe( + false, + ); +}); + +test("unterminated character classes fall back to a literal bracket", () => { + expect( + modelMatchesScopePattern("zai/glm-5.[2", "zai", { id: "glm-5.2" }), + ).toBe(false); + expect( + modelMatchesScopePattern("zai/glm-5.[2", "zai", { id: "glm-5.[2" }), + ).toBe(true); +}); + +test("a leading bracket inside a class is a literal member", () => { + expect( + modelMatchesScopePattern("zai/glm-5.[]23]", "zai", { id: "glm-5.]" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[]23]", "zai", { id: "glm-5.2" }), + ).toBe(true); + expect( + modelMatchesScopePattern("zai/glm-5.[]23]", "zai", { id: "glm-5.4" }), + ).toBe(false); +}); + test("readEnabledModelPatterns reads enabledModels from a settings file", async () => { directory = await mkdtemp(join(tmpdir(), "pi-model-scope-")); const settingsPath = join(directory, "settings.json"); diff --git a/web/server/modelScope.ts b/web/server/modelScope.ts index 03f4c90..e78e0da 100644 --- a/web/server/modelScope.ts +++ b/web/server/modelScope.ts @@ -47,7 +47,9 @@ export async function readEnabledModelPatterns( /** * Convert a glob to a RegExp with minimatch's default semantics for the * characters we support: `*` and `?` never cross `/`, and `[...]` character - * classes pass through. Everything else is literal. + * classes (sets, ranges, `!`/`^` negation, a leading `]` as a literal member, + * `\`-escaped members, and unterminated `[` treated as a literal) translate + * directly. Everything else is literal. */ function globToRegExp(pattern: string): RegExp { let source = ""; @@ -56,11 +58,74 @@ function globToRegExp(pattern: string): RegExp { if (char === "*") source += "[^/]*"; else if (char === "?") source += "[^/]"; else if (char === "\\") source += "\\\\"; - else source += char.replace(/[.+^${}()|[\]]/g, "\\$&"); + else if (char === "[") { + const charClass = parseCharClass(pattern, index); + if (charClass) { + source += charClass.source; + index = charClass.nextIndex - 1; + } else { + // Unterminated class: minimatch treats the `[` as a literal. + source += "\\["; + } + } else source += char.replace(/[.+^${}()|[\]]/g, "\\$&"); } return new RegExp(`^${source}$`, "i"); } +/** Escape a character so it is a literal member of a RegExp character class. */ +function escapeClassMember(char: string): string { + return char === "\\" || char === "]" || char === "^" ? `\\${char}` : char; +} + +/** + * Translate the `[...]` character class starting at `start` into a RegExp + * class source with minimatch's semantics, returning the source and the index + * just past the closing `]`. `undefined` when the class is unterminated. + */ +function parseCharClass( + pattern: string, + start: number, +): { source: string; nextIndex: number } | undefined { + let index = start + 1; + let negate = false; + if (pattern[index] === "!" || pattern[index] === "^") { + negate = true; + index += 1; + } + const members: string[] = []; + // A `]` immediately after `[` (or the negation) is a literal member. + if (pattern[index] === "]") { + members.push("\\]"); + index += 1; + } + let closed = false; + while (index < pattern.length) { + const char = pattern[index]; + if (char === "\\") { + const next = pattern[index + 1]; + if (next === undefined) return undefined; + members.push(escapeClassMember(next)); + index += 2; + continue; + } + if (char === "]") { + closed = true; + index += 1; + break; + } + members.push(escapeClassMember(char)); + index += 1; + } + if (!closed) return undefined; + // Minimatch classes never match the path separator: a `/` member can never + // match (dropped), and a negated class excludes `/` alongside its members. + const literalMembers = members.filter((member) => member !== "/"); + return { + source: `[${negate ? "^/" : ""}${literalMembers.join("")}]`, + nextIndex: index, + }; +} + function hasGlobCharacter(pattern: string): boolean { return /[*?[]/.test(pattern); }