fix(net): stabilize shared wire graph audits

This commit is contained in:
2026-09-05 04:52:02 +08:00
parent 33904e0e58
commit e47d9d3007
2 changed files with 110 additions and 2 deletions
@@ -64,8 +64,11 @@ function propertyPath(parent: string, key: string | symbol): string {
}
function assertNoInheritedToJson(prototype: object | null, path: string): void {
const visited = new Set<object>();
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<object>,
audits: CapturedNode[],
snapshots: Map<object, unknown>,
): 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<string>(['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<string, unknown>;
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<object, unknown>();
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<string, unknown>;
snapshots.set(envelope, snapshot);
const ancestors = new Set<object>([envelope]);
let dataSnapshot: unknown;
try {
dataSnapshot = snapshotJsonValue(data, '$.data', ancestors, audits, snapshots);
} finally {
ancestors.delete(envelope);
}
const snapshotValues = new Map<string, unknown>([
['app', app],
['route', route],
['rpc', rpc],
['data', snapshotJsonValue(data, '$.data', new Set<object>(), audits)],
['data', dataSnapshot],
]);
const snapshot = Object.create(null) as Record<string, unknown>;
for (const { key } of captured.properties) {
Object.defineProperty(snapshot, key as string, {
configurable: true,
@@ -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<readonly [string, () => object, RegExp]> = [
[