From c118ef802ff4753af7de1aa50334c2b9f615beb0 Mon Sep 17 00:00:00 2001 From: 212duo <48330894+212duo@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:35:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(core):=20=E8=8A=82=E7=82=B9=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=8C=87=E7=BA=B9=E9=80=92=E5=BD=92=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E5=86=85=E9=83=A8=20=5F=20=E5=89=8D=E7=BC=80=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildNodeContentKey 只在节点顶层过滤 _ 前缀的内部字段,嵌套对象 (如 reality-opts 中的 _spider-x)未过滤。机场每次更新订阅都会 轮换 spider-x,导致内容指纹变化、智能匹配永远失败,节点被当成 全新节点重复插入列表末尾。 修复 stableJsonStringify 递归时同样忽略 _ 前缀键,与顶层行为一致。 新增 node-identity 单测和 source-node-refresh 场景测试覆盖。 --- packages/core/src/node-identity.test.ts | 56 +++++++++++++++++ packages/core/src/node-identity.ts | 7 ++- .../subscription/source-node-refresh.test.ts | 62 +++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/node-identity.test.ts diff --git a/packages/core/src/node-identity.test.ts b/packages/core/src/node-identity.test.ts new file mode 100644 index 0000000..1fc333b --- /dev/null +++ b/packages/core/src/node-identity.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedNode } from "./types/node"; +import { buildNodeContentKey } from "./node-identity"; + +// 测试数据全部为虚构,不包含任何真实订阅信息 +function realityNode(name: string, uuid: string, spiderX: string, patch: Record = {}): ParsedNode { + return { + name, + type: "vless", + server: "reality.example.com", + port: 443, + uuid, + tls: true, + "client-fingerprint": "chrome", + flow: "xtls-rprx-vision", + network: "tcp", + "reality-opts": { + "public-key": "FAKE_PUBLIC_KEY_AAAAAAAAAAAAAAAA", + "short-id": "0000", + "_spider-x": spiderX, + }, + ...patch, + } as unknown as ParsedNode; +} + +const UUID_A = "11111111-1111-1111-1111-111111111111"; +const UUID_B = "22222222-2222-2222-2222-222222222222"; + +describe("buildNodeContentKey", () => { + it("ignores nested internal underscore fields such as reality-opts._spider-x", () => { + const stored = realityNode("Reality Node A (v1)", UUID_A, "/spider-old"); + const fresh = realityNode("Reality Node A (v2)", UUID_A, "/spider-new"); + + // spider-x 轮换 + 显示名动态变化,不应改变内容指纹 + expect(buildNodeContentKey(fresh)).toBe(buildNodeContentKey(stored)); + }); + + it("still distinguishes nodes whose real identity fields differ", () => { + const nodeA = realityNode("Node A", UUID_A, "/spider-a"); + const nodeB = realityNode("Node B", UUID_B, "/spider-a"); + + expect(buildNodeContentKey(nodeB)).not.toBe(buildNodeContentKey(nodeA)); + }); + + it("keeps ignoring top-level internal fields (existing behavior)", () => { + const base = realityNode("Same Node", UUID_A, "/spider"); + const withMeta = { + ...base, + _sourceIds: ["source-a"], + _originName: "origin-a", + _meta: { importedAt: 1 }, + }; + + expect(buildNodeContentKey(withMeta)).toBe(buildNodeContentKey(base)); + }); +}); diff --git a/packages/core/src/node-identity.ts b/packages/core/src/node-identity.ts index a1ac8dd..f9a51fd 100644 --- a/packages/core/src/node-identity.ts +++ b/packages/core/src/node-identity.ts @@ -20,7 +20,12 @@ export function stableJsonStringify(value: unknown): string { const obj = input as Record; const keys = Object.keys(obj).sort(); const out: Record = {}; - for (const key of keys) out[key] = normalize(obj[key]); + for (const key of keys) { + // 与 buildNodeContentKey 顶层行为一致:递归忽略 SubBoost 内部 _ 前缀字段 + // (如 reality-opts._spider-x,机场每次更新轮换会导致内容指纹变化、节点匹配失败) + if (key.startsWith("_")) continue; + out[key] = normalize(obj[key]); + } return out; }; diff --git a/packages/core/src/subscription/source-node-refresh.test.ts b/packages/core/src/subscription/source-node-refresh.test.ts index e4ed0e1..ff4eb6d 100644 --- a/packages/core/src/subscription/source-node-refresh.test.ts +++ b/packages/core/src/subscription/source-node-refresh.test.ts @@ -554,6 +554,68 @@ describe("source node refresh helpers", () => { [SOURCE_IDS_KEY]: ["source-b", "source-a"], }); }); + + it("keeps matching a reality node when the airport rotates spider-x but uuid stays stable", () => { + // 测试数据全部为虚构,不包含任何真实订阅信息 + const state = [ + { + name: "Reality Node A (v1)", + type: "vless", + server: "reality.example.com", + port: 443, + uuid: "11111111-1111-1111-1111-111111111111", + tls: true, + "client-fingerprint": "chrome", + flow: "xtls-rprx-vision", + network: "tcp", + udp: true, + "reality-opts": { + "public-key": "FAKE_PUBLIC_KEY_AAAAAAAAAAAAAAAA", + "short-id": "0000", + "_spider-x": "/spider-old", + }, + [ORIGIN_NAME_KEY]: "Reality Node A (v1)", + [SOURCE_IDS_KEY]: ["source-a"], + }, + ]; + const parsed = prepareSourceParsedNodes( + [ + { + name: "Reality Node A (v2)", + type: "vless", + server: "reality.example.com", + port: 443, + uuid: "11111111-1111-1111-1111-111111111111", + tls: true, + "client-fingerprint": "chrome", + flow: "xtls-rprx-vision", + network: "tcp", + udp: true, + "reality-opts": { + "public-key": "FAKE_PUBLIC_KEY_AAAAAAAAAAAAAAAA", + "short-id": "0000", + "_spider-x": "/spider-new", + }, + }, + ], + {} + ); + + const result = mergeParsedSourceNodes(state, parsed, [], { + sourceId: "source-a", + }); + + // 匹配成功:不产生重复节点,保留原位置与原显示名,spider-x 更新为新值 + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]).toMatchObject({ + name: "Reality Node A (v1)", + uuid: "11111111-1111-1111-1111-111111111111", + [SOURCE_IDS_KEY]: ["source-a"], + }); + expect( + (result.nodes[0] as unknown as { "reality-opts"?: { "_spider-x"?: string } })["reality-opts"]?.["_spider-x"] + ).toBe("/spider-new"); + }); }); describe("subscription response info helpers", () => { From 22288c0947cb0d80c55b3619bd2beb2dc554dc73 Mon Sep 17 00:00:00 2001 From: Ryson-32 <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:32:44 +0800 Subject: [PATCH 2/2] fix(core): preserve node refresh identity contracts --- packages/core/src/node-identity.test.ts | 12 ++- packages/core/src/node-identity.ts | 17 ++-- .../source-node-refresh-reality.test.ts | 87 +++++++++++++++++++ .../subscription/source-node-refresh.test.ts | 61 ------------- .../src/subscription/source-node-refresh.ts | 5 +- 5 files changed, 113 insertions(+), 69 deletions(-) create mode 100644 packages/core/src/subscription/source-node-refresh-reality.test.ts diff --git a/packages/core/src/node-identity.test.ts b/packages/core/src/node-identity.test.ts index 1fc333b..3100164 100644 --- a/packages/core/src/node-identity.test.ts +++ b/packages/core/src/node-identity.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ParsedNode } from "./types/node"; -import { buildNodeContentKey } from "./node-identity"; +import { buildNodeContentKey, stableJsonStringify } from "./node-identity"; // 测试数据全部为虚构,不包含任何真实订阅信息 function realityNode(name: string, uuid: string, spiderX: string, patch: Record = {}): ParsedNode { @@ -26,6 +26,16 @@ function realityNode(name: string, uuid: string, spiderX: string, patch: Record< const UUID_A = "11111111-1111-1111-1111-111111111111"; const UUID_B = "22222222-2222-2222-2222-222222222222"; +describe("stableJsonStringify", () => { + it("preserves nested underscore-prefixed keys for generic serialization", () => { + const first = stableJsonStringify({ nested: { _nonce: "a", value: 1 } }); + const second = stableJsonStringify({ nested: { _nonce: "b", value: 1 } }); + + expect(first).toBe('{"nested":{"_nonce":"a","value":1}}'); + expect(second).not.toBe(first); + }); +}); + describe("buildNodeContentKey", () => { it("ignores nested internal underscore fields such as reality-opts._spider-x", () => { const stored = realityNode("Reality Node A (v1)", UUID_A, "/spider-old"); diff --git a/packages/core/src/node-identity.ts b/packages/core/src/node-identity.ts index f9a51fd..a0b9ff6 100644 --- a/packages/core/src/node-identity.ts +++ b/packages/core/src/node-identity.ts @@ -7,7 +7,10 @@ export interface NodeContentKeyOptions { ignoreServername?: boolean; } -export function stableJsonStringify(value: unknown): string { +function stableJsonStringifyWithKeyFilter( + value: unknown, + includeKey: (key: string) => boolean +): string { const seen = new WeakSet(); const normalize = (input: unknown): unknown => { @@ -21,9 +24,7 @@ export function stableJsonStringify(value: unknown): string { const keys = Object.keys(obj).sort(); const out: Record = {}; for (const key of keys) { - // 与 buildNodeContentKey 顶层行为一致:递归忽略 SubBoost 内部 _ 前缀字段 - // (如 reality-opts._spider-x,机场每次更新轮换会导致内容指纹变化、节点匹配失败) - if (key.startsWith("_")) continue; + if (!includeKey(key)) continue; out[key] = normalize(obj[key]); } return out; @@ -32,6 +33,10 @@ export function stableJsonStringify(value: unknown): string { return JSON.stringify(normalize(value)); } +export function stableJsonStringify(value: unknown): string { + return stableJsonStringifyWithKeyFilter(value, () => true); +} + export function buildNodeContentKey( node: ParsedNode, opts?: NodeContentKeyOptions @@ -48,7 +53,9 @@ export function buildNodeContentKey( }) ); - return stableJsonStringify(filtered); + // 节点内部字段不属于内容身份;例如机场轮换 reality-opts._spider-x + // 时,节点的稳定身份不应随之变化。 + return stableJsonStringifyWithKeyFilter(filtered, (key) => !key.startsWith("_")); } export function buildScopedNodeIdentityKey(scope: string, node: ParsedNode): string { diff --git a/packages/core/src/subscription/source-node-refresh-reality.test.ts b/packages/core/src/subscription/source-node-refresh-reality.test.ts new file mode 100644 index 0000000..3ab5887 --- /dev/null +++ b/packages/core/src/subscription/source-node-refresh-reality.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedNode } from "../types/node"; +import { ORIGIN_NAME_KEY, SOURCE_IDS_KEY } from "./node-source-state"; +import { mergeParsedSourceNodes, prepareSourceParsedNodes } from "./source-node-refresh"; + +const REALITY_UUID = "11111111-1111-1111-1111-111111111111"; + +function realityNode(name: string, spiderX: string, patch: Record = {}): ParsedNode { + return { + name, + type: "vless", + server: "reality.example.com", + port: 443, + uuid: REALITY_UUID, + tls: true, + "client-fingerprint": "chrome", + flow: "xtls-rprx-vision", + network: "tcp", + udp: true, + "reality-opts": { + "public-key": "FAKE_PUBLIC_KEY_AAAAAAAAAAAAAAAA", + "short-id": "0000", + "_spider-x": spiderX, + }, + ...patch, + } as ParsedNode; +} + +function ssNode(name: string): ParsedNode { + return { + name, + type: "ss", + server: `${name.toLowerCase()}.example.com`, + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + } as ParsedNode; +} + +describe("source node refresh reality identity", () => { + it("keeps matching a reality node when spider-x rotates and updates its automatic name", () => { + const state = [ + ssNode("before"), + realityNode("Reality Node A (v1)", "/spider-old", { + [ORIGIN_NAME_KEY]: "Reality Node A (v1)", + [SOURCE_IDS_KEY]: ["source-a"], + }), + ssNode("after"), + ]; + const parsed = prepareSourceParsedNodes([realityNode("Reality Node A (v2)", "/spider-new")], {}); + + const result = mergeParsedSourceNodes(state, parsed, [], { sourceId: "source-a" }); + + expect(result.nodes.map((node) => node.name)).toEqual(["before", "Reality Node A (v2)", "after"]); + expect(result.nodes[1]).toMatchObject({ + uuid: REALITY_UUID, + [ORIGIN_NAME_KEY]: "Reality Node A (v2)", + [SOURCE_IDS_KEY]: ["source-a"], + }); + expect( + (result.nodes[1] as unknown as { "reality-opts"?: { "_spider-x"?: string } })["reality-opts"]?.["_spider-x"] + ).toBe("/spider-new"); + }); + + it("preserves a manual name while refreshing the origin and spider-x", () => { + const state = [ + realityNode("Pinned Reality Name", "/spider-old", { + [ORIGIN_NAME_KEY]: "Reality Node A (v1)", + [SOURCE_IDS_KEY]: ["source-a"], + }), + ]; + const parsed = prepareSourceParsedNodes([realityNode("Reality Node A (v2)", "/spider-new")], {}); + + const result = mergeParsedSourceNodes(state, parsed, [], { sourceId: "source-a" }); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]).toMatchObject({ + name: "Pinned Reality Name", + uuid: REALITY_UUID, + [ORIGIN_NAME_KEY]: "Reality Node A (v2)", + [SOURCE_IDS_KEY]: ["source-a"], + }); + expect( + (result.nodes[0] as unknown as { "reality-opts"?: { "_spider-x"?: string } })["reality-opts"]?.["_spider-x"] + ).toBe("/spider-new"); + }); +}); diff --git a/packages/core/src/subscription/source-node-refresh.test.ts b/packages/core/src/subscription/source-node-refresh.test.ts index ff4eb6d..165cca8 100644 --- a/packages/core/src/subscription/source-node-refresh.test.ts +++ b/packages/core/src/subscription/source-node-refresh.test.ts @@ -555,67 +555,6 @@ describe("source node refresh helpers", () => { }); }); - it("keeps matching a reality node when the airport rotates spider-x but uuid stays stable", () => { - // 测试数据全部为虚构,不包含任何真实订阅信息 - const state = [ - { - name: "Reality Node A (v1)", - type: "vless", - server: "reality.example.com", - port: 443, - uuid: "11111111-1111-1111-1111-111111111111", - tls: true, - "client-fingerprint": "chrome", - flow: "xtls-rprx-vision", - network: "tcp", - udp: true, - "reality-opts": { - "public-key": "FAKE_PUBLIC_KEY_AAAAAAAAAAAAAAAA", - "short-id": "0000", - "_spider-x": "/spider-old", - }, - [ORIGIN_NAME_KEY]: "Reality Node A (v1)", - [SOURCE_IDS_KEY]: ["source-a"], - }, - ]; - const parsed = prepareSourceParsedNodes( - [ - { - name: "Reality Node A (v2)", - type: "vless", - server: "reality.example.com", - port: 443, - uuid: "11111111-1111-1111-1111-111111111111", - tls: true, - "client-fingerprint": "chrome", - flow: "xtls-rprx-vision", - network: "tcp", - udp: true, - "reality-opts": { - "public-key": "FAKE_PUBLIC_KEY_AAAAAAAAAAAAAAAA", - "short-id": "0000", - "_spider-x": "/spider-new", - }, - }, - ], - {} - ); - - const result = mergeParsedSourceNodes(state, parsed, [], { - sourceId: "source-a", - }); - - // 匹配成功:不产生重复节点,保留原位置与原显示名,spider-x 更新为新值 - expect(result.nodes).toHaveLength(1); - expect(result.nodes[0]).toMatchObject({ - name: "Reality Node A (v1)", - uuid: "11111111-1111-1111-1111-111111111111", - [SOURCE_IDS_KEY]: ["source-a"], - }); - expect( - (result.nodes[0] as unknown as { "reality-opts"?: { "_spider-x"?: string } })["reality-opts"]?.["_spider-x"] - ).toBe("/spider-new"); - }); }); describe("subscription response info helpers", () => { diff --git a/packages/core/src/subscription/source-node-refresh.ts b/packages/core/src/subscription/source-node-refresh.ts index a977053..f73ee18 100644 --- a/packages/core/src/subscription/source-node-refresh.ts +++ b/packages/core/src/subscription/source-node-refresh.ts @@ -332,7 +332,8 @@ export function mergeParsedSourceNodes( if (sourceIds.includes(sourceId)) { hadExistingSourceNodes = true; - const originName = resolveOriginName(node, { allowDisplayNameFallback }) ?? originOf(node); + const previousOriginName = originOf(node); + const originName = resolveOriginName(node, { allowDisplayNameFallback }) ?? previousOriginName; const fresh = originName ? takeFresh(originName) : null; const base = withoutNodeSourceIds(node, removed); @@ -341,7 +342,7 @@ export function mergeParsedSourceNodes( continue; } - const keepUserName = originName ? isUserRenamed(node.name, originName) : false; + const keepUserName = previousOriginName ? isUserRenamed(node.name, previousOriginName) : false; const desiredName = keepUserName ? node.name : fresh.name; const extraIds = base ? getNodeSourceIds(base) : sourceIds.filter((id) => id !== sourceId);