fix(housekeeping): harden readonly schema facade
This commit is contained in:
1 parent
139e8cfc3a
commit
2970dff563
3 files changed
+782
-15
No files matched your search
@@ -274,6 +274,75 @@ describe("dispatchHousekeepingCommand", () => {
|
|||||||
expect(valid).toMatchObject({ ok: true });
|
expect(valid).toMatchObject({ ok: true });
|
||||||
expect(executions).toBe(1);
|
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 () => {
|
it("rejects a missing required reason before the command can execute", async () => {
|
||||||
let executed = false;
|
let executed = false;
|
||||||
const audit = auditRecorder();
|
const audit = auditRecorder();
|
||||||
|
|||||||
@@ -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", () => {
|
describe("housekeeping command registry", () => {
|
||||||
it("returns the validated snapshot registered under its global ID", () => {
|
it("returns the validated snapshot registered under its global ID", () => {
|
||||||
const registered = command("people.registry.lookup");
|
const registered = command("people.registry.lookup");
|
||||||
@@ -506,6 +545,337 @@ describe("housekeeping command registry", () => {
|
|||||||
true,
|
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<z.output<typeof input>, { 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<z.output<typeof input>, { 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<z.output<typeof input>, { 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<z.output<typeof input>, { 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<string, { count: number }>([
|
||||||
|
["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<z.output<typeof input>, { 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<string, { count: number }>;
|
||||||
|
exposedSet: ReadonlySet<string>;
|
||||||
|
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<unknown, unknown>): Map<unknown, unknown> {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(Set.prototype, setEscape, {
|
||||||
|
configurable: true,
|
||||||
|
value(this: Set<unknown>): Set<unknown> {
|
||||||
|
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<string, { count: number }>).set("escaped-forged", {
|
||||||
|
count: 11,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
mutationWasRejected(() =>
|
||||||
|
(escapedSet as Set<string>).add("escaped-forged"),
|
||||||
|
),
|
||||||
|
mutationWasRejected(() => (escapedDate as Date).setTime(0)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const mapMutationResults = [
|
||||||
|
mutationWasRejected(() =>
|
||||||
|
(publicMap as Map<string, { count: number }>).set("forged", {
|
||||||
|
count: 9,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
mutationWasRejected(() =>
|
||||||
|
(publicMap as Map<string, { count: number }>).delete("registered"),
|
||||||
|
),
|
||||||
|
mutationWasRejected(() =>
|
||||||
|
(publicMap as Map<string, { count: number }>).clear(),
|
||||||
|
),
|
||||||
|
mutationWasRejected(() =>
|
||||||
|
Map.prototype.set.call(
|
||||||
|
publicMap as Map<string, { count: number }>,
|
||||||
|
"prototype-forged",
|
||||||
|
{ count: 10 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const setMutationResults = [
|
||||||
|
mutationWasRejected(() => (publicSet as Set<string>).add("forged")),
|
||||||
|
mutationWasRejected(() =>
|
||||||
|
(publicSet as Set<string>).delete("registered"),
|
||||||
|
),
|
||||||
|
mutationWasRejected(() => (publicSet as Set<string>).clear()),
|
||||||
|
mutationWasRejected(() =>
|
||||||
|
Set.prototype.add.call(publicSet as Set<string>, "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", () => {
|
it("seals the global registry after deterministic bootstrap", () => {
|
||||||
sealHousekeepingCommandRegistry();
|
sealHousekeepingCommandRegistry();
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export interface HousekeepingCommand<I, O> {
|
|||||||
/**
|
/**
|
||||||
* Registration snapshots structural/built-in Zod graphs and resolvable lazy
|
* Registration snapshots structural/built-in Zod graphs and resolvable lazy
|
||||||
* edges. Stateful custom refinements, transforms, preprocessors, and other
|
* 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<I>;
|
readonly input: z.ZodType<I>;
|
||||||
readonly requiresReason: boolean;
|
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<PropertyKey, ReadonlyIntrinsicMethod>([
|
||||||
|
["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<PropertyKey, ReadonlyIntrinsicMethod>([
|
||||||
|
["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<PropertyKey, ReadonlyIntrinsicMethod>();
|
||||||
|
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<T extends z.ZodType>(schema: T): T {
|
function createReadonlyValidationFacade<T extends z.ZodType>(schema: T): T {
|
||||||
const views = new WeakMap<object, unknown>();
|
const views = new WeakMap<object, unknown>();
|
||||||
const parseResultMethods = new Set<PropertyKey>([
|
const dataFirstValidationMethods = new Set<PropertyKey>([
|
||||||
"parse",
|
"parse",
|
||||||
"safeParse",
|
"safeParse",
|
||||||
"parseAsync",
|
"parseAsync",
|
||||||
@@ -371,8 +416,186 @@ function createReadonlyValidationFacade<T extends z.ZodType>(schema: T): T {
|
|||||||
"safeDecode",
|
"safeDecode",
|
||||||
"safeEncodeAsync",
|
"safeEncodeAsync",
|
||||||
"safeDecodeAsync",
|
"safeDecodeAsync",
|
||||||
|
]);
|
||||||
|
const parseResultMethods = new Set<PropertyKey>([
|
||||||
|
...dataFirstValidationMethods,
|
||||||
"toJSONSchema",
|
"toJSONSchema",
|
||||||
]);
|
]);
|
||||||
|
const unsafeCallbackMessage =
|
||||||
|
"callback-bearing schema arguments are not supported";
|
||||||
|
|
||||||
|
function rejectUnsafeCallback(): never {
|
||||||
|
throw new TypeError(unsafeCallbackMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectCallbackValues(
|
||||||
|
source: object,
|
||||||
|
seen: WeakSet<object>,
|
||||||
|
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<object>,
|
||||||
|
): 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<object>();
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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 {
|
function readonlyView(value: unknown): unknown {
|
||||||
if (
|
if (
|
||||||
@@ -395,16 +618,13 @@ function createReadonlyValidationFacade<T extends z.ZodType>(schema: T): T {
|
|||||||
views.set(value, detached);
|
views.set(value, detached);
|
||||||
return Object.freeze(detached);
|
return Object.freeze(detached);
|
||||||
}
|
}
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) return dateFacade(value);
|
||||||
const detached = new Date(value.getTime());
|
|
||||||
views.set(value, detached);
|
|
||||||
return Object.freeze(detached);
|
|
||||||
}
|
|
||||||
return objectFacade(value);
|
return objectFacade(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function functionFacade(value: (...args: never[]) => unknown): unknown {
|
function functionFacade(value: (...args: never[]) => unknown): unknown {
|
||||||
const wrapped = function (this: unknown, ...args: unknown[]) {
|
const wrapped = function (this: unknown, ...args: unknown[]) {
|
||||||
|
assertCallbackFreeArguments(args);
|
||||||
const result = new.target
|
const result = new.target
|
||||||
? Reflect.construct(value, args)
|
? Reflect.construct(value, args)
|
||||||
: Reflect.apply(value, this, args);
|
: Reflect.apply(value, this, args);
|
||||||
@@ -421,22 +641,128 @@ function createReadonlyValidationFacade<T extends z.ZodType>(schema: T): T {
|
|||||||
return Object.freeze(detached);
|
return Object.freeze(detached);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rejectedMutation = Object.freeze((): never => {
|
||||||
|
throw new TypeError("readonly detached view");
|
||||||
|
});
|
||||||
|
|
||||||
function mapFacade(
|
function mapFacade(
|
||||||
value: ReadonlyMap<unknown, unknown>,
|
value: ReadonlyMap<unknown, unknown>,
|
||||||
): ReadonlyMap<unknown, unknown> {
|
): ReadonlyMap<unknown, unknown> {
|
||||||
const detached = new Map<unknown, unknown>();
|
const detached = new Map<unknown, unknown>();
|
||||||
views.set(value, detached);
|
let facade: ReadonlyMap<unknown, unknown>;
|
||||||
|
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) {
|
for (const [key, item] of value) {
|
||||||
detached.set(readonlyView(key), readonlyView(item));
|
detached.set(readonlyView(key), readonlyView(item));
|
||||||
}
|
}
|
||||||
return Object.freeze(detached);
|
Object.freeze(detached);
|
||||||
|
return facade;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFacade(value: ReadonlySet<unknown>): ReadonlySet<unknown> {
|
function setFacade(value: ReadonlySet<unknown>): ReadonlySet<unknown> {
|
||||||
const detached = new Set<unknown>();
|
const detached = new Set<unknown>();
|
||||||
views.set(value, detached);
|
let facade: ReadonlySet<unknown>;
|
||||||
|
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));
|
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 {
|
function objectFacade(value: object): object {
|
||||||
@@ -455,9 +781,10 @@ function createReadonlyValidationFacade<T extends z.ZodType>(schema: T): T {
|
|||||||
method: (...args: never[]) => unknown,
|
method: (...args: never[]) => unknown,
|
||||||
receiver: object,
|
receiver: object,
|
||||||
): (...args: unknown[]) => unknown {
|
): (...args: unknown[]) => unknown {
|
||||||
return Object.freeze((...args: unknown[]) =>
|
return Object.freeze((...args: unknown[]) => {
|
||||||
readonlyView(Reflect.apply(method, receiver, args)),
|
assertCallbackFreeArguments(args);
|
||||||
);
|
return readonlyView(Reflect.apply(method, receiver, args));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyReadonlyProperties(
|
function copyReadonlyProperties(
|
||||||
@@ -581,7 +908,8 @@ function createReadonlyValidationFacade<T extends z.ZodType>(schema: T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Object.freeze((...args: unknown[]) => {
|
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) {
|
if (result instanceof z.ZodType) {
|
||||||
return isolateValidationGraph(result, "derived-schema");
|
return isolateValidationGraph(result, "derived-schema");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user