* 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>
90 lines
2.9 KiB
TypeScript
90 lines
2.9 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { acquireLock, releaseLock, type LockHandle } from '../src/core/pglite-lock';
|
|
|
|
const TEST_DIR = join(tmpdir(), 'gbrain-lock-test-' + process.pid);
|
|
|
|
describe('pglite-lock', () => {
|
|
beforeEach(() => {
|
|
// Clean up test directory
|
|
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
|
|
mkdirSync(TEST_DIR, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
test('acquires and releases lock', async () => {
|
|
const lock = await acquireLock(TEST_DIR);
|
|
expect(lock.acquired).toBe(true);
|
|
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
|
|
|
|
await releaseLock(lock);
|
|
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(false);
|
|
});
|
|
|
|
test('prevents concurrent lock acquisition', async () => {
|
|
const lock1 = await acquireLock(TEST_DIR, { timeoutMs: 2000 });
|
|
expect(lock1.acquired).toBe(true);
|
|
|
|
// Second lock attempt should timeout
|
|
await expect(acquireLock(TEST_DIR, { timeoutMs: 1000 })).rejects.toThrow(/Timed out/);
|
|
|
|
await releaseLock(lock1);
|
|
});
|
|
|
|
test('detects and cleans stale lock from dead process', async () => {
|
|
// Simulate a stale lock from a dead process
|
|
const lockDir = join(TEST_DIR, '.gbrain-lock');
|
|
mkdirSync(lockDir);
|
|
writeFileSync(join(lockDir, 'lock'), JSON.stringify({
|
|
pid: 999999999, // Non-existent PID
|
|
acquired_at: Date.now(),
|
|
command: 'test',
|
|
}));
|
|
|
|
// Should clean up the stale lock and acquire
|
|
const lock = await acquireLock(TEST_DIR);
|
|
expect(lock.acquired).toBe(true);
|
|
|
|
await releaseLock(lock);
|
|
});
|
|
|
|
test('skips lock for in-memory (undefined dataDir)', async () => {
|
|
const lock = await acquireLock(undefined);
|
|
expect(lock.acquired).toBe(true);
|
|
expect(lock.lockDir).toBe('');
|
|
|
|
// Release should be a no-op
|
|
await releaseLock(lock);
|
|
});
|
|
|
|
test('lock file contains PID and command', async () => {
|
|
const lock = await acquireLock(TEST_DIR);
|
|
const lockData = JSON.parse(readFileSync(join(TEST_DIR, '.gbrain-lock', 'lock'), 'utf-8'));
|
|
|
|
expect(lockData.pid).toBe(process.pid);
|
|
expect(lockData.acquired_at).toBeDefined();
|
|
expect(lockData.command).toBeDefined();
|
|
|
|
await releaseLock(lock);
|
|
});
|
|
|
|
test('releases lock on disconnect even if DB close fails', async () => {
|
|
const lock = await acquireLock(TEST_DIR);
|
|
expect(lock.acquired).toBe(true);
|
|
|
|
// Simulate DB already closed
|
|
await releaseLock(lock);
|
|
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(false);
|
|
|
|
// Second acquisition should work
|
|
const lock2 = await acquireLock(TEST_DIR);
|
|
expect(lock2.acquired).toBe(true);
|
|
await releaseLock(lock2);
|
|
});
|
|
});
|