import { parseJsonl, findJsonlPath, JsonlReader, sanitizeSessionId, findSessionJsonl, scanClaudeProjects, JsonlFileInfo } from './jsonl-utils'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as readline from 'readline'; import { Readable } from 'stream'; // Mock fs module jest.mock('fs', () => ({ readFileSync: jest.fn(), existsSync: jest.fn(), readdirSync: jest.fn(), createReadStream: jest.fn(), statSync: jest.fn() })); // Mock readline module for JsonlReader.streamMessages jest.mock('readline', () => ({ createInterface: jest.fn() })); // Mock os module const mockHomedir = jest.fn(() => '/mock/home'); jest.mock('os', () => ({ homedir: mockHomedir })); const mockedFs = jest.mocked(fs); const mockedReadline = jest.mocked(readline); describe('jsonl-utils', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('parseJsonl', () => { it('should parse valid JSONL file correctly', () => { const jsonlContent = [ '{"id":"msg-1","timestamp":"2024-01-01T10:00:00Z","usage":{"input_tokens":100,"output_tokens":50}}', '{"id":"msg-2","timestamp":"2024-01-01T10:01:00Z","usage":{"cache_creation_input_tokens":200}}', '{"message":{"id":"msg-3","usage":{"input_tokens":150}},"timestamp":"2024-01-01T10:02:00Z"}' ].join('\n'); mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(jsonlContent); const result = parseJsonl('/test/session.jsonl'); expect(result).toHaveLength(3); // Check first message expect(result[0]).toEqual({ id: 'msg-1', timestamp: new Date('2024-01-01T10:00:00Z').getTime(), usage: { input_tokens: 100, output_tokens: 50 }, content: { id: 'msg-1', timestamp: '2024-01-01T10:00:00Z', usage: { input_tokens: 100, output_tokens: 50 } } }); // Check message with cache creation expect(result[1].usage?.cache_creation_input_tokens).toBe(200); // Check nested message format expect(result[2].id).toBe('msg-3'); expect(result[2].usage?.input_tokens).toBe(150); }); it('should expand tilde in file path', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue('{"id":"test","timestamp":"2024-01-01T10:00:00Z"}'); parseJsonl('~/session.jsonl'); // Should call existsSync and readFileSync with expanded path expect(mockedFs.existsSync).toHaveBeenCalled(); expect(mockedFs.readFileSync).toHaveBeenCalled(); // Verify the path was expanded (contains home directory) const existsCall = mockedFs.existsSync.mock.calls[0][0] as string; expect(existsCall).toMatch(/\/.*\/session\.jsonl/); expect(existsCall).not.toContain('~'); }); it('should return empty array for non-existent file', () => { mockedFs.existsSync.mockReturnValue(false); const result = parseJsonl('/non/existent/file.jsonl'); expect(result).toEqual([]); expect(mockedFs.readFileSync).not.toHaveBeenCalled(); }); it('should handle malformed JSON lines gracefully', () => { const jsonlContent = [ '{"id":"msg-1","timestamp":"2024-01-01T10:00:00Z"}', '{invalid json}', '', '{"id":"msg-2","timestamp":"2024-01-01T10:01:00Z"}' ].join('\n'); mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(jsonlContent); const result = parseJsonl('/test/session.jsonl'); expect(result).toHaveLength(2); expect(result[0].id).toBe('msg-1'); expect(result[1].id).toBe('msg-2'); }); it('should handle file read errors', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockImplementation(() => { throw new Error('File read error'); }); const result = parseJsonl('/error/file.jsonl'); expect(result).toEqual([]); }); it('should filter out empty lines and whitespace', () => { const jsonlContent = [ '{"id":"msg-1","timestamp":"2024-01-01T10:00:00Z"}', ' ', '', '{"id":"msg-2","timestamp":"2024-01-01T10:01:00Z"}', '\t\n' ].join('\n'); mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(jsonlContent); const result = parseJsonl('/test/session.jsonl'); expect(result).toHaveLength(2); }); it('should extract message IDs from different formats', () => { const jsonlContent = [ '{"message":{"id":"nested-id"},"timestamp":"2024-01-01T10:00:00Z"}', '{"id":"direct-id","timestamp":"2024-01-01T10:01:00Z"}', '{"uuid":"uuid-id","timestamp":"2024-01-01T10:02:00Z"}', '{"no_id_field":true,"timestamp":"2024-01-01T10:03:00Z"}' ].join('\n'); mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(jsonlContent); const result = parseJsonl('/test/session.jsonl'); expect(result).toHaveLength(4); expect(result[0].id).toBe('nested-id'); expect(result[1].id).toBe('direct-id'); expect(result[2].id).toBe('uuid-id'); expect(result[3].id).toBeUndefined(); }); it('should handle usage data from different locations', () => { const jsonlContent = [ '{"id":"msg-1","usage":{"input_tokens":100},"timestamp":"2024-01-01T10:00:00Z"}', '{"id":"msg-2","message":{"usage":{"output_tokens":50}},"timestamp":"2024-01-01T10:01:00Z"}', '{"id":"msg-3","timestamp":"2024-01-01T10:02:00Z"}' ].join('\n'); mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(jsonlContent); const result = parseJsonl('/test/session.jsonl'); expect(result).toHaveLength(3); expect(result[0].usage?.input_tokens).toBe(100); expect(result[1].usage?.output_tokens).toBe(50); expect(result[2].usage).toBeUndefined(); }); }); describe('findJsonlPath', () => { it('should find JSONL file for session ID', () => { const sessionId = 'abc12345'; const projectsDir = '/mock/home/.claude/projects'; mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'project1', isDirectory: () => true }, { name: 'project2', isDirectory: () => true } ] as any) .mockReturnValueOnce(['session-abc12345-def.jsonl', 'other.txt'] as any) .mockReturnValueOnce(['unrelated.jsonl'] as any); const result = findJsonlPath(sessionId); expect(result).toMatch(/\/.*\.claude\/projects\/project1\/session-abc12345-def\.jsonl$/); // Check that readdirSync was called with the projects directory expect(mockedFs.readdirSync).toHaveBeenCalledWith(expect.stringMatching(/\.claude\/projects$/), { withFileTypes: true }); }); it('should return null when projects directory does not exist', () => { mockedFs.existsSync.mockReturnValue(false); const result = findJsonlPath('test-session'); expect(result).toBeNull(); }); it('should return null when no matching file found', () => { const sessionId = 'nonexistent'; mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'project1', isDirectory: () => true } ] as any) .mockReturnValueOnce(['different-session.jsonl', 'other.txt'] as any); const result = findJsonlPath(sessionId); expect(result).toBeNull(); }); it('should handle directory read errors', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync.mockImplementation(() => { throw new Error('Permission denied'); }); const result = findJsonlPath('test-session'); expect(result).toBeNull(); }); it('should skip non-directory entries', () => { const sessionId = 'test123'; mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'file.txt', isDirectory: () => false }, { name: 'actual-project', isDirectory: () => true } ] as any) .mockReturnValueOnce(['session-test123.jsonl'] as any); const result = findJsonlPath(sessionId); expect(result).toMatch(/\/.*\.claude\/projects\/actual-project\/session-test123\.jsonl$/); }); it('should match partial session ID in filename', () => { const sessionId = 'abc123'; mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'project1', isDirectory: () => true } ] as any) .mockReturnValueOnce(['conversation-abc123-def-456.jsonl'] as any); const result = findJsonlPath(sessionId); expect(result).toMatch(/\/.*\.claude\/projects\/project1\/conversation-abc123-def-456\.jsonl$/); }); }); describe('JsonlReader', () => { describe('streamMessages', () => { const mockCreateAsyncIterator = (lines: string[]) => { const mockRl = { [Symbol.asyncIterator]: async function* () { for (const line of lines) { yield line; } } }; mockedReadline.createInterface.mockReturnValue(mockRl as any); }; it('should process messages from JSONL file with processor function', async () => { const testPath = '/test/session.jsonl'; mockedFs.existsSync.mockReturnValue(true); mockedFs.createReadStream.mockReturnValue(new Readable() as any); const lines = [ '{"id":"msg-1","usage":{"input_tokens":100,"output_tokens":50}}', '{"message":{"id":"msg-2","usage":{"cache_creation_input_tokens":200}}}' ]; mockCreateAsyncIterator(lines); const processedResults: any[] = []; const processor = (msg: any, lineNumber: number) => { processedResults.push({ msg, lineNumber }); return msg.id || msg.message?.id; }; const result = await JsonlReader.streamMessages(testPath, processor); expect(processedResults).toHaveLength(2); expect(processedResults[0].lineNumber).toBe(1); expect(processedResults[1].lineNumber).toBe(2); expect(result).toEqual(['msg-1', 'msg-2']); }); it('should handle non-existent file gracefully', async () => { mockedFs.existsSync.mockReturnValue(false); const processor = jest.fn(); const result = await JsonlReader.streamMessages('/non/existent.jsonl', processor); expect(result).toEqual([]); expect(processor).not.toHaveBeenCalled(); expect(mockedFs.createReadStream).not.toHaveBeenCalled(); }); it('should skip empty lines and continue processing', async () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.createReadStream.mockReturnValue(new Readable() as any); const lines = [ '{"id":"msg-1","usage":{"input_tokens":100}}', '', ' ', '{"id":"msg-2","usage":{"output_tokens":50}}' ]; mockCreateAsyncIterator(lines); const processedMessages: any[] = []; const processor = (msg: any) => { processedMessages.push(msg); return msg.id; }; const result = await JsonlReader.streamMessages('/test/file.jsonl', processor); expect(processedMessages).toHaveLength(2); expect(result).toEqual(['msg-1', 'msg-2']); }); it('should handle malformed JSON lines gracefully', async () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.createReadStream.mockReturnValue(new Readable() as any); const lines = [ '{"id":"msg-1","usage":{"input_tokens":100}}', '{invalid json}', '{"id":"msg-2","usage":{"output_tokens":50}}' ]; mockCreateAsyncIterator(lines); const processedMessages: any[] = []; const processor = (msg: any) => { processedMessages.push(msg); return msg.id; }; const result = await JsonlReader.streamMessages('/test/file.jsonl', processor); expect(processedMessages).toHaveLength(2); expect(processedMessages[0].id).toBe('msg-1'); expect(processedMessages[1].id).toBe('msg-2'); expect(result).toEqual(['msg-1', 'msg-2']); }); it('should handle stream errors and return partial results', async () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.createReadStream.mockImplementation(() => { throw new Error('Stream error'); }); const processor = jest.fn((msg) => msg.id); const result = await JsonlReader.streamMessages('/error/file.jsonl', processor); expect(result).toEqual([]); expect(processor).not.toHaveBeenCalled(); }); it('should filter null results from processor', async () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.createReadStream.mockReturnValue(new Readable() as any); const lines = [ '{"id":"msg-1","usage":{"input_tokens":100}}', '{"id":"msg-2","no_usage":true}', '{"id":"msg-3","usage":{"output_tokens":50}}' ]; mockCreateAsyncIterator(lines); const processor = (msg: any) => { // Only return messages with usage return msg.usage ? msg.id : null; }; const result = await JsonlReader.streamMessages('/test/file.jsonl', processor); expect(result).toEqual(['msg-1', 'msg-3']); }); }); describe('readLastMessage', () => { const mockCreateAsyncIterator = (lines: string[]) => { const mockRl = { [Symbol.asyncIterator]: async function* () { for (const line of lines) { yield line; } } }; mockedReadline.createInterface.mockReturnValue(mockRl as any); }; it('should return the last message that matches filter', async () => { const testPath = '/test/session.jsonl'; mockedFs.existsSync.mockReturnValue(true); mockedFs.createReadStream.mockReturnValue(new Readable() as any); const lines = [ '{"id":"msg-1","type":"user"}', '{"id":"msg-2","usage":{"input_tokens":100}}', '{"id":"msg-3","type":"user"}', '{"id":"msg-4","usage":{"output_tokens":50}}' ]; mockCreateAsyncIterator(lines); const filter = (msg: any) => !!msg.usage; const result = await JsonlReader.readLastMessage(testPath, filter); expect(result).toBeTruthy(); expect(result?.id).toBe('msg-4'); expect(result?.usage?.output_tokens).toBe(50); }); it('should return the last message when no filter provided', async () => { const testPath = '/test/session.jsonl'; mockedFs.existsSync.mockReturnValue(true); mockedFs.createReadStream.mockReturnValue(new Readable() as any); const lines = [ '{"id":"msg-1","type":"user"}', '{"id":"msg-2","usage":{"input_tokens":100}}', '{"id":"msg-3","type":"assistant"}' ]; mockCreateAsyncIterator(lines); const result = await JsonlReader.readLastMessage(testPath); expect(result).toBeTruthy(); expect(result?.id).toBe('msg-3'); expect(result?.type).toBe('assistant'); }); it('should return null when no messages match filter', async () => { const testPath = '/test/session.jsonl'; mockedFs.existsSync.mockReturnValue(true); mockedFs.createReadStream.mockReturnValue(new Readable() as any); const lines = [ '{"id":"msg-1","type":"user"}', '{"id":"msg-2","type":"user"}' ]; mockCreateAsyncIterator(lines); const filter = (msg: any) => !!msg.usage; // Looking for messages with usage const result = await JsonlReader.readLastMessage(testPath, filter); expect(result).toBeNull(); }); it('should return null for non-existent file', async () => { mockedFs.existsSync.mockReturnValue(false); const result = await JsonlReader.readLastMessage('/non/existent.jsonl'); expect(result).toBeNull(); }); it('should handle empty file', async () => { const testPath = '/test/empty.jsonl'; mockedFs.existsSync.mockReturnValue(true); mockedFs.createReadStream.mockReturnValue(new Readable() as any); mockCreateAsyncIterator([]); const result = await JsonlReader.readLastMessage(testPath); expect(result).toBeNull(); }); it('should handle malformed JSON gracefully and return last valid message', async () => { const testPath = '/test/session.jsonl'; mockedFs.existsSync.mockReturnValue(true); mockedFs.createReadStream.mockReturnValue(new Readable() as any); const lines = [ '{"id":"msg-1","type":"user"}', '{invalid json}', '{"id":"msg-2","type":"assistant"}' ]; mockCreateAsyncIterator(lines); const result = await JsonlReader.readLastMessage(testPath); expect(result).toBeTruthy(); expect(result?.id).toBe('msg-2'); }); }); describe('parseJsonl (static method)', () => { it('should delegate to the original parseJsonl function', () => { const testPath = '/test/session.jsonl'; const jsonlContent = '{"id":"msg-1","usage":{"input_tokens":100}}'; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(jsonlContent); const result = JsonlReader.parseJsonl(testPath); expect(result).toHaveLength(1); expect(result[0].id).toBe('msg-1'); expect(result[0].usage?.input_tokens).toBe(100); }); }); }); describe('scanClaudeProjects', () => { it('should scan and return JSONL file information securely', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'project1-safe', isDirectory: () => true }, { name: 'project2-also-safe', isDirectory: () => true } ] as any) .mockReturnValueOnce(['session1.jsonl', 'session2.jsonl'] as any) .mockReturnValueOnce(['session3.jsonl', 'backup.save'] as any); const mockStats1 = { mtime: new Date('2024-01-01') } as any; const mockStats2 = { mtime: new Date('2024-01-02') } as any; const mockStats3 = { mtime: new Date('2024-01-03') } as any; mockedFs.statSync .mockReturnValueOnce(mockStats1) .mockReturnValueOnce(mockStats2) .mockReturnValueOnce(mockStats3); const result = scanClaudeProjects(); expect(result).toHaveLength(3); expect(result[0]).toEqual({ sessionId: 'session1', projectDir: 'project1-safe', filePath: expect.stringContaining('session1.jsonl'), lastModified: mockStats1.mtime }); expect(result[1]).toEqual({ sessionId: 'session2', projectDir: 'project1-safe', filePath: expect.stringContaining('session2.jsonl'), lastModified: mockStats2.mtime }); expect(result[2]).toEqual({ sessionId: 'session3', projectDir: 'project2-also-safe', filePath: expect.stringContaining('session3.jsonl'), lastModified: mockStats3.mtime }); }); it('should return empty array when projects directory does not exist', () => { mockedFs.existsSync.mockReturnValue(false); const result = scanClaudeProjects(); expect(result).toEqual([]); expect(mockedFs.readdirSync).not.toHaveBeenCalled(); }); it('should handle top-level directory read errors gracefully', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync.mockImplementation(() => { throw new Error('Permission denied'); }); const result = scanClaudeProjects(); expect(result).toEqual([]); }); it('should skip projects with read errors and continue with others', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'project-accessible', isDirectory: () => true }, { name: 'project-restricted', isDirectory: () => true } ] as any) .mockReturnValueOnce(['session1.jsonl'] as any) .mockImplementationOnce(() => { throw new Error('Permission denied'); }); const mockStats = { mtime: new Date() } as any; mockedFs.statSync.mockReturnValue(mockStats); const result = scanClaudeProjects(); expect(result).toHaveLength(1); expect(result[0].sessionId).toBe('session1'); expect(result[0].projectDir).toBe('project-accessible'); }); it('should filter out .save files automatically', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'test-project', isDirectory: () => true } ] as any) .mockReturnValueOnce([ 'session.jsonl', 'backup.save', 'session.jsonl.save', 'other.txt', 'another.jsonl' ] as any); const mockStats = { mtime: new Date() } as any; mockedFs.statSync.mockReturnValue(mockStats); const result = scanClaudeProjects(); expect(result).toHaveLength(2); expect(result.map(r => r.sessionId)).toEqual(['session', 'another']); }); it('should skip non-directory entries in projects folder', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'actual-project', isDirectory: () => true }, { name: 'some-file.txt', isDirectory: () => false }, { name: 'another-project', isDirectory: () => true } ] as any) .mockReturnValueOnce(['session1.jsonl'] as any) .mockReturnValueOnce(['session2.jsonl'] as any); const mockStats = { mtime: new Date() } as any; mockedFs.statSync.mockReturnValue(mockStats); const result = scanClaudeProjects(); expect(result).toHaveLength(2); expect(result[0].projectDir).toBe('actual-project'); expect(result[1].projectDir).toBe('another-project'); }); it('should sanitize session IDs from filenames', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'test-project', isDirectory: () => true } ] as any) .mockReturnValueOnce(['session$(malicious).jsonl', 'normal-session.jsonl'] as any); const mockStats = { mtime: new Date() } as any; mockedFs.statSync.mockReturnValue(mockStats); const result = scanClaudeProjects(); expect(result).toHaveLength(2); expect(result[0].sessionId).toBe('sessionmalicious'); // Sanitized expect(result[1].sessionId).toBe('normal-session'); // Unchanged }); it('should skip directories with suspicious names (security)', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'legitimate-project', isDirectory: () => true }, { name: '../../../etc', isDirectory: () => true }, { name: 'project$(malicious)', isDirectory: () => true }, { name: 'good_project-123', isDirectory: () => true } ] as any) .mockReturnValueOnce(['session1.jsonl'] as any) // legitimate-project .mockReturnValueOnce(['session2.jsonl'] as any); // good_project-123 const mockStats = { mtime: new Date() } as any; mockedFs.statSync.mockReturnValue(mockStats); const result = scanClaudeProjects(); // Should only get files from legitimate directories expect(result).toHaveLength(2); expect(result[0].projectDir).toBe('legitimate-project'); expect(result[1].projectDir).toBe('good_project-123'); // Should have skipped the suspicious directories completely expect(mockedFs.readdirSync).toHaveBeenCalledTimes(3); // Main dir + 2 safe subdirs }); it('should construct secure file paths', () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readdirSync .mockReturnValueOnce([ { name: 'test-project', isDirectory: () => true } ] as any) .mockReturnValueOnce(['session.jsonl'] as any); const mockStats = { mtime: new Date() } as any; mockedFs.statSync.mockReturnValue(mockStats); const result = scanClaudeProjects(); expect(result).toHaveLength(1); expect(result[0].filePath).toMatch(/\.claude\/projects\/test-project\/session\.jsonl$/); expect(result[0].filePath).not.toContain('..'); expect(result[0].filePath).not.toContain('//'); }); }); describe('Security Functions', () => { describe('sanitizeSessionId', () => { it('should sanitize session IDs to prevent shell injection', () => { // Test legitimate session IDs pass through unchanged expect(sanitizeSessionId({ sessionId: 'valid-session-123' })).toBe('valid-session-123'); expect(sanitizeSessionId({ sessionId: 'test_session' })).toBe('test_session'); expect(sanitizeSessionId({ sessionId: 'abc123XYZ' })).toBe('abc123XYZ'); // Test malicious inputs are sanitized expect(sanitizeSessionId({ sessionId: '"; rm -rf /; "' })).toBe('rm-rf'); expect(sanitizeSessionId({ sessionId: 'test$(whoami)' })).toBe('testwhoami'); expect(sanitizeSessionId({ sessionId: 'session; cat /etc/passwd' })).toBe('sessioncatetcpasswd'); expect(sanitizeSessionId({ sessionId: 'test`id`' })).toBe('testid'); expect(sanitizeSessionId({ sessionId: 'test|ls' })).toBe('testls'); expect(sanitizeSessionId({ sessionId: 'test&echo hi' })).toBe('testechohi'); expect(sanitizeSessionId({ sessionId: 'test>file' })).toBe('testfile'); expect(sanitizeSessionId({ sessionId: 'test { // Empty and whitespace expect(sanitizeSessionId({ sessionId: '' })).toBe(''); expect(sanitizeSessionId({ sessionId: ' ' })).toBe(''); expect(sanitizeSessionId({ sessionId: '\t\n' })).toBe(''); // Special characters expect(sanitizeSessionId({ sessionId: '!!!@@@###' })).toBe(''); expect(sanitizeSessionId({ sessionId: 'test@example.com' })).toBe('testexamplecom'); expect(sanitizeSessionId({ sessionId: 'test.with.dots' })).toBe('testwithdots'); expect(sanitizeSessionId({ sessionId: 'test with spaces' })).toBe('testwithspaces'); }); it('should only allow alphanumeric, underscore, and hyphen characters', () => { const allowedChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; expect(sanitizeSessionId({ sessionId: allowedChars })).toBe(allowedChars); const disallowedChars = '!@#$%^&*()+=[]{}|\\:";\'<>?,./~`'; expect(sanitizeSessionId({ sessionId: disallowedChars })).toBe(''); }); }); describe('findSessionJsonl', () => { it('should sanitize session ID before searching (unit test)', () => { // Test the core sanitization logic that findSessionJsonl uses const maliciousId = 'test"; rm -rf /; echo "session'; const sanitized = sanitizeSessionId({ sessionId: maliciousId }); expect(sanitized).toBe('testrm-rfechosession'); expect(sanitized).not.toContain(';'); expect(sanitized).not.toContain('"'); expect(sanitized).not.toContain('/'); expect(sanitized).not.toContain(' '); }); it('should construct safe file paths', () => { const sessionId = 'test-session'; const sanitizedId = sanitizeSessionId({ sessionId }); const projectsDir = path.join(os.homedir(), '.claude', 'projects'); const targetFileName = `${sanitizedId}.jsonl`; expect(sanitizedId).toBe('test-session'); expect(targetFileName).toBe('test-session.jsonl'); expect(projectsDir).toContain('.claude/projects'); }); it('should validate path traversal attacks are prevented', () => { const attackInputs = [ '../../../etc/passwd', 'test$(cat /etc/passwd)', 'test`whoami`', 'test|ls -la', 'test&echo malicious', 'test>output.txt', 'test { const sanitized = sanitizeSessionId({ sessionId: input }); expect(sanitized).not.toContain('/'); expect(sanitized).not.toContain('.'); expect(sanitized).not.toContain('`'); expect(sanitized).not.toContain('$'); expect(sanitized).not.toContain(';'); expect(sanitized).not.toContain('|'); expect(sanitized).not.toContain('&'); expect(sanitized).not.toContain('>'); expect(sanitized).not.toContain('<'); }); }); it('should handle various shell injection patterns', () => { const shellInjectionPatterns = [ { input: '; rm -rf /', expected: 'rm-rf' }, { input: '`whoami`', expected: 'whoami' }, { input: '$(id)', expected: 'id' }, { input: '|| cat /etc/passwd', expected: 'catetcpasswd' }, { input: '&& echo attack', expected: 'echoattack' }, { input: '> /tmp/malicious', expected: 'tmpmalicious' }, { input: '< /etc/hosts', expected: 'etchosts' } ]; shellInjectionPatterns.forEach(({ input, expected }) => { const result = sanitizeSessionId({ sessionId: input }); expect(result).toBe(expected); }); }); }); }); });