mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* fix: security hardening — search DoS, slug hijack, symlink traversal, content bombs, stdin guard 4 security vulnerabilities closed: - Search limit clamped to 100 (MAX_SEARCH_LIMIT) with statement_timeout 8s - Frontmatter slug authority enforced (path-derived, mismatch rejected) - Symlink traversal blocked (lstatSync in walker + importFromFile) - Content size guard on importFromContent (Buffer.byteLength, 5MB) - Stdin size guard in parseOpArgs (5MB cap) Search pagination added (--offset param on search + query operations). Clamp warning emitted when limit is capped. Co-Authored-By: garagon <garagon@users.noreply.github.com> * fix: PGLite concurrent access lock — prevent Aborted() crash File-based advisory lock using atomic mkdir with PID tracking and 5-minute stale detection. Clear error messages show which process holds the lock and how to recover. Co-Authored-By: danbr <danbr@users.noreply.github.com> * fix: 12 data integrity fixes + stale embedding prevention CTE searchKeyword rewrite (SQL-level LIMIT, not JS splice). Write validation on addLink/addTag/addTimelineEntry/putRawData/createVersion. Health metrics now measure real problems (stale_pages, orphan_pages, dead_links). Orphan chunk cleanup on empty pages. Embedding error logging. contentHash now covers all PageInput fields. Stale embedding NULL'd when chunk_text changes (prevents wrong vector on new text). hybridSearch stops double-embedding query. MCP param validation. type/exclude_slugs search filters now work. pgcrypto extension for Postgres <13. Co-Authored-By: win4r <win4r@users.noreply.github.com> * perf: 30x embedAll speedup + O(n²) fix + ask alias Sliding worker pool (concurrency 20, tunable via GBRAIN_EMBED_CONCURRENCY). O(n²) chunk lookup in embedPage replaced with Map. gbrain ask alias for query (CLI-only, not in MCP tools-json). .idea added to .gitignore. Co-Authored-By: stephenhungg <stephenhungg@users.noreply.github.com> Co-Authored-By: sharziki <sharziki@users.noreply.github.com> Co-Authored-By: hnshah <hnshah@users.noreply.github.com> Co-Authored-By: doguabaris <doguabaris@users.noreply.github.com> * chore: bump version and changelog (v0.9.1) Community fix wave: 10 PRs, 7 contributors. 4 security fixes, PGLite crash fix, 12 data integrity fixes, 30x embed speedup, search pagination, ask alias. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: garagon <garagon@users.noreply.github.com> Co-authored-by: danbr <danbr@users.noreply.github.com> Co-authored-by: win4r <win4r@users.noreply.github.com> Co-authored-by: stephenhungg <stephenhungg@users.noreply.github.com> Co-authored-by: sharziki <sharziki@users.noreply.github.com> Co-authored-by: hnshah <hnshah@users.noreply.github.com> Co-authored-by: doguabaris <doguabaris@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { validateSlug, contentHash, rowToPage, rowToChunk, rowToSearchResult } from '../src/core/utils.ts';
|
|
|
|
describe('validateSlug', () => {
|
|
test('accepts valid slugs', () => {
|
|
expect(validateSlug('people/sarah-chen')).toBe('people/sarah-chen');
|
|
expect(validateSlug('concepts/rag')).toBe('concepts/rag');
|
|
expect(validateSlug('simple')).toBe('simple');
|
|
});
|
|
|
|
test('normalizes to lowercase', () => {
|
|
expect(validateSlug('People/Sarah-Chen')).toBe('people/sarah-chen');
|
|
expect(validateSlug('UPPER')).toBe('upper');
|
|
});
|
|
|
|
test('rejects empty slug', () => {
|
|
expect(() => validateSlug('')).toThrow('Invalid slug');
|
|
});
|
|
|
|
test('rejects path traversal', () => {
|
|
expect(() => validateSlug('../etc/passwd')).toThrow('path traversal');
|
|
expect(() => validateSlug('test/../hack')).toThrow('path traversal');
|
|
});
|
|
|
|
test('rejects leading slash', () => {
|
|
expect(() => validateSlug('/absolute/path')).toThrow('start with /');
|
|
});
|
|
});
|
|
|
|
describe('contentHash', () => {
|
|
test('returns deterministic hash', () => {
|
|
const page = { title: 'Test', type: 'concept' as const, compiled_truth: 'hello', timeline: 'world' };
|
|
const h1 = contentHash(page);
|
|
const h2 = contentHash(page);
|
|
expect(h1).toBe(h2);
|
|
});
|
|
|
|
test('changes when content changes', () => {
|
|
const h1 = contentHash({ title: 'Test', type: 'concept' as const, compiled_truth: 'hello', timeline: 'world' });
|
|
const h2 = contentHash({ title: 'Test', type: 'concept' as const, compiled_truth: 'hello', timeline: 'changed' });
|
|
expect(h1).not.toBe(h2);
|
|
});
|
|
|
|
test('returns hex string', () => {
|
|
const h = contentHash({ title: 'Test', type: 'concept' as const, compiled_truth: 'test', timeline: '' });
|
|
expect(h).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
});
|
|
|
|
describe('rowToPage', () => {
|
|
test('parses string frontmatter', () => {
|
|
const page = rowToPage({
|
|
id: 1, slug: 'test', type: 'concept', title: 'Test',
|
|
compiled_truth: 'body', timeline: '',
|
|
frontmatter: '{"key":"val"}',
|
|
content_hash: 'abc', created_at: '2024-01-01', updated_at: '2024-01-01',
|
|
});
|
|
expect(page.frontmatter.key).toBe('val');
|
|
});
|
|
|
|
test('handles object frontmatter', () => {
|
|
const page = rowToPage({
|
|
id: 1, slug: 'test', type: 'concept', title: 'Test',
|
|
compiled_truth: 'body', timeline: '',
|
|
frontmatter: { key: 'val' },
|
|
content_hash: 'abc', created_at: '2024-01-01', updated_at: '2024-01-01',
|
|
});
|
|
expect(page.frontmatter.key).toBe('val');
|
|
});
|
|
|
|
test('creates Date objects', () => {
|
|
const page = rowToPage({
|
|
id: 1, slug: 'test', type: 'concept', title: 'Test',
|
|
compiled_truth: '', timeline: '', frontmatter: '{}',
|
|
content_hash: null, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
|
});
|
|
expect(page.created_at).toBeInstanceOf(Date);
|
|
expect(page.updated_at).toBeInstanceOf(Date);
|
|
});
|
|
});
|
|
|
|
describe('rowToChunk', () => {
|
|
test('nulls embedding by default', () => {
|
|
const chunk = rowToChunk({
|
|
id: 1, page_id: 1, chunk_index: 0, chunk_text: 'text',
|
|
chunk_source: 'compiled_truth', embedding: new Float32Array(10),
|
|
model: 'test', token_count: 5, embedded_at: '2024-01-01',
|
|
});
|
|
expect(chunk.embedding).toBeNull();
|
|
});
|
|
|
|
test('includes embedding when requested', () => {
|
|
const emb = new Float32Array(10).fill(0.5);
|
|
const chunk = rowToChunk({
|
|
id: 1, page_id: 1, chunk_index: 0, chunk_text: 'text',
|
|
chunk_source: 'compiled_truth', embedding: emb,
|
|
model: 'test', token_count: 5, embedded_at: '2024-01-01',
|
|
}, true);
|
|
expect(chunk.embedding).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('rowToSearchResult', () => {
|
|
test('coerces score to number', () => {
|
|
const r = rowToSearchResult({
|
|
slug: 'test', page_id: 1, title: 'Test', type: 'concept',
|
|
chunk_text: 'text', chunk_source: 'compiled_truth',
|
|
score: '0.95', stale: false,
|
|
});
|
|
expect(typeof r.score).toBe('number');
|
|
expect(r.score).toBe(0.95);
|
|
});
|
|
});
|