From fbb1e0f645ac25bf2ef62b2e7f2736dd743efd46 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 16 May 2026 13:58:37 +0200 Subject: [PATCH] Tighten Sium validation integrity --- continue.md | 32 ++++++------ src/arts/sium/README.md | 7 ++- src/arts/sium/core/combinator-helpers.ts | 5 +- src/arts/sium/core/refines.ts | 11 ++++- src/arts/sium/core/types.ts | 15 +++--- src/arts/sium/test/combinators.test.ts | 55 ++++++++++++++++++++- src/arts/sium/test/refines.test.ts | 63 ++++++++++++++++-------- 7 files changed, 139 insertions(+), 49 deletions(-) diff --git a/continue.md b/continue.md index a16afde9f..63fcdce90 100644 --- a/continue.md +++ b/continue.md @@ -33,8 +33,8 @@ Arranque rapido para mañana: o se deja como placeholder documentado. Hoy Avatar es `eidos` scope. Decidido: no hay accion versionable; solo existe un directorio local vacio (`components/`) y Git no lo registra. No crear placeholder Soma. - 3. Si se sigue con Sium: abordar P0 de integridad (`isPlainObjectRecord`, - `email`, `regex` con flags mutables, doc de `ctx.rootValue`). + 3. Sium P0 de integridad queda cerrado: `isPlainObjectRecord`, `email`, + `regex` con flags mutables y docs de `ctx.rootValue`. 4. Mantener fuera de commits los logs sueltos (`.codex-vite-*`, `debug.log`) salvo que el usuario pida conservarlos. @@ -90,19 +90,21 @@ Actualizacion 2026-05-16: parecerse a Zod. - Algunos fixes descritos como "1 linea" no lo son si queremos mantener contratos `decodeSync`, `~sium` y `Form.AutoFields` coherentes. -- Sium P0 recomendado, sin cambios breaking: - 1. Corregir `isPlainObjectRecord(...)` para aceptar solo objetos planos. No - usar `value.constructor === Object` como unica regla: preferir - `Object.getPrototypeOf(value) === Object.prototype || null` para no romper - objetos null-prototype y reducir falsos positivos. - 2. Mejorar `email()` con una regex conservadora de producto, no RFC completa: - dominio sin puntos dobles y TLD practico de 2+ caracteres. - 3. Cachear `regex(pattern)` sin mutar `lastIndex`. Si el pattern trae `g` o - `y`, clonar sin flags mutables o resetear antes de probar. - 4. Alinear README con `ctx.rootValue`: hoy solo es seguro en refines de objeto - ya decodificado. Las validaciones cross-field deben vivir en el object - refine, no en field refines. - 5. Revisar `optional/nullable/defaulted` con schema interno async. El problema +- Sium P0 cerrado, sin cambios breaking: + 1. `isPlainObjectRecord(...)` acepta solo objetos planos o null-prototype; se + rechazan `Date`, `RegExp` e instancias de clase. + 2. `email()` usa una regex conservadora de producto: dominio sin puntos + dobles y TLD practico de 2+ caracteres. + 3. `regex(pattern)` clona una vez el patron sin flags mutables `g`/`y`, de + modo que no toca `lastIndex` del regex recibido. + 4. README y tipos de Sium aclaran que `ctx.rootValue` es passthrough opcional; + el core no lo sintetiza. Las validaciones cross-field sobre datos + decodificados deben vivir en un `refine()` del `object()` y usar el valor + decodificado recibido por ese refine. + 5. Validado: `npx vitest run src/arts/sium/test` -> 18 archivos, 366 tests OK; + `npm run check` -> 0 errores, 0 warnings; `npm run test` -> 233 archivos, + 2262 tests OK. + 6. Pendiente deliberado: revisar `optional/nullable/defaulted` con schema interno async. El problema es real, pero la solucion no es mover un `if`: `createSchema.decodeSync` lanza por `spec.async === true` antes de entrar en `decodeImpl`. Hace falta una decision de runtime (`syncShortCircuit`, decodeSync custom por wrapper, diff --git a/src/arts/sium/README.md b/src/arts/sium/README.md index a5ea55c12..ee2e4a402 100644 --- a/src/arts/sium/README.md +++ b/src/arts/sium/README.md @@ -497,9 +497,12 @@ type Ctx = { `ctx.rootInput` es el input original. -`ctx.rootValue` solo esta garantizado en refines de objeto despues de decodificar campos. +`ctx.rootValue` es passthrough opcional. Sium lo conserva si el caller o un adapter lo +pasan en el contexto, pero el core no lo sintetiza automaticamente. -Para validaciones cross-field sobre input de usuario, usa `ctx.rootInput`. +Para validaciones cross-field sobre input de usuario, usa `ctx.rootInput`. Para validaciones +cross-field sobre datos ya decodificados, coloca un `refine()` sobre el schema `object()` y usa +el objeto decodificado que recibe ese refine. ## Ejemplos diff --git a/src/arts/sium/core/combinator-helpers.ts b/src/arts/sium/core/combinator-helpers.ts index 3e6feb208..cbdd04c16 100644 --- a/src/arts/sium/core/combinator-helpers.ts +++ b/src/arts/sium/core/combinator-helpers.ts @@ -4,7 +4,10 @@ import { SiumValidationError } from '../errors'; import type { Ctx, Issue } from './types'; export function isPlainObjectRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; } export function createChildCtx(ctx: Ctx, segment: string | number): Partial { diff --git a/src/arts/sium/core/refines.ts b/src/arts/sium/core/refines.ts index 58f4b11b5..c896725a0 100644 --- a/src/arts/sium/core/refines.ts +++ b/src/arts/sium/core/refines.ts @@ -2,7 +2,12 @@ import { refine } from './pipe'; import type { Step } from './types'; import { getMeasuredValue, withDynamicIssue, withDynamicParams } from './refine-helpers'; -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const EMAIL_REGEX = + /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/; + +function createStableRegex(pattern: RegExp): RegExp { + return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, '')); +} /** * Creates a minimum-value / minimum-length refine for numbers, strings, and arrays. @@ -121,9 +126,11 @@ export function length(n: number): Step * ``` */ export function regex(pattern: RegExp): Step { + const tester = createStableRegex(pattern); + return refine( (value) => { - return typeof value === 'string' && new RegExp(pattern.source, pattern.flags).test(value); + return typeof value === 'string' && tester.test(value); }, { code: 'regex', diff --git a/src/arts/sium/core/types.ts b/src/arts/sium/core/types.ts index c6560c185..a2e22ed3e 100644 --- a/src/arts/sium/core/types.ts +++ b/src/arts/sium/core/types.ts @@ -20,9 +20,10 @@ * (`#?sium.errors.X|fallback`) so the adapter can resolve or fall back * without needing separate fields. * 5. `ctx.rootInput` is always available (raw user input). `ctx.rootValue` is - * only guaranteed inside object-level refines (after all fields decoded); - * inside field-level refines it is partial or undefined — use `rootInput` - * for cross-field checks against user input. + * optional passthrough context: Sium preserves it when an outer caller + * provides it, but core combinators do not synthesize it automatically. For + * cross-field checks over decoded data, put a refine on the object schema and + * use the decoded object value passed to that refine. */ import type { StandardSchemaV1 } from './standard-schema'; @@ -187,9 +188,10 @@ export type Result = * - `rootInput` — always available. The raw input passed to the outermost * `decode` call. Use this for cross-field validation that operates on * user-entered values. - * - `rootValue` — only guaranteed inside **object-level** refines (after all - * fields have decoded). Inside **field-level** refines it is partial or - * `undefined` (the sibling fields may not be decoded yet). + * - `rootValue` — optional passthrough value. Sium preserves it when supplied + * by the caller or adapter, but the core does not assign decoded root values + * automatically. Cross-field validation over decoded data should live in an + * object-level refine and use the decoded object value passed to that refine. * - `locale` — set by adapters (`sium/svelte` injects it from * the active language engine). Used by refinements that need locale-aware * validation (date parsing, collation, etc.). @@ -284,4 +286,3 @@ export interface Step { } // ─── Errors ───────────────────────────────────────────────────────────────── - diff --git a/src/arts/sium/test/combinators.test.ts b/src/arts/sium/test/combinators.test.ts index 1279ebccb..32e43289b 100644 --- a/src/arts/sium/test/combinators.test.ts +++ b/src/arts/sium/test/combinators.test.ts @@ -53,6 +53,49 @@ describe('object()', () => { throw new Error('Expected object() to reject non-object inputs'); }); + it.each([ + ['Date', new Date('2026-01-01T00:00:00.000Z')], + ['RegExp', /x/], + [ + 'class instance', + new (class Box { + a = 'x'; + b = 1; + })() + ] + ])('rejects %s inputs as non-plain objects', (_, input) => { + const schema = object({ a: string(), b: number() }); + + try { + schema.decodeSync(input as never); + } catch (error) { + expect(getIssues(error)).toEqual([ + { + path: [], + code: 'type', + message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}', + params: { + expected: 'object', + actual: 'object' + } + } + ]); + return; + } + + throw new Error('Expected object() to reject non-plain objects'); + }); + + it('accepts null-prototype objects as plain records', () => { + const schema = object({ a: string(), b: number() }); + const input = Object.assign(Object.create(null) as { a: string; b: number }, { + a: 'x', + b: 1 + }); + + expect(schema.decodeSync(input)).toEqual({ a: 'x', b: 1 }); + }); + it('prefixes field issues with the field key', () => { const schema = object({ a: string(), b: number() }); @@ -195,6 +238,14 @@ describe('object()', () => { }); }); + it('throws SiumEncodeExpectsObjectError on non-plain encode input', () => { + const schema = object({ a: string(), b: number() }); + + expect(() => schema.encode(new Date('2026-01-01T00:00:00.000Z') as never)).toThrow( + SiumEncodeExpectsObjectError + ); + }); + it('rejects extras on encode when unknownKeys is strict', () => { const schema = object({ a: string(), b: number() }, { unknownKeys: 'strict' }); @@ -522,7 +573,8 @@ describe('discriminated()', () => { { path: [], code: 'discriminated_unknown_value', - message: '#?sium.errors.discriminated_unknown_value|Unknown value "{{actual}}" for discriminator "{{key}}" (expected one of {{expected}})', + message: + '#?sium.errors.discriminated_unknown_value|Unknown value "{{actual}}" for discriminator "{{key}}" (expected one of {{expected}})', params: { key: 'kind', actual: 'c', @@ -589,4 +641,3 @@ describe('discriminated()', () => { expect(() => schema.decodeSync({ kind: 'a', val: 'x' })).toThrow(SiumAsyncSchemaError); }); }); - diff --git a/src/arts/sium/test/refines.test.ts b/src/arts/sium/test/refines.test.ts index f5638e846..835d43a5b 100644 --- a/src/arts/sium/test/refines.test.ts +++ b/src/arts/sium/test/refines.test.ts @@ -157,34 +157,58 @@ describe('regex()', () => { expect(asString.decodeSync('123')).toBe('123'); expect(asItems.decodeSync('456')).toBe('456'); }); -}); -describe('email()', () => { - it.each(['a@b.co', 'first.last+tag@example.com', 'x@y.z'])('accepts valid email %s', (value) => { - const schema = pipe(string(), email()); + it('does not mutate global or sticky pattern state', () => { + const global = /a/g; + global.lastIndex = 1; + const sticky = /^a/y; + sticky.lastIndex = 1; - expect(schema.decodeSync(value)).toBe(value); + const globalSchema = pipe(string(), regex(global)); + const stickySchema = pipe(string(), regex(sticky)); + + expect(globalSchema.decodeSync('a')).toBe('a'); + expect(stickySchema.decodeSync('a')).toBe('a'); + expect(global.lastIndex).toBe(1); + expect(sticky.lastIndex).toBe(1); }); +}); - it.each(['plainaddress', '@missing.com', 'spaces in@example.com', 'no-at.example.com'])( - 'rejects invalid email %s', - async (value) => { +describe('email()', () => { + it.each(['a@b.co', 'first.last+tag@example.com', 'x@y.zz', 'a@sub.example.com'])( + 'accepts valid email %s', + (value) => { const schema = pipe(string(), email()); - await expect(schema.validate(value)).resolves.toEqual({ - ok: false, - issues: [ - { - path: [], - code: 'email', - message: '#?sium.errors.email|Must be a valid email address', - params: {} - } - ] - }); + expect(schema.decodeSync(value)).toBe(value); } ); + it.each([ + 'plainaddress', + '@missing.com', + 'spaces in@example.com', + 'no-at.example.com', + 'x@y.z', + 'a@b..com', + 'a@-example.com', + 'a@example-.com' + ])('rejects invalid email %s', async (value) => { + const schema = pipe(string(), email()); + + await expect(schema.validate(value)).resolves.toEqual({ + ok: false, + issues: [ + { + path: [], + code: 'email', + message: '#?sium.errors.email|Must be a valid email address', + params: {} + } + ] + }); + }); + it('emits email issues with empty params', async () => { const schema = pipe(string(), email()); @@ -310,4 +334,3 @@ describe('composition', () => { expect(() => schema.decodeSync('plainaddress')).toThrow(); }); }); -