From 9cb6b28a0f3f51211b26a60592197b411a0c8b59 Mon Sep 17 00:00:00 2001 From: Tara <39621793+Tara8573@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:41:50 +0800 Subject: [PATCH] fix(redis): keep user wildcards in fuzzy key search Refs: #9012 --- .../src/lib/redis/redisKeyPattern.spec.ts | 56 ++++++++++++++++++- apps/desktop/src/lib/redis/redisKeyPattern.ts | 13 ++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts b/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts index 5c8ad3ef76..87d4b4e9f2 100644 --- a/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts +++ b/apps/desktop/src/lib/redis/redisKeyPattern.spec.ts @@ -1,5 +1,59 @@ import { describe, expect, it } from "vitest"; -import { createRedisKeyPatternMatcher, redisGroupSubtreePattern, redisKeyMatchesPattern } from "@/lib/redis/redisKeyPattern"; +import { createRedisKeyPatternMatcher, escapeRedisGlobText, redisGroupSubtreePattern, redisKeyMatchesPattern, redisKeySearchPattern } from "@/lib/redis/redisKeyPattern"; + +describe("redisKeySearchPattern", () => { + it("returns `*` for empty input regardless of fuzzy", () => { + expect(redisKeySearchPattern("", false)).toBe("*"); + expect(redisKeySearchPattern(" ", true)).toBe("*"); + }); + + it("non-fuzzy (pattern) mode returns the trimmed value verbatim", () => { + expect(redisKeySearchPattern("user:*", false)).toBe("user:*"); + expect(redisKeySearchPattern(" prod:login_fail_count:* ", false)).toBe("prod:login_fail_count:*"); + }); + + it("fuzzy mode wraps plain text as `*text*` for substring-contains matching", () => { + expect(redisKeySearchPattern("admin", true)).toBe("*admin*"); + expect(redisKeySearchPattern("login_fail_count", true)).toBe("*login_fail_count*"); + }); + + it("fuzzy mode keeps user `*` as a wildcard so explicit globs still match (#9012)", () => { + // `prod:login_fail_count:*` must NOT escape the `*` to `\*`; the key + // prod:login_fail_count:admin:2026-09-14 must be findable. + expect(redisKeySearchPattern("prod:login_fail_count:*", true)).toBe("*prod:login_fail_count:**"); + expect(redisKeySearchPattern("prod:login_fail_count:admin:2026*", true)).toBe("*prod:login_fail_count:admin:2026**"); + // A bare `2026*` becomes `*2026**` (substring contains, wildcard honoured). + expect(redisKeySearchPattern("2026*", true)).toBe("*2026**"); + }); + + it("fuzzy mode keeps `?` as a single-char wildcard", () => { + expect(redisKeySearchPattern("user:?", true)).toBe("*user:?*"); + }); + + it("fuzzy mode still escapes `[` / `]` / `\\` to avoid char-class / escape ambiguity", () => { + expect(redisKeySearchPattern("a[b", true)).toBe("*a\\[b*"); + expect(redisKeySearchPattern("a]b", true)).toBe("*a\\]b*"); + expect(redisKeySearchPattern("a\\b", true)).toBe("*a\\\\b*"); + }); +}); + +describe("escapeRedisGlobText", () => { + it("non-fuzzy (default) escapes the full glob metacharacter set", () => { + expect(escapeRedisGlobText("a*b")).toBe("a\\*b"); + expect(escapeRedisGlobText("a?b")).toBe("a\\?b"); + expect(escapeRedisGlobText("a[b")).toBe("a\\[b"); + expect(escapeRedisGlobText("a]b")).toBe("a\\]b"); + expect(escapeRedisGlobText("a\\b")).toBe("a\\\\b"); + }); + + it("fuzzy escapes only `[` / `]` / `\\`, leaving `*` / `?` as wildcards", () => { + expect(escapeRedisGlobText("a*b", true)).toBe("a*b"); + expect(escapeRedisGlobText("a?b", true)).toBe("a?b"); + expect(escapeRedisGlobText("a[b", true)).toBe("a\\[b"); + expect(escapeRedisGlobText("a]b", true)).toBe("a\\]b"); + expect(escapeRedisGlobText("a\\b", true)).toBe("a\\\\b"); + }); +}); describe("redisGroupSubtreePattern", () => { it("builds a prefix pattern for single and nested groups", () => { diff --git a/apps/desktop/src/lib/redis/redisKeyPattern.ts b/apps/desktop/src/lib/redis/redisKeyPattern.ts index 9ac4671c5a..a0b7322c8b 100644 --- a/apps/desktop/src/lib/redis/redisKeyPattern.ts +++ b/apps/desktop/src/lib/redis/redisKeyPattern.ts @@ -1,8 +1,17 @@ +// Full glob metacharacter set — escaped when a value must match literally +// (group subtree patterns, non-fuzzy text). const REDIS_GLOB_SPECIAL_CHARS = /[\\*?[\]]/g; -const REDIS_GLOB_SPECIAL_CHARS_FUZZY = /[\\*?[\]]/g; +// In fuzzy mode the user's `*` / `?` are intentional wildcards ("search keys +// containing this text"), so only `[` / `]` / `\` are escaped to avoid +// accidental character-class / escape ambiguity. The outer `*…*` wrap added +// by `redisKeySearchPattern` keeps the substring-contains contract, so inputs +// like `prod:*` or `2026*` match the same keys a SCAN with that glob would. +// (#9012: previously `*` was escaped too, so `prod:*` became a literal-`*` +// substring match and found nothing.) +const REDIS_GLOB_LITERAL_CHARS_FUZZY = /[\\[\]]/g; export function escapeRedisGlobText(value: string, fuzzy = false): string { - return value.replace(fuzzy ? REDIS_GLOB_SPECIAL_CHARS_FUZZY : REDIS_GLOB_SPECIAL_CHARS, "\\$&"); + return value.replace(fuzzy ? REDIS_GLOB_LITERAL_CHARS_FUZZY : REDIS_GLOB_SPECIAL_CHARS, "\\$&"); } export function redisKeySearchPattern(value: string, fuzzy: boolean): string {