Tighten Sium validation integrity

active-uix
dev 5 months ago
parent 7a85ea59c2
commit fbb1e0f645

@ -33,8 +33,8 @@ Arranque rapido para mañana:
o se deja como placeholder documentado. Hoy Avatar es `eidos` scope. o se deja como placeholder documentado. Hoy Avatar es `eidos` scope.
Decidido: no hay accion versionable; solo existe un directorio local Decidido: no hay accion versionable; solo existe un directorio local
vacio (`components/`) y Git no lo registra. No crear placeholder Soma. vacio (`components/`) y Git no lo registra. No crear placeholder Soma.
3. Si se sigue con Sium: abordar P0 de integridad (`isPlainObjectRecord`, 3. Sium P0 de integridad queda cerrado: `isPlainObjectRecord`, `email`,
`email`, `regex` con flags mutables, doc de `ctx.rootValue`). `regex` con flags mutables y docs de `ctx.rootValue`.
4. Mantener fuera de commits los logs sueltos (`.codex-vite-*`, 4. Mantener fuera de commits los logs sueltos (`.codex-vite-*`,
`debug.log`) salvo que el usuario pida conservarlos. `debug.log`) salvo que el usuario pida conservarlos.
@ -90,19 +90,21 @@ Actualizacion 2026-05-16:
parecerse a Zod. parecerse a Zod.
- Algunos fixes descritos como "1 linea" no lo son si queremos mantener - Algunos fixes descritos como "1 linea" no lo son si queremos mantener
contratos `decodeSync`, `~sium` y `Form.AutoFields` coherentes. contratos `decodeSync`, `~sium` y `Form.AutoFields` coherentes.
- Sium P0 recomendado, sin cambios breaking: - Sium P0 cerrado, sin cambios breaking:
1. Corregir `isPlainObjectRecord(...)` para aceptar solo objetos planos. No 1. `isPlainObjectRecord(...)` acepta solo objetos planos o null-prototype; se
usar `value.constructor === Object` como unica regla: preferir rechazan `Date`, `RegExp` e instancias de clase.
`Object.getPrototypeOf(value) === Object.prototype || null` para no romper 2. `email()` usa una regex conservadora de producto: dominio sin puntos
objetos null-prototype y reducir falsos positivos. dobles y TLD practico de 2+ caracteres.
2. Mejorar `email()` con una regex conservadora de producto, no RFC completa: 3. `regex(pattern)` clona una vez el patron sin flags mutables `g`/`y`, de
dominio sin puntos dobles y TLD practico de 2+ caracteres. modo que no toca `lastIndex` del regex recibido.
3. Cachear `regex(pattern)` sin mutar `lastIndex`. Si el pattern trae `g` o 4. README y tipos de Sium aclaran que `ctx.rootValue` es passthrough opcional;
`y`, clonar sin flags mutables o resetear antes de probar. el core no lo sintetiza. Las validaciones cross-field sobre datos
4. Alinear README con `ctx.rootValue`: hoy solo es seguro en refines de objeto decodificados deben vivir en un `refine()` del `object()` y usar el valor
ya decodificado. Las validaciones cross-field deben vivir en el object decodificado recibido por ese refine.
refine, no en field refines. 5. Validado: `npx vitest run src/arts/sium/test` -> 18 archivos, 366 tests OK;
5. Revisar `optional/nullable/defaulted` con schema interno async. El problema `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` es real, pero la solucion no es mover un `if`: `createSchema.decodeSync`
lanza por `spec.async === true` antes de entrar en `decodeImpl`. Hace falta lanza por `spec.async === true` antes de entrar en `decodeImpl`. Hace falta
una decision de runtime (`syncShortCircuit`, decodeSync custom por wrapper, una decision de runtime (`syncShortCircuit`, decodeSync custom por wrapper,

@ -497,9 +497,12 @@ type Ctx = {
`ctx.rootInput` es el input original. `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 ## Ejemplos

@ -4,7 +4,10 @@ import { SiumValidationError } from '../errors';
import type { Ctx, Issue } from './types'; import type { Ctx, Issue } from './types';
export function isPlainObjectRecord(value: unknown): value is Record<string, unknown> { export function isPlainObjectRecord(value: unknown): value is Record<string, unknown> {
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<Ctx> { export function createChildCtx(ctx: Ctx, segment: string | number): Partial<Ctx> {

@ -2,7 +2,12 @@ import { refine } from './pipe';
import type { Step } from './types'; import type { Step } from './types';
import { getMeasuredValue, withDynamicIssue, withDynamicParams } from './refine-helpers'; 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. * Creates a minimum-value / minimum-length refine for numbers, strings, and arrays.
@ -121,9 +126,11 @@ export function length(n: number): Step<string | unknown[], string | unknown[]>
* ``` * ```
*/ */
export function regex(pattern: RegExp): Step<string, string> { export function regex(pattern: RegExp): Step<string, string> {
const tester = createStableRegex(pattern);
return refine( return refine(
(value) => { (value) => {
return typeof value === 'string' && new RegExp(pattern.source, pattern.flags).test(value); return typeof value === 'string' && tester.test(value);
}, },
{ {
code: 'regex', code: 'regex',

@ -20,9 +20,10 @@
* (`#?sium.errors.X|fallback`) so the adapter can resolve or fall back * (`#?sium.errors.X|fallback`) so the adapter can resolve or fall back
* without needing separate fields. * without needing separate fields.
* 5. `ctx.rootInput` is always available (raw user input). `ctx.rootValue` is * 5. `ctx.rootInput` is always available (raw user input). `ctx.rootValue` is
* only guaranteed inside object-level refines (after all fields decoded); * optional passthrough context: Sium preserves it when an outer caller
* inside field-level refines it is partial or undefined — use `rootInput` * provides it, but core combinators do not synthesize it automatically. For
* for cross-field checks against user input. * 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'; import type { StandardSchemaV1 } from './standard-schema';
@ -187,9 +188,10 @@ export type Result<T> =
* - `rootInput` — always available. The raw input passed to the outermost * - `rootInput` — always available. The raw input passed to the outermost
* `decode` call. Use this for cross-field validation that operates on * `decode` call. Use this for cross-field validation that operates on
* user-entered values. * user-entered values.
* - `rootValue` — only guaranteed inside **object-level** refines (after all * - `rootValue` — optional passthrough value. Sium preserves it when supplied
* fields have decoded). Inside **field-level** refines it is partial or * by the caller or adapter, but the core does not assign decoded root values
* `undefined` (the sibling fields may not be decoded yet). * 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 * - `locale` — set by adapters (`sium/svelte` injects it from
* the active language engine). Used by refinements that need locale-aware * the active language engine). Used by refinements that need locale-aware
* validation (date parsing, collation, etc.). * validation (date parsing, collation, etc.).
@ -284,4 +286,3 @@ export interface Step<I, O> {
} }
// ─── Errors ───────────────────────────────────────────────────────────────── // ─── Errors ─────────────────────────────────────────────────────────────────

@ -53,6 +53,49 @@ describe('object()', () => {
throw new Error('Expected object() to reject non-object inputs'); 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', () => { it('prefixes field issues with the field key', () => {
const schema = object({ a: string(), b: number() }); 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', () => { it('rejects extras on encode when unknownKeys is strict', () => {
const schema = object({ a: string(), b: number() }, { unknownKeys: 'strict' }); const schema = object({ a: string(), b: number() }, { unknownKeys: 'strict' });
@ -522,7 +573,8 @@ describe('discriminated()', () => {
{ {
path: [], path: [],
code: 'discriminated_unknown_value', 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: { params: {
key: 'kind', key: 'kind',
actual: 'c', actual: 'c',
@ -589,4 +641,3 @@ describe('discriminated()', () => {
expect(() => schema.decodeSync({ kind: 'a', val: 'x' })).toThrow(SiumAsyncSchemaError); expect(() => schema.decodeSync({ kind: 'a', val: 'x' })).toThrow(SiumAsyncSchemaError);
}); });
}); });

@ -157,34 +157,58 @@ describe('regex()', () => {
expect(asString.decodeSync('123')).toBe('123'); expect(asString.decodeSync('123')).toBe('123');
expect(asItems.decodeSync('456')).toBe('456'); expect(asItems.decodeSync('456')).toBe('456');
}); });
});
describe('email()', () => { it('does not mutate global or sticky pattern state', () => {
it.each(['a@b.co', 'first.last+tag@example.com', 'x@y.z'])('accepts valid email %s', (value) => { const global = /a/g;
const schema = pipe(string(), email()); 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'])( describe('email()', () => {
'rejects invalid email %s', it.each(['a@b.co', 'first.last+tag@example.com', 'x@y.zz', 'a@sub.example.com'])(
async (value) => { 'accepts valid email %s',
(value) => {
const schema = pipe(string(), email()); const schema = pipe(string(), email());
await expect(schema.validate(value)).resolves.toEqual({ expect(schema.decodeSync(value)).toBe(value);
ok: false,
issues: [
{
path: [],
code: 'email',
message: '#?sium.errors.email|Must be a valid email address',
params: {}
}
]
});
} }
); );
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 () => { it('emits email issues with empty params', async () => {
const schema = pipe(string(), email()); const schema = pipe(string(), email());
@ -310,4 +334,3 @@ describe('composition', () => {
expect(() => schema.decodeSync('plainaddress')).toThrow(); expect(() => schema.decodeSync('plainaddress')).toThrow();
}); });
}); });

Loading…
Cancel
Save

Powered by TurnKey Linux.