@pithy-sh/core 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/recorder.d.ts +10 -2
- package/dist/audit/recorder.d.ts.map +1 -1
- package/dist/capability/capability.d.ts +12 -0
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +2 -0
- package/dist/capability/purge.d.ts +80 -0
- package/dist/capability/purge.d.ts.map +1 -0
- package/dist/capability/purge.js +76 -0
- package/dist/cli/commandCatalog.generated.d.ts +54 -2
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +55 -0
- package/dist/cli/commandFacts.d.ts +1 -0
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +1 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +7 -0
- package/dist/controlPlane/data/connection.d.ts +2 -12
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +1 -1
- package/dist/controlPlane/discovery/adminRoute.d.ts +29 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +3 -0
- package/dist/controlPlane/error/errors.d.ts +4 -3
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.d.ts +79 -0
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +37 -3
- package/dist/controlPlane/http/responses.d.ts +98 -0
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +41 -1
- package/dist/controlPlane/http/routes.d.ts +1 -0
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +17 -3
- package/dist/controlPlane/scope/scope.d.ts +13 -3
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +14 -4
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +2 -0
- package/dist/crypto/envelope.d.ts +86 -0
- package/dist/crypto/envelope.d.ts.map +1 -0
- package/dist/crypto/envelope.js +129 -0
- package/dist/crypto/fieldKeys.d.ts +41 -0
- package/dist/crypto/fieldKeys.d.ts.map +1 -0
- package/dist/crypto/fieldKeys.js +56 -0
- package/dist/data/codecs.d.ts +23 -10
- package/dist/data/codecs.d.ts.map +1 -1
- package/dist/data/codecs.js +50 -36
- package/dist/error/extend.d.ts +6 -5
- package/dist/error/extend.d.ts.map +1 -1
- package/dist/error/http.d.ts +24 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +48 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +16 -0
- package/dist/error/pithyError.d.ts +18 -7
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/logger/logger.d.ts +12 -5
- package/dist/logger/logger.d.ts.map +1 -1
- package/dist/logger/record.d.ts +14 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/privacy/blindIndex.d.ts +27 -0
- package/dist/privacy/blindIndex.d.ts.map +1 -0
- package/dist/privacy/blindIndex.js +57 -0
- package/dist/privacy/classes.d.ts +142 -0
- package/dist/privacy/classes.d.ts.map +1 -0
- package/dist/privacy/classes.js +209 -0
- package/dist/privacy/classify.d.ts +336 -0
- package/dist/privacy/classify.d.ts.map +1 -0
- package/dist/privacy/classify.js +1024 -0
- package/dist/privacy/encrypted.d.ts +210 -0
- package/dist/privacy/encrypted.d.ts.map +1 -0
- package/dist/privacy/encrypted.js +350 -0
- package/dist/privacy/encryptedColumns.d.ts +64 -0
- package/dist/privacy/encryptedColumns.d.ts.map +1 -0
- package/dist/privacy/encryptedColumns.js +134 -0
- package/dist/privacy/fieldRotation.d.ts +34 -0
- package/dist/privacy/fieldRotation.d.ts.map +1 -0
- package/dist/privacy/fieldRotation.js +186 -0
- package/dist/privacy/inventory.d.ts +12 -0
- package/dist/privacy/inventory.d.ts.map +1 -0
- package/dist/privacy/inventory.js +154 -0
- package/dist/privacy/posture.d.ts +114 -0
- package/dist/privacy/posture.d.ts.map +1 -0
- package/dist/privacy/posture.js +136 -0
- package/dist/privacy/redact.d.ts +7 -0
- package/dist/privacy/redact.d.ts.map +1 -0
- package/dist/privacy/redact.js +404 -0
- package/dist/privacy/report.d.ts +58 -0
- package/dist/privacy/report.d.ts.map +1 -0
- package/dist/privacy/report.js +15 -0
- package/dist/privacy/retirement.d.ts +68 -0
- package/dist/privacy/retirement.d.ts.map +1 -0
- package/dist/privacy/retirement.js +72 -0
- package/dist/privacy/sealed.d.ts +87 -0
- package/dist/privacy/sealed.d.ts.map +1 -0
- package/dist/privacy/sealed.js +221 -0
- package/dist/privacy/types.d.ts +559 -0
- package/dist/privacy/types.d.ts.map +1 -0
- package/dist/privacy/types.js +2 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +1 -1
- package/src/audit/recorder.ts +10 -2
- package/src/capability/capability.ts +15 -0
- package/src/capability/purge.ts +124 -0
- package/src/cli/commandCatalog.generated.ts +59 -0
- package/src/cli/commandFacts.ts +1 -0
- package/src/controlPlane/capability.ts +11 -0
- package/src/controlPlane/discovery/adminRoute.ts +11 -0
- package/src/controlPlane/error/errors.ts +4 -3
- package/src/controlPlane/http/handlers.ts +57 -4
- package/src/controlPlane/http/responses.ts +100 -0
- package/src/controlPlane/http/routes.ts +31 -1
- package/src/controlPlane/scope/scope.ts +14 -3
- package/src/controlPlane/token/base64url.ts +1 -1
- package/src/createBackend.ts +4 -0
- package/src/crypto/envelope.ts +196 -0
- package/src/crypto/fieldKeys.ts +76 -0
- package/src/data/codecs.ts +69 -37
- package/src/error/extend.ts +6 -5
- package/src/error/payload.ts +42 -0
- package/src/error/pithyError.ts +21 -7
- package/src/index.ts +1 -0
- package/src/logger/logger.ts +12 -5
- package/src/privacy/blindIndex.ts +81 -0
- package/src/privacy/classes.ts +277 -0
- package/src/privacy/classify.ts +1447 -0
- package/src/privacy/encrypted.ts +513 -0
- package/src/privacy/encryptedColumns.ts +185 -0
- package/src/privacy/fieldRotation.ts +234 -0
- package/src/privacy/inventory.ts +147 -0
- package/src/privacy/posture.ts +205 -0
- package/src/privacy/redact.ts +423 -0
- package/src/privacy/report.ts +64 -0
- package/src/privacy/retirement.ts +102 -0
- package/src/privacy/sealed.ts +323 -0
- package/src/privacy/types.ts +804 -0
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,1447 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
import { isSqliteJson, SQLiteBoolean, SQLiteDate } from "../data/codecs";
|
|
6
|
+
import { ownPrivacyClasses, privacyClasses, rankPrivacyClasses, strongestPrivacyClass } from "./classes";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **Reading a privacy marker off a schema, and every marker off a schema tree** (#132).
|
|
10
|
+
*
|
|
11
|
+
* The marker is `meta({ privacy })` on the schema a marker was handed. {@link privacyOf} reads it through the
|
|
12
|
+
* wrappers that are the same field — `pii(z.string()).optional()` is `pii` — and {@link classifyPrivacy} names
|
|
13
|
+
* each position of a schema that `redact` masks.
|
|
14
|
+
*
|
|
15
|
+
* ## The redaction contract
|
|
16
|
+
*
|
|
17
|
+
* `redact`, `classifyPrivacy` and `Redacted<T>` all read the one record this module builds for a position, so they
|
|
18
|
+
* agree on every one. The contract they keep is stated on #132 and in `docs/PRIVACY.md`:
|
|
19
|
+
*
|
|
20
|
+
* - **Walked exactly**: objects (strict, loose, catchall, and `pick`, `omit`, `partial`, `extend` of one), arrays,
|
|
21
|
+
* tuples and their rest, records, `Map` and `Set`; the wrappers `optional`, `nullable`, `default`, `catch` with a
|
|
22
|
+
* constant value, `readonly` and a brand; unions, discriminated unions and intersections, where an option's marks apply where
|
|
23
|
+
* the value has that option's shape — a marked string option to a string, never to an array or a number beside
|
|
24
|
+
* it — and a mark reached through no option applies to every value; the kit's `SQLiteDate`, `SQLiteBoolean` and
|
|
25
|
+
* `sqliteJson(schema)`, read through to the schema they decode to; and recursion, a `z.lazy` or a getter, to the
|
|
26
|
+
* one limit below.
|
|
27
|
+
* - **Everything else is opaque**: a transform, a pipe, a preprocess, any other codec, `z.custom`, `any`, `unknown`,
|
|
28
|
+
* and an `.overwrite()` or a `.catch()` with a fallback function over a structure, which make a new value.
|
|
29
|
+
* Nothing inside one is walked. With a mark anywhere beneath it, its position is masked whole with the strongest
|
|
30
|
+
* class it carries; with none, it is kept, a union option beside a marked one included.
|
|
31
|
+
* - **A mark on a container masks it whole**, and the container carries every class marked inside it as well as its
|
|
32
|
+
* own: `pii(z.object({ token: secret(z.string()) }))` is listed `pii` and `secret`, and masked `[secret]`.
|
|
33
|
+
* - **Nesting is bounded once**, at {@link MAX_RECURSION_DEPTH} levels: a position deeper than that is masked whole as
|
|
34
|
+
* {@link TRUNCATED_MASK}, whatever nests it, a recursion or a schema written out level by level. It is a safety
|
|
35
|
+
* cutoff rather than a privacy class. A recursion that repeats a position (the same set of schemas) already read on
|
|
36
|
+
* its path needs no cut in the list: its classes are those of the position already read. Recursions defined once
|
|
37
|
+
* side by side in a union, with cycles of different lengths, can reach the limit before any position repeats. Where
|
|
38
|
+
* a read reaches the limit on a chain of positions not yet seen on its path, a factory or plain nesting, or such a
|
|
39
|
+
* union, nothing past it was read, so the list assumes every class there, each `truncated`, as past the budget, and
|
|
40
|
+
* `hasPrivacy` answers yes for each: the list never says less than is marked. A path that reaches the limit ends
|
|
41
|
+
* alone: every other path is still read, so a class beside a recursion is found whatever the field order, and a
|
|
42
|
+
* position masked whole for a class it carries is masked with that class, cut off below or not. The cutoff is a flag
|
|
43
|
+
* beside the classes, never instead of them. Every answer is a function of the schema and the value alone.
|
|
44
|
+
* - **One budget per call, {@link MAX_READ_SCHEMAS} schemas.** `redact`, `classifyPrivacy`, `hasPrivacy` and
|
|
45
|
+
* `walkPrivacy` each read at most that many in one call, and every position and every search in it draws on it: each
|
|
46
|
+
* schema a read meets counts one, and so does each schema a lazy or an object's shape yields, where a getter runs and
|
|
47
|
+
* may build one. So a call's time and memory grow linearly with the size of the schema and the value, and are bounded
|
|
48
|
+
* beyond that by the budget and by what the schema's own getters build. Reads go shallowest first, so a class above the point the budget runs out on is found and
|
|
49
|
+
* kept. Past it the call fails closed: `redact` masks each position it has not resolved whole, with the strongest
|
|
50
|
+
* class it found there or {@link TRUNCATED_MASK} where it found none, and the list names every class at each position
|
|
51
|
+
* it has not read, each `truncated`: nothing there was read, so any class may be marked there, and the list never
|
|
52
|
+
* says less than the schema marks. It is one of five places the list says more than `redact` masks, beside a position
|
|
53
|
+
* cut at the depth limit, the classes a cut assumes, a reference's own path and an intersection that narrows. A position read once is read once
|
|
54
|
+
* wherever it sits in the call, so a schema reused in many fields costs one, and each call starts with the whole
|
|
55
|
+
* budget and keeps nothing for the next, so every answer is a function of the schema and the value alone. A position
|
|
56
|
+
* the walk cannot read at all, nested deeper than the stack can follow, is listed with every class, each `truncated`:
|
|
57
|
+
* nothing here throws.
|
|
58
|
+
* - **`classifyPrivacy` writes out at most {@link MAX_LISTED_PATHS} paths.** A schema reused in two fields at every
|
|
59
|
+
* level has a path per field per level, two to the depth; past the cap a position met again is listed at its own path
|
|
60
|
+
* once per class marked beneath it, with `sameAs` the path it was first written out under: `redact` masks the paths
|
|
61
|
+
* below it, not that path itself. So the paths it writes out are bounded too, however many reach one schema, and
|
|
62
|
+
* every class is still listed.
|
|
63
|
+
* - **An intersection is not narrowed.** Where one side narrows a field so it can never hold the type the other side
|
|
64
|
+
* marks, `classifyPrivacy` may still list that field: the inventory can list more than `redact` masks, and never
|
|
65
|
+
* less. `z.intersection(z.object({ b: z.number() }), z.object({ b: z.union([pii(z.string()), z.number()]) }))`
|
|
66
|
+
* lists `b` as `pii`, and `redact` keeps a `b` of `3`.
|
|
67
|
+
*
|
|
68
|
+
* ## What a path looks like
|
|
69
|
+
*
|
|
70
|
+
* Object keys join with `.`, an array or set element is `[]`, a tuple position is `[i]`, a record's or a map's value
|
|
71
|
+
* and an object's catchall are `*`, and a record's or a map's key is `<key>`: `address.line1`, `devices[].token`,
|
|
72
|
+
* `pair[1]`, `notes.*`, `byEmail.<key>`. A marked root is the empty path. A position masked whole is listed once per
|
|
73
|
+
* class it carries, and nothing below it is.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/** One marked path in a schema, and its class. The per-field record the inventory is built from. */
|
|
77
|
+
export interface PrivacyField {
|
|
78
|
+
/** Where in the value the field sits: `email`, `address.line1`, `devices[].token`, or `""` for the root. */
|
|
79
|
+
path: string;
|
|
80
|
+
/** The class it was marked with. */
|
|
81
|
+
class: string;
|
|
82
|
+
/**
|
|
83
|
+
* Set where the class is assumed rather than found: a position the call ran out of {@link MAX_READ_SCHEMAS} before it
|
|
84
|
+
* read, or a read beneath a position that ran out of it, a position cut at {@link MAX_RECURSION_DEPTH} on a chain of
|
|
85
|
+
* schemas not yet seen on its path, or a schema the walk could not read at all, nested deeper than the stack. Each
|
|
86
|
+
* lists {@link unreadClasses}. Absent everywhere the schema was read to the end.
|
|
87
|
+
*/
|
|
88
|
+
truncated?: true;
|
|
89
|
+
/**
|
|
90
|
+
* Set on a reference: the path this field's position was first listed under. Once the walk has written out
|
|
91
|
+
* {@link MAX_LISTED_PATHS} paths, a position it meets again is listed once per class marked beneath it, with the path
|
|
92
|
+
* it was first written out under, rather than once per path below it. The paths below `sameAs` are the paths below
|
|
93
|
+
* this one.
|
|
94
|
+
*/
|
|
95
|
+
sameAs?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Whether the field rests encrypted in its store (#390): `true` for a field marked `encrypted()`, `false` for one
|
|
98
|
+
* that is not. Set by the inventory, on the observed side of #132's attestation split, and **absent where the core
|
|
99
|
+
* cannot say** — an assumed class, a reference, or a core older than #390 — never defaulted to `false`.
|
|
100
|
+
*/
|
|
101
|
+
encryptedAtRest?: boolean;
|
|
102
|
+
/** Set on an encrypted field whose storage is not D1: nothing can prove it re-sealed, so it blocks retirement. */
|
|
103
|
+
unrotatable?: true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* **The one recursion limit**, shared by `redact`, `classifyPrivacy` and `Redacted<T>` (`./types` reads this constant's
|
|
108
|
+
* type). Depth is counted in levels of nesting: every object, array, tuple, record, map and set a position sits inside.
|
|
109
|
+
* It applies to all nesting, not only recursion: a position deeper than this is masked whole as {@link TRUNCATED_MASK}
|
|
110
|
+
* and typed `unknown`, whatever nests it: a `z.lazy`, a getter that returns one schema, one that builds a new schema
|
|
111
|
+
* each read, or a schema written out level by level. The list assumes every class there, unless the chain repeats a
|
|
112
|
+
* position (the same set of schemas) already read on its path. A search for a mark beneath an opaque node or a marked
|
|
113
|
+
* container ends a path at the same depth, counting each lazy too, and reads every other path; so does the one
|
|
114
|
+
* `Redacted<T>` makes. So does a read of the shapes a choice can have, and of the schemas that reach one position, each
|
|
115
|
+
* counting the lazies nested above a schema: a lazy that builds a new choice each read never meets a schema twice. A
|
|
116
|
+
* union of many lazies, none inside another, counts one.
|
|
117
|
+
*/
|
|
118
|
+
export const MAX_RECURSION_DEPTH = 256;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* **The one budget**: how many schemas one call of `redact`, `classifyPrivacy`, `hasPrivacy` or `walkPrivacy` reads.
|
|
122
|
+
* Every position and every search in that call draws on it. Each schema a read meets counts one — a step of the read of
|
|
123
|
+
* a position, of the shapes a choice can have, or of a search beneath a marked container or an opaque node — and so
|
|
124
|
+
* does each schema a lazy or an object's shape yields: a lazy's target, and each field of a shape, where a getter runs
|
|
125
|
+
* and may build a new schema. A schema met again in the same read costs nothing. So a call reads at most this many
|
|
126
|
+
* schemas, and runs at most this many of the schema's own getters beyond the fields of the one object it was reading
|
|
127
|
+
* when the budget ran out. So its time and memory grow linearly with the size of the schema and the value, and are
|
|
128
|
+
* bounded beyond that by the budget and by what one of those getters builds. Past it the call fails closed: what it has not read, it assumes. `redact` masks each position it has
|
|
129
|
+
* not resolved whole, with the strongest class it found there or {@link TRUNCATED_MASK} where it found none; the list
|
|
130
|
+
* names every class at each position it has not read, each `truncated`. Far past any schema written out by hand, and
|
|
131
|
+
* past any recursion defined once: only a schema that builds new ones as it is read, or one wider than any written out,
|
|
132
|
+
* reaches it. Each call starts with the whole budget and keeps nothing for the next, so every answer is a function of
|
|
133
|
+
* the schema and the value alone, whichever call came first.
|
|
134
|
+
*/
|
|
135
|
+
export const MAX_READ_SCHEMAS = 10_000;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* How many paths {@link walkPrivacy} writes out before a position it met already is listed by reference. A schema
|
|
139
|
+
* reused in two fields at every level of thirty has thirty-one schemas and a billion paths, so a list of every path
|
|
140
|
+
* cannot be built; past this many, each new position is still read and written out once, and each one met again is
|
|
141
|
+
* listed at its own path with every class beneath it and `sameAs` the path it was written out under. So the paths it
|
|
142
|
+
* writes out are bounded too, however many reach one schema. Far past any table written out: a book of 22 groups of 22
|
|
143
|
+
* contacts of 22 addresses is written out whole.
|
|
144
|
+
*/
|
|
145
|
+
export const MAX_LISTED_PATHS = 50_000;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What `redact` puts where it stops: a value nested past {@link MAX_RECURSION_DEPTH}, an opaque value over a recursion
|
|
149
|
+
* that reaches it, and a position the call ran out of {@link MAX_READ_SCHEMAS} before it read, where no class was
|
|
150
|
+
* found. A safety cutoff rather than a privacy class, so it is never a placeholder for one.
|
|
151
|
+
*/
|
|
152
|
+
export const TRUNCATED_MASK = "[truncated]";
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The classes {@link classifyPrivacy} lists for a position it did not read: every class registered, strongest first,
|
|
156
|
+
* because nothing there was read and any of them may be marked below it. So the list never says less than the schema
|
|
157
|
+
* marks, and `hasPrivacy` answers yes for each. A read that runs out of {@link MAX_READ_SCHEMAS} assumes them where it
|
|
158
|
+
* stopped, a cut at {@link MAX_RECURSION_DEPTH} on a chain of schemas not yet seen assumes them where it cut, and a
|
|
159
|
+
* schema the walk cannot read at all is listed with them.
|
|
160
|
+
*/
|
|
161
|
+
export function unreadClasses(): string[] {
|
|
162
|
+
return rankPrivacyClasses(privacyClasses().map((each) => each.name));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* One call of `redact`, `classifyPrivacy`, `hasPrivacy` or `walkPrivacy`: how much of {@link MAX_READ_SCHEMAS} it has
|
|
167
|
+
* left, and what it has read, so a schema met again in the call is read once. Made new for each call and kept by none.
|
|
168
|
+
*/
|
|
169
|
+
export interface Call {
|
|
170
|
+
/** How many more schemas the call may read. */
|
|
171
|
+
left: number;
|
|
172
|
+
/** The shapes each schema's output can have, read to its end. */
|
|
173
|
+
readonly kinds: Map<z.ZodType, KindsFound>;
|
|
174
|
+
/** The fewest lazies above each schema from which a read of its shapes is known to run past the limit. */
|
|
175
|
+
readonly stops: Map<z.ZodType, number>;
|
|
176
|
+
/** What a search found beneath each schema. */
|
|
177
|
+
readonly searched: Map<z.ZodType, Search>;
|
|
178
|
+
/** Every position read, by the schemas that reach it. */
|
|
179
|
+
readonly positions: Map<string, Position>;
|
|
180
|
+
/** Every position walked into, by its structural nodes and its masks. */
|
|
181
|
+
readonly walked: Map<string, Position>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A call with the whole budget, and nothing read. */
|
|
185
|
+
export function newCall(): Call {
|
|
186
|
+
return {
|
|
187
|
+
left: MAX_READ_SCHEMAS,
|
|
188
|
+
kinds: new Map(),
|
|
189
|
+
stops: new Map(),
|
|
190
|
+
searched: new Map(),
|
|
191
|
+
positions: new Map(),
|
|
192
|
+
walked: new Map(),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Spend one of the call's budget on a read: `false`, and nothing spent, once there is none left. */
|
|
197
|
+
function take(call: Call): boolean {
|
|
198
|
+
if (call.left <= 0) return false;
|
|
199
|
+
call.left -= 1;
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* An object's shape, read for the call: reading it runs the shape's getters, each of which may build a schema, so the
|
|
205
|
+
* call pays one for each field it holds as well as the one it took to open it. The budget may go below nothing here,
|
|
206
|
+
* by at most the width of that one object, and the next read stops.
|
|
207
|
+
*/
|
|
208
|
+
function readShape(schema: z.ZodType, call: Call): Record<PropertyKey, z.ZodType> {
|
|
209
|
+
const shape = shapeOf(schema);
|
|
210
|
+
call.left -= Reflect.ownKeys(shape).length;
|
|
211
|
+
return shape;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A Zod schema's internal definition, as much of it as the walk reads. */
|
|
215
|
+
interface Def {
|
|
216
|
+
type: string;
|
|
217
|
+
innerType?: z.ZodType;
|
|
218
|
+
element?: z.ZodType;
|
|
219
|
+
options?: z.ZodType[];
|
|
220
|
+
left?: z.ZodType;
|
|
221
|
+
right?: z.ZodType;
|
|
222
|
+
items?: z.ZodType[];
|
|
223
|
+
rest?: z.ZodType | null;
|
|
224
|
+
keyType?: z.ZodType;
|
|
225
|
+
valueType?: z.ZodType;
|
|
226
|
+
catchall?: z.ZodType;
|
|
227
|
+
shape?: Record<PropertyKey, z.ZodType>;
|
|
228
|
+
out?: z.ZodType;
|
|
229
|
+
catchValue?: unknown;
|
|
230
|
+
checks?: readonly unknown[];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function defOf(schema: z.ZodType): Def {
|
|
234
|
+
return (schema as unknown as { _zod: { def: Def } })._zod.def;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isSchema(value: unknown): value is z.ZodType {
|
|
238
|
+
return typeof value === "object" && value !== null && "_zod" in value;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The wrappers that are the same position as what they wrap. A brand is a type, and no node at all. */
|
|
242
|
+
const WRAPPERS: ReadonlySet<string> = new Set([
|
|
243
|
+
"optional",
|
|
244
|
+
"nullable",
|
|
245
|
+
"default",
|
|
246
|
+
"prefault",
|
|
247
|
+
"catch",
|
|
248
|
+
"readonly",
|
|
249
|
+
"nonoptional",
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
/** The kinds a value is walked into. */
|
|
253
|
+
const STRUCTURAL: ReadonlySet<string> = new Set(["object", "array", "tuple", "record", "map", "set"]);
|
|
254
|
+
|
|
255
|
+
/** The tag Zod puts on the function it wraps a constant `.catch()` value in. */
|
|
256
|
+
const CONSTANT_CATCH = "~constantCatch";
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Whether a node hands back a value its own code made rather than the one it read: a `.catch()` with a fallback
|
|
260
|
+
* function, or an `.overwrite()`. A constant fallback is a value, and is walked as the wrapper it is.
|
|
261
|
+
*/
|
|
262
|
+
function makesValue(def: Def): boolean {
|
|
263
|
+
if (def.type === "catch") {
|
|
264
|
+
const fallback = def.catchValue as { [CONSTANT_CATCH]?: unknown } | undefined;
|
|
265
|
+
if (typeof fallback !== "function" || fallback[CONSTANT_CATCH] !== true) return true;
|
|
266
|
+
}
|
|
267
|
+
return (def.checks ?? []).some(
|
|
268
|
+
(check) => (check as { _zod?: { def?: { check?: unknown } } })._zod?.def?.check === "overwrite",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The value a constant `.catch()` hands back, or `undefined` for any other node. */
|
|
273
|
+
function constantFallback(def: Def): { value: unknown } | undefined {
|
|
274
|
+
if (def.type !== "catch" || makesValue(def)) return undefined;
|
|
275
|
+
try {
|
|
276
|
+
return { value: (def.catchValue as () => unknown)() };
|
|
277
|
+
} catch {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The shapes a value can have, as far as a choice is concerned: what `typeof` says, with an object told apart by what
|
|
284
|
+
* it is. An option's marks apply to a value of a shape the option names, and to no other.
|
|
285
|
+
*/
|
|
286
|
+
export type ValueKind =
|
|
287
|
+
| "string"
|
|
288
|
+
| "number"
|
|
289
|
+
| "bigint"
|
|
290
|
+
| "boolean"
|
|
291
|
+
| "symbol"
|
|
292
|
+
| "function"
|
|
293
|
+
| "date"
|
|
294
|
+
| "object"
|
|
295
|
+
| "array"
|
|
296
|
+
| "map"
|
|
297
|
+
| "set";
|
|
298
|
+
|
|
299
|
+
/** The shapes a value is walked into. */
|
|
300
|
+
const STRUCTURE: readonly ValueKind[] = ["object", "array", "map", "set"];
|
|
301
|
+
|
|
302
|
+
/** Whether a value of one of `kinds` could be walked into. */
|
|
303
|
+
function holdsStructure(kinds: ReadonlySet<ValueKind>): boolean {
|
|
304
|
+
return STRUCTURE.some((kind) => kinds.has(kind));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A node's shapes, as a choice reads them: every shape for one that {@link makesValue} over a structure, as its own
|
|
309
|
+
* code may hand back anything. A scalar's is still its own: what it makes is the scalar a mark masks or keeps.
|
|
310
|
+
*/
|
|
311
|
+
function widened(def: Def, kinds: ReadonlySet<ValueKind>): ReadonlySet<ValueKind> {
|
|
312
|
+
return holdsStructure(kinds) && makesValue(def) ? EVERY_KIND : kinds;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Every {@link ValueKind}, in the order a mask lists them. */
|
|
316
|
+
const VALUE_KINDS: readonly ValueKind[] = [
|
|
317
|
+
"string",
|
|
318
|
+
"number",
|
|
319
|
+
"bigint",
|
|
320
|
+
"boolean",
|
|
321
|
+
"symbol",
|
|
322
|
+
"function",
|
|
323
|
+
"date",
|
|
324
|
+
"object",
|
|
325
|
+
"array",
|
|
326
|
+
"map",
|
|
327
|
+
"set",
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
const EVERY_KIND: ReadonlySet<ValueKind> = new Set(VALUE_KINDS);
|
|
331
|
+
const NO_KIND: ReadonlySet<ValueKind> = new Set();
|
|
332
|
+
|
|
333
|
+
/** The shape of a value, or `undefined` for `null` and `undefined`, which are kept wherever they are. */
|
|
334
|
+
export function kindOfValue(value: unknown): ValueKind | undefined {
|
|
335
|
+
const type = typeof value;
|
|
336
|
+
if (type === "undefined" || value === null) return undefined;
|
|
337
|
+
if (type !== "object") return type as Exclude<typeof type, "undefined" | "object">;
|
|
338
|
+
if (Array.isArray(value)) return "array";
|
|
339
|
+
if (value instanceof Map) return "map";
|
|
340
|
+
if (value instanceof Set) return "set";
|
|
341
|
+
return value instanceof Date ? "date" : "object";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The prototype a value of each kind has where it is what its schema parses: a plain object's, a `Date`'s … */
|
|
345
|
+
const TYPICAL: Partial<Record<ValueKind, object>> = {
|
|
346
|
+
object: Object.prototype,
|
|
347
|
+
array: Array.prototype,
|
|
348
|
+
map: Map.prototype,
|
|
349
|
+
set: Set.prototype,
|
|
350
|
+
date: Date.prototype,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Whether a value of `kind` is one its schema parses: a plain object, an `Array.prototype` array, a `Map`, a `Set` or
|
|
355
|
+
* a `Date` of its own prototype, never a subclass or a class instance. A scalar and a function always are.
|
|
356
|
+
*/
|
|
357
|
+
function isTypical(value: object, kind: ValueKind): boolean {
|
|
358
|
+
const typical = TYPICAL[kind];
|
|
359
|
+
if (typical === undefined) return true;
|
|
360
|
+
const prototype = Object.getPrototypeOf(value);
|
|
361
|
+
return prototype === typical || (kind === "object" && prototype === null);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The one shape each kind of schema produces. A record is an object, and a tuple an array. */
|
|
365
|
+
const SCHEMA_KINDS: Readonly<Record<string, ValueKind>> = {
|
|
366
|
+
string: "string",
|
|
367
|
+
template_literal: "string",
|
|
368
|
+
number: "number",
|
|
369
|
+
nan: "number",
|
|
370
|
+
bigint: "bigint",
|
|
371
|
+
boolean: "boolean",
|
|
372
|
+
symbol: "symbol",
|
|
373
|
+
function: "function",
|
|
374
|
+
date: "date",
|
|
375
|
+
object: "object",
|
|
376
|
+
record: "object",
|
|
377
|
+
array: "array",
|
|
378
|
+
tuple: "array",
|
|
379
|
+
map: "map",
|
|
380
|
+
set: "set",
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The kinds a scalar, a literal, an enum or a node that holds no value names, or `undefined` for an opaque node, which
|
|
385
|
+
* takes a value of any kind and prototype: a transform, a pipe, `z.custom`, `any`, `unknown`, or one that makes its
|
|
386
|
+
* own value.
|
|
387
|
+
*/
|
|
388
|
+
function scalarKinds(node: z.ZodType): ReadonlySet<ValueKind> | undefined {
|
|
389
|
+
const def = defOf(node);
|
|
390
|
+
if (makesValue(def)) return undefined;
|
|
391
|
+
const one = SCHEMA_KINDS[def.type];
|
|
392
|
+
if (one !== undefined) return new Set([one]);
|
|
393
|
+
if (NO_VALUE.has(def.type)) return NO_KIND;
|
|
394
|
+
if (def.type !== "literal" && def.type !== "enum") return undefined;
|
|
395
|
+
const values = (node as unknown as { _zod: { values?: ReadonlySet<unknown> } })._zod.values;
|
|
396
|
+
return values ? new Set([...values].flatMap((value) => [kindOfValue(value)].filter(isKind))) : undefined;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The kinds of schema that hold no value a mask could apply to. */
|
|
400
|
+
const NO_VALUE: ReadonlySet<string> = new Set(["null", "undefined", "void", "never"]);
|
|
401
|
+
|
|
402
|
+
/** A schema's shapes, read to its end, and how many lazies deep the read went below it. */
|
|
403
|
+
interface KindsFound {
|
|
404
|
+
readonly kinds: ReadonlySet<ValueKind>;
|
|
405
|
+
readonly reach: number;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** A schema on the path of a read of {@link kindsOf}: where on the path it sits, and which time it was put there. */
|
|
409
|
+
interface OnPath {
|
|
410
|
+
readonly index: number;
|
|
411
|
+
readonly stamp: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A schema read inside a cycle that comes round to a schema above it: its answer, and the schema on the path that cycle
|
|
416
|
+
* comes round to, as it was put there. The answer holds while that schema is still on the path, put there that time.
|
|
417
|
+
*/
|
|
418
|
+
interface WithinCycle {
|
|
419
|
+
readonly found: KindsFound;
|
|
420
|
+
readonly anchor: z.ZodType;
|
|
421
|
+
readonly stamp: number;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* One read of {@link kindsOf}: whether it ran past the limit or out of the call's budget, how deep it went, the schemas
|
|
426
|
+
* on the path, the shallowest place on the path a cycle below came round to, and the answers of the schemas it read
|
|
427
|
+
* inside a cycle.
|
|
428
|
+
*/
|
|
429
|
+
interface KindsRead {
|
|
430
|
+
readonly call: Call;
|
|
431
|
+
stopped: boolean;
|
|
432
|
+
/** Whether it stopped at the recursion limit, rather than at the budget. */
|
|
433
|
+
past: boolean;
|
|
434
|
+
deepest: number;
|
|
435
|
+
low: number;
|
|
436
|
+
stamps: number;
|
|
437
|
+
path: Map<z.ZodType, OnPath>;
|
|
438
|
+
stack: z.ZodType[];
|
|
439
|
+
within: Map<z.ZodType, WithinCycle>;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The shapes a schema's output can have: read through its wrappers, the options of a union, the sides of an
|
|
444
|
+
* intersection, a lazy and the kit's codecs, every option to the end. Every shape for anything opaque, whose output
|
|
445
|
+
* nothing here reads, for a read that meets a path more than {@link MAX_RECURSION_DEPTH} lazies long, and for one that
|
|
446
|
+
* runs out of the call's {@link MAX_READ_SCHEMAS}: a lazy that builds a new choice each read never meets a schema
|
|
447
|
+
* twice, and every shape is the answer that masks the most. Depth first, and the first path that runs out ends the
|
|
448
|
+
* read. A schema read to its end is remembered for the call with how deep it went, so a choice reused in many places
|
|
449
|
+
* is read once. A schema whose cycles all come round to itself or below is remembered for every read; one inside a
|
|
450
|
+
* cycle that comes round above it is remembered while that schema is still on the path, so a choice reused inside a
|
|
451
|
+
* cycle is read once too.
|
|
452
|
+
*/
|
|
453
|
+
function kindsOf(schema: z.ZodType, call: Call, lazies = 0): ReadonlySet<ValueKind> {
|
|
454
|
+
const read: KindsRead = {
|
|
455
|
+
call,
|
|
456
|
+
stopped: false,
|
|
457
|
+
past: false,
|
|
458
|
+
deepest: 0,
|
|
459
|
+
low: Number.POSITIVE_INFINITY,
|
|
460
|
+
stamps: 0,
|
|
461
|
+
path: new Map(),
|
|
462
|
+
stack: [],
|
|
463
|
+
within: new Map(),
|
|
464
|
+
};
|
|
465
|
+
const found = readKinds(schema, lazies, read);
|
|
466
|
+
return read.stopped ? EVERY_KIND : found;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** A schema's answer met again, `depth` lazies down: it would go as deep below as it did before. */
|
|
470
|
+
function replay(known: KindsFound, depth: number, read: KindsRead): ReadonlySet<ValueKind> {
|
|
471
|
+
if (depth + known.reach > MAX_RECURSION_DEPTH) {
|
|
472
|
+
read.stopped = true;
|
|
473
|
+
read.past = true;
|
|
474
|
+
}
|
|
475
|
+
read.deepest = Math.max(read.deepest, depth + known.reach);
|
|
476
|
+
return read.stopped ? EVERY_KIND : known.kinds;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** One step of {@link kindsOf}, `depth` lazies down. */
|
|
480
|
+
function readKinds(schema: z.ZodType, depth: number, read: KindsRead): ReadonlySet<ValueKind> {
|
|
481
|
+
if (read.stopped) return EVERY_KIND;
|
|
482
|
+
// Past the limit, or as deep as a read of this schema was seen to run past it: this path, and the read, end here.
|
|
483
|
+
const stop = read.call.stops.get(schema);
|
|
484
|
+
if (depth > MAX_RECURSION_DEPTH || (stop !== undefined && depth >= stop)) {
|
|
485
|
+
read.stopped = true;
|
|
486
|
+
read.past = true;
|
|
487
|
+
return EVERY_KIND;
|
|
488
|
+
}
|
|
489
|
+
read.deepest = Math.max(read.deepest, depth);
|
|
490
|
+
const known = read.call.kinds.get(schema);
|
|
491
|
+
if (known) return replay(known, depth, read);
|
|
492
|
+
// Each schema read costs one of the call's budget; past it, every shape, the answer that masks the most.
|
|
493
|
+
if (!take(read.call)) {
|
|
494
|
+
read.stopped = true;
|
|
495
|
+
return EVERY_KIND;
|
|
496
|
+
}
|
|
497
|
+
const def = defOf(schema);
|
|
498
|
+
const one = SCHEMA_KINDS[def.type];
|
|
499
|
+
if (one !== undefined) return widened(def, new Set([one]));
|
|
500
|
+
if (NO_VALUE.has(def.type)) return NO_KIND;
|
|
501
|
+
if (def.type === "literal" || def.type === "enum") {
|
|
502
|
+
const values = (schema as unknown as { _zod: { values?: ReadonlySet<unknown> } })._zod.values;
|
|
503
|
+
return values ? new Set([...values].flatMap((value) => [kindOfValue(value)].filter(isKind))) : EVERY_KIND;
|
|
504
|
+
}
|
|
505
|
+
// A lazy that only ever resolves to itself holds nothing parse could return: every shape, so nothing is missed.
|
|
506
|
+
const onPath = read.path.get(schema);
|
|
507
|
+
if (onPath) {
|
|
508
|
+
read.low = Math.min(read.low, onPath.index);
|
|
509
|
+
return EVERY_KIND;
|
|
510
|
+
}
|
|
511
|
+
// Read inside a cycle before, and the schema that cycle comes round to is still on the path, put there that time.
|
|
512
|
+
const cycled = read.within.get(schema);
|
|
513
|
+
const anchor = cycled && read.path.get(cycled.anchor);
|
|
514
|
+
if (cycled && anchor?.stamp === cycled.stamp) {
|
|
515
|
+
read.low = Math.min(read.low, anchor.index);
|
|
516
|
+
return replay(cycled.found, depth, read);
|
|
517
|
+
}
|
|
518
|
+
const { deepest, low } = read;
|
|
519
|
+
const index = read.stack.length;
|
|
520
|
+
read.stamps += 1;
|
|
521
|
+
read.deepest = depth;
|
|
522
|
+
read.low = Number.POSITIVE_INFINITY;
|
|
523
|
+
read.path.set(schema, { index, stamp: read.stamps });
|
|
524
|
+
read.stack.push(schema);
|
|
525
|
+
let found: ReadonlySet<ValueKind>;
|
|
526
|
+
try {
|
|
527
|
+
found = widened(def, readStep(schema, def, depth, read));
|
|
528
|
+
} finally {
|
|
529
|
+
read.path.delete(schema);
|
|
530
|
+
read.stack.pop();
|
|
531
|
+
}
|
|
532
|
+
const reach = read.deepest - depth;
|
|
533
|
+
const below = read.low;
|
|
534
|
+
read.deepest = Math.max(deepest, read.deepest);
|
|
535
|
+
read.low = Math.min(low, below);
|
|
536
|
+
// Run past the limit with every cycle below coming round to this schema or below it: a read of it from this many
|
|
537
|
+
// lazies down, or more, runs past it again, whatever reached it. So a chain of new lazies is read once, not once a
|
|
538
|
+
// level.
|
|
539
|
+
if (read.stopped && read.past && below >= index) {
|
|
540
|
+
const known = read.call.stops.get(schema);
|
|
541
|
+
read.call.stops.set(schema, known === undefined ? depth : Math.min(known, depth));
|
|
542
|
+
}
|
|
543
|
+
if (!read.stopped) {
|
|
544
|
+
const answer: KindsFound = { kinds: found, reach };
|
|
545
|
+
// Every cycle below came round to this schema or below it: the answer is the schema's, whatever reached it.
|
|
546
|
+
if (below >= index) read.call.kinds.set(schema, answer);
|
|
547
|
+
else {
|
|
548
|
+
// A cycle came round above it: the answer holds while the schema it came round to is on the path.
|
|
549
|
+
const to = read.stack[below] as z.ZodType;
|
|
550
|
+
read.within.set(schema, { found: answer, anchor: to, stamp: (read.path.get(to) as OnPath).stamp });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return found;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** {@link readKinds} for a node that holds other schemas. */
|
|
557
|
+
function readStep(schema: z.ZodType, def: Def, depth: number, read: KindsRead): ReadonlySet<ValueKind> {
|
|
558
|
+
if (WRAPPERS.has(def.type) && def.innerType) return readKinds(def.innerType, depth, read);
|
|
559
|
+
if (def.type === "union" && def.options) {
|
|
560
|
+
const joined = new Set<ValueKind>();
|
|
561
|
+
for (const option of def.options) {
|
|
562
|
+
for (const kind of readKinds(option, depth, read)) joined.add(kind);
|
|
563
|
+
// Every shape already, or a path that ran out: the options left can add none.
|
|
564
|
+
if (joined.size === EVERY_KIND.size || read.stopped) break;
|
|
565
|
+
}
|
|
566
|
+
return joined;
|
|
567
|
+
}
|
|
568
|
+
if (def.type === "intersection" && def.left && def.right) {
|
|
569
|
+
const left = readKinds(def.left, depth, read);
|
|
570
|
+
if (left.size === 0) return NO_KIND;
|
|
571
|
+
const right = readKinds(def.right, depth, read);
|
|
572
|
+
return new Set([...left].filter((kind) => right.has(kind)));
|
|
573
|
+
}
|
|
574
|
+
if (def.type === "lazy") {
|
|
575
|
+
// Resolving a lazy runs its getter, which may build a schema: the call pays for it as for a read.
|
|
576
|
+
if (!take(read.call)) {
|
|
577
|
+
read.stopped = true;
|
|
578
|
+
return EVERY_KIND;
|
|
579
|
+
}
|
|
580
|
+
const inner = lazyInner(schema);
|
|
581
|
+
return inner ? readKinds(inner, depth + 1, read) : EVERY_KIND;
|
|
582
|
+
}
|
|
583
|
+
const codec = kitCodecOutput(schema);
|
|
584
|
+
return codec ? readKinds(codec, depth, read) : EVERY_KIND;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function isKind(kind: ValueKind | undefined): kind is ValueKind {
|
|
588
|
+
return kind !== undefined;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The class a field was marked with, read through its wrappers — `pii(z.string()).optional()` is `pii` — or
|
|
593
|
+
* `null` when nothing along the chain carries one. A field marked twice, `pii(secret(x))`, is its strongest class.
|
|
594
|
+
* It never throws: a schema whose metadata cannot be read, cloned by `.describe()` or `.meta()` more times than Zod
|
|
595
|
+
* can read it back through, may carry any class, so it answers the strongest of {@link unreadClasses}.
|
|
596
|
+
*/
|
|
597
|
+
export function privacyOf(schema: z.ZodType): string | null {
|
|
598
|
+
try {
|
|
599
|
+
for (let node: z.ZodType | undefined = schema; node; ) {
|
|
600
|
+
const found = strongestPrivacyClass(ownPrivacyClasses(node));
|
|
601
|
+
if (found !== undefined) return found;
|
|
602
|
+
const def = defOf(node);
|
|
603
|
+
node = WRAPPERS.has(def.type) ? def.innerType : undefined;
|
|
604
|
+
}
|
|
605
|
+
} catch {
|
|
606
|
+
return strongestPrivacyClass(unreadClasses()) ?? null;
|
|
607
|
+
}
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** The schema one of the kit's three codecs decodes to, or `undefined` for any other node. */
|
|
612
|
+
function kitCodecOutput(schema: z.ZodType): z.ZodType | undefined {
|
|
613
|
+
const def = defOf(schema);
|
|
614
|
+
if (def.type !== "pipe") return undefined;
|
|
615
|
+
const kit = def === defOf(SQLiteDate) || def === defOf(SQLiteBoolean) || isSqliteJson(schema);
|
|
616
|
+
return kit ? def.out : undefined;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** What a lazy stands for: Zod's own cached target, the schema `parse` ran. Never its getter called again. */
|
|
620
|
+
function lazyInner(schema: z.ZodType): z.ZodType | undefined {
|
|
621
|
+
const inner = (schema as unknown as { _zod: { innerType?: unknown } })._zod.innerType;
|
|
622
|
+
return isSchema(inner) ? inner : undefined;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** An object's fields, read through the shape Zod freezes on first read, so every walk sees the schemas `parse` does. */
|
|
626
|
+
function shapeOf(schema: z.ZodType): Record<PropertyKey, z.ZodType> {
|
|
627
|
+
return defOf(schema).shape ?? {};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Every schema a node holds, whatever its kind: what a search for a mark reads through. */
|
|
631
|
+
function children(schema: z.ZodType): z.ZodType[] {
|
|
632
|
+
const def = defOf(schema);
|
|
633
|
+
if (def.type === "lazy") {
|
|
634
|
+
const inner = lazyInner(schema);
|
|
635
|
+
return inner ? [inner] : [];
|
|
636
|
+
}
|
|
637
|
+
const found: z.ZodType[] = def.type === "object" ? Object.values(shapeOf(schema)).filter(isSchema) : [];
|
|
638
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(def))) {
|
|
639
|
+
// A check is not a node, and an accessor — a default's value, a shape — is never run here: it may be the
|
|
640
|
+
// adopter's own function. A lazy's getter is read above, through its cache, and an object's shape through Zod's.
|
|
641
|
+
if (key === "checks" || key === "getter" || !("value" in descriptor)) continue;
|
|
642
|
+
const slot: unknown = descriptor.value;
|
|
643
|
+
if (isSchema(slot)) found.push(slot);
|
|
644
|
+
else if (Array.isArray(slot)) for (const each of slot) if (isSchema(each)) found.push(each);
|
|
645
|
+
}
|
|
646
|
+
return found;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** What a search found beneath a schema. */
|
|
650
|
+
interface Search {
|
|
651
|
+
/** Every class it found marked. */
|
|
652
|
+
readonly classes: ReadonlySet<string>;
|
|
653
|
+
/** Whether a path ended at the recursion limit, or the search at the budget. */
|
|
654
|
+
readonly truncated: boolean;
|
|
655
|
+
/**
|
|
656
|
+
* Whether every class is assumed beneath it: the search ended at the call's {@link MAX_READ_SCHEMAS}, or a path of
|
|
657
|
+
* schemas it had not met reached {@link MAX_RECURSION_DEPTH}. Either way, what lies past it was never read.
|
|
658
|
+
*/
|
|
659
|
+
readonly unread: boolean;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Every class marked anywhere beneath a schema, on every side of every node. Level by level, each level in the order
|
|
664
|
+
* the schema declares it, and each schema once. A path more than {@link MAX_RECURSION_DEPTH} levels long, counting
|
|
665
|
+
* each lazy and each structural node, ends there and marks the search `truncated` and `unread`; every other path is
|
|
666
|
+
* still read, so a class beside a recursion is found whatever the field order. Each schema is met once, so a recursion
|
|
667
|
+
* defined once comes round to a schema already read and never reaches the limit: only a chain of schemas not met
|
|
668
|
+
* before does, a factory or nesting deeper than the limit, and past it any class may be marked, so every one is
|
|
669
|
+
* assumed there. Each schema it meets costs one of the call's
|
|
670
|
+
* {@link MAX_READ_SCHEMAS}, so shallowest first, a class above the point the budget runs out on is found. There the
|
|
671
|
+
* search ends `truncated` and `unread`: what lies past it was never read, so every class is assumed there, and the
|
|
672
|
+
* search never says less than the schema marks. Remembered for the call, so a schema searched again costs nothing.
|
|
673
|
+
*/
|
|
674
|
+
function search(schema: z.ZodType, call: Call): Search {
|
|
675
|
+
const known = call.searched.get(schema);
|
|
676
|
+
if (known) return known;
|
|
677
|
+
const classes = new Set<string>();
|
|
678
|
+
let truncated = false;
|
|
679
|
+
let unread = false;
|
|
680
|
+
let spent = false;
|
|
681
|
+
const seen = new Set<z.ZodType>();
|
|
682
|
+
let level: z.ZodType[] = [schema];
|
|
683
|
+
for (let depth = 0; level.length > 0 && !spent; depth += 1) {
|
|
684
|
+
const next: z.ZodType[] = [];
|
|
685
|
+
// Reversed, so the classes come out in the order the schema declares them.
|
|
686
|
+
const stack = [...level].reverse();
|
|
687
|
+
for (let node = stack.pop(); node; node = stack.pop()) {
|
|
688
|
+
if (seen.has(node)) continue;
|
|
689
|
+
// Past the budget, nothing more is read, so any class may be marked there: the search assumes every one.
|
|
690
|
+
if (!take(call)) {
|
|
691
|
+
truncated = true;
|
|
692
|
+
unread = true;
|
|
693
|
+
spent = true;
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
seen.add(node);
|
|
697
|
+
// A mark does not end the search: a container marked whole carries every class marked inside it too.
|
|
698
|
+
for (const own of ownPrivacyClasses(node)) classes.add(own);
|
|
699
|
+
const def = defOf(node);
|
|
700
|
+
if (def.type !== "lazy" && !STRUCTURAL.has(def.type)) {
|
|
701
|
+
const inside = children(node);
|
|
702
|
+
for (let index = inside.length - 1; index >= 0; index -= 1) stack.push(inside[index] as z.ZodType);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
// Past the limit, this path ends here, and only this one. Every schema on it was new, so any class may be past it.
|
|
706
|
+
if (depth + 1 > MAX_RECURSION_DEPTH) {
|
|
707
|
+
truncated = true;
|
|
708
|
+
unread = true;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
// Opening a node is what may build a schema — a lazy's target, an object's shape read through its getters — so
|
|
712
|
+
// the call pays for it as for a read, and for each field a shape holds.
|
|
713
|
+
if (!take(call)) {
|
|
714
|
+
truncated = true;
|
|
715
|
+
unread = true;
|
|
716
|
+
spent = true;
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
if (def.type === "object") readShape(node, call);
|
|
720
|
+
for (const child of children(node)) next.push(child);
|
|
721
|
+
}
|
|
722
|
+
level = next;
|
|
723
|
+
}
|
|
724
|
+
const found = { classes, truncated, unread };
|
|
725
|
+
call.searched.set(schema, found);
|
|
726
|
+
return found;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** How a position is masked whole. */
|
|
730
|
+
export interface Mask {
|
|
731
|
+
/**
|
|
732
|
+
* The strongest class found at the position: its placeholder is the mask, unless `own`. Never a class only assumed.
|
|
733
|
+
* Empty where none was found — a recursion that reached the limit, or a read that ran out of
|
|
734
|
+
* {@link MAX_READ_SCHEMAS}, is all there is — and only there is the mask {@link TRUNCATED_MASK}.
|
|
735
|
+
*/
|
|
736
|
+
readonly className: string;
|
|
737
|
+
/** Whether the class's own mask applies: one class, from a marker, nothing opaque, and nothing cut off. */
|
|
738
|
+
readonly own: boolean;
|
|
739
|
+
/**
|
|
740
|
+
* Every class marked at the position, in the order met, then every class a read that ran out of
|
|
741
|
+
* {@link MAX_READ_SCHEMAS}, or reached {@link MAX_RECURSION_DEPTH} on a chain of schemas not yet seen, assumed. Never
|
|
742
|
+
* the cutoff, which is not a class.
|
|
743
|
+
*/
|
|
744
|
+
readonly classes: readonly string[];
|
|
745
|
+
/**
|
|
746
|
+
* The classes in `classes` that no read found, only assumed: a read here ran out of {@link MAX_READ_SCHEMAS}, or
|
|
747
|
+
* reached {@link MAX_RECURSION_DEPTH} on a chain of schemas not yet seen, so any class may be marked past it. Listed
|
|
748
|
+
* `truncated`.
|
|
749
|
+
*/
|
|
750
|
+
readonly assumed: readonly string[];
|
|
751
|
+
/**
|
|
752
|
+
* Whether a recursion here reached {@link MAX_RECURSION_DEPTH}, or a read ran out of {@link MAX_READ_SCHEMAS}: a flag
|
|
753
|
+
* beside the classes, never instead of them. A class found here is still the mask.
|
|
754
|
+
*/
|
|
755
|
+
readonly truncated: boolean;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** One position: every schema that reaches it, flattened through wrappers, choices, lazies and the kit's codecs. */
|
|
759
|
+
export interface Position {
|
|
760
|
+
/**
|
|
761
|
+
* Set when some value here is masked whole: every class the position masks, whatever the value's shape. What
|
|
762
|
+
* `classifyPrivacy` lists.
|
|
763
|
+
*/
|
|
764
|
+
readonly mask: Mask | undefined;
|
|
765
|
+
/**
|
|
766
|
+
* How an object's string key is masked when it is read here. A string key is a string whatever the key schema says,
|
|
767
|
+
* a number key's included, so every mark applies to it but one that only a symbol option reaches.
|
|
768
|
+
*/
|
|
769
|
+
readonly nameMask: Mask | undefined;
|
|
770
|
+
/** Whether every value here is masked whole, whatever its shape. */
|
|
771
|
+
readonly whole: boolean;
|
|
772
|
+
/**
|
|
773
|
+
* How a value is masked here, by its shape: a mark reached through a union option applies to a value of that
|
|
774
|
+
* option's shape, and one reached through no option to every value. `undefined` where the value is walked or kept.
|
|
775
|
+
*/
|
|
776
|
+
maskFor(value: unknown): Mask | undefined;
|
|
777
|
+
/**
|
|
778
|
+
* Whether a value's kind and prototype miss what the schemas here expect, and how it is masked where they do: a
|
|
779
|
+
* class instance or a plain object where none of its kind is named, a `Date`, an array, a `Map` or a `Set` of a
|
|
780
|
+
* subclass or with fields where an object is, an array-like instance where an array is. `undefined` where it fits:
|
|
781
|
+
* a schema here names its kind and it is typical of that kind, or only an opaque node here takes its kind. A misfit
|
|
782
|
+
* is never walked. Its mask is the strongest class beneath the schemas here of its kind, or beneath every schema here
|
|
783
|
+
* where none names it, and `undefined`, to keep it as it is, where nothing there is marked.
|
|
784
|
+
*/
|
|
785
|
+
misfit(value: unknown): { readonly mask: Mask | undefined } | undefined;
|
|
786
|
+
/** The structural kinds here — `object`, `array`, `map` … — a value is walked by: none of a shape masked whole. */
|
|
787
|
+
readonly kinds: ReadonlySet<string>;
|
|
788
|
+
/** Whether anything here but a structural node may hold the value: a scalar, an opaque node. */
|
|
789
|
+
readonly other: boolean;
|
|
790
|
+
/** The position at an object key or a record's value. */
|
|
791
|
+
field(key: PropertyKey): Position | undefined;
|
|
792
|
+
/** The keys the objects here name, in order. */
|
|
793
|
+
readonly keys: readonly PropertyKey[];
|
|
794
|
+
/** The position at a key no object names: a catchall, a record's value. */
|
|
795
|
+
readonly catchall: Position | undefined;
|
|
796
|
+
/** The position a record's keys are read at. */
|
|
797
|
+
readonly key: Position | undefined;
|
|
798
|
+
/** The position at element `index` of an array or a tuple. */
|
|
799
|
+
item(index: number): Position | undefined;
|
|
800
|
+
/** How many items the longest tuple here names. */
|
|
801
|
+
readonly items: number;
|
|
802
|
+
/** The position at an element past every tuple item: an array's element, a tuple's rest. */
|
|
803
|
+
readonly rest: Position | undefined;
|
|
804
|
+
/** The positions of a map's keys and values. */
|
|
805
|
+
readonly mapKey: Position | undefined;
|
|
806
|
+
readonly mapValue: Position | undefined;
|
|
807
|
+
/** The position of a set's elements. */
|
|
808
|
+
readonly element: Position | undefined;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** An id per schema, so a list of schemas has a key. */
|
|
812
|
+
const ids = new WeakMap<z.ZodType, number>();
|
|
813
|
+
let lastId = 0;
|
|
814
|
+
function idOf(schema: z.ZodType): number {
|
|
815
|
+
let id = ids.get(schema);
|
|
816
|
+
if (id === undefined) {
|
|
817
|
+
lastId += 1;
|
|
818
|
+
id = lastId;
|
|
819
|
+
ids.set(schema, id);
|
|
820
|
+
}
|
|
821
|
+
return id;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** The schema each leaf of one kind holds at a slot, read by `pick`. */
|
|
825
|
+
function slots(leaves: readonly z.ZodType[], kinds: readonly string[], pick: (def: Def) => unknown): z.ZodType[] {
|
|
826
|
+
return leaves
|
|
827
|
+
.filter((leaf) => kinds.includes(defOf(leaf).type))
|
|
828
|
+
.flatMap((leaf) => [pick(defOf(leaf))].filter(isSchema));
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** A catchall a value's extra key is parsed by: none for a stripped object, `never` for a strict one. */
|
|
832
|
+
function openCatchall(def: Def): z.ZodType | undefined {
|
|
833
|
+
return def.catchall && defOf(def.catchall).type !== "never" ? def.catchall : undefined;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** The position `schemas` reach, read once in the call for that list of schemas. */
|
|
837
|
+
export function positionOf(schemas: readonly z.ZodType[], call: Call): Position | undefined {
|
|
838
|
+
const distinct = [...new Set(schemas)];
|
|
839
|
+
if (distinct.length === 0) return undefined;
|
|
840
|
+
const key = distinct.map(idOf).join(",");
|
|
841
|
+
const known = call.positions.get(key);
|
|
842
|
+
if (known) return known;
|
|
843
|
+
const { position, leaves, signature } = readPosition(distinct, call);
|
|
844
|
+
// A position walked into is the same position whatever wrappers reached it: `Node` and `Node.optional()` are one,
|
|
845
|
+
// so a recursion defined once comes round to the position it started at. Its masks are part of what it is.
|
|
846
|
+
let found = position;
|
|
847
|
+
if (leaves.length > 0) {
|
|
848
|
+
const leafKey = `${leaves.map(idOf).join(",")}${position.other ? "+" : ""}#${signature}`;
|
|
849
|
+
found = call.walked.get(leafKey) ?? position;
|
|
850
|
+
call.walked.set(leafKey, found);
|
|
851
|
+
}
|
|
852
|
+
call.positions.set(key, found);
|
|
853
|
+
return found;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** One mark, or one opaque node's classes, at a position: and the shapes of value it applies to. */
|
|
857
|
+
interface Reach {
|
|
858
|
+
/** The shapes of value it applies to: every shape, narrowed by each union option it was reached through. */
|
|
859
|
+
readonly gate: ReadonlySet<ValueKind>;
|
|
860
|
+
readonly classes: readonly string[];
|
|
861
|
+
/** Whether it came from an opaque node or a recursion that ran out, rather than a marker. */
|
|
862
|
+
readonly opaque: boolean;
|
|
863
|
+
readonly truncated: boolean;
|
|
864
|
+
/**
|
|
865
|
+
* Whether every class is assumed here: a read here or beneath it ran out of {@link MAX_READ_SCHEMAS}, or reached
|
|
866
|
+
* {@link MAX_RECURSION_DEPTH} on a chain of schemas it had not met.
|
|
867
|
+
*/
|
|
868
|
+
readonly unread: boolean;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** The kind of value a structural node holds. */
|
|
872
|
+
function leafKind(leaf: z.ZodType): ValueKind {
|
|
873
|
+
return SCHEMA_KINDS[defOf(leaf).type] as ValueKind;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Every class in `reaches` and beneath `leaves`, and every class assumed where a read there ran out: what a mask made
|
|
878
|
+
* of them carries. `undefined` where none is marked and no read there was cut off or ran out.
|
|
879
|
+
*/
|
|
880
|
+
function carried(reaches: readonly Reach[], leaves: readonly z.ZodType[], call: Call): Mask | undefined {
|
|
881
|
+
const searches = leaves.map((leaf) => search(leaf, call));
|
|
882
|
+
const found = new Set([
|
|
883
|
+
...reaches.flatMap((reach) => reach.classes),
|
|
884
|
+
...searches.flatMap((each) => [...each.classes]),
|
|
885
|
+
]);
|
|
886
|
+
const truncated = reaches.some((reach) => reach.truncated) || searches.some((each) => each.truncated);
|
|
887
|
+
// A read that ran out of the budget never saw what lies past it: every class may be marked there.
|
|
888
|
+
const unread = reaches.some((reach) => reach.unread) || searches.some((each) => each.unread);
|
|
889
|
+
if (found.size === 0 && !truncated && !unread) return undefined;
|
|
890
|
+
const assumed = unread ? unreadClasses().filter((name) => !found.has(name)) : [];
|
|
891
|
+
// The placeholder is the strongest class found, never one only assumed: none found is the cutoff.
|
|
892
|
+
return {
|
|
893
|
+
className: strongestPrivacyClass([...found]) ?? "",
|
|
894
|
+
own: false,
|
|
895
|
+
classes: [...found, ...assumed],
|
|
896
|
+
assumed,
|
|
897
|
+
truncated,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** The mask made of `reaches` and the structural nodes masked with them, or `undefined` when none applies. */
|
|
902
|
+
function maskOf(reaches: readonly Reach[], leaves: readonly z.ZodType[], call: Call): Mask | undefined {
|
|
903
|
+
if (reaches.length === 0) return undefined;
|
|
904
|
+
const mask = carried(reaches, leaves, call);
|
|
905
|
+
if (mask === undefined) return undefined;
|
|
906
|
+
const own = !mask.truncated && reaches.every((reach) => !reach.opaque) && mask.classes.length === 1;
|
|
907
|
+
return { ...mask, own };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* The mask for a value of a kind or a prototype the schemas here do not expect: every class in `reaches` and beneath
|
|
912
|
+
* `leaves`, as beneath an opaque node, or `undefined` where none is marked and no read there was cut off or ran out.
|
|
913
|
+
*/
|
|
914
|
+
function misfitMaskOf(reaches: readonly Reach[], leaves: readonly z.ZodType[], call: Call): Mask | undefined {
|
|
915
|
+
return carried(reaches, leaves, call);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** A mask, written out: two positions with the same leaves and the same masks are one. */
|
|
919
|
+
function maskKey(mask: Mask): string {
|
|
920
|
+
const read = mask.truncated ? "truncated" : "read";
|
|
921
|
+
return `${mask.own ? "own" : "strongest"}:${read}:${mask.classes.join("|")}:${mask.assumed.join("|")}`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** A set of shapes, written out the same way whatever order it was built in. */
|
|
925
|
+
function gateKey(gate: ReadonlySet<ValueKind>): string {
|
|
926
|
+
return VALUE_KINDS.map((kind) => (gate.has(kind) ? "1" : "0")).join("");
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Flatten a position and decide how it is masked. Its children are read when first asked for. */
|
|
930
|
+
function readPosition(
|
|
931
|
+
schemas: readonly z.ZodType[],
|
|
932
|
+
call: Call,
|
|
933
|
+
): {
|
|
934
|
+
position: Position;
|
|
935
|
+
leaves: readonly z.ZodType[];
|
|
936
|
+
signature: string;
|
|
937
|
+
} {
|
|
938
|
+
const reaches: Reach[] = [];
|
|
939
|
+
const leaves = new Set<z.ZodType>();
|
|
940
|
+
// Each schema met with the shapes it applies to, and whether as an option: a choice whose sides wrap one schema
|
|
941
|
+
// reaches it once per path, and what it adds is the same each time, so it is read once.
|
|
942
|
+
const met = new Set<string>();
|
|
943
|
+
const lazyGates = new Set<string>();
|
|
944
|
+
let other = false;
|
|
945
|
+
// The kinds a scalar here names, and the kinds an opaque node here takes, whatever their prototype.
|
|
946
|
+
const named = new Set<ValueKind>();
|
|
947
|
+
const taken = new Set<ValueKind>();
|
|
948
|
+
/** An opaque node: masked whole with every class beneath it, and kept where there is none. */
|
|
949
|
+
const opaque = (node: z.ZodType, gate: ReadonlySet<ValueKind>) => {
|
|
950
|
+
const own = scalarKinds(node);
|
|
951
|
+
for (const kind of gate) if (!own || own.has(kind)) (own ? named : taken).add(kind);
|
|
952
|
+
const inside = search(node, call);
|
|
953
|
+
// A recursion beneath it that reached the limit counts as possibly marked: the value is masked whole.
|
|
954
|
+
if (inside.classes.size > 0 || inside.truncated)
|
|
955
|
+
reaches.push({
|
|
956
|
+
gate,
|
|
957
|
+
classes: [...inside.classes],
|
|
958
|
+
opaque: true,
|
|
959
|
+
truncated: inside.truncated,
|
|
960
|
+
unread: inside.unread,
|
|
961
|
+
});
|
|
962
|
+
other = true;
|
|
963
|
+
};
|
|
964
|
+
/**
|
|
965
|
+
* A schema the call ran out of its budget before it read, or a lazy nested past the limit: cut off, and only this
|
|
966
|
+
* path. Nothing beneath it was read, so any class may be marked there, and every one is assumed. A lazy is met once
|
|
967
|
+
* per position, so only a chain of lazies not met before, each built anew, reaches the limit.
|
|
968
|
+
*/
|
|
969
|
+
const cut = (gate: ReadonlySet<ValueKind>) => {
|
|
970
|
+
reaches.push({ gate, classes: [], opaque: true, truncated: true, unread: true });
|
|
971
|
+
for (const kind of gate) taken.add(kind);
|
|
972
|
+
other = true;
|
|
973
|
+
};
|
|
974
|
+
// Each schema with the shapes of value it applies to, every shape until a union option narrows it, and whether it
|
|
975
|
+
// is an option, narrowed to its own shapes when it is read. Level by level, a level for each lazy the schemas sit
|
|
976
|
+
// beneath at this position, each level in the order the schema declares it, so a path that runs out ends alone.
|
|
977
|
+
type Entry = [z.ZodType, ReadonlySet<ValueKind>, boolean];
|
|
978
|
+
let level: Entry[] = schemas.map((schema): Entry => [schema, EVERY_KIND, false]);
|
|
979
|
+
for (let lazies = 0; level.length > 0; lazies += 1) {
|
|
980
|
+
const next: Entry[] = [];
|
|
981
|
+
const stack = [...level].reverse();
|
|
982
|
+
for (let entry = stack.pop(); entry; entry = stack.pop()) {
|
|
983
|
+
const [node, reached, option] = entry;
|
|
984
|
+
const seen = `${idOf(node)}:${gateKey(reached)}:${option ? "option" : ""}`;
|
|
985
|
+
if (met.has(seen)) continue;
|
|
986
|
+
met.add(seen);
|
|
987
|
+
// Each schema read costs one of the call's budget. Past it, this schema was never read.
|
|
988
|
+
if (!take(call)) {
|
|
989
|
+
cut(reached);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
// An option applies where the value has its shape: its marks are narrowed to that shape.
|
|
993
|
+
const kinds = option ? kindsOf(node, call, lazies) : EVERY_KIND;
|
|
994
|
+
const gate = option ? new Set([...reached].filter((kind) => kinds.has(kind))) : reached;
|
|
995
|
+
if (gate.size === 0) continue;
|
|
996
|
+
if (ownPrivacyClasses(node).length > 0) {
|
|
997
|
+
// A marked container is masked whole, so every class marked inside it is a class it carries, as beneath an
|
|
998
|
+
// opaque node: `pii(z.object({ token: secret(…) }))` is listed `pii` and `secret`, and masked `[secret]`.
|
|
999
|
+
const inside = search(node, call);
|
|
1000
|
+
reaches.push({
|
|
1001
|
+
gate,
|
|
1002
|
+
// Its own classes are known whether or not the search beneath it had the budget to read it.
|
|
1003
|
+
classes: [...new Set([...ownPrivacyClasses(node), ...inside.classes])],
|
|
1004
|
+
opaque: false,
|
|
1005
|
+
truncated: inside.truncated,
|
|
1006
|
+
unread: inside.unread,
|
|
1007
|
+
});
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
const def = defOf(node);
|
|
1011
|
+
const codec = kitCodecOutput(node);
|
|
1012
|
+
if (makesValue(def) && holdsStructure(kindsOf(node, call, lazies))) {
|
|
1013
|
+
// A fallback function or an `.overwrite()` over a structure makes a new value: opaque, as a transform is.
|
|
1014
|
+
opaque(node, gate);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if (def.type === "lazy") {
|
|
1018
|
+
const key = `${idOf(node)}:${gateKey(gate)}`;
|
|
1019
|
+
if (lazyGates.has(key)) continue;
|
|
1020
|
+
lazyGates.add(key);
|
|
1021
|
+
// A lazy nested in more lazies than the limit: this path is cut off, and only this one.
|
|
1022
|
+
if (lazies >= MAX_RECURSION_DEPTH) {
|
|
1023
|
+
cut(gate);
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
// Resolving it runs its getter, which may build a schema: the call pays for it as for a read.
|
|
1027
|
+
if (!take(call)) {
|
|
1028
|
+
cut(gate);
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
const inner = lazyInner(node);
|
|
1032
|
+
if (inner) next.push([inner, gate, false]);
|
|
1033
|
+
} else if (WRAPPERS.has(def.type) && def.innerType) {
|
|
1034
|
+
// A constant `.catch()` fallback is a value parse hands back here, so its kind is one the schema names.
|
|
1035
|
+
const fallback = constantFallback(def);
|
|
1036
|
+
const kind = fallback && kindOfValue(fallback.value);
|
|
1037
|
+
if (fallback && kind && gate.has(kind)) (isTypical(fallback.value as object, kind) ? named : taken).add(kind);
|
|
1038
|
+
stack.push([def.innerType, gate, false]);
|
|
1039
|
+
} else if (def.type === "union" && def.options) {
|
|
1040
|
+
for (const each of [...def.options].reverse()) stack.push([each, gate, true]);
|
|
1041
|
+
} else if (def.type === "intersection") {
|
|
1042
|
+
// A value of an intersection has both sides' shapes, so both apply wherever it does.
|
|
1043
|
+
for (const side of [def.right, def.left].filter(isSchema)) stack.push([side, gate, false]);
|
|
1044
|
+
} else if (codec) stack.push([codec, gate, false]);
|
|
1045
|
+
else if (STRUCTURAL.has(def.type)) {
|
|
1046
|
+
if (!gate.has(leafKind(node))) continue;
|
|
1047
|
+
// An object's shape is read here, once, and paid for by the field: its getters may build what it holds.
|
|
1048
|
+
if (def.type === "object") {
|
|
1049
|
+
if (!take(call)) {
|
|
1050
|
+
cut(gate);
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
readShape(node, call);
|
|
1054
|
+
}
|
|
1055
|
+
leaves.add(node);
|
|
1056
|
+
} else opaque(node, gate);
|
|
1057
|
+
}
|
|
1058
|
+
level = next;
|
|
1059
|
+
}
|
|
1060
|
+
const allLeaves = [...leaves];
|
|
1061
|
+
const byKind = new Map<ValueKind, Mask>();
|
|
1062
|
+
for (const kind of VALUE_KINDS) {
|
|
1063
|
+
const applying = reaches.filter((reach) => reach.gate.has(kind));
|
|
1064
|
+
const mask = maskOf(
|
|
1065
|
+
applying,
|
|
1066
|
+
allLeaves.filter((leaf) => leafKind(leaf) === kind),
|
|
1067
|
+
call,
|
|
1068
|
+
);
|
|
1069
|
+
if (mask) byKind.set(kind, mask);
|
|
1070
|
+
}
|
|
1071
|
+
// A structural node of a shape that is masked is never walked: a value of that shape is masked whole.
|
|
1072
|
+
const leafList = allLeaves.filter((leaf) => !byKind.has(leafKind(leaf)));
|
|
1073
|
+
const mask = maskOf(
|
|
1074
|
+
reaches,
|
|
1075
|
+
allLeaves.filter((leaf) => byKind.has(leafKind(leaf))),
|
|
1076
|
+
call,
|
|
1077
|
+
);
|
|
1078
|
+
const nameMask = maskOf(
|
|
1079
|
+
reaches.filter((reach) => [...reach.gate].some((kind) => kind !== "symbol")),
|
|
1080
|
+
allLeaves.filter((leaf) => byKind.has(leafKind(leaf))),
|
|
1081
|
+
call,
|
|
1082
|
+
);
|
|
1083
|
+
const whole = byKind.size === VALUE_KINDS.length;
|
|
1084
|
+
for (const leaf of allLeaves) named.add(leafKind(leaf));
|
|
1085
|
+
const signature = `${[...byKind].map(([kind, each]) => `${kind}=${maskKey(each)}`).join(";")}/${gateKey(named)}/${gateKey(taken)}`;
|
|
1086
|
+
const memo = new Map<string, Position | undefined>();
|
|
1087
|
+
const once = (slot: string, read: () => readonly z.ZodType[]): Position | undefined => {
|
|
1088
|
+
if (!memo.has(slot)) memo.set(slot, positionOf(read(), call));
|
|
1089
|
+
return memo.get(slot);
|
|
1090
|
+
};
|
|
1091
|
+
const fields = new Map<PropertyKey, Position | undefined>();
|
|
1092
|
+
const objects = leafList.filter((leaf) => defOf(leaf).type === "object");
|
|
1093
|
+
const tuples = leafList.filter((leaf) => defOf(leaf).type === "tuple").map(defOf);
|
|
1094
|
+
const records = slots(leafList, ["record"], (def) => def.valueType);
|
|
1095
|
+
const arrays = slots(leafList, ["array"], (def) => def.element);
|
|
1096
|
+
const catchalls = objects.flatMap((leaf) => [openCatchall(defOf(leaf))].filter(isSchema));
|
|
1097
|
+
// Each object's keys are indexed once, so a key's schemas are looked up, never scanned for across every object here.
|
|
1098
|
+
const open = objects.flatMap((leaf, index) => {
|
|
1099
|
+
const schema = openCatchall(defOf(leaf));
|
|
1100
|
+
return schema ? [{ index, schema }] : [];
|
|
1101
|
+
});
|
|
1102
|
+
let naming: Map<PropertyKey, number[]> | undefined;
|
|
1103
|
+
const namingOf = (): Map<PropertyKey, number[]> => {
|
|
1104
|
+
if (!naming) {
|
|
1105
|
+
naming = new Map();
|
|
1106
|
+
objects.forEach((leaf, index) => {
|
|
1107
|
+
for (const key of Reflect.ownKeys(shapeOf(leaf))) {
|
|
1108
|
+
const list = naming?.get(key);
|
|
1109
|
+
if (list) list.push(index);
|
|
1110
|
+
else naming?.set(key, [index]);
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
return naming;
|
|
1115
|
+
};
|
|
1116
|
+
/** The schemas a key is read by, in the objects' order: an object's own where it names the key, else its catchall. */
|
|
1117
|
+
const namedBy = (name: PropertyKey, indexes: readonly number[]): z.ZodType[] => {
|
|
1118
|
+
const out: z.ZodType[] = [];
|
|
1119
|
+
let next = 0;
|
|
1120
|
+
for (const index of indexes) {
|
|
1121
|
+
for (; next < open.length && (open[next]?.index ?? index) < index; next += 1)
|
|
1122
|
+
out.push(open[next]?.schema as z.ZodType);
|
|
1123
|
+
if (open[next]?.index === index) next += 1;
|
|
1124
|
+
out.push(shapeOf(objects[index] as z.ZodType)[name] as z.ZodType);
|
|
1125
|
+
}
|
|
1126
|
+
for (; next < open.length; next += 1) out.push(open[next]?.schema as z.ZodType);
|
|
1127
|
+
return out;
|
|
1128
|
+
};
|
|
1129
|
+
// Every key no object names is read by the open catchalls alone: one position for all of them.
|
|
1130
|
+
let unnamed: { readonly at: Position | undefined } | undefined;
|
|
1131
|
+
const misfits = new Map<ValueKind, { mask: Mask | undefined }>();
|
|
1132
|
+
const position: Position = {
|
|
1133
|
+
mask,
|
|
1134
|
+
nameMask,
|
|
1135
|
+
whole,
|
|
1136
|
+
maskFor: (value) => {
|
|
1137
|
+
const kind = kindOfValue(value);
|
|
1138
|
+
return kind === undefined ? undefined : byKind.get(kind);
|
|
1139
|
+
},
|
|
1140
|
+
misfit: (value) => {
|
|
1141
|
+
const kind = kindOfValue(value);
|
|
1142
|
+
if (kind === undefined) return undefined;
|
|
1143
|
+
const expected = named.has(kind);
|
|
1144
|
+
if (expected ? isTypical(value as object, kind) : taken.has(kind)) return undefined;
|
|
1145
|
+
let misfit = misfits.get(kind);
|
|
1146
|
+
if (!misfit) {
|
|
1147
|
+
// Where a schema here names its kind, the value is one of those gone wrong; where none does, it could be any.
|
|
1148
|
+
misfit = {
|
|
1149
|
+
mask: expected
|
|
1150
|
+
? misfitMaskOf(
|
|
1151
|
+
[],
|
|
1152
|
+
leafList.filter((leaf) => leafKind(leaf) === kind),
|
|
1153
|
+
call,
|
|
1154
|
+
)
|
|
1155
|
+
: misfitMaskOf(reaches, allLeaves, call),
|
|
1156
|
+
};
|
|
1157
|
+
misfits.set(kind, misfit);
|
|
1158
|
+
}
|
|
1159
|
+
return misfit;
|
|
1160
|
+
},
|
|
1161
|
+
kinds: new Set(leafList.map((leaf) => defOf(leaf).type)),
|
|
1162
|
+
other,
|
|
1163
|
+
get keys() {
|
|
1164
|
+
return [...namingOf().keys()];
|
|
1165
|
+
},
|
|
1166
|
+
field: (name) => {
|
|
1167
|
+
if (!fields.has(name)) {
|
|
1168
|
+
const indexes = namingOf().get(name);
|
|
1169
|
+
if (indexes) fields.set(name, positionOf([...namedBy(name, indexes), ...records], call));
|
|
1170
|
+
else {
|
|
1171
|
+
unnamed ??= { at: positionOf([...open.map((each) => each.schema), ...records], call) };
|
|
1172
|
+
fields.set(name, unnamed.at);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return fields.get(name);
|
|
1176
|
+
},
|
|
1177
|
+
get catchall() {
|
|
1178
|
+
return once("catchall", () => [...catchalls, ...records]);
|
|
1179
|
+
},
|
|
1180
|
+
get key() {
|
|
1181
|
+
return once("key", () => slots(leafList, ["record"], (def) => def.keyType));
|
|
1182
|
+
},
|
|
1183
|
+
items: tuples.reduce((most, def) => Math.max(most, def.items?.length ?? 0), 0),
|
|
1184
|
+
item: (index) =>
|
|
1185
|
+
once(`item:${index}`, () => [
|
|
1186
|
+
...arrays,
|
|
1187
|
+
...tuples.flatMap((def) => [def.items?.[index] ?? def.rest].filter(isSchema)),
|
|
1188
|
+
]),
|
|
1189
|
+
get rest() {
|
|
1190
|
+
return once("rest", () => [...arrays, ...tuples.flatMap((def) => [def.rest].filter(isSchema))]);
|
|
1191
|
+
},
|
|
1192
|
+
get mapKey() {
|
|
1193
|
+
return once("mapKey", () => slots(leafList, ["map"], (def) => def.keyType));
|
|
1194
|
+
},
|
|
1195
|
+
get mapValue() {
|
|
1196
|
+
return once("mapValue", () => slots(leafList, ["map"], (def) => def.valueType));
|
|
1197
|
+
},
|
|
1198
|
+
get element() {
|
|
1199
|
+
return once("element", () => slots(leafList, ["set"], (def) => def.valueType));
|
|
1200
|
+
},
|
|
1201
|
+
};
|
|
1202
|
+
return { position, leaves: leafList, signature };
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** The position at the root of a schema, read in `call`: a new one, with the whole budget, unless one is given. */
|
|
1206
|
+
export function rootPosition(schema: z.ZodType, call: Call = newCall()): Position {
|
|
1207
|
+
return positionOf([schema], call) as Position;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** What a walk says beside one masked path and class. */
|
|
1211
|
+
export interface MarkNote {
|
|
1212
|
+
/**
|
|
1213
|
+
* Set where the class is assumed rather than found: a read ran out of its budget there, or the schema could not be
|
|
1214
|
+
* read at all, so any class may be marked below it.
|
|
1215
|
+
*/
|
|
1216
|
+
readonly truncated?: true;
|
|
1217
|
+
/**
|
|
1218
|
+
* Set on a reference, once {@link MAX_LISTED_PATHS} paths are written out: the path this position was first listed
|
|
1219
|
+
* under. The class is marked somewhere beneath both, and the paths beneath this one are those beneath that one.
|
|
1220
|
+
*/
|
|
1221
|
+
readonly sameAs?: string;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* What a walk reports. `marked` is called once per masked path and class. `unmarkedKey`, when given, is called for each
|
|
1226
|
+
* object key whose position is not masked whole, before the walk steps into it — the hook `pithy privacy scan` reads a
|
|
1227
|
+
* field's name through, so the scan and the inventory walk one tree the same way.
|
|
1228
|
+
*/
|
|
1229
|
+
export interface PrivacyVisitor {
|
|
1230
|
+
/** One masked path and class, with a {@link MarkNote} where the class is assumed or the path is a reference. */
|
|
1231
|
+
marked(path: string, className: string, note?: MarkNote): void;
|
|
1232
|
+
unmarkedKey?(path: string, key: string): void;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** `parent.key`, or `key` at the root; an index or a bracket joins without a dot. */
|
|
1236
|
+
function join(path: string, segment: string): string {
|
|
1237
|
+
return path === "" || segment.startsWith("[") ? `${path}${segment}` : `${path}.${segment}`;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** One position the walk meets, at one path. */
|
|
1241
|
+
interface Frame {
|
|
1242
|
+
readonly at: Position | undefined;
|
|
1243
|
+
readonly path: string;
|
|
1244
|
+
readonly depth: number;
|
|
1245
|
+
readonly key?: string;
|
|
1246
|
+
readonly parent?: Frame;
|
|
1247
|
+
/** The positions below, once this one is read. */
|
|
1248
|
+
below?: Frame[];
|
|
1249
|
+
/** Set when its schema could not be read at all: nested deeper than the stack, or a getter that throws. */
|
|
1250
|
+
unread?: true;
|
|
1251
|
+
/** Set when {@link MAX_LISTED_PATHS} paths were written out before this one and its position was walked already. */
|
|
1252
|
+
sameAs?: Frame;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** Whether a frame's position is one it sits below: a recursion defined once, come round. */
|
|
1256
|
+
function onLine(frame: Frame): boolean {
|
|
1257
|
+
for (let above = frame.parent; above; above = above.parent) if (above.at === frame.at) return true;
|
|
1258
|
+
return false;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Whether a frame is cut off at the limit with every class assumed: nested deeper than {@link MAX_RECURSION_DEPTH}, at
|
|
1263
|
+
* a position not met on its path. A position is a set of schemas, and it is the position that is compared: a recursion
|
|
1264
|
+
* defined once comes round to a position (the same set of schemas) already read, whose classes are listed where it was
|
|
1265
|
+
* read, so it needs no cut. A chain that keeps producing positions not yet seen, a factory, nesting deeper than the
|
|
1266
|
+
* limit, or once-defined cycles of different lengths side by side in a union, was never read past it: any class may be
|
|
1267
|
+
* marked there, though each schema on the chain may be one already read.
|
|
1268
|
+
*/
|
|
1269
|
+
function cutOff(frame: Frame): boolean {
|
|
1270
|
+
return frame.depth > MAX_RECURSION_DEPTH && !onLine(frame);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* A frame for the position `read` returns. A schema nested deeper than the stack can follow, or one whose getter
|
|
1275
|
+
* throws, cannot be read: its frame is `unread`, listed with every class, and nothing throws.
|
|
1276
|
+
*/
|
|
1277
|
+
function frameAt(
|
|
1278
|
+
read: () => Position | undefined,
|
|
1279
|
+
place: { path: string; depth: number; key?: string; parent?: Frame },
|
|
1280
|
+
): Frame {
|
|
1281
|
+
try {
|
|
1282
|
+
return { ...place, at: read() };
|
|
1283
|
+
} catch {
|
|
1284
|
+
return { ...place, at: undefined, unread: true };
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** The frames below a position that is read, in the order the schema names them. */
|
|
1289
|
+
function framesBelow(frame: Frame, at: Position): Frame[] {
|
|
1290
|
+
const next = frame.depth + 1;
|
|
1291
|
+
const below: Frame[] = [];
|
|
1292
|
+
const add = (child: () => Position | undefined, path: string, depth: number, key?: string) => {
|
|
1293
|
+
const found = frameAt(
|
|
1294
|
+
child,
|
|
1295
|
+
key === undefined ? { path, depth, parent: frame } : { path, depth, key, parent: frame },
|
|
1296
|
+
);
|
|
1297
|
+
// A slot no schema here holds is no frame: only a position, or one that could not be read, is written out.
|
|
1298
|
+
if (found.at !== undefined || found.unread) below.push(found);
|
|
1299
|
+
};
|
|
1300
|
+
for (const key of at.keys) if (typeof key === "string") add(() => at.field(key), join(frame.path, key), next, key);
|
|
1301
|
+
add(() => at.catchall, join(frame.path, "*"), next);
|
|
1302
|
+
// A key is read where its object is, and never nested below it.
|
|
1303
|
+
add(() => at.key, join(frame.path, "<key>"), frame.depth);
|
|
1304
|
+
for (let index = 0; index < at.items; index += 1) add(() => at.item(index), `${frame.path}[${index}]`, next);
|
|
1305
|
+
add(() => at.rest, `${frame.path}[]`, next);
|
|
1306
|
+
add(() => at.mapKey, join(frame.path, "<key>"), next);
|
|
1307
|
+
add(() => at.mapValue, join(frame.path, "*"), next);
|
|
1308
|
+
add(() => at.element, `${frame.path}[]`, next);
|
|
1309
|
+
return below;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** Every class a frame lists at its own path, once each, with whether it is only assumed. */
|
|
1313
|
+
function ownClasses(frame: Frame): [string, boolean][] {
|
|
1314
|
+
const found = new Map<string, boolean>();
|
|
1315
|
+
const mask = frame.at?.mask;
|
|
1316
|
+
if (mask) for (const name of mask.classes) found.set(name, mask.assumed.includes(name));
|
|
1317
|
+
if (frame.unread || cutOff(frame)) for (const name of unreadClasses()) if (!found.has(name)) found.set(name, true);
|
|
1318
|
+
return [...found];
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Every class listed beneath each position the walk read, by the first frame that read it, and whether each is only
|
|
1323
|
+
* assumed. A class found anywhere beneath is found. A position beneath itself, a recursion defined once, is read until
|
|
1324
|
+
* nothing changes, last read first, so a chain of reused schemas settles in one pass.
|
|
1325
|
+
*/
|
|
1326
|
+
function classesBeneath(walked: ReadonlyMap<Position, Frame>): Map<Position, Map<string, boolean>> {
|
|
1327
|
+
const beneath = new Map<Position, Map<string, boolean>>();
|
|
1328
|
+
for (const at of walked.keys()) beneath.set(at, new Map());
|
|
1329
|
+
const firsts = [...walked].reverse();
|
|
1330
|
+
for (let changed = true; changed; ) {
|
|
1331
|
+
changed = false;
|
|
1332
|
+
for (const [at, first] of firsts) {
|
|
1333
|
+
const into = beneath.get(at) as Map<string, boolean>;
|
|
1334
|
+
const merge = (name: string, assumed: boolean) => {
|
|
1335
|
+
const known = into.get(name);
|
|
1336
|
+
if (known === undefined || (known && !assumed)) {
|
|
1337
|
+
into.set(name, assumed);
|
|
1338
|
+
changed = true;
|
|
1339
|
+
}
|
|
1340
|
+
};
|
|
1341
|
+
for (const child of first.below ?? []) {
|
|
1342
|
+
// Past the limit only a cut lists anything, and nothing beneath it is read.
|
|
1343
|
+
if (child.depth > MAX_RECURSION_DEPTH && !cutOff(child)) continue;
|
|
1344
|
+
for (const [name, assumed] of ownClasses(child)) merge(name, assumed);
|
|
1345
|
+
if (child.depth > MAX_RECURSION_DEPTH) continue;
|
|
1346
|
+
const below = child.at && beneath.get(child.at);
|
|
1347
|
+
if (below) for (const [name, assumed] of below) merge(name, assumed);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
return beneath;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Walk a schema tree the way {@link classifyPrivacy} does, from `path`, and report to `visitor`. Exported so a second
|
|
1356
|
+
* reader of the tree — the CLI's scan — cannot walk it differently. A position met again below itself is a recursion
|
|
1357
|
+
* defined once, and is not walked again: its fields are listed once. A position nested more than
|
|
1358
|
+
* {@link MAX_RECURSION_DEPTH} levels deep is cut off by `redact` as {@link TRUNCATED_MASK}; where it is a position not
|
|
1359
|
+
* met on its path, nothing past it was read, so it is listed with every class, each truncated.
|
|
1360
|
+
*
|
|
1361
|
+
* Positions are read level by level, shallowest first, and reported in the schema's own order, all in one call with one
|
|
1362
|
+
* {@link MAX_READ_SCHEMAS}. Each distinct position is read once, wherever it sits; every level above the one the budget
|
|
1363
|
+
* runs out on is read whole, and each position it did not read is listed with {@link unreadClasses}, each truncated.
|
|
1364
|
+
* Once {@link MAX_LISTED_PATHS} paths are written out, a position met again is a reference: its path is listed with
|
|
1365
|
+
* every class beneath it and the path it was first written out under, and nothing below it is written out again. So the
|
|
1366
|
+
* walk is bounded by the budget, however many paths reach one schema. A schema that cannot be read at all, nested
|
|
1367
|
+
* deeper than the stack or behind a getter that throws, is listed with every class, each truncated. It never throws.
|
|
1368
|
+
*/
|
|
1369
|
+
export function walkPrivacy(schema: z.ZodType, visitor: PrivacyVisitor, path = ""): void {
|
|
1370
|
+
const call = newCall();
|
|
1371
|
+
const root = frameAt(() => rootPosition(schema, call), { path, depth: 0 });
|
|
1372
|
+
// Read, breadth first, so the call's budget runs out on the deepest levels it reaches, never on a shallow branch.
|
|
1373
|
+
const queue: Frame[] = [root];
|
|
1374
|
+
// Each position is read once, wherever it sits, by the first frame that met it: a schema reused in many fields
|
|
1375
|
+
// costs the budget once. Past the budget a new position is read as cut off, masked whole with every class assumed.
|
|
1376
|
+
const walked = new Map<Position, Frame>();
|
|
1377
|
+
let references = false;
|
|
1378
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
1379
|
+
const frame = queue[index] as Frame;
|
|
1380
|
+
const { at } = frame;
|
|
1381
|
+
if (at === undefined || frame.depth > MAX_RECURSION_DEPTH || at.whole) continue;
|
|
1382
|
+
if (at.kinds.size === 0 || onLine(frame)) continue;
|
|
1383
|
+
const first = walked.get(at);
|
|
1384
|
+
if (first === undefined) {
|
|
1385
|
+
walked.set(at, frame);
|
|
1386
|
+
} else if (queue.length >= MAX_LISTED_PATHS) {
|
|
1387
|
+
// Enough paths are written out: this one stands for the paths below the first, and adds none of its own.
|
|
1388
|
+
frame.sameAs = first;
|
|
1389
|
+
references = true;
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
frame.below = framesBelow(frame, at);
|
|
1393
|
+
for (const child of frame.below) queue.push(child);
|
|
1394
|
+
}
|
|
1395
|
+
const beneath = references ? classesBeneath(walked) : new Map<Position, Map<string, boolean>>();
|
|
1396
|
+
// Report, depth first, in the order the schema names each position.
|
|
1397
|
+
const stack: Frame[] = [root];
|
|
1398
|
+
for (let frame = stack.pop(); frame; frame = stack.pop()) {
|
|
1399
|
+
const { at } = frame;
|
|
1400
|
+
if (frame.depth <= MAX_RECURSION_DEPTH && at !== undefined && at.mask === undefined && frame.key !== undefined)
|
|
1401
|
+
visitor.unmarkedKey?.(frame.path, frame.key);
|
|
1402
|
+
// Past the limit a value is cut off as `[truncated]`: on a chain of new schemas every class is assumed there, and
|
|
1403
|
+
// a recursion defined once, come round, lists nothing it has not listed where it was read.
|
|
1404
|
+
if (frame.depth <= MAX_RECURSION_DEPTH || cutOff(frame))
|
|
1405
|
+
for (const [className, assumed] of ownClasses(frame))
|
|
1406
|
+
visitor.marked(frame.path, className, assumed ? { truncated: true } : undefined);
|
|
1407
|
+
if (frame.depth > MAX_RECURSION_DEPTH) continue;
|
|
1408
|
+
const reference = frame.sameAs;
|
|
1409
|
+
if (reference?.at) {
|
|
1410
|
+
for (const [className, assumed] of beneath.get(reference.at) ?? []) {
|
|
1411
|
+
visitor.marked(
|
|
1412
|
+
frame.path,
|
|
1413
|
+
className,
|
|
1414
|
+
assumed ? { truncated: true, sameAs: reference.path } : { sameAs: reference.path },
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (frame.below)
|
|
1419
|
+
for (let index = frame.below.length - 1; index >= 0; index -= 1) stack.push(frame.below[index] as Frame);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Every position `redact` masks in a schema, and its class — the Art. 30 record for one object, derived rather than
|
|
1425
|
+
* maintained. Empty for a schema with no markers. A path reached twice is listed once per class. It never throws.
|
|
1426
|
+
*/
|
|
1427
|
+
export function classifyPrivacy(schema: z.ZodType): PrivacyField[] {
|
|
1428
|
+
const fields: PrivacyField[] = [];
|
|
1429
|
+
const byKey = new Map<string, PrivacyField>();
|
|
1430
|
+
walkPrivacy(schema, {
|
|
1431
|
+
marked: (path, className, note) => {
|
|
1432
|
+
const key = `${path}\u0000${className}`;
|
|
1433
|
+
if (byKey.has(key)) return;
|
|
1434
|
+
const field: PrivacyField = { path, class: className };
|
|
1435
|
+
if (note?.truncated) field.truncated = true;
|
|
1436
|
+
if (note?.sameAs !== undefined) field.sameAs = note.sameAs;
|
|
1437
|
+
byKey.set(key, field);
|
|
1438
|
+
fields.push(field);
|
|
1439
|
+
},
|
|
1440
|
+
});
|
|
1441
|
+
return fields;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** Whether a schema holds a field of `className` at any depth: exactly what {@link classifyPrivacy} lists. */
|
|
1445
|
+
export function hasPrivacy(schema: z.ZodType, className: string): boolean {
|
|
1446
|
+
return classifyPrivacy(schema).some((field) => field.class === className);
|
|
1447
|
+
}
|