diff --git a/src/features/housekeeping/foundation/commands/dispatcher.test.ts b/src/features/housekeeping/foundation/commands/dispatcher.test.ts index eeb5130b..25ac6c61 100644 --- a/src/features/housekeeping/foundation/commands/dispatcher.test.ts +++ b/src/features/housekeeping/foundation/commands/dispatcher.test.ts @@ -274,6 +274,75 @@ describe("dispatchHousekeepingCommand", () => { expect(valid).toMatchObject({ ok: true }); expect(executions).toBe(1); }); + it("rejects public error callbacks before they can weaken dispatcher validation", async () => { + const commandId = "system.dispatch.callback-schema-mutation"; + let executions = 0; + register( + baseCommand(commandId, { + input: z.object({ value: z.string().min(3) }), + execute: async (context) => { + executions += 1; + return ok(null, context.correlationId); + }, + }), + ); + const registered = getHousekeepingCommand(commandId); + if (!registered) throw new Error("registered command missing"); + + let callbackCalls = 0; + let capturedInstance: unknown; + let attackError: unknown; + try { + registered.input.safeParse( + { value: "x" }, + { + error: (issue) => { + callbackCalls += 1; + capturedInstance = issue.inst; + if (issue.inst) { + const internal = issue.inst._zod as { + def?: { minimum?: number }; + }; + if (typeof internal.def?.minimum === "number") { + internal.def.minimum = 0; + } + } + return "forged validation error"; + }, + }, + ); + } catch (error) { + attackError = error; + } + + const forged = await dispatchHousekeepingCommand( + { commandId, input: { value: "x" } }, + dependencies(), + ); + const valid = await dispatchHousekeepingCommand( + { commandId, input: { value: "valid" } }, + dependencies(), + ); + + expect({ + attackError: + attackError instanceof TypeError ? attackError.message : undefined, + callbackCalls, + capturedInstance, + forgedOk: forged.ok, + forgedCode: forged.ok ? undefined : forged.error.code, + validOk: valid.ok, + executions, + }).toEqual({ + attackError: "callback-bearing schema arguments are not supported", + callbackCalls: 0, + capturedInstance: undefined, + forgedOk: false, + forgedCode: "VALIDATION", + validOk: true, + executions: 1, + }); + }); it("rejects a missing required reason before the command can execute", async () => { let executed = false; const audit = auditRecorder(); diff --git a/src/features/housekeeping/foundation/commands/registry.test.ts b/src/features/housekeeping/foundation/commands/registry.test.ts index 39498895..0fa09b76 100644 --- a/src/features/housekeeping/foundation/commands/registry.test.ts +++ b/src/features/housekeeping/foundation/commands/registry.test.ts @@ -71,6 +71,45 @@ function attemptMutation(mutate: () => void): void { } } +function mutationWasRejected(mutate: () => void): boolean { + try { + mutate(); + return false; + } catch { + return true; + } +} + +const unsafeSchemaCallbackMessage = + "callback-bearing schema arguments are not supported"; + +const callbackOptionMethodNames = [ + "parse", + "safeParse", + "parseAsync", + "safeParseAsync", + "spa", + "encode", + "decode", + "encodeAsync", + "decodeAsync", + "safeEncode", + "safeDecode", + "safeEncodeAsync", + "safeDecodeAsync", +] as const; + +function attemptMinimumMutation(instance: unknown): void { + if (typeof instance !== "object" || instance === null) return; + const internal = Reflect.get(instance, "_zod") as + | { def?: { minimum?: number } } + | undefined; + if (typeof internal?.def?.minimum !== "number") return; + attemptMutation(() => { + if (internal.def) internal.def.minimum = 0; + }); +} + describe("housekeeping command registry", () => { it("returns the validated snapshot registered under its global ID", () => { const registered = command("people.registry.lookup"); @@ -506,6 +545,337 @@ describe("housekeeping command registry", () => { true, ); }); + it.each(callbackOptionMethodNames)( + "rejects callback-bearing %s options before private issue nodes are exposed", + async (methodName) => { + const input = z.object({ value: z.string().min(3) }); + const id = `people.registry.callback-${methodName.toLowerCase()}`; + registerHousekeepingCommand({ + ...command(id), + input, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand, { id: number }>); + const registered = getHousekeepingCommand(id); + if (!registered) throw new Error("registered command missing"); + + let callbackCalls = 0; + let capturedInstance: unknown; + const errorCallback = (issue: unknown): string => { + callbackCalls += 1; + capturedInstance = Reflect.get(issue as object, "inst"); + attemptMinimumMutation(capturedInstance); + return "forged validation error"; + }; + const method = Reflect.get(registered.input, methodName); + if (typeof method !== "function") { + throw new Error(`public schema method missing: ${methodName}`); + } + const attack = Promise.resolve().then(() => + Reflect.apply(method, registered.input, [ + { value: "x" }, + { error: errorCallback }, + ]), + ); + + await expect(attack).rejects.toThrow(unsafeSchemaCallbackMessage); + expect(callbackCalls).toBe(0); + expect(capturedInstance).toBeUndefined(); + expect(registered.input.safeParse({ value: "x" }).success).toBe(false); + expect(registered.input.safeParse({ value: "valid" }).success).toBe(true); + }, + ); + + it("rejects JSON-schema overrides before private schema or check nodes are exposed", () => { + const input = z.object({ value: z.string().min(3) }); + const id = "people.registry.callback-json-schema"; + registerHousekeepingCommand({ + ...command(id), + input, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand, { id: number }>); + const registered = getHousekeepingCommand(id); + if (!registered) throw new Error("registered command missing"); + + let callbackCalls = 0; + let capturedSchema: unknown; + let attackError: unknown; + try { + registered.input.toJSONSchema({ + override: ({ zodSchema }) => { + callbackCalls += 1; + capturedSchema = zodSchema; + const checks = (zodSchema._zod.def as { checks?: unknown[] }).checks; + if (checks?.[0]) attemptMinimumMutation(checks[0]); + }, + }); + } catch (error) { + attackError = error; + } + + expect({ + attackError: + attackError instanceof TypeError ? attackError.message : undefined, + callbackCalls, + capturedSchema, + shortValueAccepted: registered.input.safeParse({ value: "x" }).success, + validValueAccepted: registered.input.safeParse({ value: "valid" }) + .success, + }).toEqual({ + attackError: unsafeSchemaCallbackMessage, + callbackCalls: 0, + capturedSchema: undefined, + shortValueAccepted: false, + validValueAccepted: true, + }); + }); + + it("rejects an error callback concealed behind an option Proxy", () => { + const input = z.object({ value: z.string().min(3) }); + const id = "people.registry.callback-proxy-options"; + registerHousekeepingCommand({ + ...command(id), + input, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand, { id: number }>); + const registered = getHousekeepingCommand(id); + if (!registered) throw new Error("registered command missing"); + + let callbackCalls = 0; + let capturedInstance: unknown; + const errorCallback = (issue: unknown): string => { + callbackCalls += 1; + capturedInstance = Reflect.get(issue as object, "inst"); + attemptMinimumMutation(capturedInstance); + return "forged validation error"; + }; + const concealedOptions = new Proxy( + {}, + { + get: (_target, property) => + property === "error" ? errorCallback : undefined, + getOwnPropertyDescriptor: () => undefined, + ownKeys: () => [], + }, + ); + let attackError: unknown; + try { + registered.input.safeParse({ value: "x" }, concealedOptions as never); + } catch (error) { + attackError = error; + } + + expect({ + attackError: + attackError instanceof TypeError ? attackError.message : undefined, + callbackCalls, + capturedInstance, + shortValueAccepted: registered.input.safeParse({ value: "x" }).success, + validValueAccepted: registered.input.safeParse({ value: "valid" }) + .success, + }).toEqual({ + attackError: unsafeSchemaCallbackMessage, + callbackCalls: 0, + capturedInstance: undefined, + shortValueAccepted: false, + validValueAccepted: true, + }); + }); + + it("keeps callback-free sync and async parsing available", async () => { + const input = z.object({ value: z.string().min(3) }); + const id = "people.registry.callback-free-parse"; + registerHousekeepingCommand({ + ...command(id), + input, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand, { id: number }>); + const registered = getHousekeepingCommand(id); + if (!registered) throw new Error("registered command missing"); + + expect( + registered.input.parse({ value: "valid" }, { jitless: true }), + ).toEqual({ value: "valid" }); + expect( + registered.input.safeParse({ value: "valid" }, { reportInput: true }), + ).toMatchObject({ success: true, data: { value: "valid" } }); + await expect( + registered.input.parseAsync({ value: "valid" }, { jitless: true }), + ).resolves.toEqual({ value: "valid" }); + await expect( + registered.input.safeParseAsync({ value: "x" }, { reportInput: true }), + ).resolves.toMatchObject({ success: false }); + }); + + it("exposes detached Map, Set, and Date values as behaviorally readonly views", () => { + const sourceMap = new Map([ + ["registered", { count: 1 }], + ]); + const sourceSet = new Set(["registered"]); + const sourceDate = new Date("2026-08-26T12:34:56.000Z"); + const input = z.object({ value: z.string() }); + Object.assign(input.def, { + exposedMap: sourceMap, + exposedSet: sourceSet, + exposedDate: sourceDate, + }); + const id = "people.registry.readonly-builtins"; + registerHousekeepingCommand({ + ...command(id), + input, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand, { id: number }>); + const registered = getHousekeepingCommand(id); + if (!registered) throw new Error("registered command missing"); + const publicDefinition = registered.input + .def as typeof registered.input.def & { + exposedMap: ReadonlyMap; + exposedSet: ReadonlySet; + exposedDate: Date; + }; + const publicMap = publicDefinition.exposedMap; + const publicSet = publicDefinition.exposedSet; + const publicDate = publicDefinition.exposedDate; + + sourceMap.set("caller-late", { count: 2 }); + sourceSet.add("caller-late"); + sourceDate.setTime(0); + const mapForEachReceivers: unknown[] = []; + const mapEntries: Array<[string, number]> = []; + publicMap.forEach((value, key, collection) => { + mapEntries.push([key, value.count]); + mapForEachReceivers.push(collection); + }); + const setForEachReceivers: unknown[] = []; + const setEntries: string[] = []; + publicSet.forEach((value, duplicate, collection) => { + expect(duplicate).toBe(value); + setEntries.push(value); + setForEachReceivers.push(collection); + }); + const dateIsoBefore = publicDate.toISOString(); + + const mapEscape = Symbol("map-escape"); + const setEscape = Symbol("set-escape"); + const dateEscape = Symbol("date-escape"); + Object.defineProperty(Map.prototype, mapEscape, { + configurable: true, + value(this: Map): Map { + return this; + }, + }); + Object.defineProperty(Set.prototype, setEscape, { + configurable: true, + value(this: Set): Set { + return this; + }, + }); + Object.defineProperty(Date.prototype, dateEscape, { + configurable: true, + value(this: Date): Date { + return this; + }, + }); + let escapedMap: unknown; + let escapedSet: unknown; + let escapedDate: unknown; + try { + const mapMethod = Reflect.get(publicMap, mapEscape); + const setMethod = Reflect.get(publicSet, setEscape); + const dateMethod = Reflect.get(publicDate, dateEscape); + if ( + typeof mapMethod !== "function" || + typeof setMethod !== "function" || + typeof dateMethod !== "function" + ) { + throw new Error("dynamic builtin method missing"); + } + escapedMap = Reflect.apply(mapMethod, publicMap, []); + escapedSet = Reflect.apply(setMethod, publicSet, []); + escapedDate = Reflect.apply(dateMethod, publicDate, []); + } finally { + Reflect.deleteProperty(Map.prototype, mapEscape); + Reflect.deleteProperty(Set.prototype, setEscape); + Reflect.deleteProperty(Date.prototype, dateEscape); + } + const escapeMutationResults = [ + mutationWasRejected(() => + (escapedMap as Map).set("escaped-forged", { + count: 11, + }), + ), + mutationWasRejected(() => + (escapedSet as Set).add("escaped-forged"), + ), + mutationWasRejected(() => (escapedDate as Date).setTime(0)), + ]; + + const mapMutationResults = [ + mutationWasRejected(() => + (publicMap as Map).set("forged", { + count: 9, + }), + ), + mutationWasRejected(() => + (publicMap as Map).delete("registered"), + ), + mutationWasRejected(() => + (publicMap as Map).clear(), + ), + mutationWasRejected(() => + Map.prototype.set.call( + publicMap as Map, + "prototype-forged", + { count: 10 }, + ), + ), + ]; + const setMutationResults = [ + mutationWasRejected(() => (publicSet as Set).add("forged")), + mutationWasRejected(() => + (publicSet as Set).delete("registered"), + ), + mutationWasRejected(() => (publicSet as Set).clear()), + mutationWasRejected(() => + Set.prototype.add.call(publicSet as Set, "prototype-forged"), + ), + ]; + const dateMutationResults = Object.getOwnPropertyNames(Date.prototype) + .filter((property) => property.startsWith("set")) + .map((property) => + mutationWasRejected(() => { + const method = Reflect.get(publicDate, property); + if (typeof method !== "function") { + throw new TypeError(`date mutator unavailable: ${property}`); + } + Reflect.apply(method, publicDate, [0]); + }), + ); + dateMutationResults.push( + mutationWasRejected(() => Date.prototype.setTime.call(publicDate, 0)), + ); + let dateIsoAfter: string | undefined; + try { + dateIsoAfter = publicDate.toISOString(); + } catch { + dateIsoAfter = undefined; + } + + expect(dateMutationResults.length).toBeGreaterThan(1); + expect(dateMutationResults.every(Boolean)).toBe(true); + expect(escapeMutationResults).toEqual([true, true, true]); + expect(mapEntries).toEqual([["registered", 1]]); + expect(mapForEachReceivers).toHaveLength(1); + expect(mapForEachReceivers[0]).toBe(publicMap); + expect(mapMutationResults).toEqual([true, true, true, true]); + expect([...publicMap.entries()]).toEqual([["registered", { count: 1 }]]); + expect(setEntries).toEqual(["registered"]); + expect(setForEachReceivers).toHaveLength(1); + expect(setForEachReceivers[0]).toBe(publicSet); + expect(setMutationResults).toEqual([true, true, true, true]); + expect([...publicSet]).toEqual(["registered"]); + expect(dateIsoBefore).toBe("2026-08-26T12:34:56.000Z"); + expect(dateIsoAfter).toBe("2026-08-26T12:34:56.000Z"); + }); it("seals the global registry after deterministic bootstrap", () => { sealHousekeepingCommandRegistry(); diff --git a/src/features/housekeeping/foundation/commands/registry.ts b/src/features/housekeeping/foundation/commands/registry.ts index 9b38d654..d456d2af 100644 --- a/src/features/housekeeping/foundation/commands/registry.ts +++ b/src/features/housekeeping/foundation/commands/registry.ts @@ -24,7 +24,8 @@ export interface HousekeepingCommand { /** * Registration snapshots structural/built-in Zod graphs and resolvable lazy * edges. Stateful custom refinements, transforms, preprocessors, and other - * executable schema callbacks are rejected. + * executable schema callbacks are rejected. The registered public facade + * accepts callback-free parse, codec, and JSON-schema options only. */ readonly input: z.ZodType; readonly requiresReason: boolean; @@ -355,9 +356,53 @@ function isZodInternalNode(value: object): value is ZodInternalNode { ); } +type ReadonlyIntrinsicMethod = (...args: never[]) => unknown; + +const MAP_READ_METHODS = new Map([ + ["get", Map.prototype.get as unknown as ReadonlyIntrinsicMethod], + ["has", Map.prototype.has as unknown as ReadonlyIntrinsicMethod], + ["entries", Map.prototype.entries as unknown as ReadonlyIntrinsicMethod], + ["keys", Map.prototype.keys as unknown as ReadonlyIntrinsicMethod], + ["values", Map.prototype.values as unknown as ReadonlyIntrinsicMethod], + [ + Symbol.iterator, + Map.prototype[Symbol.iterator] as unknown as ReadonlyIntrinsicMethod, + ], +]); +const MAP_SIZE_GETTER = Object.getOwnPropertyDescriptor(Map.prototype, "size") + ?.get as ReadonlyIntrinsicMethod | undefined; +const SET_READ_METHODS = new Map([ + ["has", Set.prototype.has as unknown as ReadonlyIntrinsicMethod], + ["entries", Set.prototype.entries as unknown as ReadonlyIntrinsicMethod], + ["keys", Set.prototype.keys as unknown as ReadonlyIntrinsicMethod], + ["values", Set.prototype.values as unknown as ReadonlyIntrinsicMethod], + [ + Symbol.iterator, + Set.prototype[Symbol.iterator] as unknown as ReadonlyIntrinsicMethod, + ], +]); +const SET_SIZE_GETTER = Object.getOwnPropertyDescriptor(Set.prototype, "size") + ?.get as ReadonlyIntrinsicMethod | undefined; +const DATE_READ_METHODS = new Map(); +for (const property of Reflect.ownKeys(Date.prototype)) { + if ( + property === "constructor" || + (typeof property === "string" && property.startsWith("set")) + ) { + continue; + } + const descriptor = Object.getOwnPropertyDescriptor(Date.prototype, property); + if (typeof descriptor?.value === "function") { + DATE_READ_METHODS.set( + property, + descriptor.value as ReadonlyIntrinsicMethod, + ); + } +} + function createReadonlyValidationFacade(schema: T): T { const views = new WeakMap(); - const parseResultMethods = new Set([ + const dataFirstValidationMethods = new Set([ "parse", "safeParse", "parseAsync", @@ -371,8 +416,186 @@ function createReadonlyValidationFacade(schema: T): T { "safeDecode", "safeEncodeAsync", "safeDecodeAsync", + ]); + const parseResultMethods = new Set([ + ...dataFirstValidationMethods, "toJSONSchema", ]); + const unsafeCallbackMessage = + "callback-bearing schema arguments are not supported"; + + function rejectUnsafeCallback(): never { + throw new TypeError(unsafeCallbackMessage); + } + + function inspectCallbackValues( + source: object, + seen: WeakSet, + skipConstructor: boolean, + ): void { + let keys: readonly PropertyKey[]; + try { + keys = Reflect.ownKeys(source); + } catch { + rejectUnsafeCallback(); + } + for (const key of keys) { + if (skipConstructor && key === "constructor") continue; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(source, key); + } catch { + rejectUnsafeCallback(); + } + if (!descriptor) continue; + if (!("value" in descriptor)) rejectUnsafeCallback(); + assertCallbackFreeValue(descriptor.value, seen); + } + } + + function isOpaqueValidationArgument(value: object): boolean { + try { + if (value instanceof z.ZodType) return true; + const internalDescriptor = Object.getOwnPropertyDescriptor(value, "_zod"); + if (!internalDescriptor || !("value" in internalDescriptor)) { + return false; + } + const internal = internalDescriptor.value; + return ( + typeof internal === "object" && + internal !== null && + typeof (internal as { constr?: unknown }).constr === "function" && + "def" in internal + ); + } catch { + rejectUnsafeCallback(); + } + } + + function assertCallbackFreeValue( + value: unknown, + seen: WeakSet, + ): void { + if (typeof value === "function") rejectUnsafeCallback(); + if (typeof value !== "object" || value === null || seen.has(value)) { + return; + } + seen.add(value); + if (isOpaqueValidationArgument(value)) return; + + inspectCallbackValues(value, seen, false); + if ( + Array.isArray(value) || + value instanceof Map || + value instanceof Set || + value instanceof WeakMap || + value instanceof WeakSet || + value instanceof Date || + value instanceof RegExp || + value instanceof Promise || + ArrayBuffer.isView(value) + ) { + return; + } + + let prototype: object | null; + try { + prototype = Object.getPrototypeOf(value); + } catch { + rejectUnsafeCallback(); + } + while (prototype && prototype !== Object.prototype) { + inspectCallbackValues(prototype, seen, true); + try { + prototype = Object.getPrototypeOf(prototype); + } catch { + rejectUnsafeCallback(); + } + } + } + + function assertCallbackFreeArguments(args: readonly unknown[]): void { + const seen = new WeakSet(); + for (const argument of args) assertCallbackFreeValue(argument, seen); + } + + function readOption(options: object, property: PropertyKey): unknown { + try { + return Reflect.get(options, property, options); + } catch { + rejectUnsafeCallback(); + } + } + + function optionRecord(value: unknown): object { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + rejectUnsafeCallback(); + } + return value; + } + + function sanitizeParseOptions(value: unknown): object | undefined { + if (value === undefined) return undefined; + const options = optionRecord(value); + if (readOption(options, "error") !== undefined) rejectUnsafeCallback(); + + const safe = Object.create(null) as Record; + for (const property of ["reportInput", "jitless"] as const) { + const option = readOption(options, property); + if (option === undefined) continue; + if (typeof option !== "boolean") rejectUnsafeCallback(); + safe[property] = option; + } + return Object.freeze(safe); + } + + function sanitizeJsonSchemaOptions(value: unknown): object | undefined { + if (value === undefined) return undefined; + const options = optionRecord(value); + for (const property of [ + "override", + "processors", + "metadata", + "external", + ] as const) { + if (readOption(options, property) !== undefined) { + rejectUnsafeCallback(); + } + } + + const safe = Object.create(null) as Record; + for (const property of [ + "target", + "unrepresentable", + "io", + "cycles", + "reused", + ] as const) { + const option = readOption(options, property); + if (option === undefined) continue; + if (typeof option !== "string") rejectUnsafeCallback(); + safe[property] = option; + } + return Object.freeze(safe); + } + + function prepareSafeSchemaMethodArguments( + property: PropertyKey, + args: readonly unknown[], + ): readonly unknown[] { + assertCallbackFreeArguments( + dataFirstValidationMethods.has(property) ? args.slice(1) : args, + ); + if (dataFirstValidationMethods.has(property)) { + const options = sanitizeParseOptions(args[1]); + return options === undefined ? [args[0]] : [args[0], options]; + } + if (property === "toJSONSchema") { + const options = sanitizeJsonSchemaOptions(args[0]); + return options === undefined ? [] : [options]; + } + return args; + } function readonlyView(value: unknown): unknown { if ( @@ -395,16 +618,13 @@ function createReadonlyValidationFacade(schema: T): T { views.set(value, detached); return Object.freeze(detached); } - if (value instanceof Date) { - const detached = new Date(value.getTime()); - views.set(value, detached); - return Object.freeze(detached); - } + if (value instanceof Date) return dateFacade(value); return objectFacade(value); } function functionFacade(value: (...args: never[]) => unknown): unknown { const wrapped = function (this: unknown, ...args: unknown[]) { + assertCallbackFreeArguments(args); const result = new.target ? Reflect.construct(value, args) : Reflect.apply(value, this, args); @@ -421,22 +641,128 @@ function createReadonlyValidationFacade(schema: T): T { return Object.freeze(detached); } + const rejectedMutation = Object.freeze((): never => { + throw new TypeError("readonly detached view"); + }); + function mapFacade( value: ReadonlyMap, ): ReadonlyMap { const detached = new Map(); - views.set(value, detached); + let facade: ReadonlyMap; + facade = new Proxy(detached, { + defineProperty: () => false, + deleteProperty: () => false, + get: (target, property) => { + if ( + property === "set" || + property === "delete" || + property === "clear" + ) { + return rejectedMutation; + } + if (property === "forEach") { + return Object.freeze((callback: unknown, thisArg?: unknown): void => { + if (typeof callback !== "function") { + throw new TypeError("Map forEach callback missing"); + } + for (const [key, item] of target) { + Reflect.apply(callback, thisArg, [item, key, facade]); + } + }); + } + if (property === "size" && MAP_SIZE_GETTER) { + return Reflect.apply(MAP_SIZE_GETTER, target, []); + } + const intrinsic = MAP_READ_METHODS.get(property); + if (intrinsic) { + return Object.freeze((...args: unknown[]) => + Reflect.apply(intrinsic, target, args), + ); + } + return readonlyView(Reflect.get(target, property, facade)); + }, + set: () => false, + setPrototypeOf: () => false, + }); + views.set(value, facade); + views.set(facade, facade); for (const [key, item] of value) { detached.set(readonlyView(key), readonlyView(item)); } - return Object.freeze(detached); + Object.freeze(detached); + return facade; } function setFacade(value: ReadonlySet): ReadonlySet { const detached = new Set(); - views.set(value, detached); + let facade: ReadonlySet; + facade = new Proxy(detached, { + defineProperty: () => false, + deleteProperty: () => false, + get: (target, property) => { + if ( + property === "add" || + property === "delete" || + property === "clear" + ) { + return rejectedMutation; + } + if (property === "forEach") { + return Object.freeze((callback: unknown, thisArg?: unknown): void => { + if (typeof callback !== "function") { + throw new TypeError("Set forEach callback missing"); + } + for (const item of target) { + Reflect.apply(callback, thisArg, [item, item, facade]); + } + }); + } + if (property === "size" && SET_SIZE_GETTER) { + return Reflect.apply(SET_SIZE_GETTER, target, []); + } + const intrinsic = SET_READ_METHODS.get(property); + if (intrinsic) { + return Object.freeze((...args: unknown[]) => + Reflect.apply(intrinsic, target, args), + ); + } + return readonlyView(Reflect.get(target, property, facade)); + }, + set: () => false, + setPrototypeOf: () => false, + }); + views.set(value, facade); + views.set(facade, facade); for (const item of value) detached.add(readonlyView(item)); - return Object.freeze(detached); + Object.freeze(detached); + return facade; + } + + function dateFacade(value: Date): Date { + const detached = new Date(value.getTime()); + const facade = new Proxy(detached, { + defineProperty: () => false, + deleteProperty: () => false, + get: (target, property) => { + if (typeof property === "string" && property.startsWith("set")) { + return rejectedMutation; + } + const intrinsic = DATE_READ_METHODS.get(property); + if (intrinsic) { + return Object.freeze((...args: unknown[]) => + Reflect.apply(intrinsic, target, args), + ); + } + return readonlyView(Reflect.get(target, property, facade)); + }, + set: () => false, + setPrototypeOf: () => false, + }); + views.set(value, facade); + views.set(facade, facade); + Object.freeze(detached); + return facade; } function objectFacade(value: object): object { @@ -455,9 +781,10 @@ function createReadonlyValidationFacade(schema: T): T { method: (...args: never[]) => unknown, receiver: object, ): (...args: unknown[]) => unknown { - return Object.freeze((...args: unknown[]) => - readonlyView(Reflect.apply(method, receiver, args)), - ); + return Object.freeze((...args: unknown[]) => { + assertCallbackFreeArguments(args); + return readonlyView(Reflect.apply(method, receiver, args)); + }); } function copyReadonlyProperties( @@ -581,7 +908,8 @@ function createReadonlyValidationFacade(schema: T): T { } return Object.freeze((...args: unknown[]) => { - const result = Reflect.apply(method, target, args); + const safeArgs = prepareSafeSchemaMethodArguments(property, args); + const result = Reflect.apply(method, target, safeArgs); if (result instanceof z.ZodType) { return isolateValidationGraph(result, "derived-schema"); }