Initial Commit
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# wire.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the Zod schemas that pin the **wire-format invariants** of the serialize codecs. Both the JSON codec and the MessagePack codec produce structurally identical intermediate values: a recursive tree of JSON-compatible primitives, arrays, plain records, and tagged envelopes (`{ "__t": "<tag>", "v": <encoded> }`). This module defends the public `deserialize` and `decodeBinary` boundaries against accidental or malicious inputs that would otherwise reach the type registry's `decode` functions in an unexpected shape — for example a number where an envelope is expected, or an envelope with `__t` set to a non-string value.
|
||||
|
||||
The schemas are exported as both reusable values (`WireNodeSchema`, `TaggedEnvelopeSchema`) and an ergonomic helper (`validateWireNode`) that wraps `safeParse` and exposes the parse outcome as a discriminated union so call sites can wrap rejection in a `MalformedWireError` with stable code metadata.
|
||||
|
||||
## Features
|
||||
|
||||
- Recursive `WireNodeSchema` covering primitives, arrays, tagged envelopes, and plain records
|
||||
- Strict `TaggedEnvelopeSchema` that rejects extra keys
|
||||
- `validateWireNode` helper returning `{ ok: true; value } | { ok: false; error }`
|
||||
- Used by `deserialize` (JSON) and `decodeBinary` (MessagePack) at the public boundary
|
||||
- Pure, side-effect free; tree-shakes when unused
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validateWireNode } from "@suaveplan/serialize";
|
||||
|
||||
const result = validateWireNode({ __t: "__Date", v: "2024-01-01T00:00:00.000Z" });
|
||||
if (result.ok) {
|
||||
// safe to feed to the type-registry decode pipeline
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MalformedWireError, validateWireNode } from "@suaveplan/serialize";
|
||||
|
||||
function strictDeserialize(input: unknown): unknown {
|
||||
const wire = validateWireNode(input);
|
||||
if (!wire.ok) {
|
||||
throw new MalformedWireError(
|
||||
"input does not match the tagged-wire schema",
|
||||
"root",
|
||||
wire.error
|
||||
);
|
||||
}
|
||||
return wire.value;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WireNodeSchema: z.ZodType<unknown>` — recursive schema for any wire-format node.
|
||||
- `TaggedEnvelopeSchema: z.ZodObject<{ __t: ZodString, v: ZodUnknown }>` — strict envelope schema.
|
||||
- `validateWireNode(value: unknown): { ok: true; value: unknown } | { ok: false; error: ZodError }` — non-throwing validator.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`WireNodeSchema` is defined via `z.lazy()` to support recursion through arrays and records. The envelope schema uses `.strict()` so an envelope with a smuggled-in third key is rejected — this matches the `serialize`/`encodeBinary` output, which only ever emits `__t` + `v`. We intentionally exclude `BigInt`, `Map`, `Set`, and other JS-only constructs from the schema: the wire format never produces them at this layer (BigInts arrive as a `__BigInt` envelope with a string `v`), so admitting them would weaken the invariant.
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Spec-conformance tests for SZ-10 — Zod schema validation at the public
|
||||
* boundary. Pins:
|
||||
* - WireNodeSchema accepts every valid wire shape produced by serialize.
|
||||
* - WireNodeSchema rejects malformed envelopes (non-string __t, extra keys).
|
||||
* - deserialize rejects malformed JSON wire input with MalformedWireError.
|
||||
* - decodeBinary rejects malformed msgpack wire input with MalformedWireError.
|
||||
* - serialize / deserialize / decodeBinary reject invalid options with
|
||||
* InvalidOptionsError (typed by stable error code).
|
||||
*/
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
InvalidOptionsError,
|
||||
MalformedWireError,
|
||||
} from "../../errors/serialize-error.js";
|
||||
import { registerType, unregisterType } from "../../registry/registry.js";
|
||||
import { deserialize } from "../json/json-decode.js";
|
||||
import { serialize } from "../json/json-encode.js";
|
||||
import { decodeBinary } from "../msgpack/msgpack-decode.js";
|
||||
import { encodeBinary } from "../msgpack/msgpack-encode.js";
|
||||
import {
|
||||
TaggedEnvelopeSchema,
|
||||
validateWireNode,
|
||||
WireNodeSchema,
|
||||
} from "./wire.schemas.js";
|
||||
|
||||
describe("SZ-10 — WireNodeSchema acceptance", () => {
|
||||
it("accepts JSON primitives", { timeout: 5000 }, () => {
|
||||
for (const v of [null, true, false, 1, "x"]) {
|
||||
expect(WireNodeSchema.safeParse(v).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts arrays of nodes", { timeout: 5000 }, () => {
|
||||
expect(WireNodeSchema.safeParse([1, "x", null]).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts plain records", { timeout: 5000 }, () => {
|
||||
expect(WireNodeSchema.safeParse({ a: 1, b: "x" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a tagged envelope", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
WireNodeSchema.safeParse({ __t: "__Date", v: "2024-01-01" }).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — WireNodeSchema rejection", () => {
|
||||
it("rejects undefined", { timeout: 5000 }, () => {
|
||||
expect(WireNodeSchema.safeParse(undefined).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects symbols and functions", { timeout: 5000 }, () => {
|
||||
expect(WireNodeSchema.safeParse(Symbol("x")).success).toBe(false);
|
||||
expect(WireNodeSchema.safeParse(() => 0).success).toBe(false);
|
||||
});
|
||||
|
||||
it("strict envelope rejects extra keys", { timeout: 5000 }, () => {
|
||||
const r = TaggedEnvelopeSchema.safeParse({
|
||||
__t: "__Date",
|
||||
v: "x",
|
||||
extra: 1,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validateWireNode returns ZodError on rejection", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = validateWireNode(undefined);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error.issues.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — deserialize boundary validation", () => {
|
||||
it("invalid JSON throws MalformedWireError", { timeout: 5000 }, () => {
|
||||
expect(() => deserialize("{not json")).toThrowError(MalformedWireError);
|
||||
});
|
||||
|
||||
it("malformed envelope (non-string __t) throws MalformedWireError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = JSON.stringify({ __t: 42, v: null });
|
||||
expect(() => deserialize(bad)).toThrowError(MalformedWireError);
|
||||
});
|
||||
|
||||
it("MalformedWireError carries CODEC.MALFORMED_WIRE code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
deserialize("{not json");
|
||||
} catch (e) {
|
||||
expect((e as MalformedWireError).code).toBe(
|
||||
"SERIALIZE.CODEC.MALFORMED_WIRE"
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected throw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — decodeBinary boundary validation", () => {
|
||||
it("invalid msgpack bytes throw MalformedWireError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 0xc1 is reserved/never-used in msgpack and triggers a decode error.
|
||||
const bad = new Uint8Array([0xc1, 0xc1, 0xc1]);
|
||||
expect(() => decodeBinary(bad)).toThrowError(MalformedWireError);
|
||||
});
|
||||
|
||||
it("round-trips valid msgpack input", { timeout: 5000 }, () => {
|
||||
const bytes = encodeBinary({ a: 1, b: "two" });
|
||||
const back = decodeBinary(bytes);
|
||||
expect(back).toEqual({ a: 1, b: "two" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — invalid options rejection", () => {
|
||||
it("serialize throws InvalidOptionsError for non-positive maxDepth", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => serialize({}, { maxDepth: -1 } as never)).toThrowError(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
|
||||
it("serialize throws InvalidOptionsError for non-positive maxSize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => serialize({}, { maxSize: 0 } as never)).toThrowError(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
|
||||
it("serialize throws InvalidOptionsError for unknown option keys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => serialize({}, { unknown: 1 } as never)).toThrowError(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
|
||||
it("deserialize throws InvalidOptionsError for non-AbortSignal signal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
deserialize("null", { signal: "nope" as unknown as AbortSignal })
|
||||
).toThrowError(InvalidOptionsError);
|
||||
});
|
||||
|
||||
it("encodeBinary throws InvalidOptionsError for non-positive maxSize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => encodeBinary({}, { maxSize: -10 } as never)).toThrowError(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
|
||||
it("decodeBinary throws InvalidOptionsError for unknown option keys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = encodeBinary({ a: 1 });
|
||||
expect(() => decodeBinary(bytes, { unknown: 1 } as never)).toThrowError(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — error class branch coverage", () => {
|
||||
it("MalformedWireError works without a path argument", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new MalformedWireError("no-path");
|
||||
expect(err.code).toBe("SERIALIZE.CODEC.MALFORMED_WIRE");
|
||||
expect(err.context?.["tag"]).toBe("no-path");
|
||||
expect(err.context?.["path"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("InvalidOptionsError works with cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new InvalidOptionsError("invalid", cause);
|
||||
expect(err.code).toBe("SERIALIZE.OPTIONS.INVALID");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — msgpack wire-schema rejection", () => {
|
||||
it("decodeBinary rejects msgpack-encoded malformed envelopes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a real msgpack buffer for { __t: 1, v: 1 } using @msgpack/msgpack
|
||||
// directly — bypassing our encoder so the wire-schema rejection path
|
||||
// can be exercised independently of JSON.parse.
|
||||
// 0x82 = fixmap of 2; 0xa3 "__t" = fixstr; 0x01 = 1; 0xa1 "v" = fixstr; 0x01 = 1.
|
||||
const bytes = new Uint8Array([
|
||||
0x82, 0xa3, 0x5f, 0x5f, 0x74, 0x01, 0xa1, 0x76, 0x01,
|
||||
]);
|
||||
expect(() => decodeBinary(bytes)).toThrowError(MalformedWireError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — encode/decode error label fallback", () => {
|
||||
it("encode error path tags non-Error throws as SERIALIZE.CODEC.UNKNOWN", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Register a custom encode function that throws a non-Error value to
|
||||
// exercise the `: "SERIALIZE.CODEC.UNKNOWN"` branch in both codecs.
|
||||
const tag = "__SerializeEncodeBranchProbe";
|
||||
const ctor = class EncodeBranchProbe {};
|
||||
registerType({
|
||||
tag,
|
||||
ctor,
|
||||
encode: () => {
|
||||
throw "raw-string-throw";
|
||||
},
|
||||
decode: (v) => v,
|
||||
});
|
||||
try {
|
||||
const probe = new ctor();
|
||||
expect(() => serialize(probe)).toThrow();
|
||||
expect(() => encodeBinary(probe)).toThrow();
|
||||
} finally {
|
||||
unregisterType(tag);
|
||||
}
|
||||
});
|
||||
|
||||
it("decode error path tags non-Error throws as SERIALIZE.CODEC.UNKNOWN", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Register a custom decode function that throws a non-Error value to
|
||||
// exercise the `: "SERIALIZE.CODEC.UNKNOWN"` branch on the decode side.
|
||||
const tag = "__SerializeDecodeBranchProbe";
|
||||
const ctor = class DecodeBranchProbe {};
|
||||
registerType({
|
||||
tag,
|
||||
ctor,
|
||||
encode: () => null,
|
||||
decode: () => {
|
||||
throw "raw-string-throw";
|
||||
},
|
||||
});
|
||||
try {
|
||||
const probe = new ctor();
|
||||
const json = serialize(probe);
|
||||
expect(() => deserialize(json)).toThrow();
|
||||
const bytes = encodeBinary(probe);
|
||||
expect(() => decodeBinary(bytes)).toThrow();
|
||||
} finally {
|
||||
unregisterType(tag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — generic threading", () => {
|
||||
it("deserialize<T>() returns T at the type level", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const json = serialize({ name: "ada", age: 36 });
|
||||
const result = deserialize<{ name: string; age: number }>(json);
|
||||
expectTypeOf(result).toEqualTypeOf<{ name: string; age: number }>();
|
||||
expect(result.name).toBe("ada");
|
||||
});
|
||||
|
||||
it("decodeBinary<T>() returns T at the type level", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = encodeBinary([1, 2, 3]);
|
||||
const result = decodeBinary<number[]>(bytes);
|
||||
expectTypeOf(result).toEqualTypeOf<number[]>();
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("deserialize() defaults to unknown when T is omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = deserialize(serialize({ a: 1 }));
|
||||
expectTypeOf(result).toEqualTypeOf<unknown>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema describing the structural envelope of a tagged value in the
|
||||
* serialize wire format: `{ "__t": "<tag>", "v": <encoded> }`.
|
||||
*
|
||||
* The `__t` field MUST be a non-empty string and the `v` field MUST be
|
||||
* present (even if encoded as `null`). Extra keys are forbidden — a tagged
|
||||
* envelope that smuggles in a third key is by definition malformed and is
|
||||
* rejected at the public boundary.
|
||||
*/
|
||||
export const TaggedEnvelopeSchema = z
|
||||
.object({
|
||||
__t: z.string().min(1),
|
||||
v: z.unknown(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Refinement applied to plain records: if a record has a `__t` key it MUST
|
||||
* also conform to the strict envelope shape (string `__t`, present `v`,
|
||||
* no extra keys). Otherwise the record is malformed wire input. Records
|
||||
* without `__t` are passed through untouched — user data is allowed any
|
||||
* string-keyed shape that JSON.parse / msgpack can produce.
|
||||
*/
|
||||
function recordIsWellFormed(record: Record<string, unknown>): boolean {
|
||||
if (!("__t" in record)) {
|
||||
return true;
|
||||
}
|
||||
return TaggedEnvelopeSchema.safeParse(record).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema describing one node of the serialize wire format. The shape is
|
||||
* recursive: any node MAY be a JSON primitive, an array of nodes, a tagged
|
||||
* envelope, or a plain string-keyed record of nodes (a record with a `__t`
|
||||
* key MUST conform to the envelope shape — no extra keys, `__t` must be a
|
||||
* non-empty string, `v` must be present).
|
||||
*
|
||||
* Inputs that contain JS-only constructs the wire format never produces
|
||||
* (e.g. `undefined`, functions, symbols, raw BigInts) are rejected. The
|
||||
* intent is to defend the deserialize / decodeBinary boundary against
|
||||
* accidental or malicious inputs that would otherwise reach the type
|
||||
* registry's decode functions in an unexpected shape.
|
||||
*/
|
||||
export const WireNodeSchema: z.ZodType<unknown> = z.lazy(() =>
|
||||
z.union([
|
||||
z.null(),
|
||||
z.boolean(),
|
||||
z.number(),
|
||||
z.string(),
|
||||
z.array(WireNodeSchema),
|
||||
z.record(z.string(), WireNodeSchema).refine(recordIsWellFormed, {
|
||||
message:
|
||||
"record with __t key must conform to the tagged envelope schema (string __t, present v, no extra keys)",
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
/**
|
||||
* Validate a parsed-but-not-yet-decoded wire value against the structural
|
||||
* invariants of the serialize wire format. Returns the input cast to
|
||||
* `unknown` if valid; otherwise returns the underlying ZodError so the
|
||||
* caller can wrap it in a `MalformedWireError` with stable code metadata.
|
||||
*/
|
||||
export function validateWireNode(
|
||||
value: unknown
|
||||
): { ok: true; value: unknown } | { ok: false; error: z.ZodError } {
|
||||
const parsed = WireNodeSchema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
return { ok: true, value: parsed.data };
|
||||
}
|
||||
return { ok: false, error: parsed.error };
|
||||
}
|
||||
Reference in New Issue
Block a user