Skip to content

Commit 99a8eaa

Browse files
authored
Merge pull request #1370 from TheWizardsCode/copilot/unescape-strings-before-inserting-db
Unescape plain-text fields before persisting to SQLite
2 parents 1bef0f3 + 510db03 commit 99a8eaa

File tree

2 files changed

+195
-7
lines changed

2 files changed

+195
-7
lines changed

src/persistent-store.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ export function normalizeSqliteBindings(values: unknown[]): Array<number | strin
6767
return values.map(normalizeSqliteValue);
6868
}
6969

70+
/**
71+
* Unescape backslash escape sequences in a plain-text string before persisting.
72+
* Converts common two-character escape artifacts (e.g. backslash-n from CLI
73+
* argument passing) into their actual character equivalents so stored text is
74+
* human-readable and free of accidental escape artifacts.
75+
*
76+
* Only the following sequences are converted (single-pass, left-to-right):
77+
* \n -> newline
78+
* \t -> tab
79+
* \r -> carriage return
80+
* \\ -> single backslash
81+
*
82+
* All other characters (including quotes and backticks) are left unchanged.
83+
* This function must NOT be applied to JSON strings or structured fields.
84+
*/
85+
export function unescapeText(s: string): string {
86+
const map: Record<string, string> = { '\\': '\\', n: '\n', t: '\t', r: '\r' };
87+
return s.replace(/\\(\\|n|t|r)/g, (_, c: string) => map[c]);
88+
}
89+
7090
export class SqlitePersistentStore {
7191
private db: Database.Database;
7292
private dbPath: string;
@@ -337,14 +357,27 @@ export class SqlitePersistentStore {
337357
// runtime normalization elsewhere.
338358
const normalizedStatus = normalizeStatusValue(item.status) ?? item.status;
339359

360+
// Unescape plain-text fields so backslash escape artifacts (e.g. \n from
361+
// CLI argument passing) are stored as the intended characters.
362+
// Structured/JSON fields (tags, refs, audit JSON) must NOT be unescaped here.
363+
const titleVal = unescapeText(item.title ?? '');
364+
const descriptionVal = unescapeText(item.description ?? '');
365+
const deleteReasonVal = unescapeText(item.deleteReason ?? '');
366+
// Unescape only the plain-text field within the structured audit object.
367+
let auditVal: string | null = null;
368+
if (item.audit) {
369+
const auditCopy = { ...item.audit, text: unescapeText(item.audit.text ?? '') };
370+
auditVal = JSON.stringify(auditCopy);
371+
}
372+
340373
// Ensure we never pass `undefined` into better-sqlite3 bindings (it only
341374
// accepts numbers, strings, bigints, buffers and null). Normalize tags to
342375
// a JSON string and convert any undefined to null before running.
343376
const tagsVal = Array.isArray(item.tags) ? JSON.stringify(item.tags) : JSON.stringify([]);
344377
const values: any[] = [
345378
item.id,
346-
item.title,
347-
item.description,
379+
titleVal,
380+
descriptionVal,
348381
normalizedStatus,
349382
item.priority,
350383
item.sortIndex,
@@ -357,14 +390,14 @@ export class SqlitePersistentStore {
357390
item.issueType ?? '',
358391
item.createdBy ?? '',
359392
item.deletedBy ?? '',
360-
item.deleteReason ?? '',
393+
deleteReasonVal,
361394
item.risk ?? '',
362395
item.effort ?? '',
363396
item.githubIssueNumber ?? null,
364397
item.githubIssueId ?? null,
365398
item.githubIssueUpdatedAt ?? null,
366399
item.needsProducerReview ? 1 : 0,
367-
item.audit ? JSON.stringify(item.audit) : null,
400+
auditVal,
368401
];
369402

370403
const normalized = normalizeSqliteBindings(values);
@@ -601,11 +634,14 @@ export class SqlitePersistentStore {
601634
// Pre-construction: stringify references, coerce optional fields.
602635
// Preserve existing || behavior for githubCommentUpdatedAt so that
603636
// falsy values (including empty string) become null.
637+
// Unescape the comment body so backslash escape artifacts are stored as
638+
// the intended characters. The refs JSON and other structured fields are
639+
// intentionally left unchanged.
604640
const values: unknown[] = [
605641
comment.id,
606642
comment.workItemId,
607643
comment.author,
608-
comment.comment,
644+
unescapeText(comment.comment),
609645
comment.createdAt,
610646
JSON.stringify(comment.references),
611647
comment.githubCommentId ?? null,

tests/normalize-sqlite-bindings.test.ts

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/**
2-
* Tests for normalizeSqliteValue and normalizeSqliteBindings
2+
* Tests for normalizeSqliteValue, normalizeSqliteBindings, and unescapeText
33
* (WL-0MLRSV1XF14KM6WT)
44
*/
55

66
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7-
import { normalizeSqliteValue, normalizeSqliteBindings } from '../src/persistent-store.js';
7+
import { normalizeSqliteValue, normalizeSqliteBindings, unescapeText } from '../src/persistent-store.js';
88
import { WorklogDatabase } from '../src/database.js';
99
import { createTempDir, cleanupTempDir, createTempJsonlPath, createTempDbPath } from './test-utils.js';
1010

@@ -287,3 +287,155 @@ describe('SQLite binding round-trip', () => {
287287
expect(outbound[0].toId).toBe(b.id);
288288
});
289289
});
290+
291+
// ---------------------------------------------------------------------------
292+
// Unit tests for unescapeText
293+
// ---------------------------------------------------------------------------
294+
295+
describe('unescapeText', () => {
296+
it('returns an empty string unchanged', () => {
297+
expect(unescapeText('')).toBe('');
298+
});
299+
300+
it('passes through plain text with no escape sequences', () => {
301+
expect(unescapeText('Hello World')).toBe('Hello World');
302+
});
303+
304+
it('converts \\n to a real newline', () => {
305+
expect(unescapeText('Line\\nBreak')).toBe('Line\nBreak');
306+
});
307+
308+
it('converts \\t to a real tab', () => {
309+
expect(unescapeText('Col\\tValue')).toBe('Col\tValue');
310+
});
311+
312+
it('converts \\r to a real carriage return', () => {
313+
expect(unescapeText('Foo\\rBar')).toBe('Foo\rBar');
314+
});
315+
316+
it('converts \\\\ to a single backslash', () => {
317+
expect(unescapeText('path\\\\file')).toBe('path\\file');
318+
});
319+
320+
it('handles multiple escape sequences in a single string', () => {
321+
expect(unescapeText('a\\nb\\tc\\\\d')).toBe('a\nb\tc\\d');
322+
});
323+
324+
it('does not double-decode when a backslash precedes a backslash-n', () => {
325+
// Input: 4 chars: \ \ n -> backslash + n (not a newline)
326+
expect(unescapeText('\\\\n')).toBe('\\n');
327+
});
328+
329+
it('preserves double quotes unchanged', () => {
330+
expect(unescapeText('say "hello"')).toBe('say "hello"');
331+
});
332+
333+
it('preserves backticks unchanged', () => {
334+
expect(unescapeText('use `code`')).toBe('use `code`');
335+
});
336+
337+
it('preserves unrecognised backslash sequences unchanged', () => {
338+
// \x is not a recognised sequence; the backslash is kept as-is
339+
expect(unescapeText('foo\\xbar')).toBe('foo\\xbar');
340+
});
341+
});
342+
343+
// ---------------------------------------------------------------------------
344+
// Integration round-trip tests: unescaping applied on DB write
345+
// ---------------------------------------------------------------------------
346+
347+
describe('unescapeText round-trip via DB', () => {
348+
let tempDir: string;
349+
let dbPath: string;
350+
let jsonlPath: string;
351+
let db: WorklogDatabase;
352+
353+
beforeEach(() => {
354+
tempDir = createTempDir();
355+
dbPath = createTempDbPath(tempDir);
356+
jsonlPath = createTempJsonlPath(tempDir);
357+
db = new WorklogDatabase('UT', dbPath, jsonlPath, true, true);
358+
});
359+
360+
afterEach(() => {
361+
db.close();
362+
cleanupTempDir(tempDir);
363+
});
364+
365+
it('stores description with real newline when input contains \\n escape artifact', () => {
366+
const created = db.create({
367+
title: 'Escape test',
368+
description: 'Line\\nBreak',
369+
});
370+
371+
const loaded = db.get(created.id);
372+
expect(loaded).toBeDefined();
373+
// Stored text must contain a real newline, not the two-char sequence \n
374+
expect(loaded!.description).toBe('Line\nBreak');
375+
expect(loaded!.description).not.toContain('\\n');
376+
});
377+
378+
it('stores title with real newline when input contains \\n escape artifact', () => {
379+
const created = db.create({
380+
title: 'Title\\nWith Escape',
381+
});
382+
383+
const loaded = db.get(created.id);
384+
expect(loaded).toBeDefined();
385+
expect(loaded!.title).toBe('Title\nWith Escape');
386+
expect(loaded!.title).not.toContain('\\n');
387+
});
388+
389+
it('stores comment body with real newline when input contains \\n escape artifact', () => {
390+
const item = db.create({ title: 'Escape comment test' });
391+
392+
db.createComment({
393+
workItemId: item.id,
394+
author: 'tester',
395+
comment: 'First\\nSecond',
396+
references: [],
397+
});
398+
399+
const comments = db.getCommentsForWorkItem(item.id);
400+
expect(comments).toHaveLength(1);
401+
expect(comments[0].comment).toBe('First\nSecond');
402+
expect(comments[0].comment).not.toContain('\\n');
403+
});
404+
405+
it('unescapes audit text field but leaves audit JSON structure intact', () => {
406+
const created = db.create({
407+
title: 'Audit escape test',
408+
audit: {
409+
time: '2026-01-01T00:00:00.000Z',
410+
author: 'tester',
411+
text: 'Ready to close: Yes\\nExtra detail',
412+
status: 'Complete',
413+
},
414+
});
415+
416+
const loaded = db.get(created.id);
417+
expect(loaded).toBeDefined();
418+
expect(loaded!.audit).toBeDefined();
419+
// audit.text should have a real newline
420+
expect(loaded!.audit!.text).toBe('Ready to close: Yes\nExtra detail');
421+
expect(loaded!.audit!.text).not.toContain('\\n');
422+
// Structured audit fields must remain intact
423+
expect(loaded!.audit!.author).toBe('tester');
424+
expect(loaded!.audit!.status).toBe('Complete');
425+
});
426+
427+
it('does not alter tags (JSON field) when description contains escape artifacts', () => {
428+
const created = db.create({
429+
title: 'Tags intact',
430+
description: 'Desc\\nValue',
431+
tags: ['tag\\none', 'normal'],
432+
});
433+
434+
const loaded = db.get(created.id);
435+
expect(loaded).toBeDefined();
436+
// Description should be unescaped
437+
expect(loaded!.description).toBe('Desc\nValue');
438+
// Tags are JSON-structured; the raw tag values are preserved as-is
439+
expect(loaded!.tags).toEqual(['tag\\none', 'normal']);
440+
});
441+
});

0 commit comments

Comments
 (0)