Skip to content

Commit c118ef8

Browse files
committed
fix(core): 节点内容指纹递归忽略内部 _ 前缀字段
buildNodeContentKey 只在节点顶层过滤 _ 前缀的内部字段,嵌套对象 (如 reality-opts 中的 _spider-x)未过滤。机场每次更新订阅都会 轮换 spider-x,导致内容指纹变化、智能匹配永远失败,节点被当成 全新节点重复插入列表末尾。 修复 stableJsonStringify 递归时同样忽略 _ 前缀键,与顶层行为一致。 新增 node-identity 单测和 source-node-refresh 场景测试覆盖。
1 parent 6a3557e commit c118ef8

3 files changed

Lines changed: 124 additions & 1 deletion

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { ParsedNode } from "./types/node";
3+
import { buildNodeContentKey } from "./node-identity";
4+
5+
// 测试数据全部为虚构,不包含任何真实订阅信息
6+
function realityNode(name: string, uuid: string, spiderX: string, patch: Record<string, unknown> = {}): ParsedNode {
7+
return {
8+
name,
9+
type: "vless",
10+
server: "reality.example.com",
11+
port: 443,
12+
uuid,
13+
tls: true,
14+
"client-fingerprint": "chrome",
15+
flow: "xtls-rprx-vision",
16+
network: "tcp",
17+
"reality-opts": {
18+
"public-key": "FAKE_PUBLIC_KEY_AAAAAAAAAAAAAAAA",
19+
"short-id": "0000",
20+
"_spider-x": spiderX,
21+
},
22+
...patch,
23+
} as unknown as ParsedNode;
24+
}
25+
26+
const UUID_A = "11111111-1111-1111-1111-111111111111";
27+
const UUID_B = "22222222-2222-2222-2222-222222222222";
28+
29+
describe("buildNodeContentKey", () => {
30+
it("ignores nested internal underscore fields such as reality-opts._spider-x", () => {
31+
const stored = realityNode("Reality Node A (v1)", UUID_A, "/spider-old");
32+
const fresh = realityNode("Reality Node A (v2)", UUID_A, "/spider-new");
33+
34+
// spider-x 轮换 + 显示名动态变化,不应改变内容指纹
35+
expect(buildNodeContentKey(fresh)).toBe(buildNodeContentKey(stored));
36+
});
37+
38+
it("still distinguishes nodes whose real identity fields differ", () => {
39+
const nodeA = realityNode("Node A", UUID_A, "/spider-a");
40+
const nodeB = realityNode("Node B", UUID_B, "/spider-a");
41+
42+
expect(buildNodeContentKey(nodeB)).not.toBe(buildNodeContentKey(nodeA));
43+
});
44+
45+
it("keeps ignoring top-level internal fields (existing behavior)", () => {
46+
const base = realityNode("Same Node", UUID_A, "/spider");
47+
const withMeta = {
48+
...base,
49+
_sourceIds: ["source-a"],
50+
_originName: "origin-a",
51+
_meta: { importedAt: 1 },
52+
};
53+
54+
expect(buildNodeContentKey(withMeta)).toBe(buildNodeContentKey(base));
55+
});
56+
});

packages/core/src/node-identity.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ export function stableJsonStringify(value: unknown): string {
2020
const obj = input as Record<string, unknown>;
2121
const keys = Object.keys(obj).sort();
2222
const out: Record<string, unknown> = {};
23-
for (const key of keys) out[key] = normalize(obj[key]);
23+
for (const key of keys) {
24+
// 与 buildNodeContentKey 顶层行为一致:递归忽略 SubBoost 内部 _ 前缀字段
25+
// (如 reality-opts._spider-x,机场每次更新轮换会导致内容指纹变化、节点匹配失败)
26+
if (key.startsWith("_")) continue;
27+
out[key] = normalize(obj[key]);
28+
}
2429
return out;
2530
};
2631

packages/core/src/subscription/source-node-refresh.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,68 @@ describe("source node refresh helpers", () => {
554554
[SOURCE_IDS_KEY]: ["source-b", "source-a"],
555555
});
556556
});
557+
558+
it("keeps matching a reality node when the airport rotates spider-x but uuid stays stable", () => {
559+
// 测试数据全部为虚构,不包含任何真实订阅信息
560+
const state = [
561+
{
562+
name: "Reality Node A (v1)",
563+
type: "vless",
564+
server: "reality.example.com",
565+
port: 443,
566+
uuid: "11111111-1111-1111-1111-111111111111",
567+
tls: true,
568+
"client-fingerprint": "chrome",
569+
flow: "xtls-rprx-vision",
570+
network: "tcp",
571+
udp: true,
572+
"reality-opts": {
573+
"public-key": "FAKE_PUBLIC_KEY_AAAAAAAAAAAAAAAA",
574+
"short-id": "0000",
575+
"_spider-x": "/spider-old",
576+
},
577+
[ORIGIN_NAME_KEY]: "Reality Node A (v1)",
578+
[SOURCE_IDS_KEY]: ["source-a"],
579+
},
580+
];
581+
const parsed = prepareSourceParsedNodes(
582+
[
583+
{
584+
name: "Reality Node A (v2)",
585+
type: "vless",
586+
server: "reality.example.com",
587+
port: 443,
588+
uuid: "11111111-1111-1111-1111-111111111111",
589+
tls: true,
590+
"client-fingerprint": "chrome",
591+
flow: "xtls-rprx-vision",
592+
network: "tcp",
593+
udp: true,
594+
"reality-opts": {
595+
"public-key": "FAKE_PUBLIC_KEY_AAAAAAAAAAAAAAAA",
596+
"short-id": "0000",
597+
"_spider-x": "/spider-new",
598+
},
599+
},
600+
],
601+
{}
602+
);
603+
604+
const result = mergeParsedSourceNodes(state, parsed, [], {
605+
sourceId: "source-a",
606+
});
607+
608+
// 匹配成功:不产生重复节点,保留原位置与原显示名,spider-x 更新为新值
609+
expect(result.nodes).toHaveLength(1);
610+
expect(result.nodes[0]).toMatchObject({
611+
name: "Reality Node A (v1)",
612+
uuid: "11111111-1111-1111-1111-111111111111",
613+
[SOURCE_IDS_KEY]: ["source-a"],
614+
});
615+
expect(
616+
(result.nodes[0] as unknown as { "reality-opts"?: { "_spider-x"?: string } })["reality-opts"]?.["_spider-x"]
617+
).toBe("/spider-new");
618+
});
557619
});
558620

559621
describe("subscription response info helpers", () => {

0 commit comments

Comments
 (0)