From 33904e0e58f7f970f32cbaf5b15b0815ecbdbc76 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 04:40:11 +0800 Subject: [PATCH] fix(net): serialize validated wire snapshots --- .../assets/framework/net/wire-client.ts | 211 +++++++++++++++--- .../framework-tests/net/wire-client.test.ts | 149 ++++++++++++- 2 files changed, 327 insertions(+), 33 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/framework/net/wire-client.ts b/cocoscreator_projects/YouleNexus/assets/framework/net/wire-client.ts index 8c7e1ce..ea6c4b5 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/net/wire-client.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/net/wire-client.ts @@ -47,21 +47,130 @@ function childPath(parent: string, key: string): string { : `${parent}[${JSON.stringify(key)}]`; } -function dataPropertyValue(owner: object, key: string, path: string): unknown { - const descriptor = Object.getOwnPropertyDescriptor(owner, key); - if (!descriptor) return invalidEnvelope(path, 'missing own data property'); - if (!descriptor.enumerable) return invalidEnvelope(path, 'property must be enumerable'); +interface CapturedProperty { + readonly key: string | symbol; + readonly descriptor: PropertyDescriptor; +} + +interface CapturedNode { + readonly source: object; + readonly path: string; + readonly prototype: object | null; + readonly properties: readonly CapturedProperty[]; +} + +function propertyPath(parent: string, key: string | symbol): string { + return typeof key === 'string' ? childPath(parent, key) : parent; +} + +function assertNoInheritedToJson(prototype: object | null, path: string): void { + let current = prototype; + while (current !== null) { + if (Object.getOwnPropertyDescriptor(current, 'toJSON')) { + invalidEnvelope(path, 'inherited toJSON is not allowed'); + } + current = Object.getPrototypeOf(current); + } +} + +function copyDescriptor(descriptor: PropertyDescriptor): PropertyDescriptor { + if (Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + return { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + value: descriptor.value, + writable: descriptor.writable, + }; + } + return { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + get: descriptor.get, + set: descriptor.set, + }; +} + +function captureNode(source: object, path: string, audits: CapturedNode[]): CapturedNode { + const prototype = Object.getPrototypeOf(source); + assertNoInheritedToJson(prototype, path); + const keys = Reflect.ownKeys(source); + const properties = keys.map((key): CapturedProperty => { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (!descriptor) invalidEnvelope(propertyPath(path, key), 'own property disappeared during validation'); + return { key, descriptor: copyDescriptor(descriptor) }; + }); + const captured = { source, path, prototype, properties }; + audits.push(captured); + return captured; +} + +function capturedDataValue( + captured: CapturedNode, + key: string, + path: string, + requireEnumerable: boolean, +): unknown { + const property = captured.properties.find((candidate) => candidate.key === key); + if (!property) return invalidEnvelope(path, 'missing own data property'); + const { descriptor } = property; + if (requireEnumerable && !descriptor.enumerable) { + return invalidEnvelope(path, 'property must be enumerable'); + } if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) { return invalidEnvelope(path, 'accessor properties are not allowed'); } return descriptor.value; } -function validateJsonValue(value: unknown, path: string, ancestors: Set): void { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return; +function descriptorMatches(before: PropertyDescriptor, after: PropertyDescriptor): boolean { + if (before.configurable !== after.configurable || before.enumerable !== after.enumerable) { + return false; + } + const beforeIsData = Object.prototype.hasOwnProperty.call(before, 'value'); + const afterIsData = Object.prototype.hasOwnProperty.call(after, 'value'); + if (beforeIsData !== afterIsData) return false; + if (beforeIsData) { + return before.writable === after.writable && Object.is(before.value, after.value); + } + return before.get === after.get && before.set === after.set; +} + +function verifyStableGraph(audits: readonly CapturedNode[]): void { + for (const captured of audits) { + const prototype = Object.getPrototypeOf(captured.source); + if (!Object.is(prototype, captured.prototype)) { + invalidEnvelope(captured.path, 'prototype changed during validation'); + } + assertNoInheritedToJson(prototype, captured.path); + + const keys = Reflect.ownKeys(captured.source); + if ( + keys.length !== captured.properties.length + || keys.some((key, index) => !Object.is(key, captured.properties[index]!.key)) + ) { + invalidEnvelope(captured.path, 'own keys changed during validation'); + } + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + const descriptor = Object.getOwnPropertyDescriptor(captured.source, key); + if (!descriptor || !descriptorMatches(captured.properties[index]!.descriptor, descriptor)) { + invalidEnvelope(propertyPath(captured.path, key), 'property descriptor changed during validation'); + } + } + } +} + +function snapshotJsonValue( + value: unknown, + path: string, + ancestors: Set, + audits: CapturedNode[], +): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; if (typeof value === 'number') { if (!Number.isFinite(value)) invalidEnvelope(path, 'expected finite number'); - return; + if (Object.is(value, -0)) invalidEnvelope(path, 'negative zero is not protocol JSON data'); + return value; } if (typeof value !== 'object') { invalidEnvelope(path, `${typeof value} is not protocol JSON data`); @@ -71,46 +180,68 @@ function validateJsonValue(value: unknown, path: string, ancestors: Set) if (ancestors.has(objectValue)) invalidEnvelope(path, 'cycle is not protocol JSON data'); ancestors.add(objectValue); try { + const captured = captureNode(objectValue, path, audits); if (Array.isArray(objectValue)) { - if (Object.getPrototypeOf(objectValue) !== Array.prototype) { + if (captured.prototype !== Array.prototype) { invalidEnvelope(path, 'expected plain array'); } + const length = capturedDataValue(captured, 'length', childPath(path, 'length'), false); + if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0) { + invalidEnvelope(childPath(path, 'length'), 'expected valid array length'); + } const allowedKeys = new Set(['length']); - for (let index = 0; index < objectValue.length; index++) { + const snapshot: unknown[] = []; + Object.setPrototypeOf(snapshot, null); + for (let index = 0; index < (length as number); index++) { const key = String(index); const itemPath = `${path}[${index}]`; allowedKeys.add(key); - if (!Object.prototype.hasOwnProperty.call(objectValue, key)) { + if (!captured.properties.some((property) => property.key === key)) { invalidEnvelope(itemPath, 'array hole is not protocol JSON data'); } - validateJsonValue( - dataPropertyValue(objectValue, key, itemPath), + const itemSnapshot = snapshotJsonValue( + capturedDataValue(captured, key, itemPath, false), itemPath, ancestors, + audits, ); + Object.defineProperty(snapshot, key, { + configurable: true, + enumerable: true, + value: itemSnapshot, + writable: true, + }); } - for (const key of Reflect.ownKeys(objectValue)) { + for (const { key } of captured.properties) { if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed'); - if (!allowedKeys.has(key as string)) { - invalidEnvelope(childPath(path, key as string), 'extra array property is not allowed'); + if (!allowedKeys.has(key)) { + invalidEnvelope(childPath(path, key), 'extra array property is not allowed'); } } - return; + return snapshot; } - const prototype = Object.getPrototypeOf(objectValue); - if (prototype !== Object.prototype && prototype !== null) { + if (captured.prototype !== Object.prototype && captured.prototype !== null) { invalidEnvelope(path, 'expected plain object or null-prototype object'); } - for (const key of Reflect.ownKeys(objectValue)) { + const snapshot = Object.create(null) as Record; + for (const { key } of captured.properties) { if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed'); - const propertyPath = childPath(path, key as string); - validateJsonValue( - dataPropertyValue(objectValue, key as string, propertyPath), - propertyPath, + const nestedPath = childPath(path, key); + const nestedSnapshot = snapshotJsonValue( + capturedDataValue(captured, key, nestedPath, true), + nestedPath, ancestors, + audits, ); + Object.defineProperty(snapshot, key, { + configurable: true, + enumerable: true, + value: nestedSnapshot, + writable: true, + }); } + return snapshot; } finally { ancestors.delete(objectValue); } @@ -120,19 +251,20 @@ function serializeOutbound(envelope: OutboundEnvelope): string { if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) { return invalidEnvelope('$', 'expected object'); } - const prototype = Object.getPrototypeOf(envelope); - if (prototype !== Object.prototype && prototype !== null) { + const audits: CapturedNode[] = []; + const captured = captureNode(envelope, '$', audits); + if (captured.prototype !== Object.prototype && captured.prototype !== null) { return invalidEnvelope('$', 'expected plain object or null-prototype object'); } const required = new Set(['app', 'route', 'rpc', 'data']); - for (const key of Reflect.ownKeys(envelope)) { + for (const { key } of captured.properties) { if (typeof key === 'symbol') return invalidEnvelope('$', 'top-level symbol key is not allowed'); if (!required.has(key)) return invalidEnvelope(childPath('$', key), 'extra top-level property'); } - const app = dataPropertyValue(envelope, 'app', '$.app'); - const route = dataPropertyValue(envelope, 'route', '$.route'); - const rpc = dataPropertyValue(envelope, 'rpc', '$.rpc'); - const data = dataPropertyValue(envelope, 'data', '$.data'); + const app = capturedDataValue(captured, 'app', '$.app', true); + const route = capturedDataValue(captured, 'route', '$.route', true); + const rpc = capturedDataValue(captured, 'rpc', '$.rpc', true); + const data = capturedDataValue(captured, 'data', '$.data', true); if (app !== APP) invalidEnvelope('$.app', `expected ${APP}`); if (typeof route !== 'string' || route.length === 0) { invalidEnvelope('$.route', 'expected non-empty string'); @@ -140,8 +272,23 @@ function serializeOutbound(envelope: OutboundEnvelope): string { if (typeof rpc !== 'string' || rpc.length === 0) { invalidEnvelope('$.rpc', 'expected non-empty string'); } - validateJsonValue(data, '$.data', new Set()); - const frame = JSON.stringify(envelope); + const snapshotValues = new Map([ + ['app', app], + ['route', route], + ['rpc', rpc], + ['data', snapshotJsonValue(data, '$.data', new Set(), audits)], + ]); + const snapshot = Object.create(null) as Record; + for (const { key } of captured.properties) { + Object.defineProperty(snapshot, key as string, { + configurable: true, + enumerable: true, + value: snapshotValues.get(key as string), + writable: true, + }); + } + verifyStableGraph(audits); + const frame = JSON.stringify(snapshot); if (typeof frame !== 'string') return invalidEnvelope('$', 'not JSON serializable'); return frame; } diff --git a/cocoscreator_projects/framework-tests/net/wire-client.test.ts b/cocoscreator_projects/framework-tests/net/wire-client.test.ts index 56c7aec..1381c16 100644 --- a/cocoscreator_projects/framework-tests/net/wire-client.test.ts +++ b/cocoscreator_projects/framework-tests/net/wire-client.test.ts @@ -199,6 +199,8 @@ test('send rejects every non-JSON data graph edge with its exact path', async () sparse.length = 1; const arrayWithExtra = [] as unknown[] & { extra?: number }; arrayWithExtra.extra = 1; + const arrayWithSymbol = [] as unknown[]; + Object.defineProperty(arrayWithSymbol, Symbol('extra'), { value: 1 }); const cyclic: Record = {}; cyclic.self = cyclic; const withAccessor: Record = {}; @@ -220,9 +222,11 @@ test('send rejects every non-JSON data graph edge with its exact path', async () [{ value: 1n }, /\$\.data\.value.*bigint/i], [{ value: Number.NaN }, /\$\.data\.value.*finite number/i], [{ value: Number.POSITIVE_INFINITY }, /\$\.data\.value.*finite number/i], + [{ value: -0 }, /\$\.data\.value.*negative zero/i], [{ nested: sparse }, /\$\.data\.nested\[0\].*array hole/i], [{ nested: arrayWithExtra }, /\$\.data\.nested\.extra.*extra array property/i], - [{ nested: new Date(0) }, /\$\.data\.nested.*plain object/i], + [{ nested: arrayWithSymbol }, /\$\.data\.nested.*symbol key/i], + [{ nested: new Date(0) }, /\$\.data\.nested.*inherited toJSON/i], [{ nested: new Exotic() }, /\$\.data\.nested.*plain object/i], [{ nested: withAccessor }, /\$\.data\.nested\.secret.*accessor/i], [{ nested: withHidden }, /\$\.data\.nested\.hidden.*enumerable/i], @@ -240,6 +244,149 @@ test('send rejects every non-JSON data graph edge with its exact path', async () assert.deepEqual(transport.sent, []); }); +test('send preserves a dense non-enumerable array index in the validated wire bytes', async () => { + const { client, transports } = makeHarness(); + client.start(); + const transport = transports[0]!; + await transport.flush(); + + const values: unknown[] = []; + Object.defineProperty(values, '0', { + configurable: true, + enumerable: false, + value: 'descriptor value', + writable: true, + }); + const completed = { + app: 'youle', + route: 'agent', + rpc: 'array_descriptor', + data: { values }, + } as OutboundEnvelope; + + client.send(completed); + + assert.equal(transport.sent[0], JSON.stringify(completed)); + assert.equal( + transport.sent[0], + '{"app":"youle","route":"agent","rpc":"array_descriptor","data":{"values":["descriptor value"]}}', + ); +}); + +test('send rejects inherited toJSON without invoking it', async () => { + const { client, transports } = makeHarness(); + client.start(); + const transport = transports[0]!; + await transport.flush(); + const original = Object.getOwnPropertyDescriptor(Object.prototype, 'toJSON'); + let calls = 0; + Object.defineProperty(Object.prototype, 'toJSON', { + configurable: true, + value() { + calls++; + return { changed: true }; + }, + }); + + try { + assert.throws( + () => client.send({ app: 'youle', route: 'agent', rpc: 'x', data: { value: 1 } }), + /\$.*inherited toJSON/i, + ); + assert.equal(calls, 0); + assert.deepEqual(transport.sent, []); + } finally { + if (original) Object.defineProperty(Object.prototype, 'toJSON', original); + else delete (Object.prototype as { toJSON?: unknown }).toJSON; + } +}); + +test('send serializes stable descriptor snapshots without invoking Proxy get traps', async () => { + const { client, transports } = makeHarness(); + client.start(); + const transport = transports[0]!; + await transport.flush(); + const target = { value: 1 }; + let getCalls = 0; + const proxied = new Proxy(target, { + get(source, key, receiver) { + getCalls++; + if (key === 'value') return 2; + return Reflect.get(source, key, receiver); + }, + }); + + client.send({ app: 'youle', route: 'agent', rpc: 'proxy', data: { proxied } }); + + assert.equal(getCalls, 0); + assert.equal( + transport.sent[0], + '{"app":"youle","route":"agent","rpc":"proxy","data":{"proxied":{"value":1}}}', + ); +}); + +test('send rejects source graphs whose descriptors, own-key order, or prototype change', async () => { + const cases: ReadonlyArray object, RegExp]> = [ + [ + 'descriptor', + () => { + const target = { value: 1 }; + let calls = 0; + return new Proxy(target, { + getOwnPropertyDescriptor(source, key) { + const descriptor = Reflect.getOwnPropertyDescriptor(source, key); + if (key !== 'value' || !descriptor) return descriptor; + calls++; + return { ...descriptor, value: calls === 1 ? 1 : 2 }; + }, + }); + }, + /\$\.data\.unstable\.value.*descriptor.*changed/i, + ], + [ + 'own keys', + () => { + const target = { first: 1, second: 2 }; + let calls = 0; + return new Proxy(target, { + ownKeys() { + calls++; + return calls === 1 ? ['first', 'second'] : ['second', 'first']; + }, + }); + }, + /\$\.data\.unstable.*own keys.*changed/i, + ], + [ + 'prototype', + () => { + const target = { value: 1 }; + let calls = 0; + return new Proxy(target, { + getPrototypeOf() { + calls++; + return calls === 1 ? Object.prototype : null; + }, + }); + }, + /\$\.data\.unstable.*prototype.*changed/i, + ], + ]; + + for (const [name, makeUnstable, expected] of cases) { + const { client, transports } = makeHarness(); + client.start(); + const transport = transports[0]!; + await transport.flush(); + + assert.throws( + () => client.send({ app: 'youle', route: 'agent', rpc: name, data: { unstable: makeUnstable() } }), + expected, + ); + assert.deepEqual(transport.sent, []); + } +}); + test('send rejects top-level symbol, non-enumerable and accessor properties', async () => { const { client, transports } = makeHarness(); client.start();