From e47d9d3007ae2581ddec91092b059beb1de2cd4e Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 04:52:02 +0800 Subject: [PATCH] fix(net): stabilize shared wire graph audits --- .../assets/framework/net/wire-client.ts | 22 ++++- .../framework-tests/net/wire-client.test.ts | 90 +++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/framework/net/wire-client.ts b/cocoscreator_projects/YouleNexus/assets/framework/net/wire-client.ts index ea6c4b5..97d0978 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/net/wire-client.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/net/wire-client.ts @@ -64,8 +64,11 @@ function propertyPath(parent: string, key: string | symbol): string { } function assertNoInheritedToJson(prototype: object | null, path: string): void { + const visited = new Set(); let current = prototype; while (current !== null) { + if (visited.has(current)) invalidEnvelope(path, 'prototype cycle is not allowed'); + visited.add(current); if (Object.getOwnPropertyDescriptor(current, 'toJSON')) { invalidEnvelope(path, 'inherited toJSON is not allowed'); } @@ -165,6 +168,7 @@ function snapshotJsonValue( path: string, ancestors: Set, audits: CapturedNode[], + snapshots: Map, ): unknown { if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; if (typeof value === 'number') { @@ -178,6 +182,7 @@ function snapshotJsonValue( const objectValue = value as object; if (ancestors.has(objectValue)) invalidEnvelope(path, 'cycle is not protocol JSON data'); + if (snapshots.has(objectValue)) return snapshots.get(objectValue); ancestors.add(objectValue); try { const captured = captureNode(objectValue, path, audits); @@ -192,6 +197,7 @@ function snapshotJsonValue( const allowedKeys = new Set(['length']); const snapshot: unknown[] = []; Object.setPrototypeOf(snapshot, null); + snapshots.set(objectValue, snapshot); for (let index = 0; index < (length as number); index++) { const key = String(index); const itemPath = `${path}[${index}]`; @@ -204,6 +210,7 @@ function snapshotJsonValue( itemPath, ancestors, audits, + snapshots, ); Object.defineProperty(snapshot, key, { configurable: true, @@ -225,6 +232,7 @@ function snapshotJsonValue( invalidEnvelope(path, 'expected plain object or null-prototype object'); } const snapshot = Object.create(null) as Record; + snapshots.set(objectValue, snapshot); for (const { key } of captured.properties) { if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed'); const nestedPath = childPath(path, key); @@ -233,6 +241,7 @@ function snapshotJsonValue( nestedPath, ancestors, audits, + snapshots, ); Object.defineProperty(snapshot, key, { configurable: true, @@ -252,6 +261,7 @@ function serializeOutbound(envelope: OutboundEnvelope): string { return invalidEnvelope('$', 'expected object'); } const audits: CapturedNode[] = []; + const snapshots = new Map(); const captured = captureNode(envelope, '$', audits); if (captured.prototype !== Object.prototype && captured.prototype !== null) { return invalidEnvelope('$', 'expected plain object or null-prototype object'); @@ -272,13 +282,21 @@ function serializeOutbound(envelope: OutboundEnvelope): string { if (typeof rpc !== 'string' || rpc.length === 0) { invalidEnvelope('$.rpc', 'expected non-empty string'); } + const snapshot = Object.create(null) as Record; + snapshots.set(envelope, snapshot); + const ancestors = new Set([envelope]); + let dataSnapshot: unknown; + try { + dataSnapshot = snapshotJsonValue(data, '$.data', ancestors, audits, snapshots); + } finally { + ancestors.delete(envelope); + } const snapshotValues = new Map([ ['app', app], ['route', route], ['rpc', rpc], - ['data', snapshotJsonValue(data, '$.data', new Set(), audits)], + ['data', dataSnapshot], ]); - const snapshot = Object.create(null) as Record; for (const { key } of captured.properties) { Object.defineProperty(snapshot, key as string, { configurable: true, diff --git a/cocoscreator_projects/framework-tests/net/wire-client.test.ts b/cocoscreator_projects/framework-tests/net/wire-client.test.ts index 1381c16..5e8b8a1 100644 --- a/cocoscreator_projects/framework-tests/net/wire-client.test.ts +++ b/cocoscreator_projects/framework-tests/net/wire-client.test.ts @@ -325,6 +325,96 @@ test('send serializes stable descriptor snapshots without invoking Proxy get tra ); }); +test('send audits one shared source identity instead of accepting path-local stable reads', async () => { + const { client, transports } = makeHarness(); + client.start(); + const transport = transports[0]!; + await transport.flush(); + const target = { value: 0 }; + const reportedValues = [1, 2, 1, 2] as const; + let descriptorReads = 0; + const shared = new Proxy(target, { + getOwnPropertyDescriptor(source, key) { + const descriptor = Reflect.getOwnPropertyDescriptor(source, key); + if (key !== 'value' || !descriptor) return descriptor; + const value = reportedValues[descriptorReads]; + descriptorReads++; + if (value === undefined) throw new Error('shared source was audited more than twice'); + return { ...descriptor, value }; + }, + }); + + assert.throws( + () => client.send({ + app: 'youle', + route: 'agent', + rpc: 'shared_unstable', + data: { left: shared, right: shared }, + }), + /\$\.data\.left\.value.*descriptor.*changed/i, + ); + assert.equal(descriptorReads, 2, 'the shared source must have one capture and one verification'); + assert.deepEqual(transport.sent, []); +}); + +test('send expands an ordinary shared DAG reference at every wire path', async () => { + const { client, transports } = makeHarness(); + client.start(); + const transport = transports[0]!; + await transport.flush(); + const shared = { score: 7 }; + const completed = { + app: 'youle', + route: 'agent', + rpc: 'shared_dag', + data: { left: shared, right: shared }, + } as OutboundEnvelope; + + client.send(completed); + + assert.equal( + transport.sent[0], + '{"app":"youle","route":"agent","rpc":"shared_dag","data":{"left":{"score":7},"right":{"score":7}}}', + ); +}); + +test('send rejects a cyclic prototype identity within a finite prototype walk', async () => { + const { client, transports } = makeHarness(); + client.start(); + const transport = transports[0]!; + await transport.flush(); + const finiteWalkLimit = 4; + let prototypeReads = 0; + let getCalls = 0; + let selfPrototype!: object; + selfPrototype = new Proxy(Object.create(null) as object, { + getPrototypeOf() { + prototypeReads++; + if (prototypeReads > finiteWalkLimit) { + throw new Error('prototype walk exceeded the finite test bound'); + } + return selfPrototype; + }, + get(source, key, receiver) { + getCalls++; + return Reflect.get(source, key, receiver); + }, + }); + + assert.throws( + () => client.send({ + app: 'youle', + route: 'agent', + rpc: 'prototype_cycle', + data: { loop: selfPrototype }, + }), + /\$\.data\.loop.*prototype.*cycle/i, + ); + assert.ok(prototypeReads <= finiteWalkLimit); + assert.equal(getCalls, 0, 'prototype validation must not invoke Proxy get'); + assert.deepEqual(transport.sent, []); +}); + test('send rejects source graphs whose descriptors, own-key order, or prototype change', async () => { const cases: ReadonlyArray object, RegExp]> = [ [