* 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>
146 lines
5.2 KiB
TypeScript
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');
|
|
});
|
|
});
|