Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions src/lib/timeline-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,43 @@ export async function insertEvents(events: TimelineEvent[], projectDir?: string)
}
}

function buildWhereFilter(opts: SearchOptions): string | undefined {
/** Escape single quotes for safe use in SQL string literals */
export function escapeSQL(value: string): string {
return value.replace(/'/g, "''");
}

/** Validate that a value matches expected EVENT_TYPES to prevent injection */
export function isValidEventType(value: string): value is EventType {
return (EVENT_TYPES as readonly string[]).includes(value);
}

/** Validate ISO-8601 timestamp format */
export function isValidTimestamp(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(value);
}

export function buildWhereFilter(opts: SearchOptions): string | undefined {
const clauses: string[] = [];
if (opts.project) clauses.push(`project = '${opts.project}'`);
if (opts.branch) clauses.push(`branch = '${opts.branch}'`);
if (opts.type) clauses.push(`type = '${opts.type}'`);
if (opts.since) clauses.push(`timestamp >= '${opts.since}'`);
if (opts.until) clauses.push(`timestamp <= '${opts.until}'`);
if (opts.project) clauses.push(`project = '${escapeSQL(opts.project)}'`);
if (opts.branch) clauses.push(`branch = '${escapeSQL(opts.branch)}'`);
if (opts.type) {
if (!isValidEventType(opts.type)) {
throw new Error(`Invalid event type: ${opts.type}`);
}
clauses.push(`type = '${opts.type}'`);
}
if (opts.since) {
if (!isValidTimestamp(opts.since)) {
throw new Error(`Invalid timestamp for 'since': ${opts.since}`);
}
clauses.push(`timestamp >= '${opts.since}'`);
}
if (opts.until) {
if (!isValidTimestamp(opts.until)) {
throw new Error(`Invalid timestamp for 'until': ${opts.until}`);
}
clauses.push(`timestamp <= '${opts.until}'`);
}
return clauses.length > 0 ? clauses.join(" AND ") : undefined;
}

Expand Down Expand Up @@ -358,7 +388,7 @@ export async function searchExact(
opts: SearchOptions = {},
): Promise<TimelineRecord[]> {
const limit = opts.limit || 50;
const likeClauses = [`content LIKE '%${query.replace(/'/g, "''")}%'`];
const likeClauses = [`content LIKE '%${escapeSQL(query)}%'`];
const where = buildWhereFilter(opts);
const fullWhere = where ? `${likeClauses[0]} AND ${where}` : likeClauses[0];

Expand Down
120 changes: 120 additions & 0 deletions tests/lib/timeline-db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import {
escapeSQL,
isValidEventType,
isValidTimestamp,
buildWhereFilter,
EVENT_TYPES,
} from "../../src/lib/timeline-db.js";

describe("escapeSQL", () => {
it("escapes single quotes", () => {
expect(escapeSQL("it's")).toBe("it''s");
});

it("escapes multiple single quotes", () => {
expect(escapeSQL("it's a 'test'")).toBe("it''s a ''test''");
});

it("passes through safe strings", () => {
expect(escapeSQL("hello world")).toBe("hello world");
});

it("handles empty string", () => {
expect(escapeSQL("")).toBe("");
});
});

describe("isValidEventType", () => {
it("accepts all valid event types", () => {
for (const t of EVENT_TYPES) {
expect(isValidEventType(t)).toBe(true);
}
});

it("rejects invalid types", () => {
expect(isValidEventType("invalid")).toBe(false);
expect(isValidEventType("' OR 1=1 --")).toBe(false);
expect(isValidEventType("")).toBe(false);
});
});

describe("isValidTimestamp", () => {
it("accepts valid ISO timestamps", () => {
expect(isValidTimestamp("2024-01-15")).toBe(true);
expect(isValidTimestamp("2024-01-15T10:30:00Z")).toBe(true);
expect(isValidTimestamp("2024-01-15T10:30:00.123Z")).toBe(true);
expect(isValidTimestamp("2024-01-15T10:30:00+05:30")).toBe(true);
expect(isValidTimestamp("2024-01-15T10:30:00-08:00")).toBe(true);
});

it("rejects invalid timestamps", () => {
expect(isValidTimestamp("not-a-date")).toBe(false);
expect(isValidTimestamp("' OR 1=1 --")).toBe(false);
expect(isValidTimestamp("2024/01/15")).toBe(false);
expect(isValidTimestamp("")).toBe(false);
});
});

describe("buildWhereFilter", () => {
it("returns undefined for empty options", () => {
expect(buildWhereFilter({})).toBeUndefined();
});

it("builds single project filter", () => {
expect(buildWhereFilter({ project: "/my/project" })).toBe(
"project = '/my/project'"
);
});

it("escapes quotes in project path", () => {
expect(buildWhereFilter({ project: "it's/a/path" })).toBe(
"project = 'it''s/a/path'"
);
});

it("escapes quotes in branch name", () => {
expect(buildWhereFilter({ branch: "fix/it's-broken" })).toBe(
"branch = 'fix/it''s-broken'"
);
});

it("combines multiple filters with AND", () => {
const result = buildWhereFilter({
project: "/proj",
branch: "main",
type: "prompt",
});
expect(result).toBe(
"project = '/proj' AND branch = 'main' AND type = 'prompt'"
);
});

it("throws on invalid event type", () => {
expect(() => buildWhereFilter({ type: "malicious' OR 1=1 --" as any })).toThrow(
"Invalid event type"
);
});

it("throws on invalid since timestamp", () => {
expect(() => buildWhereFilter({ since: "'; DROP TABLE events; --" })).toThrow(
"Invalid timestamp"
);
});

it("throws on invalid until timestamp", () => {
expect(() => buildWhereFilter({ until: "not-a-date" })).toThrow(
"Invalid timestamp"
);
});

it("accepts valid timestamp range", () => {
const result = buildWhereFilter({
since: "2024-01-01T00:00:00Z",
until: "2024-12-31T23:59:59Z",
});
expect(result).toBe(
"timestamp >= '2024-01-01T00:00:00Z' AND timestamp <= '2024-12-31T23:59:59Z'"
);
});
});
Loading