@ultimat3/schema 22.8.2 → 22.9.1

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/README.md CHANGED
@@ -46,6 +46,13 @@ namespace member (`t.nullable`) and a free function (`nullableSchema`) — symme
46
46
 
47
47
  Unknown object keys are **dropped**, never forwarded — an action cannot be mass-assigned.
48
48
 
49
+ Every string-backed schema (`string` `uuid` `email` `url` `timezone` `locale` `slug` `cursor`, with
50
+ any `.min/.max/.pattern`) and every `t.record` key **refuses U+0000** — the one character Postgres
51
+ `text` and `jsonb` cannot store, so a NUL that passed reached the row write as a 500. Tabs, newlines
52
+ and the other C0 controls stay legal text. The issue names the fact, never the value:
53
+ `title: expected a non-empty string, received a string of 3 characters that contains a NUL
54
+ character (U+0000)`. Not published in the JSON Schema — it is a storage fact, not a format.
55
+
49
56
  ### Cross-field rules live on the schema
50
57
 
51
58
  A rule the IR cannot state structurally still belongs to the schema, or it moves into a handler and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "22.8.2",
3
+ "version": "22.9.1",
4
4
  "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/validators.ts CHANGED
@@ -30,6 +30,10 @@ import { isIanaZoneName } from './time-zone-name';
30
30
  const EMAIL_RE = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
31
31
  const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
32
32
  const CURSOR_RE = /^[A-Za-z0-9_-]+$/;
33
+ /** The one code point no Postgres `text` or `jsonb` value can hold — see `stringLike`. */
34
+ const NUL = '\u0000';
35
+ /** Names the fact, never the content: the issue-message rule in `describe-value.ts`. */
36
+ const NUL_REFUSAL = 'that contains a NUL character (U+0000)';
33
37
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
34
38
 
35
39
  export interface StringSchema extends Schema<string, string> {
@@ -84,6 +88,11 @@ function stringLike(node: SchemaNode, what: string, test?: (value: string) => bo
84
88
  const matchesPattern = patternTester(node);
85
89
  const check: Check<string> = (value, path) => {
86
90
  if (typeof value !== 'string') return fail(path, expected(what, value));
91
+ // U+0000 is the one character Postgres cannot store: `text` and `jsonb` both refuse it
92
+ // (SQLSTATE 22021 / 22P05), so a NUL that passed here reached the row write — or a `where`
93
+ // bind — as `X_DB_STATEMENT_FAILED`, a 500 blaming the server for a byte the caller sent.
94
+ // Only NUL: a tab, a newline or any other C0 control is legal text Postgres stores unchanged.
95
+ if (value.includes(NUL)) return fail(path, `${expected(what, value)} ${NUL_REFUSAL}`);
87
96
  if (node.minLength !== undefined && charCount(value) < node.minLength) {
88
97
  return fail(path, expected(`${what} of at least ${node.minLength} chars`, value));
89
98
  }
@@ -303,6 +312,12 @@ export function recordSchema<S extends AnySchema>(
303
312
  // new spelling to learn. It is `Object.entries` order — integer-like keys sort ahead of
304
313
  // string ones — so it names an entry that exists rather than a byte offset in the body.
305
314
  const at = [...path, index];
315
+ // A record is stored as `jsonb` far more often than not, and jsonb refuses a NUL in a KEY
316
+ // exactly as it does in a value — so the key is refused here, by position, not quoted.
317
+ if (key.includes(NUL)) {
318
+ issues.push({ message: `expected a record key, received one ${NUL_REFUSAL}`, path: at });
319
+ continue;
320
+ }
306
321
  if (isPrototypeKey(key)) {
307
322
  issues.push({
308
323
  // The refused NAMES are a closed set declared right here, so the message may state