Skip to content

Commit 9395f0e

Browse files
authored
fix(file-parsers): stop deleting non-BMP characters when sanitizing parsed text (#6442)
* fix(file-parsers): stop deleting non-BMP characters when sanitizing parsed text The unpaired-surrogate strip used a bare [\uD800-\uDFFF] class, which matches UTF-16 code units and so removed both halves of every valid surrogate pair — deleting all emoji, CJK Extension B, and mathematical alphanumerics from parsed output. Match only genuinely unpaired surrogates instead. Also surface PDF truncation inline. Callers read only `content`, so a bounded PDF was indistinguishable from a complete one; it now carries the same `[... ... ...]` marker csv and xlsx already use, via a shared helper. Drop two unused exports, one of which carried the same surrogate bug. * fix(file-parsers): keep text-free PDFs empty and count only pages that were read Gate the truncation notice on the sanitized, trimmed body: a text-free multi-page PDF collapses to a lone separator, so the previous length check let a notice turn a document callers treat as empty into one that looks like it holds content. Also stop counting a page the budget cut off before it yielded anything, so the notice no longer reports one more page than was shown.
1 parent 1b5ba82 commit 9395f0e

6 files changed

Lines changed: 145 additions & 44 deletions

File tree

apps/sim/lib/file-parsers/csv-parser.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Readable } from 'stream'
33
import { createLogger } from '@sim/logger'
44
import { type Options, parse } from 'csv-parse'
55
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
6-
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
6+
import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils'
77

88
const logger = createLogger('CsvParser')
99

@@ -123,7 +123,9 @@ export class CsvParser implements FileParser {
123123
parser.on('end', () => {
124124
if (!aborted) {
125125
if (rowCount > CONFIG.MAX_PREVIEW_ROWS) {
126-
processedContent += `\n[... ${rowCount.toLocaleString()} total rows, showing first ${CONFIG.MAX_PREVIEW_ROWS} ...]\n`
126+
processedContent += truncationNotice(
127+
`${rowCount.toLocaleString()} total rows, showing first ${CONFIG.MAX_PREVIEW_ROWS}`
128+
)
127129
}
128130

129131
logger.info(`CSV parsing complete: ${rowCount} rows, ${errorCount} errors`)

apps/sim/lib/file-parsers/pdf-parser.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,24 @@ function buildTextBombPdf(repeats: number): Buffer {
3333
Buffer.from('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'),
3434
]
3535

36+
return assemblePdf(objects)
37+
}
38+
39+
/** Builds a PDF whose pages carry no content stream, so nothing is extractable. */
40+
function buildTextFreePdf(pageCount: number): Buffer {
41+
const pageIds = Array.from({ length: pageCount }, (_, i) => 3 + i)
42+
43+
return assemblePdf([
44+
Buffer.from('<< /Type /Catalog /Pages 2 0 R >>'),
45+
Buffer.from(
46+
`<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${pageCount} >>`
47+
),
48+
...pageIds.map(() => Buffer.from('<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>')),
49+
])
50+
}
51+
52+
/** Serializes numbered objects into a PDF with a matching xref table and trailer. */
53+
function assemblePdf(objects: Buffer[]): Buffer {
3654
const chunks: Buffer[] = [Buffer.from('%PDF-1.4\n')]
3755
const offsets: number[] = []
3856
let offset = chunks[0].length
@@ -70,7 +88,13 @@ describe('PdfParser', () => {
7088

7189
expect(result.metadata?.truncated).toBe(true)
7290
expect(result.metadata?.warning).toMatch(/parser limit/i)
73-
expect(result.content.length).toBeLessThanOrEqual(MAX_PDF_TEXT_CHARS)
91+
expect(result.content.length).toBeLessThanOrEqual(MAX_PDF_TEXT_CHARS + 200)
92+
}, 120_000)
93+
94+
it('marks truncated content inline so callers reading only content can see it', async () => {
95+
const result = await new PdfParser().parseBuffer(buildTextBombPdf(200_000))
96+
97+
expect(result.content).toMatch(/\[\.\.\. PDF text truncated at parser limits.* \.\.\.\]/)
7498
}, 120_000)
7599

76100
it('extracts a small PDF in full and does not flag it as truncated', async () => {
@@ -80,5 +104,13 @@ describe('PdfParser', () => {
80104
expect(result.metadata?.warning).toBeUndefined()
81105
expect(result.metadata?.pageCount).toBe(1)
82106
expect(result.content).toContain('AAAA')
107+
expect(result.content).not.toContain('truncated')
108+
}, 30_000)
109+
110+
it('reports a multi-page PDF with no extractable text as empty', async () => {
111+
const result = await new PdfParser().parseBuffer(buildTextFreePdf(3))
112+
113+
expect(result.content.trim()).toBe('')
114+
expect(result.content).not.toContain('[...')
83115
}, 30_000)
84116
})

apps/sim/lib/file-parsers/pdf-parser.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { readFile } from 'fs/promises'
22
import { createLogger } from '@sim/logger'
33
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
4-
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
4+
import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils'
55

66
const logger = createLogger('PdfParser')
77

8-
/** Highest page number visited, bounding documents that declare huge page counts. */
8+
/**
9+
* Ceiling on the page loop. The character budget and the deadline already stop
10+
* extraction on their own, so this exists purely so the loop bound never comes
11+
* straight from the attacker-controlled `numPages` field.
12+
*/
913
const MAX_PDF_PAGES = 10_000
1014

1115
/** Ceiling on extracted characters — roughly 3,000 pages of dense text. */
@@ -35,6 +39,8 @@ interface BoundedExtraction {
3539
text: string
3640
/** Page count the document declares, however many pages were actually read. */
3741
totalPages: number
42+
/** Pages actually visited before a budget stopped extraction. */
43+
pagesRead: number
3844
/** True when a budget stopped extraction before the document was exhausted. */
3945
truncated: boolean
4046
}
@@ -119,7 +125,12 @@ async function extractTextWithinBudget(pdf: PdfDocumentProxy): Promise<BoundedEx
119125
const { text, used, completed } = await readPageWithinBudget(page, remainingChars, deadline)
120126

121127
remainingChars -= used
122-
pageTexts.push(text)
128+
129+
// A page the budget cut off before it yielded anything was never really
130+
// read, so it must not count toward `pagesRead` or add a blank separator.
131+
if (completed || text.length > 0) {
132+
pageTexts.push(text)
133+
}
123134
page.cleanup()
124135

125136
if (!completed) {
@@ -128,7 +139,12 @@ async function extractTextWithinBudget(pdf: PdfDocumentProxy): Promise<BoundedEx
128139
}
129140
}
130141

131-
return { text: pageTexts.join('\n').replace(/\s+/g, ' '), totalPages, truncated }
142+
return {
143+
text: pageTexts.join('\n').replace(/\s+/g, ' '),
144+
totalPages,
145+
pagesRead: pageTexts.length,
146+
truncated,
147+
}
132148
}
133149

134150
export class PdfParser implements FileParser {
@@ -162,16 +178,30 @@ export class PdfParser implements FileParser {
162178
const pdf = await getDocumentProxy(uint8Array)
163179

164180
try {
165-
const { text, totalPages, truncated } = await extractTextWithinBudget(pdf)
181+
const { text, totalPages, pagesRead, truncated } = await extractTextWithinBudget(pdf)
166182

167183
logger.info('PDF parsed successfully, pages:', totalPages, 'text length:', text.length)
168184

169185
if (truncated) {
170-
logger.warn(PDF_TRUNCATION_WARNING, { totalPages, textLength: text.length })
186+
logger.warn(PDF_TRUNCATION_WARNING, { totalPages, pagesRead, textLength: text.length })
171187
}
172188

189+
const body = sanitizeTextForUTF8(text)
190+
191+
// Callers only ever read `content`, so without an inline notice a truncated
192+
// document is indistinguishable from a complete one. Tested after sanitizing
193+
// and against `trim`, because a text-free multi-page PDF collapses to a lone
194+
// separator — appending a notice to that would turn a document callers treat
195+
// as empty into one that looks like it holds content.
196+
const notice =
197+
truncated && body.trim().length > 0
198+
? truncationNotice(
199+
`PDF text truncated at parser limits, showing first ${pagesRead} of ${totalPages} pages`
200+
)
201+
: ''
202+
173203
return {
174-
content: sanitizeTextForUTF8(text),
204+
content: body + notice,
175205
metadata: {
176206
pageCount: totalPages,
177207
source: 'unpdf',
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils'
6+
7+
const LONE_HIGH = '\uD800'
8+
const LONE_LOW = '\uDC00'
9+
10+
describe('sanitizeTextForUTF8', () => {
11+
it('preserves non-BMP characters built from valid surrogate pairs', () => {
12+
const text = 'emoji 😀 cjk-ext-b 𠮷野家 math 𝐀𝐁𝐂'
13+
14+
expect(sanitizeTextForUTF8(text)).toBe(text)
15+
})
16+
17+
it('removes unpaired surrogates', () => {
18+
expect(sanitizeTextForUTF8(`a${LONE_HIGH}b`)).toBe('ab')
19+
expect(sanitizeTextForUTF8(`a${LONE_LOW}b`)).toBe('ab')
20+
})
21+
22+
it('removes an unpaired surrogate without disturbing an adjacent valid pair', () => {
23+
expect(sanitizeTextForUTF8(`😀${LONE_HIGH}😀`)).toBe('😀😀')
24+
})
25+
26+
it('round-trips through UTF-8 after sanitizing', () => {
27+
const sanitized = sanitizeTextForUTF8(`😀${LONE_LOW}𠮷`)
28+
29+
expect(Buffer.from(sanitized, 'utf8').toString('utf8')).toBe(sanitized)
30+
expect(sanitized).toBe('😀𠮷')
31+
})
32+
33+
it('removes control characters but keeps tab, newline, and carriage return', () => {
34+
expect(sanitizeTextForUTF8('a\x07b\x7Fc')).toBe('abc')
35+
expect(sanitizeTextForUTF8('a\tb\nc\rd')).toBe('a\tb\nc\rd')
36+
})
37+
38+
it('removes null bytes and replacement characters', () => {
39+
expect(sanitizeTextForUTF8('a\x00b\uFFFDc')).toBe('abc')
40+
})
41+
42+
it('returns an empty string for empty or non-string input', () => {
43+
expect(sanitizeTextForUTF8('')).toBe('')
44+
expect(sanitizeTextForUTF8(undefined as unknown as string)).toBe('')
45+
})
46+
})
47+
48+
describe('truncationNotice', () => {
49+
it('wraps the detail in the shared inline marker', () => {
50+
expect(truncationNotice('42 total rows, showing first 10')).toBe(
51+
'\n[... 42 total rows, showing first 10 ...]\n'
52+
)
53+
})
54+
})

apps/sim/lib/file-parsers/utils.ts

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,26 @@
11
/**
2-
* Utility functions for file parsing
2+
* A bare `[\uD800-\uDFFF]` class would match both halves of a *valid* pair,
3+
* deleting every non-BMP character rather than only the malformed ones.
34
*/
5+
const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
46

57
/**
6-
* Clean text content to ensure it's safe for UTF-8 storage in PostgreSQL
7-
* Removes null bytes and control characters that can cause encoding errors
8+
* Strips control characters, replacement characters, and unpaired surrogates so
9+
* the text is safe for UTF-8 storage in PostgreSQL. Tabs, newlines, and carriage
10+
* returns are preserved.
811
*/
912
export function sanitizeTextForUTF8(text: string): string {
1013
if (!text || typeof text !== 'string') {
1114
return ''
1215
}
1316

1417
return text
15-
.replace(/\0/g, '') // Remove null bytes (0x00)
16-
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control characters except \t(0x09), \n(0x0A), \r(0x0D)
17-
.replace(/\uFFFD/g, '') // Remove Unicode replacement character
18-
.replace(/[\uD800-\uDFFF]/g, '') // Remove unpaired surrogate characters
18+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
19+
.replace(/\uFFFD/g, '')
20+
.replace(UNPAIRED_SURROGATE, '')
1921
}
2022

21-
/**
22-
* Sanitize an array of strings
23-
*/
24-
export function sanitizeTextArray(texts: string[]): string[] {
25-
return texts.map((text) => sanitizeTextForUTF8(text))
26-
}
27-
28-
/**
29-
* Check if a string contains problematic characters for UTF-8 storage
30-
*/
31-
export function hasInvalidUTF8Characters(text: string): boolean {
32-
if (!text || typeof text !== 'string') {
33-
return false
34-
}
35-
36-
// Check for null bytes and control characters
37-
return (
38-
/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(text) ||
39-
/\uFFFD/.test(text) ||
40-
/[\uD800-\uDFFF]/.test(text)
41-
)
23+
/** Formats the inline `[... detail ...]` marker parsers append when a limit stopped extraction early. */
24+
export function truncationNotice(detail: string): string {
25+
return `\n[... ${detail} ...]\n`
4226
}

apps/sim/lib/file-parsers/xlsx-parser.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger'
44
import { truncate } from '@sim/utils/string'
55
import * as XLSX from 'xlsx'
66
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
7-
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
7+
import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils'
88
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
99

1010
const logger = createLogger('XlsxParser')
@@ -155,19 +155,18 @@ export class XlsxParser implements FileParser {
155155
contentSize += chunkContent.length
156156
}
157157

158-
// Add truncation notice if needed
159158
if (actualRowCount > rowsToProcess) {
160-
const notice = `\n[... ${actualRowCount.toLocaleString()} total rows, showing first ${rowsToProcess.toLocaleString()} ...]\n`
161-
content += notice
159+
content += truncationNotice(
160+
`${actualRowCount.toLocaleString()} total rows, showing first ${rowsToProcess.toLocaleString()}`
161+
)
162162
truncated = true
163163
}
164164
} else {
165165
content += '[Empty sheet]\n'
166166
}
167167

168-
// Stop processing if content is too large
169168
if (contentSize > CONFIG.MAX_CONTENT_SIZE) {
170-
content += '\n[... Content truncated due to size limits ...]\n'
169+
content += truncationNotice('Content truncated due to size limits')
171170
truncated = true
172171
break
173172
}

0 commit comments

Comments
 (0)