fix(net): serialize validated wire snapshots

This commit is contained in:
2026-09-05 04:40:11 +08:00
parent 85db7765ac
commit 33904e0e58
2 changed files with 327 additions and 33 deletions
@@ -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<object>): 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<object>,
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<object>)
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<string>(['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<string, unknown>;
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<object>());
const frame = JSON.stringify(envelope);
const snapshotValues = new Map<string, unknown>([
['app', app],
['route', route],
['rpc', rpc],
['data', snapshotJsonValue(data, '$.data', new Set<object>(), audits)],
]);
const snapshot = Object.create(null) as Record<string, unknown>;
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;
}