fix(net): serialize validated wire snapshots
This commit is contained in:
@@ -47,21 +47,130 @@ function childPath(parent: string, key: string): string {
|
|||||||
: `${parent}[${JSON.stringify(key)}]`;
|
: `${parent}[${JSON.stringify(key)}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dataPropertyValue(owner: object, key: string, path: string): unknown {
|
interface CapturedProperty {
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(owner, key);
|
readonly key: string | symbol;
|
||||||
if (!descriptor) return invalidEnvelope(path, 'missing own data property');
|
readonly descriptor: PropertyDescriptor;
|
||||||
if (!descriptor.enumerable) return invalidEnvelope(path, 'property must be enumerable');
|
}
|
||||||
|
|
||||||
|
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')) {
|
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
||||||
return invalidEnvelope(path, 'accessor properties are not allowed');
|
return invalidEnvelope(path, 'accessor properties are not allowed');
|
||||||
}
|
}
|
||||||
return descriptor.value;
|
return descriptor.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateJsonValue(value: unknown, path: string, ancestors: Set<object>): void {
|
function descriptorMatches(before: PropertyDescriptor, after: PropertyDescriptor): boolean {
|
||||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
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 (typeof value === 'number') {
|
||||||
if (!Number.isFinite(value)) invalidEnvelope(path, 'expected finite 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') {
|
if (typeof value !== 'object') {
|
||||||
invalidEnvelope(path, `${typeof value} is not protocol JSON data`);
|
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');
|
if (ancestors.has(objectValue)) invalidEnvelope(path, 'cycle is not protocol JSON data');
|
||||||
ancestors.add(objectValue);
|
ancestors.add(objectValue);
|
||||||
try {
|
try {
|
||||||
|
const captured = captureNode(objectValue, path, audits);
|
||||||
if (Array.isArray(objectValue)) {
|
if (Array.isArray(objectValue)) {
|
||||||
if (Object.getPrototypeOf(objectValue) !== Array.prototype) {
|
if (captured.prototype !== Array.prototype) {
|
||||||
invalidEnvelope(path, 'expected plain array');
|
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']);
|
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 key = String(index);
|
||||||
const itemPath = `${path}[${index}]`;
|
const itemPath = `${path}[${index}]`;
|
||||||
allowedKeys.add(key);
|
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');
|
invalidEnvelope(itemPath, 'array hole is not protocol JSON data');
|
||||||
}
|
}
|
||||||
validateJsonValue(
|
const itemSnapshot = snapshotJsonValue(
|
||||||
dataPropertyValue(objectValue, key, itemPath),
|
capturedDataValue(captured, key, itemPath, false),
|
||||||
itemPath,
|
itemPath,
|
||||||
ancestors,
|
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 (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
|
||||||
if (!allowedKeys.has(key as string)) {
|
if (!allowedKeys.has(key)) {
|
||||||
invalidEnvelope(childPath(path, key as string), 'extra array property is not allowed');
|
invalidEnvelope(childPath(path, key), 'extra array property is not allowed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prototype = Object.getPrototypeOf(objectValue);
|
if (captured.prototype !== Object.prototype && captured.prototype !== null) {
|
||||||
if (prototype !== Object.prototype && prototype !== null) {
|
|
||||||
invalidEnvelope(path, 'expected plain object or null-prototype object');
|
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');
|
if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
|
||||||
const propertyPath = childPath(path, key as string);
|
const nestedPath = childPath(path, key);
|
||||||
validateJsonValue(
|
const nestedSnapshot = snapshotJsonValue(
|
||||||
dataPropertyValue(objectValue, key as string, propertyPath),
|
capturedDataValue(captured, key, nestedPath, true),
|
||||||
propertyPath,
|
nestedPath,
|
||||||
ancestors,
|
ancestors,
|
||||||
|
audits,
|
||||||
);
|
);
|
||||||
|
Object.defineProperty(snapshot, key, {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: nestedSnapshot,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return snapshot;
|
||||||
} finally {
|
} finally {
|
||||||
ancestors.delete(objectValue);
|
ancestors.delete(objectValue);
|
||||||
}
|
}
|
||||||
@@ -120,19 +251,20 @@ function serializeOutbound(envelope: OutboundEnvelope): string {
|
|||||||
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) {
|
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) {
|
||||||
return invalidEnvelope('$', 'expected object');
|
return invalidEnvelope('$', 'expected object');
|
||||||
}
|
}
|
||||||
const prototype = Object.getPrototypeOf(envelope);
|
const audits: CapturedNode[] = [];
|
||||||
if (prototype !== Object.prototype && prototype !== null) {
|
const captured = captureNode(envelope, '$', audits);
|
||||||
|
if (captured.prototype !== Object.prototype && captured.prototype !== null) {
|
||||||
return invalidEnvelope('$', 'expected plain object or null-prototype object');
|
return invalidEnvelope('$', 'expected plain object or null-prototype object');
|
||||||
}
|
}
|
||||||
const required = new Set(['app', 'route', 'rpc', 'data']);
|
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 (typeof key === 'symbol') return invalidEnvelope('$', 'top-level symbol key is not allowed');
|
||||||
if (!required.has(key)) return invalidEnvelope(childPath('$', key), 'extra top-level property');
|
if (!required.has(key)) return invalidEnvelope(childPath('$', key), 'extra top-level property');
|
||||||
}
|
}
|
||||||
const app = dataPropertyValue(envelope, 'app', '$.app');
|
const app = capturedDataValue(captured, 'app', '$.app', true);
|
||||||
const route = dataPropertyValue(envelope, 'route', '$.route');
|
const route = capturedDataValue(captured, 'route', '$.route', true);
|
||||||
const rpc = dataPropertyValue(envelope, 'rpc', '$.rpc');
|
const rpc = capturedDataValue(captured, 'rpc', '$.rpc', true);
|
||||||
const data = dataPropertyValue(envelope, 'data', '$.data');
|
const data = capturedDataValue(captured, 'data', '$.data', true);
|
||||||
if (app !== APP) invalidEnvelope('$.app', `expected ${APP}`);
|
if (app !== APP) invalidEnvelope('$.app', `expected ${APP}`);
|
||||||
if (typeof route !== 'string' || route.length === 0) {
|
if (typeof route !== 'string' || route.length === 0) {
|
||||||
invalidEnvelope('$.route', 'expected non-empty string');
|
invalidEnvelope('$.route', 'expected non-empty string');
|
||||||
@@ -140,8 +272,23 @@ function serializeOutbound(envelope: OutboundEnvelope): string {
|
|||||||
if (typeof rpc !== 'string' || rpc.length === 0) {
|
if (typeof rpc !== 'string' || rpc.length === 0) {
|
||||||
invalidEnvelope('$.rpc', 'expected non-empty string');
|
invalidEnvelope('$.rpc', 'expected non-empty string');
|
||||||
}
|
}
|
||||||
validateJsonValue(data, '$.data', new Set<object>());
|
const snapshotValues = new Map<string, unknown>([
|
||||||
const frame = JSON.stringify(envelope);
|
['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');
|
if (typeof frame !== 'string') return invalidEnvelope('$', 'not JSON serializable');
|
||||||
return frame;
|
return frame;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ test('send rejects every non-JSON data graph edge with its exact path', async ()
|
|||||||
sparse.length = 1;
|
sparse.length = 1;
|
||||||
const arrayWithExtra = [] as unknown[] & { extra?: number };
|
const arrayWithExtra = [] as unknown[] & { extra?: number };
|
||||||
arrayWithExtra.extra = 1;
|
arrayWithExtra.extra = 1;
|
||||||
|
const arrayWithSymbol = [] as unknown[];
|
||||||
|
Object.defineProperty(arrayWithSymbol, Symbol('extra'), { value: 1 });
|
||||||
const cyclic: Record<string, unknown> = {};
|
const cyclic: Record<string, unknown> = {};
|
||||||
cyclic.self = cyclic;
|
cyclic.self = cyclic;
|
||||||
const withAccessor: Record<string, unknown> = {};
|
const withAccessor: Record<string, unknown> = {};
|
||||||
@@ -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: 1n }, /\$\.data\.value.*bigint/i],
|
||||||
[{ value: Number.NaN }, /\$\.data\.value.*finite number/i],
|
[{ value: Number.NaN }, /\$\.data\.value.*finite number/i],
|
||||||
[{ value: Number.POSITIVE_INFINITY }, /\$\.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: sparse }, /\$\.data\.nested\[0\].*array hole/i],
|
||||||
[{ nested: arrayWithExtra }, /\$\.data\.nested\.extra.*extra array property/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: new Exotic() }, /\$\.data\.nested.*plain object/i],
|
||||||
[{ nested: withAccessor }, /\$\.data\.nested\.secret.*accessor/i],
|
[{ nested: withAccessor }, /\$\.data\.nested\.secret.*accessor/i],
|
||||||
[{ nested: withHidden }, /\$\.data\.nested\.hidden.*enumerable/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, []);
|
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<readonly [string, () => 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 () => {
|
test('send rejects top-level symbol, non-enumerable and accessor properties', async () => {
|
||||||
const { client, transports } = makeHarness();
|
const { client, transports } = makeHarness();
|
||||||
client.start();
|
client.start();
|
||||||
|
|||||||
Reference in New Issue
Block a user