diff --git a/packages/core/src/node-identity.test.ts b/packages/core/src/node-identity.test.ts new file mode 100644 index 0000000..3100164 --- /dev/null +++ b/packages/core/src/node-identity.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedNode } from "./types/node"; +import { buildNodeContentKey, stableJsonStringify } 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("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"); + 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..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 => { @@ -20,13 +23,20 @@ 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) { + if (!includeKey(key)) continue; + out[key] = normalize(obj[key]); + } return out; }; return JSON.stringify(normalize(value)); } +export function stableJsonStringify(value: unknown): string { + return stableJsonStringifyWithKeyFilter(value, () => true); +} + export function buildNodeContentKey( node: ParsedNode, opts?: NodeContentKeyOptions @@ -43,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 e4ed0e1..165cca8 100644 --- a/packages/core/src/subscription/source-node-refresh.test.ts +++ b/packages/core/src/subscription/source-node-refresh.test.ts @@ -554,6 +554,7 @@ describe("source node refresh helpers", () => { [SOURCE_IDS_KEY]: ["source-b", "source-a"], }); }); + }); 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);