From 4271bdb797d5575d296a7dac4bb3d0b47a628acb Mon Sep 17 00:00:00 2001 From: boommanpro Date: Sat, 11 Apr 2026 18:21:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(csp):=20CSP=20=E8=A7=84=E5=88=99=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 CSP (Content-Security-Policy) 规则管理模块,支持移除或修改网页 CSP 头。 功能特性: - 全局开关:一键移除所有网页 CSP 头,无需配置规则 - 规则管理:创建/编辑/删除/启用/禁用 CSP 规则 - 匹配模式:支持精确匹配、通配符、正则表达式、域名匹配四种模式 - 模式匹配引擎:参考 wproxy/whistle 规范实现 - *.domain 单级子域 / **.domain 多级子域 / ***.domain 根域+多级子域 - ^ 前缀路径通配符:* 单级 / ** 多级 / *** 任意字符 - 协议通配符 http*:// / 混合通配符 test.abc**.com - 模式测试:Drawer 内置测试工具,实时验证匹配结果 - 帮助文档:右侧 Drawer 展示匹配模式指南 - 优先级自动递增:创建/更新时自动避免 priority 冲突 技术实现: - 基于 chrome.declarativeNetRequest API 拦截并修改响应头 - Service/Client/Repo 三层架构,直接回调模式确保 DNR 规则实时更新 - chrome.storage.local 持久化配置和规则数据 - 63 个单元测试覆盖所有匹配模式 新增文件: - src/app/repo/cspRule.ts (数据层) - src/app/service/service_worker/cspRule.ts (服务层) - src/app/service/service_worker/cspInterceptor.ts (拦截器) - src/pages/options/routes/CSPRule/index.tsx (UI 页面) - src/pkg/utils/patternMatcher.ts (匹配引擎) - tests/pkg/utils/patternMatcher.test.ts (单元测试) --- src/app/repo/cspRule.ts | 86 ++ src/app/service/service_worker/client.ts | 43 + .../service/service_worker/cspInterceptor.ts | 257 ++++++ src/app/service/service_worker/cspRule.ts | 176 ++++ src/app/service/service_worker/index.ts | 11 + src/locales/en-US/translation.json | 56 ++ src/locales/zh-CN/translation.json | 56 ++ src/manifest.json | 3 +- src/pages/components/layout/Sider.tsx | 8 + src/pages/options/routes/CSPRule/index.tsx | 676 +++++++++++++++ src/pkg/utils/patternMatcher.ts | 774 ++++++++++++++++++ tests/pkg/utils/patternMatcher.test.ts | 428 ++++++++++ 12 files changed, 2573 insertions(+), 1 deletion(-) create mode 100644 src/app/repo/cspRule.ts create mode 100644 src/app/service/service_worker/cspInterceptor.ts create mode 100644 src/app/service/service_worker/cspRule.ts create mode 100644 src/pages/options/routes/CSPRule/index.tsx create mode 100644 src/pkg/utils/patternMatcher.ts create mode 100644 tests/pkg/utils/patternMatcher.test.ts diff --git a/src/app/repo/cspRule.ts b/src/app/repo/cspRule.ts new file mode 100644 index 000000000..f09916e9b --- /dev/null +++ b/src/app/repo/cspRule.ts @@ -0,0 +1,86 @@ +import { Repo } from "./repo"; + +export type CSPRuleAction = "remove" | "modify"; + +export interface CSPRule { + id: string; + name: string; + description: string; + path: string; // 匹配模式(URL/通配符/正则/域名) + action: CSPRuleAction; // "remove" 删除CSP头 或 "modify" 修改为指定值 + actionValue?: string; // 当action为modify时的新CSP值 + priority: number; // 优先级,数字越大越先匹配 + enabled: boolean; + createtime: number; + updatetime: number; +} + +/** CSP 全局配置的存储 key */ +const CSP_CONFIG_KEY = "cspConfig"; + +export interface CSPConfig { + globalEnabled: boolean; // 全局开关:开启后直接移除所有 URL 的 CSP 头 +} + +export class CSPRuleDAO extends Repo { + constructor() { + super("cspRule"); + } + + async getAllRules(): Promise { + const rules = await this.find(); + return rules.sort((a, b) => b.priority - a.priority); + } + + async getEnabledRules(): Promise { + const rules = await this.find((_, value) => value.enabled === true); + return rules.sort((a, b) => b.priority - a.priority); + } + + async saveRule(rule: CSPRule): Promise { + return this._save(rule.id, rule); + } + + async deleteRule(id: string): Promise { + return this.delete(id); + } + + async updateRule(id: string, changes: Partial): Promise { + return this.update(id, changes); + } + + async getCSPConfig(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(CSP_CONFIG_KEY, (result) => { + if (chrome.runtime.lastError) { + console.error("getCSPConfig error:", chrome.runtime.lastError); + resolve({ globalEnabled: false }); + return; + } + const raw = result?.[CSP_CONFIG_KEY]; + if (raw) { + try { + resolve(JSON.parse(raw) as CSPConfig); + return; + } catch { + // ignore + } + } + resolve({ globalEnabled: false }); + }); + }); + } + + async saveCSPConfig(config: CSPConfig): Promise { + return new Promise((resolve, reject) => { + chrome.storage.local.set({ [CSP_CONFIG_KEY]: JSON.stringify(config) }, () => { + if (chrome.runtime.lastError) { + console.error("saveCSPConfig error:", chrome.runtime.lastError); + reject(new Error(chrome.runtime.lastError.message)); + return; + } + resolve(); + }); + }); + } +} diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 0961b1f16..9659fa8be 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -14,6 +14,7 @@ import { type ScriptInfo } from "@App/pkg/utils/scriptInstall"; import type { ScriptService, TCheckScriptUpdateOption, TOpenBatchUpdatePageOption } from "./script"; import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; import { type TSetValuesParams } from "./value"; +import { type CSPRule, type CSPConfig } from "@App/app/repo/cspRule"; export class ServiceWorkerClient extends Client { constructor(msgSender: MessageSend) { @@ -328,3 +329,45 @@ export class SystemClient extends Client { return this.do("connectVSCode", params); } } + +export class CSPRuleClient extends Client { + constructor(msgSender: MessageSend) { + super(msgSender, "serviceWorker/cspRule"); + } + + getAllRules(): Promise { + return this.doThrow("getAllRules"); + } + + getEnabledRules(): Promise { + return this.doThrow("getEnabledRules"); + } + + getCSPConfig(): Promise { + return this.doThrow("getCSPConfig"); + } + + toggleGlobal(enabled: boolean): Promise { + return this.doThrow("toggleGlobal", { enabled }); + } + + createRule(rule: Omit): Promise { + return this.doThrow("createRule", rule); + } + + updateRule(params: { id: string; changes: Partial }): Promise { + return this.doThrow("updateRule", params); + } + + deleteRule(id: string): Promise { + return this.do("deleteRule", id); + } + + toggleRule(params: { id: string; enabled: boolean }): Promise { + return this.doThrow("toggleRule", params); + } + + reorderRules(ruleIds: string[]): Promise { + return this.do("reorderRules", ruleIds); + } +} diff --git a/src/app/service/service_worker/cspInterceptor.ts b/src/app/service/service_worker/cspInterceptor.ts new file mode 100644 index 000000000..29296744d --- /dev/null +++ b/src/app/service/service_worker/cspInterceptor.ts @@ -0,0 +1,257 @@ +import { CSPRuleDAO, type CSPRule } from "@App/app/repo/cspRule"; +import type Logger from "@App/app/logger/logger"; +import LoggerCore from "@App/app/logger/core"; +import { toDeclarativeNetRequestFilter } from "@App/pkg/utils/patternMatcher"; + +const CSP_RULE_ID_START = 10000; +const MAX_DYNAMIC_RULES = 5000; + +export class CSPInterceptorService { + private logger: Logger; + private cspRuleDAO: CSPRuleDAO; + private enabledRules: CSPRule[] = []; + private globalEnabled: boolean = false; + private initialized: boolean = false; + private ruleIdCounter: number = CSP_RULE_ID_START; + + constructor() { + this.logger = LoggerCore.logger().with({ service: "cspInterceptor" }); + this.cspRuleDAO = new CSPRuleDAO(); + } + + /** + * 初始化拦截器 + * 加载已有的启用规则并注册到 Chrome DNR + */ + async init() { + if (this.initialized) { + return; + } + this.initialized = true; + + // 清除历史遗留的脏数据 + try { + const existingRules = await chrome.declarativeNetRequest.getDynamicRules(); + const cspRuleIds = existingRules + .filter((rule) => rule.id >= CSP_RULE_ID_START && rule.id < CSP_RULE_ID_START + MAX_DYNAMIC_RULES) + .map((rule) => rule.id); + if (cspRuleIds.length > 0) { + this.logger.info("cleaning up legacy CSP dynamic rules", { count: cspRuleIds.length }); + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: cspRuleIds, + }); + } + } catch (e) { + this.logger.warn("failed to cleanup legacy CSP rules", { error: String(e) }); + } + + // 加载全局配置和已有规则 + const config = await this.cspRuleDAO.getCSPConfig(); + this.globalEnabled = config.globalEnabled; + this.enabledRules = await this.cspRuleDAO.getEnabledRules(); + this.logger.info("csp interceptor initialized", { + globalEnabled: this.globalEnabled, + ruleCount: this.enabledRules.length, + }); + + await this.updateDeclarativeRules(); + } + + /** + * 更新启用的规则列表并重新注册 DNR 规则 + * 由 CSPRuleService 在规则变更时直接调用 + */ + async updateRules(enabledRules: CSPRule[]) { + this.enabledRules = enabledRules; + this.logger.info("csp rules updated", { ruleCount: enabledRules.length }); + await this.updateDeclarativeRules(); + } + + /** + * 更新全局开关状态 + */ + async updateGlobalEnabled(enabled: boolean) { + this.globalEnabled = enabled; + this.logger.info("csp global enabled changed", { globalEnabled: enabled }); + await this.updateDeclarativeRules(); + } + + private async updateDeclarativeRules() { + try { + const existingRules = await chrome.declarativeNetRequest.getDynamicRules(); + const existingRuleIds = existingRules + .filter((rule) => rule.id >= CSP_RULE_ID_START && rule.id < CSP_RULE_ID_START + MAX_DYNAMIC_RULES) + .map((rule) => rule.id); + + this.logger.info("updating declarative rules", { + removeCount: existingRuleIds.length, + globalEnabled: this.globalEnabled, + enabledCount: this.enabledRules.length, + }); + + const newRules: chrome.declarativeNetRequest.Rule[] = []; + this.ruleIdCounter = CSP_RULE_ID_START; + + // 全局模式:注册一条全量移除规则 + if (this.globalEnabled) { + newRules.push({ + id: this.ruleIdCounter++, + priority: 9999, + action: { + type: "modifyHeaders" as chrome.declarativeNetRequest.RuleActionType, + responseHeaders: [ + { + header: "Content-Security-Policy", + operation: "remove" as chrome.declarativeNetRequest.HeaderOperation, + }, + { + header: "Content-Security-Policy-Report-Only", + operation: "remove" as chrome.declarativeNetRequest.HeaderOperation, + }, + { + header: "X-Content-Security-Policy", + operation: "remove" as chrome.declarativeNetRequest.HeaderOperation, + }, + { + header: "X-WebKit-CSP", + operation: "remove" as chrome.declarativeNetRequest.HeaderOperation, + }, + ], + }, + condition: { + urlFilter: "*", + resourceTypes: ["main_frame", "sub_frame"] as chrome.declarativeNetRequest.ResourceType[], + }, + }); + // 全局模式下不需要注册具体规则 + } else { + // 规则模式:按优先级注册各规则 + const sortedRules = [...this.enabledRules].sort((a, b) => b.priority - a.priority); + + for (const rule of sortedRules) { + const dnrRules = this.convertToDeclarativeRule(rule); + if (dnrRules) { + for (const dnrRule of dnrRules) { + if (newRules.length >= MAX_DYNAMIC_RULES) { + this.logger.warn("max dynamic rules limit reached", { limit: MAX_DYNAMIC_RULES }); + break; + } + newRules.push(dnrRule); + } + } + if (newRules.length >= MAX_DYNAMIC_RULES) { + break; + } + } + } + + await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: existingRuleIds, + addRules: newRules, + }); + + this.logger.info("declarative rules updated successfully", { + addedCount: newRules.length, + }); + } catch (e) { + this.logger.error("failed to update declarative rules", { error: String(e) }); + } + } + + private convertToDeclarativeRule(rule: CSPRule): chrome.declarativeNetRequest.Rule[] | null { + if (!rule.enabled) { + return null; + } + + const conditions = this.buildConditions(rule.path); + if (conditions.length === 0) { + this.logger.warn("could not build condition for rule", { ruleName: rule.name, path: rule.path }); + return null; + } + + const dnrRules: chrome.declarativeNetRequest.Rule[] = []; + + for (const condition of conditions) { + const ruleId = this.ruleIdCounter++; + // Chrome DNR 要求 priority >= 1 + const dnrPriority = Math.max(1, rule.priority); + + if (rule.action === "remove") { + dnrRules.push({ + id: ruleId, + priority: dnrPriority, + action: { + type: "modifyHeaders" as chrome.declarativeNetRequest.RuleActionType, + responseHeaders: [ + { + header: "Content-Security-Policy", + operation: "remove" as chrome.declarativeNetRequest.HeaderOperation, + }, + { + header: "Content-Security-Policy-Report-Only", + operation: "remove" as chrome.declarativeNetRequest.HeaderOperation, + }, + { + header: "X-Content-Security-Policy", + operation: "remove" as chrome.declarativeNetRequest.HeaderOperation, + }, + { + header: "X-WebKit-CSP", + operation: "remove" as chrome.declarativeNetRequest.HeaderOperation, + }, + ], + }, + condition: { + ...condition, + resourceTypes: ["main_frame", "sub_frame"] as chrome.declarativeNetRequest.ResourceType[], + }, + }); + } else if (rule.action === "modify" && rule.actionValue) { + dnrRules.push({ + id: ruleId, + priority: dnrPriority, + action: { + type: "modifyHeaders" as chrome.declarativeNetRequest.RuleActionType, + responseHeaders: [ + { + header: "Content-Security-Policy", + operation: "set" as chrome.declarativeNetRequest.HeaderOperation, + value: rule.actionValue, + }, + ], + }, + condition: { + ...condition, + resourceTypes: ["main_frame", "sub_frame"] as chrome.declarativeNetRequest.ResourceType[], + }, + }); + } + } + + return dnrRules; + } + + private buildConditions(pattern: string): Partial[] { + const conditions: Partial[] = []; + + try { + const filter = toDeclarativeNetRequestFilter(pattern); + + if (filter.regexFilter) { + conditions.push({ + regexFilter: filter.regexFilter, + }); + this.logger.debug("using regex filter", { pattern, regexFilter: filter.regexFilter }); + } else if (filter.urlFilter) { + conditions.push({ + urlFilter: filter.urlFilter, + }); + this.logger.debug("using url filter", { pattern, urlFilter: filter.urlFilter }); + } + } catch (e) { + this.logger.warn("invalid pattern", { pattern, error: String(e) }); + } + + return conditions; + } +} diff --git a/src/app/service/service_worker/cspRule.ts b/src/app/service/service_worker/cspRule.ts new file mode 100644 index 000000000..0e3e92c8f --- /dev/null +++ b/src/app/service/service_worker/cspRule.ts @@ -0,0 +1,176 @@ +import type { Group } from "@Packages/message/server"; +import type { IMessageQueue } from "@Packages/message/message_queue"; +import { CSPRuleDAO, type CSPRule, type CSPConfig } from "@App/app/repo/cspRule"; +import type Logger from "@App/app/logger/logger"; +import LoggerCore from "@App/app/logger/core"; +import { v4 as uuidv4 } from "uuid"; + +export class CSPRuleService { + private logger: Logger; + private cspRuleDAO: CSPRuleDAO; + + /** 规则变更回调,用于直接通知拦截器更新 DNR 规则 */ + private onRulesChanged?: (enabledRules: CSPRule[]) => Promise; + + /** 全局开关变更回调 */ + private onGlobalEnabledChanged?: (enabled: boolean) => Promise; + + constructor( + private group: Group, + private mq: IMessageQueue + ) { + this.logger = LoggerCore.logger().with({ service: "cspRule" }); + this.cspRuleDAO = new CSPRuleDAO(); + } + + /** + * 设置规则变更回调 + */ + setOnRulesChanged(callback: (enabledRules: CSPRule[]) => Promise) { + this.onRulesChanged = callback; + } + + /** + * 设置全局开关变更回调 + */ + setOnGlobalEnabledChanged(callback: (enabled: boolean) => Promise) { + this.onGlobalEnabledChanged = callback; + } + + private async notifyRulesChanged() { + const enabledRules = await this.getEnabledRules(); + if (this.onRulesChanged) { + try { + await this.onRulesChanged(enabledRules); + } catch (e) { + this.logger.error("failed to notify interceptor", { error: String(e) }); + } + } + this.mq.publish("cspRulesChanged", enabledRules); + } + + async getAllRules(): Promise { + const rules = await this.cspRuleDAO.getAllRules(); + const validRules: CSPRule[] = []; + for (const rule of rules) { + if (rule && rule.id && rule.name && rule.path && rule.action && typeof rule.priority === "number") { + validRules.push(rule); + } else { + this.logger.warn("removing invalid legacy CSP rule", { rule }); + if (rule && rule.id) { + await this.cspRuleDAO.deleteRule(rule.id).catch(() => {}); + } + } + } + return validRules.sort((a, b) => b.priority - a.priority); + } + + async getEnabledRules(): Promise { + return this.cspRuleDAO.getEnabledRules(); + } + + async getCSPConfig(): Promise { + return this.cspRuleDAO.getCSPConfig(); + } + + async toggleGlobal(params: { enabled: boolean }): Promise { + const config = await this.cspRuleDAO.getCSPConfig(); + config.globalEnabled = params.enabled; + await this.cspRuleDAO.saveCSPConfig(config); + this.logger.info("toggle csp global", { enabled: params.enabled }); + if (this.onGlobalEnabledChanged) { + try { + await this.onGlobalEnabledChanged(params.enabled); + } catch (e) { + this.logger.error("failed to notify interceptor about global change", { error: String(e) }); + } + } + return config; + } + + /** + * 获取当前所有规则中最大的 priority 值 + */ + private async getMaxPriority(): Promise { + const rules = await this.cspRuleDAO.getAllRules(); + if (rules.length === 0) return 0; + return Math.max(...rules.map((r) => r.priority)); + } + + /** + * 确保不与已有规则 priority 冲突,冲突时自动递增 + */ + private async resolvePriority(priority: number, excludeId?: string): Promise { + const rules = await this.cspRuleDAO.getAllRules(); + const usedPriorities = new Set(rules.filter((r) => r.id !== excludeId).map((r) => r.priority)); + let resolved = priority; + while (usedPriorities.has(resolved)) { + resolved += 1; + } + return resolved; + } + + async createRule(rule: Omit): Promise { + const now = Date.now(); + const resolvedPriority = await this.resolvePriority(rule.priority); + const newRule: CSPRule = { + ...rule, + id: uuidv4(), + priority: resolvedPriority, + createtime: now, + updatetime: now, + }; + await this.cspRuleDAO.saveRule(newRule); + this.logger.info("create csp rule", { name: rule.name, id: newRule.id, priority: newRule.priority }); + await this.notifyRulesChanged(); + return newRule; + } + + async updateRule(params: { id: string; changes: Partial }): Promise { + const { id, changes } = params; + const resolvedChanges = { ...changes, updatetime: Date.now() }; + if (changes.priority !== undefined) { + resolvedChanges.priority = await this.resolvePriority(changes.priority, id); + } + const result = await this.cspRuleDAO.updateRule(id, resolvedChanges); + if (result) { + this.logger.info("update csp rule", { id, priority: result.priority }); + await this.notifyRulesChanged(); + } + return result; + } + + async deleteRule(id: string): Promise { + await this.cspRuleDAO.deleteRule(id); + this.logger.info("delete csp rule", { id }); + await this.notifyRulesChanged(); + } + + async toggleRule(params: { id: string; enabled: boolean }): Promise { + return this.updateRule({ id: params.id, changes: { enabled: params.enabled } }); + } + + async reorderRules(ruleIds: string[]): Promise { + const rules = await this.cspRuleDAO.getAllRules(); + for (let i = 0; i < ruleIds.length; i++) { + const rule = rules.find((r) => r.id === ruleIds[i]); + if (rule) { + rule.priority = ruleIds.length - i; + await this.cspRuleDAO.saveRule(rule); + } + } + await this.notifyRulesChanged(); + } + + init() { + this.group.on("getAllRules", this.getAllRules.bind(this)); + this.group.on("getEnabledRules", this.getEnabledRules.bind(this)); + this.group.on("getCSPConfig", this.getCSPConfig.bind(this)); + this.group.on("toggleGlobal", this.toggleGlobal.bind(this)); + this.group.on("createRule", this.createRule.bind(this)); + this.group.on("updateRule", this.updateRule.bind(this)); + this.group.on("deleteRule", this.deleteRule.bind(this)); + this.group.on("toggleRule", this.toggleRule.bind(this)); + this.group.on("reorderRules", this.reorderRules.bind(this)); + } +} diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 51f381283..ec4b5d858 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -21,6 +21,8 @@ import { FaviconDAO } from "@App/app/repo/favicon"; import { onRegularUpdateCheckAlarm } from "./regular_updatecheck"; import { cacheInstance } from "@App/app/cache"; import { InfoNotification } from "./utils"; +import { CSPRuleService } from "./cspRule"; +import { CSPInterceptorService } from "./cspInterceptor"; // service worker的管理器 export default class ServiceWorkerManager { @@ -102,6 +104,15 @@ export default class ServiceWorkerManager { ); system.init(); + // CSP 规则管理 + const cspInterceptor = new CSPInterceptorService(); + const cspRule = new CSPRuleService(this.api.group("cspRule"), this.mq); + // 直接连接:规则变更时立即通知拦截器更新 DNR 规则(不依赖消息队列时序) + cspRule.setOnRulesChanged(cspInterceptor.updateRules.bind(cspInterceptor)); + cspRule.setOnGlobalEnabledChanged(cspInterceptor.updateGlobalEnabled.bind(cspInterceptor)); + cspRule.init(); + cspInterceptor.init(); + const regularScriptUpdateCheck = async () => { const res = await onRegularUpdateCheckAlarm(systemConfig, script, subscribe); if (!res?.ok) return; diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 4068a909e..8a0993cb6 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -25,6 +25,62 @@ "show_main_sidebar": "Expand sidebar", "guide": "Guide", "helpcenter": "Help Center", + "csp_rule": "CSP Rules", + "csp_rule_page_title": "CSP Rule Management", + "csp_rule_page_desc": "Manage Content-Security-Policy response header rules to resolve script injection being blocked by CSP", + "csp_create_rule": "Create Rule", + "csp_edit_rule": "Edit Rule", + "csp_delete_rule_confirm": "Are you sure to delete rule \"{name}\"?", + "csp_rule_name": "Rule Name", + "csp_rule_name_placeholder": "Enter rule name", + "csp_rule_description": "Description", + "csp_rule_description_placeholder": "Enter description", + "csp_rule_path": "Match Pattern", + "csp_rule_path_placeholder": "e.g. *://example.com/* or /https?:\\/\\/example\\.com/i", + "csp_rule_path_help": "Supports exact, wildcard, regex, and domain matching modes", + "csp_rule_action": "Action", + "csp_rule_action_remove": "Remove CSP Header", + "csp_rule_action_modify": "Modify CSP Value", + "csp_rule_action_value": "CSP Value", + "csp_rule_action_value_placeholder": "Enter new CSP policy value", + "csp_rule_priority": "Priority", + "csp_rule_enabled": "Enabled", + "csp_rule_createtime": "Created At", + "csp_rule_updatetime": "Updated At", + "csp_rule_test": "Test", + "csp_rule_guide": "Guide", + "csp_global_switch": "Remove All CSP", + "csp_global_switch_desc": "When enabled, CSP headers will be removed from all pages without configuring rules", + "csp_global_enabled": "Global CSP removal enabled", + "csp_global_disabled": "Global CSP removal disabled", + "csp_test_title": "Pattern Test", + "csp_test_pattern": "Match Pattern", + "csp_test_url": "Test URL", + "csp_test_execute": "Run Test", + "csp_test_result_matched": "Matched", + "csp_test_result_not_matched": "Not Matched", + "csp_test_pattern_type": "Pattern Type", + "csp_test_details": "Details", + "csp_guide_title": "Pattern Guide", + "csp_guide_exact_title": "Exact Match", + "csp_guide_exact_desc": "Match the exact URL", + "csp_guide_exact_example": "https://example.com/path", + "csp_guide_wildcard_title": "Wildcard Match", + "csp_guide_wildcard_desc": "Use * as wildcard to match URLs", + "csp_guide_wildcard_example": "*://example.com/* or https://*.example.com/*", + "csp_guide_regex_title": "Regular Expression", + "csp_guide_regex_desc": "Use /pattern/flags format regex", + "csp_guide_regex_example": "/https?:\\/\\/example\\.com\\/.*/i", + "csp_guide_domain_title": "Domain Match", + "csp_guide_domain_desc": "Match all pages under a specific domain", + "csp_guide_domain_example": "example.com or *.example.com", + "csp_delete_success": "Rule deleted", + "csp_save_success": "Rule saved", + "csp_toggle_success": "Rule status updated", + "csp_no_rules": "No CSP rules", + "csp_rule_name_required": "Please enter rule name", + "csp_rule_path_required": "Please enter match pattern", + "csp_rule_action_value_required": "Please enter CSP value for modify action", "general": "General", "language": "Language", "help_translate": "Help us translate", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 99377ea67..f5ddb3aa9 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -25,6 +25,62 @@ "show_main_sidebar": "展开侧边栏", "guide": "新手指引", "helpcenter": "帮助中心", + "csp_rule": "CSP 规则管理", + "csp_rule_page_title": "CSP 规则管理", + "csp_rule_page_desc": "管理 Content-Security-Policy 响应头规则,解决脚本注入被 CSP 阻止的问题", + "csp_create_rule": "新建规则", + "csp_edit_rule": "编辑规则", + "csp_delete_rule_confirm": "确定要删除规则 \"{name}\" 吗?", + "csp_rule_name": "规则名称", + "csp_rule_name_placeholder": "请输入规则名称", + "csp_rule_description": "描述", + "csp_rule_description_placeholder": "请输入规则描述", + "csp_rule_path": "匹配路径", + "csp_rule_path_placeholder": "例如: *://example.com/* 或 /https?:\\/\\/example\\.com/i", + "csp_rule_path_help": "支持精确匹配、通配符、正则表达式和域名匹配四种模式", + "csp_rule_action": "动作类型", + "csp_rule_action_remove": "移除 CSP 头", + "csp_rule_action_modify": "修改 CSP 值", + "csp_rule_action_value": "CSP 值", + "csp_rule_action_value_placeholder": "输入新的 CSP 策略值", + "csp_rule_priority": "优先级", + "csp_rule_enabled": "启用", + "csp_rule_createtime": "创建时间", + "csp_rule_updatetime": "修改时间", + "csp_rule_test": "测试", + "csp_rule_guide": "帮助", + "csp_global_switch": "全局移除 CSP", + "csp_global_switch_desc": "开启后将移除所有网页的 CSP 头,无需配置规则", + "csp_global_enabled": "已开启全局移除 CSP", + "csp_global_disabled": "已关闭全局移除 CSP", + "csp_test_title": "模式测试", + "csp_test_pattern": "匹配模式", + "csp_test_url": "测试 URL", + "csp_test_execute": "执行测试", + "csp_test_result_matched": "匹配成功", + "csp_test_result_not_matched": "不匹配", + "csp_test_pattern_type": "模式类型", + "csp_test_details": "详细信息", + "csp_guide_title": "匹配模式指南", + "csp_guide_exact_title": "精确匹配", + "csp_guide_exact_desc": "完全匹配指定的 URL", + "csp_guide_exact_example": "https://example.com/path", + "csp_guide_wildcard_title": "通配符匹配", + "csp_guide_wildcard_desc": "使用 * 作为通配符匹配 URL", + "csp_guide_wildcard_example": "*://example.com/* 或 https://*.example.com/*", + "csp_guide_regex_title": "正则表达式", + "csp_guide_regex_desc": "使用 /pattern/flags 格式的正则表达式", + "csp_guide_regex_example": "/https?:\\/\\/example\\.com\\/.*/i", + "csp_guide_domain_title": "域名匹配", + "csp_guide_domain_desc": "匹配指定域名下的所有页面", + "csp_guide_domain_example": "example.com 或 *.example.com", + "csp_delete_success": "规则已删除", + "csp_save_success": "规则已保存", + "csp_toggle_success": "规则状态已更新", + "csp_no_rules": "暂无 CSP 规则", + "csp_rule_name_required": "请输入规则名称", + "csp_rule_path_required": "请输入匹配路径", + "csp_rule_action_value_required": "修改模式下请输入 CSP 值", "general": "通用", "language": "语言", "help_translate": "协助翻译", diff --git a/src/manifest.json b/src/manifest.json index 3bdea2e5e..3260a32ea 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -41,7 +41,8 @@ "notifications", "clipboardWrite", "unlimitedStorage", - "declarativeNetRequest" + "declarativeNetRequest", + "declarativeNetRequestWithHostAccess" ], "optional_permissions": [ "background", diff --git a/src/pages/components/layout/Sider.tsx b/src/pages/components/layout/Sider.tsx index 37fd399ec..b9b2ccefe 100644 --- a/src/pages/components/layout/Sider.tsx +++ b/src/pages/components/layout/Sider.tsx @@ -1,4 +1,5 @@ import Logger from "@App/pages/options/routes/Logger"; +import CSPRule from "@App/pages/options/routes/CSPRule"; import ScriptEditor from "@App/pages/options/routes/script/ScriptEditor"; import ScriptList from "@App/pages/options/routes/ScriptList"; import Setting from "@App/pages/options/routes/Setting"; @@ -11,6 +12,7 @@ import { IconGithub, IconLeft, IconLink, + IconLock, IconQuestion, IconRight, IconSettings, @@ -72,6 +74,11 @@ const Sider: React.FC = () => { {t("tools")} + + + {t("csp_rule")} + + {t("settings")} @@ -185,6 +192,7 @@ const Sider: React.FC = () => { } /> } /> + } /> } /> } /> diff --git a/src/pages/options/routes/CSPRule/index.tsx b/src/pages/options/routes/CSPRule/index.tsx new file mode 100644 index 000000000..078b1beef --- /dev/null +++ b/src/pages/options/routes/CSPRule/index.tsx @@ -0,0 +1,676 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Button, + Card, + Drawer, + Form, + Input, + InputNumber, + Message, + Modal, + Select, + Space, + Switch, + Table, + Tag, + Typography, +} from "@arco-design/web-react"; +import { IconPlus, IconQuestionCircle, IconSearch, IconDelete, IconEdit } from "@arco-design/web-react/icon"; +import { useTranslation } from "react-i18next"; +import type { CSPRule, CSPRuleAction } from "@App/app/repo/cspRule"; +import { CSPRuleClient } from "@App/app/service/service_worker/client"; +import { message } from "@App/pages/store/global"; +import { match as patternMatch, parsePatternType } from "@App/pkg/utils/patternMatcher"; +import type { ColumnProps } from "@arco-design/web-react/es/Table"; + +const { Title, Text, Paragraph } = Typography; +const { useForm } = Form; + +function formatDateTime(ts: number): string { + const d = new Date(ts); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +type MatchTestResult = { + matched: boolean; + patternType: string; + details: string; +}; + +function CSPRulePage() { + const { t } = useTranslation(); + const [form] = useForm(); + + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + const [globalEnabled, setGlobalEnabled] = useState(false); + const [globalLoading, setGlobalLoading] = useState(false); + + const [modalVisible, setModalVisible] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [formAction, setFormAction] = useState("remove"); + + const [testDrawerVisible, setTestDrawerVisible] = useState(false); + const [testPattern, setTestPattern] = useState(""); + const [testUrl, setTestUrl] = useState(""); + const [testResult, setTestResult] = useState(null); + const [testing, setTesting] = useState(false); + + const [helpVisible, setHelpVisible] = useState(false); + + const client = useMemo(() => new CSPRuleClient(message), []); + + const loadRules = useCallback(async () => { + setLoading(true); + try { + const data = await client.getAllRules(); + setRules(data || []); + } catch (e: any) { + console.warn("[CSPRule] Failed to load rules:", e); + setRules([]); + } finally { + setLoading(false); + } + }, [client]); + + const loadConfig = useCallback(async () => { + try { + const config = await client.getCSPConfig(); + setGlobalEnabled(config.globalEnabled); + } catch { + // ignore + } + }, [client]); + + useEffect(() => { + loadRules(); + loadConfig(); + }, [loadRules, loadConfig]); + + const handleToggleGlobal = useCallback( + async (checked: boolean) => { + setGlobalLoading(true); + try { + const config = await client.toggleGlobal(checked); + setGlobalEnabled(config.globalEnabled); + Message.success(checked ? t("csp_global_enabled") : t("csp_global_disabled")); + } catch (e: any) { + Message.error(`${t("operation_failed")}: ${e.message}`); + } finally { + setGlobalLoading(false); + } + }, + [client, t] + ); + + const handleCreate = useCallback(() => { + setEditingRule(null); + setFormAction("remove"); + form.resetFields(); + setHelpVisible(false); + setModalVisible(true); + }, [form]); + + const handleEdit = useCallback( + (record: CSPRule) => { + setEditingRule(record); + setFormAction(record.action); + form.setFieldsValue({ + name: record.name, + description: record.description, + path: record.path, + action: record.action, + actionValue: record.actionValue || "", + priority: record.priority, + enabled: record.enabled, + }); + setHelpVisible(false); + setModalVisible(true); + }, + [form] + ); + + const handleSubmit = useCallback(async () => { + try { + const values = await form.validate(); + setSubmitting(true); + if (editingRule) { + await client.updateRule({ + id: editingRule.id, + changes: { + name: values.name, + description: values.description || "", + path: values.path, + action: values.action, + actionValue: values.action === "modify" ? values.actionValue : undefined, + priority: values.priority, + enabled: values.enabled, + }, + }); + Message.success(t("csp_save_success")); + } else { + await client.createRule({ + name: values.name, + description: values.description || "", + path: values.path, + action: values.action, + actionValue: values.action === "modify" ? values.actionValue : undefined, + priority: values.priority, + enabled: values.enabled, + }); + Message.success(t("csp_save_success")); + } + setModalVisible(false); + form.resetFields(); + loadRules(); + } catch (e: any) { + if (e?.message) { + Message.error(`${t("operation_failed")}: ${e.message}`); + } + } finally { + setSubmitting(false); + } + }, [editingRule, form, client, loadRules, t]); + + const handleDelete = useCallback( + (record: CSPRule) => { + Modal.confirm({ + title: t("csp_delete_rule_confirm").replace("{name}", record.name), + okButtonProps: { status: "danger" }, + onOk: async () => { + try { + await client.deleteRule(record.id); + Message.success(t("csp_delete_success")); + loadRules(); + } catch (e: any) { + Message.error(`${t("operation_failed")}: ${e.message}`); + } + }, + }); + }, + [client, loadRules, t] + ); + + const handleToggle = useCallback( + (record: CSPRule, checked: boolean) => { + client + .toggleRule({ id: record.id, enabled: checked }) + .then(() => { + setRules((prev) => prev.map((r) => (r.id === record.id ? { ...r, enabled: checked } : r))); + Message.success(t("csp_toggle_success")); + }) + .catch((e: any) => { + Message.error(`${t("operation_failed")}: ${e.message}`); + }); + }, + [client, t] + ); + + const handleOpenTest = useCallback((pathValue?: string) => { + if (pathValue) { + setTestPattern(pathValue); + } + setTestUrl(""); + setTestResult(null); + setHelpVisible(false); + setTestDrawerVisible(true); + }, []); + + const handleTest = useCallback(() => { + if (!testPattern.trim() || !testUrl.trim()) { + Message.warning(t("csp_rule_path_required")); + return; + } + setTesting(true); + setTestResult(null); + try { + const matched = patternMatch(testPattern, testUrl); + const patternType = parsePatternType(testPattern); + setTestResult({ + matched, + patternType, + details: `Pattern: ${testPattern}\nURL: ${testUrl}\nType: ${patternType}`, + }); + } catch (e: any) { + setTestResult({ + matched: false, + patternType: "error", + details: e.message, + }); + } finally { + setTesting(false); + } + }, [testPattern, testUrl, t]); + + const columns: ColumnProps[] = [ + { + title: t("csp_rule_name"), + dataIndex: "name", + key: "name", + ellipsis: true, + width: 160, + sorter: (a: CSPRule, b: CSPRule) => a.name.localeCompare(b.name), + }, + { + title: t("csp_rule_description"), + dataIndex: "description", + key: "description", + ellipsis: true, + width: 160, + render: (val: string) => val || "-", + }, + { + title: t("csp_rule_path"), + dataIndex: "path", + key: "path", + ellipsis: true, + width: 240, + render: (val: string) => ( + + {val} + + ), + }, + { + title: t("csp_rule_action"), + dataIndex: "action", + key: "action", + width: 110, + align: "center", + filters: [ + { text: t("csp_rule_action_remove"), value: "remove" }, + { text: t("csp_rule_action_modify"), value: "modify" }, + ], + onFilter: (value, row) => row.action === value, + render: (val: CSPRuleAction) => ( + + {val === "remove" ? t("csp_rule_action_remove") : t("csp_rule_action_modify")} + + ), + }, + { + title: t("csp_rule_priority"), + dataIndex: "priority", + key: "priority", + width: 80, + align: "center", + sorter: (a: CSPRule, b: CSPRule) => a.priority - b.priority, + defaultSortOrder: "descend", + }, + { + title: t("csp_rule_enabled"), + dataIndex: "enabled", + key: "enabled", + width: 80, + align: "center", + render: (_: boolean, record: CSPRule) => ( + handleToggle(record, checked)} /> + ), + }, + { + title: t("csp_rule_createtime"), + dataIndex: "createtime", + key: "createtime", + width: 170, + align: "center", + sorter: (a: CSPRule, b: CSPRule) => a.createtime - b.createtime, + defaultSortOrder: "descend", + render: (val: number) => (val ? formatDateTime(val) : "-"), + }, + { + title: t("csp_rule_updatetime"), + dataIndex: "updatetime", + key: "updatetime", + width: 170, + align: "center", + sorter: (a: CSPRule, b: CSPRule) => a.updatetime - b.updatetime, + defaultSortOrder: "descend", + render: (val: number) => (val ? formatDateTime(val) : "-"), + }, + { + title: t("action"), + key: "action_ops", + width: 120, + align: "center", + fixed: "right", + render: (_: any, record: CSPRule) => ( + + + + + + + {globalEnabled && ( +
+ {t("csp_global_switch_desc")} +
+ )} + + + + {t("csp_no_rules")} + + } + /> + + {/* 创建/编辑规则弹窗 */} + { + setModalVisible(false); + form.resetFields(); + }} + unmountOnExit + maskClosable={false} + style={{ width: 560 }} + > +
+ + + + + + + + + + + + + } + > + + + + + + + + {formAction === "modify" && ( + + + + )} + + + + + + + + + +
+ + {/* 模式测试抽屉 */} + setTestDrawerVisible(false)} + footer={ + + } + unmountOnExit + > + +
+ + {t("csp_test_pattern")} + + +
+
+ + {t("csp_test_url")} + + +
+ + {testResult && ( + + + + {testResult.matched ? t("csp_test_result_matched") : t("csp_test_result_not_matched")} + + + {t("csp_test_pattern_type")}: {testResult.patternType} + + + {t("csp_test_details")}: {testResult.details} + + + + )} +
+
+ + {/* 模式指南抽屉 */} + setHelpVisible(false)} + unmountOnExit + > + +
+ {t("csp_guide_domain_title")} + {t("csp_guide_domain_desc")} + + + *. 仅匹配子域名 | **. 匹配多级子域名 | ***. 同时匹配根域名和多级子域名 + +
+ +
+ {t("csp_guide_wildcard_title")} + {t("csp_guide_wildcard_desc")} + + + * 匹配所有 URL | 域名中 * 匹配单段 | 协议中 * 匹配任意字母或冒号 | **. 匹配多级子域 + +
+ +
+ 路径通配符(需 ^ 前缀) + + 在路径中使用通配符时,需要在表达式前加 ^ 显式声明。 + + + + 路径中 * 匹配单级(不含 / 和 ?) | ** 匹配多级(不含 ?) | *** 匹配任意字符(含 / 和 ?) + +
+ +
+ {t("csp_guide_regex_title")} + {t("csp_guide_regex_desc")} + + + 支持 JavaScript 正则表达式,格式为 /正则体/标志 + +
+ +
+ {t("csp_guide_exact_title")} + {t("csp_guide_exact_desc")} + +
+
+
+ + + ); +} + +export default CSPRulePage; diff --git a/src/pkg/utils/patternMatcher.ts b/src/pkg/utils/patternMatcher.ts new file mode 100644 index 000000000..08295a71d --- /dev/null +++ b/src/pkg/utils/patternMatcher.ts @@ -0,0 +1,774 @@ +/** + * URL 模式匹配工具类 + * + * 参考 wproxy/whistle 的匹配模式规范: + * https://wproxy.org/docs/rules/pattern.html + * + * 支持四种模式类型:exact(精确匹配)、wildcard(通配符)、regex(正则)、domain(域名) + * + * 域名通配符:star 匹配单段,starstar.domain 匹配多级子域,starstarstar.domain 匹配根域+多级子域 + * 协议通配符:http* 中 star 匹配任意字母或冒号 + * 路径通配符(需 ^ 前缀):star 单级路径,starstar 多级路径,starstarstar 任意字符 + */ + +// ============================================================ +// 类型定义 +// ============================================================ + +export type PatternType = "exact" | "wildcard" | "regex" | "domain"; + +export interface PatternValidationResult { + valid: boolean; + type: PatternType; + error?: string; +} + +export interface PatternExample { + type: PatternType; + pattern: string; + description: string; +} + +export interface DeclarativeNetRequestFilter { + urlFilter?: string; + regexFilter?: string; +} + +// ============================================================ +// 常量 +// ============================================================ + +const REGEX_DELIMITER = "/"; + +// ============================================================ +// 核心函数 +// ============================================================ + +/** + * 自动识别模式类型 + */ +export function parsePatternType(pattern: string): PatternType { + const trimmed = pattern.trim(); + + if (isRegexPattern(trimmed)) { + return "regex"; + } + + // 域名模式优先于通配符(*.example.com、**.example.com、***.example.com 是域名模式) + if (isDomainPattern(trimmed)) { + return "domain"; + } + + if (isWildcardPattern(trimmed)) { + return "wildcard"; + } + + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) { + return "exact"; + } + + return "exact"; +} + +/** + * 测试 URL 是否匹配模式 + */ +export function match(pattern: string, url: string): boolean { + if (!pattern || !url) { + return false; + } + + const type = parsePatternType(pattern); + + try { + switch (type) { + case "exact": + return matchExact(pattern.trim(), url); + case "wildcard": + return matchWildcard(pattern.trim(), url); + case "regex": + return matchRegex(pattern.trim(), url); + case "domain": + return matchDomain(pattern.trim(), url); + default: + return false; + } + } catch (e) { + console.warn(`[PatternMatcher] 匹配出错 (${type}):`, e); + return false; + } +} + +/** + * 将模式转换为 Chrome declarativeNetRequest API 的 urlFilter 或 regexFilter 格式 + */ +export function toDeclarativeNetRequestFilter(pattern: string): DeclarativeNetRequestFilter { + if (!pattern) { + throw new Error("[PatternMatcher] 模式不能为空"); + } + + const trimmed = pattern.trim(); + const type = parsePatternType(trimmed); + + switch (type) { + case "regex": + return toRegexFilter(trimmed); + case "wildcard": + return toUrlFilter(trimmed); + case "domain": + return domainToUrlFilter(trimmed); + case "exact": + return exactToUrlFilter(trimmed); + default: + throw new Error(`[PatternMatcher] 不支持的模式类型: ${type}`); + } +} + +/** + * 验证模式语法是否正确 + */ +export function validatePattern(pattern: string): PatternValidationResult { + if (!pattern || !pattern.trim()) { + return { valid: false, type: "exact", error: "模式不能为空" }; + } + + const trimmed = pattern.trim(); + const type = parsePatternType(trimmed); + + try { + switch (type) { + case "regex": + return validateRegex(trimmed); + case "wildcard": + return validateWildcard(trimmed); + case "domain": + return validateDomain(trimmed); + case "exact": + return validateExact(trimmed); + default: + return { valid: false, type, error: `未知的模式类型: ${type}` }; + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { valid: false, type, error: message }; + } +} + +/** + * 返回各类型模式的示例(参考 wproxy 规范) + */ +export function getPatternExamples(): PatternExample[] { + return [ + { + type: "domain", + pattern: "example.com", + description: "域名匹配:匹配 example.com 及其所有子域名", + }, + { + type: "domain", + pattern: "*.example.com", + description: "域名匹配:仅匹配子域名(api.example.com、shop.example.com)", + }, + { + type: "domain", + pattern: "**.example.com", + description: "域名匹配:匹配多级子域名(a.b.example.com)", + }, + { + type: "domain", + pattern: "***.example.com", + description: "域名匹配:同时匹配根域名和多级子域名", + }, + { + type: "wildcard", + pattern: "*", + description: "全局通配:匹配所有 URL", + }, + { + type: "wildcard", + pattern: "*://*.example.com/*", + description: "通配符:协议不限,匹配 example.com 的子域名下所有路径", + }, + { + type: "wildcard", + pattern: "https://*.example.com:8080/api/*", + description: "通配符:匹配指定协议、端口和路径前缀", + }, + { + type: "wildcard", + pattern: "http*://test.abc**.com", + description: "通配符:协议中 * 匹配字母/冒号,**. 匹配多级子域", + }, + { + type: "wildcard", + pattern: "^http://*.example.com/data/*/result?q=*", + description: "路径通配(需 ^ 前缀):路径中 * 匹配单级,参数中 * 匹配单参数值", + }, + { + type: "wildcard", + pattern: "^http://**.example.com/data/**file", + description: "路径通配(需 ^ 前缀):** 匹配多级路径", + }, + { + type: "regex", + pattern: "/^https:\\/\\/(www\\.)?example\\.com\\/api\\/.*/", + description: "正则表达式:完整正则匹配", + }, + { + type: "regex", + pattern: "/\\.test\\./i", + description: "正则表达式:忽略大小写匹配 .test.", + }, + { + type: "exact", + pattern: "https://www.example.com/page", + description: "精确匹配:完全匹配指定 URL", + }, + ]; +} + +// ============================================================ +// 内部辅助函数 - 模式类型识别 +// ============================================================ + +function isRegexPattern(pattern: string): boolean { + if (pattern.length < 3 || pattern[0] !== REGEX_DELIMITER) { + return false; + } + const lastSlash = pattern.lastIndexOf(REGEX_DELIMITER); + if (lastSlash <= 0) { + return false; + } + const flags = pattern.substring(lastSlash + 1); + if (flags && !/^[gimsuyv]+$/.test(flags)) { + return false; + } + const body = pattern.substring(1, lastSlash); + if (!body) { + return false; + } + return true; +} + +function isWildcardPattern(pattern: string): boolean { + if (!/[*?]/.test(pattern)) { + return false; + } + if (isRegexPattern(pattern)) { + return false; + } + return true; +} + +function isDomainPattern(pattern: string): boolean { + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(pattern)) { + return false; + } + // 支持 **. 或 ***. 或 *. 前缀 + 域名 + 可选端口 + const domainRegex = + /^(\*\*\*\.|\*\*\.|\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(:\d+)?$/; + return domainRegex.test(pattern); +} + +// ============================================================ +// 内部辅助函数 - 匹配逻辑 +// ============================================================ + +function matchExact(pattern: string, url: string): boolean { + return pattern === url; +} + +/** + * 通配符匹配(参考 wproxy 规范) + * + * 域名通配符:star.domain 单级子域,starstar.domain 多级子域,starstarstar.domain 根域+多级子域 + * 协议通配符:http* 中 star 匹配任意字母或冒号 + * 路径通配符(需 ^ 前缀):star 单级路径,starstar 多级路径,starstarstar 任意字符 + */ +function matchWildcard(pattern: string, url: string): boolean { + // 全局通配符 + if (pattern === "*" || pattern === "*://*") { + try { + new URL(url); + return true; + } catch { + return false; + } + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return false; + } + + const urlProtocol = parsedUrl.protocol + "//"; + const urlHost = parsedUrl.hostname.toLowerCase(); + const urlPort = parsedUrl.port; + const urlPath = parsedUrl.pathname; + const urlSearch = parsedUrl.search; + + // 解析模式 + let protoPart = ""; + let hostPart = ""; + let pathPart = ""; + let hasPathWildcard = false; + + // 检查 ^ 前缀(路径通配符声明) + const hasPathPrefix = pattern.startsWith("^"); + let workingPattern = hasPathPrefix ? pattern.substring(1) : pattern; + + // 提取协议部分 + const protoMatch = /^([a-zA-Z]*\*?[a-zA-Z]*:\/\/)/.exec(workingPattern); + if (protoMatch) { + protoPart = protoMatch[1]; + workingPattern = workingPattern.substring(protoPart.length); + } + + // 提取域名和路径 + const firstSlash = workingPattern.indexOf("/"); + if (firstSlash >= 0) { + hostPart = workingPattern.substring(0, firstSlash); + pathPart = workingPattern.substring(firstSlash); + if (/\*/.test(pathPart)) { + hasPathWildcard = true; + } + } else { + hostPart = workingPattern; + pathPart = "/"; + } + + // 1. 匹配协议 + if (protoPart) { + if (protoPart.includes("*")) { + // 协议中 * 匹配任意字母或冒号 + const protoRegex = "^" + protoPart.replace(/\*/g, "[a-z:]*") + "$"; + if (!new RegExp(protoRegex, "i").test(urlProtocol)) { + return false; + } + } else { + if (protoPart !== urlProtocol) { + return false; + } + } + } + + // 2. 匹配域名(分离端口) + let patternHost = hostPart; + let patternPort = ""; + const hostColonIdx = hostPart.lastIndexOf(":"); + if (hostColonIdx >= 0 && /^\d+$/.test(hostPart.substring(hostColonIdx + 1))) { + patternHost = hostPart.substring(0, hostColonIdx); + patternPort = hostPart.substring(hostColonIdx + 1); + } + if (!matchWildcardHost(patternHost, urlHost)) { + return false; + } + // 匹配端口 + if (patternPort) { + if (urlPort !== patternPort) { + return false; + } + } + + // 3. 匹配路径 + if (hasPathPrefix) { + // ^ 前缀路径通配符模式:包含查询字符串 + return matchWildcardPath(pathPart, urlPath, urlSearch); + } + + if (hasPathWildcard) { + // 非 ^ 前缀路径通配符:仅匹配 pathname,忽略查询字符串 + return matchWildcardPath(pathPart, urlPath, ""); + } + + // 普通路径匹配 + if (pathPart === "/" || pathPart === "/*") { + return true; + } + + // 路径中的 * 匹配单段 + return matchPathSimple(pathPart, urlPath); +} + +/** + * 域名通配符匹配 + */ +function matchWildcardHost(patternHost: string, urlHost: string): boolean { + // ***.domain = 同时匹配根域名 + 多级子域 + if (patternHost.startsWith("***.")) { + const base = patternHost.substring(4).toLowerCase(); + return urlHost === base || urlHost.endsWith("." + base); + } + + // **.domain = 匹配根域名和多级子域 + if (patternHost.startsWith("**.")) { + const base = patternHost.substring(3).toLowerCase(); + return urlHost === base || urlHost.endsWith("." + base); + } + + // *.domain = 匹配单级子域(不含根域名自身,参考 wproxy 规范) + if (patternHost.startsWith("*.")) { + const base = patternHost.substring(2).toLowerCase(); + return urlHost.endsWith("." + base); + } + + // 无通配符 → 精确匹配 + if (!patternHost.includes("*")) { + return patternHost.toLowerCase() === urlHost; + } + + // 混合通配符(如 test.abc**.com) + // ** 在域名中间 = 匹配零或多级子域 + if (patternHost.includes("**")) { + // 按段处理:** 段匹配零或多个子域段 + const segments = patternHost.split("."); + let regexStr = ""; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (i > 0) regexStr += "\\."; + + if (seg === "**") { + // ** 独立段 → 匹配零或多个完整子域段 + regexStr += "((\\.[^.]+)*)?"; + } else if (seg.endsWith("**")) { + // 段以 ** 结尾(如 abc**)→ 字面量前缀 + 零或多个完整子域段 + const prefix = seg.substring(0, seg.length - 2); + regexStr += escapeRegex(prefix) + "((\\.[^.]+)*)?"; + } else if (seg.startsWith("**")) { + // 段以 ** 开头(如 **com)→ 零或多个子域段 + 字面量后缀 + const suffix = seg.substring(2); + regexStr += "([^.]+(\\.[^.]+)*?\\.)?" + escapeRegex(suffix); + } else if (seg.includes("**")) { + // 段中间包含 **(如 a**b)→ 前缀 + 零或多子域 + 后缀 + const idx = seg.indexOf("**"); + const prefix = seg.substring(0, idx); + const suffix = seg.substring(idx + 2); + regexStr += escapeRegex(prefix) + "([^.]+(\\.[^.]+)*?\\.?)?" + escapeRegex(suffix); + } else if (seg === "*") { + regexStr += "[^.]+"; + } else if (seg.includes("*")) { + regexStr += seg.replace(/\*/g, "[^.]+"); + } else { + regexStr += escapeRegex(seg); + } + } + return new RegExp(`^${regexStr}$`, "i").test(urlHost); + } + + // 单 * 通配符 + const regexStr = patternHost + .split(".") + .map((segment) => { + if (segment === "*") return "[^.]+"; + return escapeRegex(segment); + }) + .join("\\."); + return new RegExp(`^${regexStr}$`, "i").test(urlHost); +} + +/** + * 路径通配符匹配(参考 wproxy 路径通配符规则) + * 需要配合 ^ 前缀使用 + * star 单级路径,starstar 多级路径,starstarstar 任意字符 + */ +function matchWildcardPath(patternPath: string, urlPath: string, urlSearch: string): boolean { + const fullPath = urlPath + urlSearch; + + // 将通配符模式转为正则 + let regexStr = ""; + + let i = 0; + while (i < patternPath.length) { + if (patternPath[i] === "*" && patternPath[i + 1] === "*" && patternPath[i + 2] === "*") { + // *** → 任意字符(含 / 和 ?) + regexStr += ".*"; + i += 3; + } else if (patternPath[i] === "*" && patternPath[i + 1] === "*") { + // ** → 多级路径(不含 ?) + regexStr += "[^?]*"; + i += 2; + } else if (patternPath[i] === "*") { + // * → 单级路径(不含 / 和 ?) + regexStr += "[^?/]*"; + i += 1; + } else if (patternPath[i] === "?") { + // ? 在路径模式中是字面量(查询字符串分隔符),不是通配符 + regexStr += escapeRegex("?"); + i += 1; + } else { + regexStr += escapeRegex(patternPath[i]); + i += 1; + } + } + + return new RegExp(`^${regexStr}$`).test(fullPath); +} + +/** + * 普通路径匹配(非 ^ 前缀) + * * 匹配单段(非 / 字符) + */ +function matchPathSimple(patternPath: string, urlPath: string): boolean { + if (!patternPath.includes("*")) { + return patternPath === urlPath; + } + + const segments = patternPath.split("/"); + const urlSegments = urlPath.split("/"); + + if (segments.length !== urlSegments.length) { + return false; + } + + for (let i = 0; i < segments.length; i++) { + if (segments[i] === "*") { + if (!urlSegments[i]) { + return false; + } + continue; + } + if (segments[i] !== urlSegments[i]) { + return false; + } + } + + return true; +} + +/** + * 域名匹配 + */ +function matchDomain(pattern: string, url: string): boolean { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return false; + } + + const urlHost = parsedUrl.hostname.toLowerCase(); + let domain = pattern.toLowerCase(); + + // 去除端口 + const colonIdx = domain.lastIndexOf(":"); + if (colonIdx >= 0) { + const portPart = domain.substring(colonIdx + 1); + if (/^\d+$/.test(portPart)) { + domain = domain.substring(0, colonIdx); + } + } + + // ***.example.com → 同时匹配根域名和多级子域 + if (domain.startsWith("***.")) { + const baseDomain = domain.substring(4); + return urlHost === baseDomain || urlHost.endsWith("." + baseDomain); + } + + // **.example.com → 匹配根域名和多级子域 + if (domain.startsWith("**.")) { + const baseDomain = domain.substring(3); + return urlHost === baseDomain || urlHost.endsWith("." + baseDomain); + } + + // *.example.com → 匹配子域名(含自身) + if (domain.startsWith("*.")) { + const baseDomain = domain.substring(2); + return urlHost === baseDomain || urlHost.endsWith("." + baseDomain); + } + + // example.com → 匹配自身及所有子域名 + return urlHost === domain || urlHost.endsWith("." + domain); +} + +/** + * 正则表达式匹配 + */ +function matchRegex(pattern: string, url: string): boolean { + const parsed = parseRegexPattern(pattern); + if (!parsed) { + return false; + } + const { body, flags } = parsed; + return new RegExp(body, flags).test(url); +} + +// ============================================================ +// 内部辅助函数 - 正则解析 +// ============================================================ + +function parseRegexPattern(pattern: string): { body: string; flags: string } | null { + if (!isRegexPattern(pattern)) { + return null; + } + const lastSlash = pattern.lastIndexOf(REGEX_DELIMITER); + const body = pattern.substring(1, lastSlash); + const flags = pattern.substring(lastSlash + 1); + try { + new RegExp(body, flags); + } catch { + return null; + } + return { body, flags }; +} + +// ============================================================ +// 内部辅助函数 - declarativeNetRequest 转换 +// ============================================================ + +function toRegexFilter(pattern: string): DeclarativeNetRequestFilter { + const parsed = parseRegexPattern(pattern); + if (!parsed) { + throw new Error(`[PatternMatcher] 无效的正则表达式: ${pattern}`); + } + return { regexFilter: parsed.body }; +} + +function toUrlFilter(pattern: string): DeclarativeNetRequestFilter { + // 全局通配 + if (pattern === "*" || pattern === "*://*") { + return { urlFilter: "*" }; + } + + let processed = pattern; + + // 去除 ^ 前缀(路径通配符声明,DNR 不需要) + if (processed.startsWith("^")) { + processed = processed.substring(1); + } + + // ***. 前缀 → ||(匹配域名及子域名) + if (processed.startsWith("***.")) { + const rest = processed.substring(4); + const slashIdx = rest.indexOf("/"); + const host = slashIdx >= 0 ? rest.substring(0, slashIdx) : rest; + const path = slashIdx >= 0 ? rest.substring(slashIdx) : "/*"; + return { urlFilter: `||${host}${path}` }; + } + + // **. 前缀 → || + if (processed.startsWith("**.")) { + const rest = processed.substring(3); + const slashIdx = rest.indexOf("/"); + const host = slashIdx >= 0 ? rest.substring(0, slashIdx) : rest; + const path = slashIdx >= 0 ? rest.substring(slashIdx) : "/*"; + return { urlFilter: `||${host}${path}` }; + } + + // 处理协议前缀 + if (processed.startsWith("http://")) { + processed = "|http://" + processed.substring(7); + } else if (processed.startsWith("https://")) { + processed = "|https://" + processed.substring(8); + } else if (processed.startsWith("*://")) { + processed = "||" + processed.substring(4); + } else if (!processed.startsWith("|") && !processed.startsWith("||")) { + processed = "||" + processed; + } + + // 路径中的 ** → *(DNR 的 * 本身匹配任意字符) + processed = processed.replace(/\/\*\*\//g, "/*"); + processed = processed.replace(/\*\*/g, "*"); + + // 确保有路径 + if (!processed.includes("/")) { + processed += "/*"; + } + + return { urlFilter: processed }; +} + +function domainToUrlFilter(pattern: string): DeclarativeNetRequestFilter { + let domain = pattern; + + const colonIdx = domain.lastIndexOf(":"); + if (colonIdx >= 0) { + const portPart = domain.substring(colonIdx + 1); + if (/^\d+$/.test(portPart)) { + domain = domain.substring(0, colonIdx); + } + } + + // ***. 和 **. 和 *. 都用 || 匹配域名及子域名 + if (domain.startsWith("***.")) { + domain = domain.substring(4); + } else if (domain.startsWith("**.")) { + domain = domain.substring(3); + } else if (domain.startsWith("*.")) { + domain = domain.substring(2); + } + + return { urlFilter: `||${domain}/*` }; +} + +function exactToUrlFilter(pattern: string): DeclarativeNetRequestFilter { + return { urlFilter: `|${pattern}|` }; +} + +// ============================================================ +// 内部辅助函数 - 验证 +// ============================================================ + +function validateRegex(pattern: string): PatternValidationResult { + const parsed = parseRegexPattern(pattern); + if (!parsed) { + return { + valid: false, + type: "regex", + error: `无效的正则表达式格式,正确格式: /正则体/标志`, + }; + } + return { valid: true, type: "regex" }; +} + +function validateWildcard(pattern: string): PatternValidationResult { + if (!pattern) { + return { valid: false, type: "wildcard", error: "通配符模式不能为空" }; + } + return { valid: true, type: "wildcard" }; +} + +function validateDomain(pattern: string): PatternValidationResult { + if (!pattern) { + return { valid: false, type: "domain", error: "域名不能为空" }; + } + + let domain = pattern; + if (domain.startsWith("***.") || domain.startsWith("**.") || domain.startsWith("*.")) { + const prefixLen = domain.startsWith("***.") ? 4 : domain.startsWith("**.") ? 3 : 2; + domain = domain.substring(prefixLen); + } + + const colonIdx = domain.lastIndexOf(":"); + if (colonIdx >= 0) { + const portPart = domain.substring(colonIdx + 1); + if (/^\d+$/.test(portPart)) { + domain = domain.substring(0, colonIdx); + } else { + return { valid: false, type: "domain", error: `无效的端口号: ${portPart}` }; + } + } + + const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/; + if (!domainRegex.test(domain)) { + return { valid: false, type: "domain", error: `无效的域名格式: ${domain}` }; + } + + return { valid: true, type: "domain" }; +} + +function validateExact(pattern: string): PatternValidationResult { + if (!pattern) { + return { valid: false, type: "exact", error: "URL 不能为空" }; + } + return { valid: true, type: "exact" }; +} + +// ============================================================ +// 通用工具函数 +// ============================================================ + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/tests/pkg/utils/patternMatcher.test.ts b/tests/pkg/utils/patternMatcher.test.ts new file mode 100644 index 000000000..8ec3ff4d0 --- /dev/null +++ b/tests/pkg/utils/patternMatcher.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it } from "vitest"; +import { + match, + parsePatternType, + toDeclarativeNetRequestFilter, + validatePattern, + getPatternExamples, +} from "@App/pkg/utils/patternMatcher"; + +describe("patternMatcher", () => { + // ============================================================ + // parsePatternType + // ============================================================ + describe("parsePatternType", () => { + it("应识别正则表达式模式", () => { + expect(parsePatternType("/https?:\\/\\/example\\.com/i")).toBe("regex"); + expect(parsePatternType("/^https:\\/\\/.*/")).toBe("regex"); + expect(parsePatternType("/test/i")).toBe("regex"); + }); + + it("应识别通配符模式", () => { + expect(parsePatternType("*://*.example.com/*")).toBe("wildcard"); + expect(parsePatternType("https://*.example.com/*")).toBe("wildcard"); + expect(parsePatternType("**.example.com/api/*")).toBe("wildcard"); + expect(parsePatternType("*")).toBe("wildcard"); + expect(parsePatternType("*://*")).toBe("wildcard"); + expect(parsePatternType("^http://*.example.com/data/*/result")).toBe("wildcard"); + // ***.example.com 无协议前缀,被识别为域名模式 + expect(parsePatternType("***.example.com")).toBe("domain"); + expect(parsePatternType("http*://test.abc**.com")).toBe("wildcard"); + }); + + it("应识别域名模式", () => { + expect(parsePatternType("example.com")).toBe("domain"); + expect(parsePatternType("*.example.com")).toBe("domain"); + expect(parsePatternType("**.example.com")).toBe("domain"); + expect(parsePatternType("***.example.com")).toBe("domain"); + expect(parsePatternType("example.com:8080")).toBe("domain"); + }); + + it("应识别精确匹配模式", () => { + expect(parsePatternType("https://example.com/path")).toBe("exact"); + expect(parsePatternType("http://localhost:3000/api")).toBe("exact"); + }); + + it("不应将正则误判为通配符", () => { + expect(parsePatternType("/https?:\\/\\/.*/i")).toBe("regex"); + }); + + it("不应将完整 URL 误判为域名", () => { + expect(parsePatternType("https://example.com")).not.toBe("domain"); + }); + }); + + // ============================================================ + // match - 精确匹配 + // ============================================================ + describe("match - exact", () => { + it("完全匹配 URL", () => { + expect(match("https://example.com/path", "https://example.com/path")).toBe(true); + }); + + it("不匹配不同的 URL", () => { + expect(match("https://example.com/path", "https://example.com/other")).toBe(false); + }); + + it("空输入返回 false", () => { + expect(match("", "https://example.com")).toBe(false); + expect(match("https://example.com", "")).toBe(false); + }); + }); + + // ============================================================ + // match - 通配符匹配(全局通配) + // ============================================================ + describe("match - wildcard (global)", () => { + it("* 应匹配所有 URL", () => { + expect(match("*", "https://example.com")).toBe(true); + expect(match("*", "http://localhost:3000/api")).toBe(true); + expect(match("*", "https://sub.example.com/path?q=1")).toBe(true); + expect(match("*", "https://example.com:8080/path#section")).toBe(true); + }); + + it("*://* 应匹配所有 URL", () => { + expect(match("*://*", "https://example.com")).toBe(true); + expect(match("*://*", "http://localhost:3000")).toBe(true); + }); + + it("* 不应匹配无效 URL", () => { + expect(match("*", "not-a-url")).toBe(false); + expect(match("*", "")).toBe(false); + }); + }); + + // ============================================================ + // match - 通配符匹配(域名通配符) + // ============================================================ + describe("match - wildcard (host)", () => { + it("*://*.example.com/* 应匹配子域名", () => { + expect(match("*://*.example.com/*", "https://sub.example.com/page")).toBe(true); + expect(match("*://*.example.com/*", "https://a.b.example.com/page")).toBe(true); + expect(match("*://*.example.com/*", "https://example.com/page")).toBe(false); + }); + + it("https://*.example.com/* 应匹配子域名", () => { + expect(match("https://*.example.com/*", "https://sub.example.com/page")).toBe(true); + expect(match("https://*.example.com/*", "http://sub.example.com/page")).toBe(false); + }); + + it("**.example.com/* 应匹配自身和子域名", () => { + expect(match("**.example.com/*", "https://example.com/page")).toBe(true); + expect(match("**.example.com/*", "https://sub.example.com/page")).toBe(true); + expect(match("**.example.com/*", "https://a.b.example.com/page")).toBe(true); + }); + + it("***.example.com 应匹配根域名和多级子域名", () => { + // ***. 域名模式(无协议前缀 → domain 类型) + expect(match("***.example.com", "https://example.com")).toBe(true); + expect(match("***.example.com", "https://sub.example.com")).toBe(true); + expect(match("***.example.com", "https://a.b.c.example.com/path")).toBe(true); + expect(match("***.example.com", "https://other.com")).toBe(false); + }); + + it("混合通配符 test.abc**.com", () => { + expect(match("http*://test.abc**.com", "https://test.abc.com")).toBe(true); + expect(match("http*://test.abc**.com", "https://test.abc.xyz.com")).toBe(true); + expect(match("http*://test.abc**.com", "https://test.abc.a.b.com")).toBe(true); + expect(match("http*://test.abc**.com", "https://test.other.com")).toBe(false); + }); + + it("协议中 * 匹配任意字母或冒号", () => { + expect(match("http*://example.com", "https://example.com")).toBe(true); + expect(match("http*://example.com", "http://example.com")).toBe(true); + }); + + it("带端口的通配符匹配", () => { + expect(match("https://*.example.com:8080/api/*", "https://sub.example.com:8080/api/page")).toBe(true); + }); + }); + + // ============================================================ + // match - 通配符匹配(路径通配符 - 需 ^ 前缀) + // ============================================================ + describe("match - wildcard (path with ^ prefix)", () => { + it("^ 前缀路径中 * 匹配单级(不含 / 和 ?)", () => { + expect(match("^http://*.example.com/data/*/result?q=*", "http://api.example.com/data/v1/result?q=123")).toBe( + true + ); + // * 不应匹配含 / 的内容 + expect(match("^http://*.example.com/data/*/result", "http://api.example.com/data/v1/v2/result")).toBe(false); + }); + + it("^ 前缀路径中 ** 匹配多级(不含 ?)", () => { + expect(match("^http://**.example.com/data/**file", "http://sub.example.com/data/path/to/testfile")).toBe(true); + expect(match("^http://**.example.com/data/**file", "http://sub.example.com/data/a/b/c/myfile")).toBe(true); + }); + + it("^ 前缀路径中 *** 匹配任意字符(含 / 和 ?)", () => { + expect(match("^http://*.example.com/data/***", "http://api.example.com/data/a/b/c?q=1&r=2")).toBe(true); + expect(match("^http://*.example.com/data/***file", "http://api.example.com/data/x/y/z/myfile")).toBe(true); + // *** 跨越 ? 匹配 + expect(match("^http://*.example.com/a/***z", "http://api.example.com/a/b/c?d=z")).toBe(true); + }); + + it("^ 前缀路径中 ? 是字面量查询字符串分隔符", () => { + expect(match("^http://example.com/api/?est", "http://example.com/api/?est")).toBe(true); + expect(match("^http://example.com/api/?est", "http://example.com/api/test")).toBe(false); + }); + }); + + // ============================================================ + // match - 通配符匹配(普通路径 - 无 ^ 前缀) + // ============================================================ + describe("match - wildcard (simple path)", () => { + it("路径中的 * 匹配单段", () => { + expect(match("https://example.com/*/page", "https://example.com/api/page")).toBe(true); + expect(match("https://example.com/*/page", "https://example.com/api/v2/page")).toBe(false); + }); + + it("路径 /* 匹配所有路径", () => { + expect(match("https://example.com/*", "https://example.com/anything")).toBe(true); + expect(match("https://example.com/*", "https://example.com/a/b")).toBe(false); + }); + }); + + // ============================================================ + // match - 正则匹配 + // ============================================================ + describe("match - regex", () => { + it("基本正则匹配", () => { + expect(match("/https?:\\/\\/example\\.com/i", "https://example.com")).toBe(true); + expect(match("/https?:\\/\\/example\\.com/i", "http://example.com")).toBe(true); + }); + + it("正则不匹配", () => { + expect(match("/https?:\\/\\/example\\.com/i", "https://other.com")).toBe(false); + }); + + it("正则应支持捕获组", () => { + expect(match("/^https:\\/\\/(www\\.)?example\\.com/", "https://example.com")).toBe(true); + expect(match("/^https:\\/\\/(www\\.)?example\\.com/", "https://www.example.com")).toBe(true); + }); + + it("无效正则不崩溃", () => { + expect(match("/[invalid/i", "https://example.com")).toBe(false); + }); + }); + + // ============================================================ + // match - 域名匹配 + // ============================================================ + describe("match - domain", () => { + it("example.com 应匹配自身和所有子域名", () => { + expect(match("example.com", "https://example.com")).toBe(true); + expect(match("example.com", "https://example.com/page")).toBe(true); + expect(match("example.com", "https://sub.example.com")).toBe(true); + expect(match("example.com", "https://a.b.example.com/path")).toBe(true); + }); + + it("*.example.com 应匹配子域名和自身(域名模式)", () => { + expect(match("*.example.com", "https://sub.example.com")).toBe(true); + expect(match("*.example.com", "https://example.com")).toBe(true); + }); + + it("**.example.com 应匹配自身和子域名", () => { + expect(match("**.example.com", "https://example.com")).toBe(true); + expect(match("**.example.com", "https://sub.example.com")).toBe(true); + expect(match("**.example.com", "https://a.b.example.com")).toBe(true); + }); + + it("***.example.com 应匹配根域名和多级子域名", () => { + expect(match("***.example.com", "https://example.com")).toBe(true); + expect(match("***.example.com", "https://sub.example.com")).toBe(true); + expect(match("***.example.com", "https://a.b.c.example.com")).toBe(true); + expect(match("***.example.com", "https://other.com")).toBe(false); + }); + + it("不应匹配不同域名", () => { + expect(match("example.com", "https://other.com")).toBe(false); + expect(match("example.com", "https://notexample.com")).toBe(false); + }); + + it("域名带端口", () => { + expect(match("example.com:8080", "https://example.com:8080/path")).toBe(true); + }); + }); + + // ============================================================ + // toDeclarativeNetRequestFilter + // ============================================================ + describe("toDeclarativeNetRequestFilter", () => { + it("全局通配 * → urlFilter: '*'", () => { + const result = toDeclarativeNetRequestFilter("*"); + expect(result).toEqual({ urlFilter: "*" }); + }); + + it("全局通配 *://* → urlFilter: '*'", () => { + const result = toDeclarativeNetRequestFilter("*://*"); + expect(result).toEqual({ urlFilter: "*" }); + }); + + it("正则模式 → regexFilter", () => { + const result = toDeclarativeNetRequestFilter("/https?:\\/\\/example\\.com/i"); + expect(result.regexFilter).toBe("https?:\\/\\/example\\.com"); + expect(result.urlFilter).toBeUndefined(); + }); + + it("域名模式 → urlFilter 以 || 开头", () => { + const result = toDeclarativeNetRequestFilter("example.com"); + expect(result.urlFilter).toBe("||example.com/*"); + }); + + it("通配符域名模式 → urlFilter 以 || 开头", () => { + const result = toDeclarativeNetRequestFilter("*://*.example.com/*"); + expect(result.urlFilter).toContain("example.com"); + }); + + it("**. 域名前缀 → urlFilter 以 || 开头", () => { + const result = toDeclarativeNetRequestFilter("**.example.com/api/*"); + expect(result.urlFilter).toBe("||example.com/api/*"); + }); + + it("***. 域名前缀 → urlFilter 以 || 开头", () => { + const result = toDeclarativeNetRequestFilter("***.example.com/api/*"); + expect(result.urlFilter).toBe("||example.com/api/*"); + }); + + it("^ 前缀路径通配符 → 去除 ^ 前缀后转换", () => { + const result = toDeclarativeNetRequestFilter("^http://*.example.com/data/*"); + expect(result.urlFilter).not.toContain("^"); + expect(result.urlFilter).toContain("example.com"); + }); + + it("精确 URL → urlFilter 以 | 开头和结尾", () => { + const result = toDeclarativeNetRequestFilter("https://example.com/path"); + expect(result.urlFilter).toBe("|https://example.com/path|"); + }); + + it("空模式应抛出错误", () => { + expect(() => toDeclarativeNetRequestFilter("")).toThrow(); + }); + }); + + // ============================================================ + // validatePattern + // ============================================================ + describe("validatePattern", () => { + it("空模式无效", () => { + const result = validatePattern(""); + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("有效正则", () => { + const result = validatePattern("/https?:\\/\\/example\\.com/i"); + expect(result.valid).toBe(true); + expect(result.type).toBe("regex"); + }); + + it("无效正则", () => { + const result = validatePattern("/[invalid/i"); + expect(result.valid).toBe(false); + expect(result.type).toBe("regex"); + }); + + it("有效通配符", () => { + const result = validatePattern("*://*.example.com/*"); + expect(result.valid).toBe(true); + expect(result.type).toBe("wildcard"); + }); + + it("有效 ^ 前缀路径通配符", () => { + const result = validatePattern("^http://*.example.com/data/*/result"); + expect(result.valid).toBe(true); + expect(result.type).toBe("wildcard"); + }); + + it("有效域名", () => { + const result = validatePattern("example.com"); + expect(result.valid).toBe(true); + expect(result.type).toBe("domain"); + }); + + it("有效 ***. 域名", () => { + const result = validatePattern("***.example.com"); + expect(result.valid).toBe(true); + expect(result.type).toBe("domain"); + }); + + it("有效精确 URL", () => { + const result = validatePattern("https://example.com/path"); + expect(result.valid).toBe(true); + expect(result.type).toBe("exact"); + }); + + it("无效域名格式(纯域名模式验证)", () => { + // -invalid.com 不匹配域名正则,会被识别为 exact 模式 + const result = validatePattern("-invalid.com"); + // 作为 exact 模式它是有效的(只是不太实用) + expect(result.valid).toBe(true); + expect(result.type).toBe("exact"); + }); + }); + + // ============================================================ + // getPatternExamples + // ============================================================ + describe("getPatternExamples", () => { + it("应返回非空示例数组", () => { + const examples = getPatternExamples(); + expect(Array.isArray(examples)).toBe(true); + expect(examples.length).toBeGreaterThan(0); + }); + + it("每个示例应有 type、pattern、description", () => { + const examples = getPatternExamples(); + for (const ex of examples) { + expect(ex).toHaveProperty("type"); + expect(ex).toHaveProperty("pattern"); + expect(ex).toHaveProperty("description"); + expect(ex.pattern).toBeTruthy(); + expect(ex.description).toBeTruthy(); + } + }); + + it("应包含所有四种模式类型", () => { + const examples = getPatternExamples(); + const types = new Set(examples.map((e) => e.type)); + expect(types.has("exact")).toBe(true); + expect(types.has("wildcard")).toBe(true); + expect(types.has("regex")).toBe(true); + expect(types.has("domain")).toBe(true); + }); + }); + + // ============================================================ + // 边界情况 + // ============================================================ + describe("edge cases", () => { + it("URL 带查询字符串", () => { + expect(match("*://*.example.com/*", "https://sub.example.com/path?q=1&r=2")).toBe(true); + expect(match("example.com", "https://example.com/search?q=test")).toBe(true); + }); + + it("URL 带片段", () => { + expect(match("example.com", "https://example.com/page#section")).toBe(true); + }); + + it("URL 带端口", () => { + expect(match("*://*.example.com/*", "https://sub.example.com:8080/path")).toBe(true); + }); + + it("大小写不敏感(域名)", () => { + expect(match("example.com", "https://EXAMPLE.COM")).toBe(true); + expect(match("example.com", "https://Example.Com/path")).toBe(true); + }); + + it("特殊字符域名", () => { + expect(match("example.com", "https://sub-example.com")).toBe(false); + }); + + it("http* 协议通配匹配 https 和 http", () => { + expect(match("http*://test.com", "https://test.com")).toBe(true); + expect(match("http*://test.com", "http://test.com")).toBe(true); + expect(match("http*://test.com", "ftp://test.com")).toBe(false); + }); + }); +});