Files
gbrain/test/file-resolver.test.ts
Garry Tan f82978d38d security: fix wave 2 — 5 vulns + typed health check DSL (v0.9.3) (#95)
* security: path traversal, query bounds, marker injection fixes

LocalStorage: contained() method validates all paths stay within storage root.
file-resolver: resolveFile validates filePath within brainRoot, marker prefix
rejects ../, absolute paths, bare '..'. file_list: LIMIT 100 on slug-filtered
branch + FILE_LIST_LIMIT constant for both branches.

Co-Authored-By: Gus <garagon@users.noreply.github.com>

* security: symlink hardening in all file walkers

All 4 walkers in files.ts (collectFiles, findRedirects, findAndClean, scan)
plus init.ts counter now use lstatSync + isSymbolicLink skip. Tests import
production collectFiles instead of reimplementing it. node_modules skipped.
CLI file list and verify queries bounded with LIMIT.

Co-Authored-By: Gus <garagon@users.noreply.github.com>

* feat: typed health check DSL + recipe migration

4 DSL types: http, env_exists, command, any_of. Replaces raw execSync
on recipe YAML. All 7 first-party recipes migrated from shell strings
to typed objects. String health_checks still accepted with deprecation
warning + metachar validation for non-embedded recipes. isUnsafeHealthCheck
blocks shell injection for user-created recipes.

Co-Authored-By: Gus <garagon@users.noreply.github.com>

* chore: bump version and changelog (v0.9.3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: E2E test for file_list LIMIT enforcement against real Postgres

Inserts 150 file rows for one slug, verifies file_list returns at most
100 (both slug-filtered and unfiltered branches). Proves the LIMIT
works at the database level, not just in unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Gus <garagon@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 07:49:13 -10:00

146 lines
5.2 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { resolveFile, parseRedirect, parseMarker } from '../src/core/file-resolver.ts';
import { LocalStorage } from '../src/core/storage/local.ts';
describe('file-resolver', () => {
let brainRoot: string;
let storageDir: string;
let storage: LocalStorage;
beforeAll(() => {
brainRoot = mkdtempSync(join(tmpdir(), 'gbrain-resolver-'));
storageDir = mkdtempSync(join(tmpdir(), 'gbrain-resolver-storage-'));
storage = new LocalStorage(storageDir);
// Create a local file
mkdirSync(join(brainRoot, 'people'), { recursive: true });
writeFileSync(join(brainRoot, 'people/sarah.json'), '{"name":"Sarah"}');
});
afterAll(() => {
rmSync(brainRoot, { recursive: true });
rmSync(storageDir, { recursive: true });
});
test('resolves local file', async () => {
const result = await resolveFile('people/sarah.json', brainRoot);
expect(result.source).toBe('local');
expect(result.data.toString()).toBe('{"name":"Sarah"}');
});
test('throws for missing file with no redirect or marker', async () => {
expect(resolveFile('nonexistent.json', brainRoot)).rejects.toThrow('not found');
});
test('resolves via .redirect breadcrumb', async () => {
// Upload to storage
await storage.upload('redirected/file.json', Buffer.from('{"from":"storage"}'));
// Create redirect breadcrumb
writeFileSync(join(brainRoot, 'people/redirected.json.redirect'),
'moved_to: supabase\nbucket: brain-files\npath: redirected/file.json\nmoved_at: 2026-04-09\noriginal_hash: sha256:abc\n'
);
const result = await resolveFile('people/redirected.json', brainRoot, storage);
expect(result.source).toBe('redirect');
expect(result.data.toString()).toBe('{"from":"storage"}');
});
test('throws when redirect exists but no storage backend', async () => {
writeFileSync(join(brainRoot, 'people/no-storage.json.redirect'),
'moved_to: supabase\nbucket: test\npath: test.json\nmoved_at: 2026-04-09\noriginal_hash: sha256:abc\n'
);
expect(resolveFile('people/no-storage.json', brainRoot)).rejects.toThrow('no storage backend');
});
test('blocks resolveFile path traversal at root level', async () => {
await expect(
resolveFile('../../etc/passwd', brainRoot, storage)
).rejects.toThrow('Path traversal blocked');
});
test('blocks .supabase marker with traversal prefix', async () => {
const subDir = join(brainRoot, 'poisoned');
mkdirSync(subDir, { recursive: true });
writeFileSync(join(subDir, '.supabase'),
'synced_at: 2026-04-12\nbucket: brain-files\nprefix: ../../etc/\nfile_count: 1\n'
);
await expect(
resolveFile('poisoned/secret.json', brainRoot, storage)
).rejects.toThrow('marker prefix contains path traversal');
});
test('blocks .supabase marker with absolute path prefix', async () => {
const subDir = join(brainRoot, 'abs');
mkdirSync(subDir, { recursive: true });
writeFileSync(join(subDir, '.supabase'),
'synced_at: 2026-04-12\nbucket: brain-files\nprefix: /etc/\nfile_count: 1\n'
);
await expect(
resolveFile('abs/passwd', brainRoot, storage)
).rejects.toThrow('marker prefix contains path traversal');
});
test('allows .supabase marker with clean prefix', async () => {
const subDir = join(brainRoot, 'media');
mkdirSync(subDir, { recursive: true });
await storage.upload('media/.raw/photo.jpg', Buffer.from('jpeg-data'));
writeFileSync(join(subDir, '.supabase'),
'synced_at: 2026-04-12\nbucket: brain-files\nprefix: media/.raw/\nfile_count: 1\n'
);
const result = await resolveFile('media/photo.jpg', brainRoot, storage);
expect(result.source).toBe('storage');
expect(result.data.toString()).toBe('jpeg-data');
});
});
describe('parseRedirect', () => {
let tmpDir: string;
beforeAll(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-redirect-'));
});
afterAll(() => {
rmSync(tmpDir, { recursive: true });
});
test('parses redirect YAML', () => {
const path = join(tmpDir, 'test.redirect');
writeFileSync(path, 'moved_to: supabase\nbucket: brain-files\npath: people/sarah.json\nmoved_at: 2026-04-09\noriginal_hash: sha256:abc123\n');
const info = parseRedirect(path);
expect(info.moved_to).toBe('supabase');
expect(info.bucket).toBe('brain-files');
expect(info.path).toBe('people/sarah.json');
expect(info.original_hash).toBe('sha256:abc123');
});
});
describe('parseMarker', () => {
let tmpDir: string;
beforeAll(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-marker-'));
});
afterAll(() => {
rmSync(tmpDir, { recursive: true });
});
test('parses .supabase marker YAML', () => {
const path = join(tmpDir, '.supabase');
writeFileSync(path, 'synced_at: 2026-04-09T14:58:00Z\nbucket: brain-files\nprefix: people/.raw/\nfile_count: 484\n');
const info = parseMarker(path);
expect(info.synced_at).toBe('2026-04-09T14:58:00Z');
expect(info.bucket).toBe('brain-files');
expect(info.prefix).toBe('people/.raw/');
expect(info.file_count as any).toBe('484');
});
});