Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 66 additions & 0 deletions packages/core/src/node-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}): 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));
});
});
18 changes: 15 additions & 3 deletions packages/core/src/node-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>();

const normalize = (input: unknown): unknown => {
Expand All @@ -20,13 +23,20 @@ export function stableJsonStringify(value: unknown): string {
const obj = input as Record<string, unknown>;
const keys = Object.keys(obj).sort();
const out: Record<string, unknown> = {};
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
Expand All @@ -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 {
Expand Down
87 changes: 87 additions & 0 deletions packages/core/src/subscription/source-node-refresh-reality.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}): 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");
});
});
1 change: 1 addition & 0 deletions packages/core/src/subscription/source-node-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ describe("source node refresh helpers", () => {
[SOURCE_IDS_KEY]: ["source-b", "source-a"],
});
});

});

describe("subscription response info helpers", () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/subscription/source-node-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);

Expand Down