Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9290733
docs
cyfung1031 Jul 17, 2026
fb767bb
✨ 增加 CSP 规则管理
cyfung1031 Jul 17, 2026
d5e4e97
Merge branch 'main' into agent/csp-rules-management
cyfung1031 Jul 17, 2026
f80d993
🐛 修复 CSP 规则并发与错误恢复
cyfung1031 Jul 17, 2026
523c253
🐛 修复 CSP 隐私窗口与编辑竞态
cyfung1031 Jul 17, 2026
b8e2bb2
Merge branch 'main' into agent/csp-rules-management
cyfung1031 Jul 17, 2026
3993eb6
🐛 修复 CSP 规则保存被状态广播失败阻断
cyfung1031 Jul 17, 2026
a0a4edc
🐛 修复 CSP 规则保存误报失败、删除气泡提前消失
cyfung1031 Jul 17, 2026
2c339d4
Merge branch 'main' into agent/csp-rules-management
cyfung1031 Jul 20, 2026
c6ab7f4
Merge branch 'main' into agent/csp-rules-management
cyfung1031 Jul 20, 2026
6589590
fix merge
cyfung1031 Jul 20, 2026
a4f4c1a
Merge branch 'main' into agent/csp-rules-management
CodFrm Aug 27, 2026
30e1d8b
🐛 CSP 规则归属改按 incognitoMode 判定,修 Firefox spanning 下隐身窗口误禁
CodFrm Aug 27, 2026
9f19587
♻️ CSP 规则管理就地泛化为通用网络规则管理
CodFrm Aug 30, 2026
2138e6b
✨ 新增 /tools/network-rules 网络规则列表页
CodFrm Aug 30, 2026
03b6924
✨ 网络规则两步式模板编辑抽屉,请求头黑名单在输入阶段拦下
CodFrm Aug 30, 2026
c4131fd
✨ Tools 摘要卡改为恒高无 CRUD,新增纯前端匹配测试对话框
CodFrm Aug 31, 2026
d7ee3a9
✨ 网络规则列表页新增批量启用、停用与删除操作栏
CodFrm Aug 31, 2026
70b1028
✨ 网络规则列表页按动作类型提示配额占用,数字取自运行时常量
CodFrm Aug 31, 2026
e18a497
🐛 修正网络规则的模拟裁决、域名条数校验与恢复期的状态竞争
CodFrm Aug 31, 2026
f7e0dd2
🐛 网络规则写入去掉回读自证,修真实 storage 键序重排导致的必然失败
CodFrm Aug 31, 2026
01503f1
🐛 网络规则默认覆盖主文档,并在请求阶段豁免 .user.js 安装导航
CodFrm Aug 31, 2026
0e4d772
🐛 网络规则拖拽语义收进手柄,成败反馈只报一次
CodFrm Aug 31, 2026
be2bf34
⚡ 网络规则行渲染 memo 化,并收拢批量进度、刷新范围与匹配裁决
CodFrm Aug 31, 2026
1cfacdd
🐛 隐身横幅撤回「规则在隐身窗口同样生效」,改说明需自行授予隐身权限
CodFrm Aug 31, 2026
aaff46b
♻️ 网络规则批量启用/停用/删除改为单次原子往返
CodFrm Aug 31, 2026
64516b0
♻️ 砍掉网络规则的隐身特判,高级区资源类型改用译名
CodFrm Aug 31, 2026
5735b49
✅ 网络规则测试改在真实 notify 单例上打桩,消除 isolate:false 下的跨文件泄漏
CodFrm Aug 31, 2026
4fd2ca1
✅ 网络规则清空选择用例按翻页/筛选拆开,各自只造所需行数
CodFrm Aug 31, 2026
1d73bd3
✅ 网络规则用例按各自断言所需裁行数,跨页断言改读页大小常量
CodFrm Aug 31, 2026
d108172
Merge remote-tracking branch 'origin/main' into pr/1598
CodFrm Sep 1, 2026
9dea527
💄 网络规则动作按类型上色,三处共用一份映射
CodFrm Sep 1, 2026
481b5a4
Merge branch 'pr/1598' into agent/csp-rules-management
CodFrm Sep 1, 2026
e097f95
✅ 网络规则测试按 aria-label 取控件,去掉整页 role 扫描
CodFrm Sep 1, 2026
a233e6d
✅ 网络规则批量停用首例单独放宽超时,ui 全局预算维持 850ms
CodFrm Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/chrome-extension-mock/declarativ_net_request.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
export default class DeclarativeNetRequest {
MAX_NUMBER_OF_SESSION_RULES = 5000;
MAX_NUMBER_OF_DYNAMIC_RULES = 30000;
MAX_NUMBER_OF_UNSAFE_DYNAMIC_RULES = 5000;
MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES = 5000;

private _sessionRules: chrome.declarativeNetRequest.Rule[] = [];
private _dynamicRules: chrome.declarativeNetRequest.Rule[] = [];
dynamicUpdateError: string | undefined;

HeaderOperation = {
APPEND: "append",
Expand Down Expand Up @@ -74,4 +79,38 @@ export default class DeclarativeNetRequest {
getSessionRules(): Promise<chrome.declarativeNetRequest.Rule[]> {
return Promise.resolve([...this._sessionRules]);
}

updateDynamicRules(options: chrome.declarativeNetRequest.UpdateRuleOptions, callback?: () => void): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (this.dynamicUpdateError) {
const message = this.dynamicUpdateError;
this.dynamicUpdateError = undefined;
(chrome.runtime as typeof chrome.runtime & { lastError?: chrome.runtime.LastError }).lastError = { message };
callback?.();
delete (chrome.runtime as typeof chrome.runtime & { lastError?: chrome.runtime.LastError }).lastError;
reject(new Error(message));
return;
}
const removeRuleIds = options.removeRuleIds ?? [];
const rules = this._dynamicRules.filter((rule) => !removeRuleIds.includes(rule.id));
for (const newRule of options.addRules ?? []) {
const index = rules.findIndex((rule) => rule.id === newRule.id);
if (index === -1) rules.push(newRule);
else rules[index] = newRule;
}
this._dynamicRules = rules;
callback?.();
resolve();
});
}

getDynamicRules(): Promise<chrome.declarativeNetRequest.Rule[]> {
return Promise.resolve([...this._dynamicRules]);
}

resetMock() {
this._sessionRules = [];
this._dynamicRules = [];
this.dynamicUpdateError = undefined;
}
}
26 changes: 25 additions & 1 deletion packages/chrome-extension-mock/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ type Schema = Record<string, unknown>;

type Callback<T> = (arg: T) => void;

function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

/**
* 真实 chrome.storage.* 写入时会序列化,读回的对象与调用方不再共享引用,且对象键按字母序重排
* (已在真实 Chrome 中实测)。mock 若原样保存调用方的对象,就会让依赖键序或引用相等的比较在单测里
* 恒真、在浏览器里失败,属于整类盲区,因此这里复现序列化语义。
* 只递归普通对象与数组:Date、Map 等非普通对象的真实序列化损耗未实测,不在此处臆造。
*/
function serializeLikeChromeStorage<T>(value: T): T {
if (Array.isArray(value)) return value.map(serializeLikeChromeStorage) as unknown as T;
if (isPlainObject(value)) {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
out[key] = serializeLikeChromeStorage(value[key]);
}
return out as unknown as T;
}
return value;
}

/**
* Storage root with independent schemas per area (optional).
* Usage:
Expand Down Expand Up @@ -141,7 +165,7 @@ class ChromeStorage<TSchema extends Schema = Schema> {
set(items: Partial<TSchema>): Promise<void>;
set(items: Partial<TSchema>, callback?: () => void): void | Promise<void> {
const apply = () => {
Object.assign(this.data, items);
Object.assign(this.data, serializeLikeChromeStorage(items));
};

if (callback) {
Expand Down
175 changes: 175 additions & 0 deletions src/app/repo/network_rule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_USER_RULES } from "@App/app/service/service_worker/dnr_rule_ids";
import {
NetworkRuleStateDAO,
NetworkRuleStorageError,
NetworkRuleStorageReadError,
NetworkRuleValidationError,
DEFAULT_NETWORK_RULE_STATE,
cspRemovalAction,
type NetworkRule,
type NetworkRuleState,
validateNetworkRuleState,
} from "./network_rule";

function rule(id: string, overrides: Partial<NetworkRule> = {}): NetworkRule {
return {
id,
name: id,
enabled: true,
condition: { requestDomains: [`${id}.example.com`] },
action: cspRemovalAction(),
createdAt: 1,
updatedAt: 1,
...overrides,
};
}

function state(rules: NetworkRule[], overrides: Partial<NetworkRuleState> = {}): NetworkRuleState {
return { ...DEFAULT_NETWORK_RULE_STATE, rules, order: rules.map((item) => item.id), ...overrides };
}

describe("NetworkRuleStateDAO", () => {
beforeEach(async () => {
await chrome.storage.local.clear();
});

it("缺失 storage key 时使用 revision 0 默认 state 且不写入 storage", async () => {
const dao = new NetworkRuleStateDAO();
expect(await dao.getState()).toBeUndefined();
expect(await chrome.storage.local.get()).toEqual({});
expect(DEFAULT_NETWORK_RULE_STATE).toEqual({
schemaVersion: 1,
revision: 0,
masterEnabled: true,
rules: [],
order: [],
});
});

it("storage read 返回 runtime.lastError 时拒绝,而不是伪装成缺失 state", async () => {
vi.spyOn(chrome.storage.local, "get").mockImplementationOnce(((
_key: string,
callback: (result: Record<string, unknown>) => void
) => {
const lastError = { message: "storage unavailable" };
Object.defineProperty(chrome.runtime, "lastError", { configurable: true, value: lastError });
callback({});
delete (chrome.runtime as unknown as Record<string, unknown>).lastError;
}) as never);

await expect(new NetworkRuleStateDAO().getState()).rejects.toBeInstanceOf(NetworkRuleStorageReadError);
});

it("保存后重新读取完整 state 并完成 round-trip", async () => {
const dao = new NetworkRuleStateDAO();
const saved = state([rule("one")], { revision: 1 });
await dao.saveState(saved);
expect(await dao.getState()).toEqual(saved);
});

it("storage 往返重排对象键序时保存仍算成功", async () => {
const dao = new NetworkRuleStateDAO();
const saved = state([rule("one")], { revision: 1 });

await expect(dao.saveState(saved)).resolves.toBeUndefined();

const readBack = await dao.getState();
expect(readBack).toEqual(saved);
// 真实 chrome.storage.local 会重排键序;mock 若退回保序,本用例会退化成同义反复,故在此钉住。
expect(Object.keys(readBack!.rules[0])).toEqual([...Object.keys(saved.rules[0])].sort());
expect(Object.keys(saved.rules[0])).not.toEqual([...Object.keys(saved.rules[0])].sort());
});

it("storage set 报告 runtime.lastError 时保存失败", async () => {
vi.spyOn(chrome.storage.local, "set").mockImplementationOnce(((_items: unknown, callback: () => void) => {
const lastError = { message: "QUOTA_BYTES quota exceeded" };
Object.defineProperty(chrome.runtime, "lastError", { configurable: true, value: lastError });
callback();
delete (chrome.runtime as unknown as Record<string, unknown>).lastError;
}) as never);

await expect(new NetworkRuleStateDAO().saveState(state([rule("one")], { revision: 1 }))).rejects.toBeInstanceOf(
NetworkRuleStorageError
);
});
});

describe("网络规则 state 结构校验", () => {
it("未知 schema 不会通过完整结构校验", () => {
expect(() => validateNetworkRuleState({ schemaVersion: 2 })).toThrowError(NetworkRuleValidationError);
});

it("order 必须是规则 ID 的完整排列", () => {
const rules = [rule("a"), rule("b")];
expect(() => validateNetworkRuleState(state(rules, { order: ["a"] }))).toThrowError(NetworkRuleValidationError);
expect(() => validateNetworkRuleState(state(rules, { order: ["a", "a"] }))).toThrowError(
NetworkRuleValidationError
);
expect(() => validateNetworkRuleState(state(rules, { order: ["a", "ghost"] }))).toThrowError(
NetworkRuleValidationError
);
expect(() => validateNetworkRuleState(state(rules, { order: ["b", "a"] }))).not.toThrow();
});

it("改写请求头时拒绝 Cookie / Authorization / Host / Origin", () => {
for (const header of ["cookie", "authorization", "host", "origin"]) {
expect(() =>
validateNetworkRuleState(
state([rule("h", { action: { type: "modifyRequestHeaders", headers: [{ header, operation: "remove" }] } })])
)
).toThrowError(NetworkRuleValidationError);
}
expect(() =>
validateNetworkRuleState(
state([
rule("h", {
action: { type: "modifyRequestHeaders", headers: [{ header: "x-flag", operation: "set", value: "1" }] },
}),
])
)
).not.toThrow();
});

it("移除 CSP 预设携带四个 CSP 头,X-Frame-Options 可选附带", () => {
expect(cspRemovalAction()).toEqual({
type: "removeResponseHeaders",
headers: [
"content-security-policy",
"content-security-policy-report-only",
"x-content-security-policy",
"x-webkit-csp",
],
});
const withXFrameOptions = cspRemovalAction(true);
expect(withXFrameOptions.type === "removeResponseHeaders" ? withXFrameOptions.headers : []).toContain(
"x-frame-options"
);
});

it("条件缺少匹配式、动作字段非法或规则数超过保留段容量时拒绝", () => {
expect(() => validateNetworkRuleState(state([rule("n", { condition: {} })]))).toThrowError(
NetworkRuleValidationError
);
expect(() =>
validateNetworkRuleState(state([rule("n", { action: { type: "redirect", url: "javascript:0" } })]))
).toThrowError(NetworkRuleValidationError);
expect(() =>
validateNetworkRuleState(state([rule("n", { action: { type: "redirect", url: "https://example.com/" } })]))
).not.toThrow();
expect(() =>
validateNetworkRuleState({ ...DEFAULT_NETWORK_RULE_STATE, rules: new Array(MAX_USER_RULES + 1).fill(rule("n")) })
).toThrowError(NetworkRuleValidationError);
});

it("域名必须已规范化且同一列表内不重复", () => {
expect(() =>
validateNetworkRuleState(state([rule("n", { condition: { requestDomains: ["Example.com"] } })]))
).toThrowError(NetworkRuleValidationError);
expect(() =>
validateNetworkRuleState(
state([rule("n", { condition: { requestDomains: ["a.example.com", "a.example.com"] } })])
)
).toThrowError(NetworkRuleValidationError);
});
});
Loading
Loading