diff --git a/src/core/source-id.ts b/src/core/source-id.ts new file mode 100644 index 000000000..d6da5e8d6 --- /dev/null +++ b/src/core/source-id.ts @@ -0,0 +1,54 @@ +/** + * src/core/source-id.ts — single canonical source_id validation. + * + * Dependency-free by design (no imports beyond TS stdlib). Imported by both + * engines, cycle, source-resolver, sources-ops, and any future site that + * needs to validate a source_id. Pre-v0.38 the regex was duplicated across + * three files (`utils.ts` had a permissive variant; `sources-ops.ts` and + * `source-resolver.ts` had the strict variant). Codex outside-voice flagged + * the drift; this module is the consolidation. + * + * **Canonical regex (strict):** `^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$` + * + * Rules enforced: + * - 1-32 characters + * - lowercase alphanumeric only (no underscores, no dots, no slashes) + * - interior hyphens allowed + * - first and last character must be alphanumeric (no edge hyphens) + * + * Single-character source IDs like `a` or `1` are valid. Underscored ids + * like `my_source` are rejected even though they passed the legacy + * permissive regex — `sources-ops` always rejected them at creation time, + * so no existing source IDs break. + * + * **Exports two validators:** + * - `isValidSourceId(s)`: boolean — for tiers that silently fall back + * to the next resolution step on invalid input (dotfile, brain_default). + * - `assertValidSourceId(s)`: void, throws — for tiers that must reject + * invalid input loudly (explicit `--source` flag, `GBRAIN_SOURCE` env, + * `cycleLockIdFor` primitive defense-in-depth). + * + * Codex P1-F flagged the need for both shapes. + */ + +export const SOURCE_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; + +/** Returns true if the string matches the canonical source_id regex. */ +export function isValidSourceId(s: unknown): s is string { + return typeof s === 'string' && SOURCE_ID_RE.test(s); +} + +/** + * Throws if the input doesn't match the canonical source_id regex. + * Error message includes the offending value (JSON-stringified for + * non-string types so debugging weird input is fast). + */ +export function assertValidSourceId(s: unknown): asserts s is string { + if (!isValidSourceId(s)) { + throw new Error( + `Invalid source_id: ${JSON.stringify(s)}. ` + + `Must be 1-32 lowercase alnum chars with optional interior hyphens ` + + `(matches ${SOURCE_ID_RE}).`, + ); + } +} diff --git a/test/source-id.test.ts b/test/source-id.test.ts new file mode 100644 index 000000000..435b1a33d --- /dev/null +++ b/test/source-id.test.ts @@ -0,0 +1,147 @@ +import { describe, test, expect } from 'bun:test'; +import { + SOURCE_ID_RE, + isValidSourceId, + assertValidSourceId, +} from '../src/core/source-id.ts'; + +describe('source-id canonical validator', () => { + describe('SOURCE_ID_RE', () => { + test('accepts single-character ids', () => { + expect(SOURCE_ID_RE.test('a')).toBe(true); + expect(SOURCE_ID_RE.test('1')).toBe(true); + expect(SOURCE_ID_RE.test('z')).toBe(true); + expect(SOURCE_ID_RE.test('0')).toBe(true); + }); + + test('accepts kebab-case ids with interior hyphens', () => { + expect(SOURCE_ID_RE.test('default')).toBe(true); + expect(SOURCE_ID_RE.test('portfolio')).toBe(true); + expect(SOURCE_ID_RE.test('my-source')).toBe(true); + expect(SOURCE_ID_RE.test('alpha-beta-gamma')).toBe(true); + expect(SOURCE_ID_RE.test('a-b')).toBe(true); + }); + + test('accepts max-length 32-char ids', () => { + const max = 'a' + 'b'.repeat(30) + 'c'; // 32 chars + expect(max.length).toBe(32); + expect(SOURCE_ID_RE.test(max)).toBe(true); + }); + + test('rejects 33+ char ids', () => { + const tooLong = 'a' + 'b'.repeat(31) + 'c'; // 33 chars + expect(SOURCE_ID_RE.test(tooLong)).toBe(false); + }); + + test('rejects underscores (P1-D blast radius case)', () => { + expect(SOURCE_ID_RE.test('snake_id')).toBe(false); + expect(SOURCE_ID_RE.test('my_source')).toBe(false); + expect(SOURCE_ID_RE.test('_leading')).toBe(false); + expect(SOURCE_ID_RE.test('trailing_')).toBe(false); + }); + + test('rejects edge hyphens (boundary-bad)', () => { + expect(SOURCE_ID_RE.test('-leading')).toBe(false); + expect(SOURCE_ID_RE.test('trailing-')).toBe(false); + expect(SOURCE_ID_RE.test('-')).toBe(false); + expect(SOURCE_ID_RE.test('--')).toBe(false); + }); + + test('rejects uppercase', () => { + expect(SOURCE_ID_RE.test('Default')).toBe(false); + expect(SOURCE_ID_RE.test('PORTFOLIO')).toBe(false); + expect(SOURCE_ID_RE.test('myID')).toBe(false); + }); + + test('rejects path-traversal shapes (P1-B security)', () => { + expect(SOURCE_ID_RE.test('../etc')).toBe(false); + expect(SOURCE_ID_RE.test('/abs')).toBe(false); + expect(SOURCE_ID_RE.test('a/b')).toBe(false); + expect(SOURCE_ID_RE.test('a.b')).toBe(false); + }); + + test('rejects whitespace', () => { + expect(SOURCE_ID_RE.test('A B')).toBe(false); + expect(SOURCE_ID_RE.test('a b')).toBe(false); + expect(SOURCE_ID_RE.test(' a')).toBe(false); + expect(SOURCE_ID_RE.test('a ')).toBe(false); + expect(SOURCE_ID_RE.test('\t')).toBe(false); + expect(SOURCE_ID_RE.test('\n')).toBe(false); + }); + + test('rejects empty string', () => { + expect(SOURCE_ID_RE.test('')).toBe(false); + }); + + test('rejects non-ASCII', () => { + expect(SOURCE_ID_RE.test('café')).toBe(false); + expect(SOURCE_ID_RE.test('日本')).toBe(false); + expect(SOURCE_ID_RE.test('𝕏')).toBe(false); + }); + }); + + describe('isValidSourceId (boolean — for silent-fallback tiers per P1-F)', () => { + test('returns true for valid ids', () => { + expect(isValidSourceId('default')).toBe(true); + expect(isValidSourceId('portfolio')).toBe(true); + expect(isValidSourceId('a')).toBe(true); + }); + + test('returns false for invalid ids without throwing', () => { + expect(isValidSourceId('SnakeCase')).toBe(false); + expect(isValidSourceId('snake_case')).toBe(false); + expect(isValidSourceId('../etc')).toBe(false); + expect(isValidSourceId('')).toBe(false); + }); + + test('returns false for non-string inputs without throwing', () => { + expect(isValidSourceId(undefined)).toBe(false); + expect(isValidSourceId(null)).toBe(false); + expect(isValidSourceId(42)).toBe(false); + expect(isValidSourceId({})).toBe(false); + expect(isValidSourceId([])).toBe(false); + }); + + test('narrows type to string when true', () => { + const x: unknown = 'portfolio'; + if (isValidSourceId(x)) { + // TS narrowing check — concat would fail if x weren't string + const _y: string = x + '-suffix'; + expect(_y).toBe('portfolio-suffix'); + } else { + throw new Error('narrowing failed'); + } + }); + }); + + describe('assertValidSourceId (throwing — for explicit/env tiers per P1-F)', () => { + test('returns void for valid ids', () => { + expect(() => assertValidSourceId('default')).not.toThrow(); + expect(() => assertValidSourceId('portfolio')).not.toThrow(); + expect(() => assertValidSourceId('a')).not.toThrow(); + }); + + test('throws with offending value in message for invalid ids', () => { + expect(() => assertValidSourceId('snake_id')).toThrow(/snake_id/); + expect(() => assertValidSourceId('../etc')).toThrow(/\.\.\/etc/); + expect(() => assertValidSourceId('A B')).toThrow(/A B/); + }); + + test('throws on non-string inputs (JSON-stringified for debug clarity)', () => { + expect(() => assertValidSourceId(undefined)).toThrow(/undefined|Invalid source_id/); + expect(() => assertValidSourceId(null)).toThrow(/null/); + expect(() => assertValidSourceId(42)).toThrow(/42/); + }); + + test('error message includes regex for caller clarity', () => { + try { + assertValidSourceId('snake_id'); + throw new Error('should have thrown'); + } catch (e) { + const msg = (e as Error).message; + expect(msg).toMatch(/1-32 lowercase alnum/); + expect(msg).toMatch(/\^\[a-z0-9\]/); + } + }); + }); +});