mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
feat(source-id): canonical dependency-free source_id validator module
New src/core/source-id.ts consolidates the three regex sites that drifted
across the codebase (utils.ts permissive, sources-ops.ts strict,
source-resolver.ts strict). Exports:
- SOURCE_ID_RE: ^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$ (strict kebab,
1-32 chars, no underscores, alphanumeric boundaries)
- isValidSourceId(s): boolean — for silent-fallback tiers (dotfile,
brain_default config)
- assertValidSourceId(s): void, throws — for explicit-validation tiers
(explicit --source flag, GBRAIN_SOURCE env, cycleLockIdFor primitive)
Dependency-free by design (no engine imports), so both PGLite and
Postgres engines can pull it without circular-import risk. Replaces
the soon-to-be-removed local validators in utils.ts and sources-ops.ts;
preserves both call shapes (boolean + throwing) per the codex outside-voice
finding that resolver tiers need both.
19 unit tests covering valid ids, length boundary (32-char max), underscore
rejection, path-traversal shapes, edge hyphens, whitespace, non-ASCII,
non-string inputs, and TypeScript narrowing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d0d0e2a64a
commit
2b744c7fb7
@@ -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}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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\]/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user