Skip to content

Commit 16fd0b0

Browse files
waleedlatif1claude
andcommitted
fix(parsers): decode text by encoding, sniff bytes before routing, read legacy .doc
Text parsers decoded every buffer as UTF-8 and then stripped U+FFFD, so a Latin-1 or Windows-1252 file silently lost every accented character, a UTF-8 BOM leaked into content and broke JSON.parse, and UTF-16 only worked for ASCII. `decodeTextBuffer` (BOM > strict UTF-8 with a guarded truncated-tail retry > Windows-1252) now backs txt/md/csv/json/jsonl/yaml, the .doc plain-text fallback and the connectors' text decode, and records `encoding`/`warning` in metadata. `parseBuffer` routed on the caller-supplied extension alone. `sniff.ts` now identifies the bytes (PDF, OLE2, ZIP central-directory part names, ODF mimetype, UTF-16 layout, HTML head) and reconciles them with the extension's family: a sniffed kind with its own parser overrides the route and records `detectedType`; binary/unknown bytes under a mismatched family are a typed `invalid_format` instead of mojibake or placeholder prose. Legacy OLE .doc goes through word-extractor (body, headers, footers, footnotes, endnotes; Word 6/95 magic maps to `unsupported_type`); the byte scrape that returned ZIP part names as degraded prose is deleted. Legacy .ppt is dropped from the registry, upload and connector allowlists and Chat's parseable set so it is refused up front. Chat's file reader and the internal file tool now treat `degraded` output as a parse failure. pdf.js `InvalidPDFException`/`FormatError`/`PasswordException` are mapped to typed parser errors at the single `openPdfDocument` choke point, and the zip guard's `ArchiveIntegrityError` surfaces from `parseBuffer` as a typed `invalid_format`, so neither classifies as transient and retries forever. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent f6d5676 commit 16fd0b0

38 files changed

Lines changed: 1620 additions & 197 deletions

‎apps/sim/connectors/azure-devops/azure-devops.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
3+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
34
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
45
import { azureDevopsConnectorMeta } from '@/connectors/azure-devops/meta'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -1182,7 +1183,7 @@ async function getFileDocument(
11821183
return null
11831184
}
11841185

1185-
const content = buffer.toString('utf8')
1186+
const content = decodeTextBuffer(buffer).text
11861187
if (!content.trim()) return null
11871188

11881189
const title = path.split('/').filter(Boolean).pop() || path

‎apps/sim/connectors/box/box.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { sleep } from '@sim/utils/helpers'
4+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
45
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
56
import { boxConnectorMeta } from '@/connectors/box/meta'
67
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -319,7 +320,7 @@ async function fetchPlainTextContent(
319320
extension: string
320321
): Promise<string> {
321322
const buffer = await downloadWithinLimit(`${BOX_API_BASE}/files/${fileId}/content`, accessToken)
322-
const text = buffer.toString('utf8')
323+
const { text } = decodeTextBuffer(buffer)
323324
return HTML_EXTENSIONS.has(extension) ? htmlToPlainText(text) : text
324325
}
325326

@@ -347,7 +348,7 @@ async function fetchExtractedText(
347348
urlTemplate.replace('{+asset_path}', ''),
348349
accessToken
349350
)
350-
return buffer.toString('utf8')
351+
return decodeTextBuffer(buffer).text
351352
}
352353
if (state === 'error' || !infoUrl) return null
353354
if (attempt === REPRESENTATION_POLL_ATTEMPTS) break

‎apps/sim/connectors/dropbox/dropbox.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
34
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
45
import { dropboxConnectorMeta } from '@/connectors/dropbox/meta'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -147,7 +148,7 @@ async function downloadFileContent(
147148
throw new ConnectorFileTooLargeError(MAX_FILE_SIZE)
148149
}
149150

150-
const text = buffer.toString('utf8')
151+
const { text } = decodeTextBuffer(buffer)
151152

152153
return isHtml ? htmlToPlainText(text) : text
153154
}

‎apps/sim/connectors/github/github.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { getErrorMessage, toError } from '@sim/utils/errors'
44
import { z } from 'zod'
55
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
6+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
67
import { type RetryOptions, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
78
import { parseGitHubRepository } from '@/lib/oauth/github-repository'
89
import { githubConnectorMeta } from '@/connectors/github/meta'
@@ -296,7 +297,7 @@ async function fetchBlobContent(
296297
throw new ConnectorFileTooLargeError(maxBytes)
297298
}
298299
if (isBinaryBuffer(buffer)) return null
299-
return buffer.toString('utf8')
300+
return decodeTextBuffer(buffer).text
300301
}
301302

302303
/** Resolves links within one snapshot; Contents can truncate dereferenced targets at 1 MiB. */

‎apps/sim/connectors/gitlab/gitlab.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { generateId } from '@sim/utils/id'
44
import { isPlainRecord } from '@sim/utils/object'
55
import type { SecureFetchResponse } from '@/lib/core/security/input-validation.server'
6+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
67
import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
78
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
89
import { gitlabConnectorMeta } from '@/connectors/gitlab/meta'
@@ -435,7 +436,7 @@ function fileToDocument(
435436
return skipped(sizeLimitSkipReason(MAX_FILE_SIZE), buffer.byteLength)
436437
}
437438

438-
const content = buffer.toString('utf8')
439+
const content = decodeTextBuffer(buffer).text
439440
const body = composeBody(title, content)
440441
if (!body.trim()) return null
441442

‎apps/sim/connectors/utils.test.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,19 +1498,15 @@ describe('htmlToPlainText entity decoding', () => {
14981498

14991499
describe('isIndexableConnectorFile', () => {
15001500
it('accepts the Office and PDF formats the knowledge base can parse', () => {
1501-
for (const name of [
1502-
'sop.pdf',
1503-
'sop.doc',
1504-
'sop.docx',
1505-
'sheet.xls',
1506-
'sheet.xlsx',
1507-
'deck.ppt',
1508-
'deck.pptx',
1509-
]) {
1501+
for (const name of ['sop.pdf', 'sop.doc', 'sop.docx', 'sheet.xls', 'sheet.xlsx', 'deck.pptx']) {
15101502
expect(isIndexableConnectorFile(name)).toBe(true)
15111503
}
15121504
})
15131505

1506+
it('refuses legacy .ppt up front because no parser reads it', () => {
1507+
expect(isIndexableConnectorFile('deck.ppt')).toBe(false)
1508+
})
1509+
15141510
it('still accepts the plain-text formats connectors already synced', () => {
15151511
for (const name of ['a.txt', 'a.md', 'a.html', 'a.htm', 'a.csv', 'a.log', 'a.tsv', 'a.rst']) {
15161512
expect(isIndexableConnectorFile(name)).toBe(true)
@@ -1578,6 +1574,30 @@ describe('extractConnectorText', () => {
15781574
it('leaves whitespace-only content alone for the caller to reject', () => {
15791575
expect(extractConnectorText(Buffer.from(' '), 'blank.txt')).toBe(' ')
15801576
})
1577+
1578+
it('decodes a Latin-1 file as Windows-1252 instead of indexing mojibake', () => {
1579+
expect(extractConnectorText(Buffer.from('Caf\xe9 \xa3 42', 'latin1'), 'notes.txt')).toBe(
1580+
'Café £ 42'
1581+
)
1582+
})
1583+
1584+
it('strips a UTF-8 BOM', () => {
1585+
expect(
1586+
extractConnectorText(
1587+
Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('a,b')]),
1588+
'data.csv'
1589+
)
1590+
).toBe('a,b')
1591+
})
1592+
1593+
it('decodes UTF-16 with a BOM', () => {
1594+
expect(
1595+
extractConnectorText(
1596+
Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('<p>Hällo</p>', 'utf16le')]),
1597+
'page.html'
1598+
)
1599+
).toBe('Hällo')
1600+
})
15811601
})
15821602

15831603
describe('pipelineParsedMimeType', () => {

‎apps/sim/connectors/utils.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isPayloadSizeLimitError,
55
readResponseToBufferWithLimit,
66
} from '@/lib/core/utils/stream-limits'
7+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
78
import { MAX_FILE_SIZE as KB_DOCUMENT_MAX_BYTES } from '@/lib/uploads/utils/validation'
89
import type { ExternalDocument } from '@/connectors/types'
910

@@ -427,7 +428,6 @@ export const PIPELINE_PARSED_MIME_TYPES: ReadonlyMap<string, string> = new Map([
427428
['xlsm', 'application/vnd.ms-excel.sheet.macroEnabled.12'],
428429
['xlsb', 'application/vnd.ms-excel.sheet.binary.macroEnabled.12'],
429430
['xltx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.template'],
430-
['ppt', 'application/vnd.ms-powerpoint'],
431431
['pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
432432
['pptm', 'application/vnd.ms-powerpoint.presentation.macroEnabled.12'],
433433
['potx', 'application/vnd.openxmlformats-officedocument.presentationml.template'],
@@ -497,16 +497,18 @@ export function pipelineParsedMimeType(fileName: string): string | undefined {
497497
*
498498
* Only for formats that are already text — anything the shared parsers handle is
499499
* delivered to them verbatim instead, via {@link pipelineParsedMimeType}. HTML is
500-
* additionally reduced to plain text; everything else is a UTF-8 decode.
500+
* additionally reduced to plain text; everything else is decoded with the shared
501+
* BOM/UTF-8/Windows-1252 detection so a Latin-1 file never indexes as mojibake.
501502
*/
502503
export function extractConnectorText(buffer: Buffer, fileName: string): string {
503504
const extension = connectorFileExtension(fileName)
505+
const { text } = decodeTextBuffer(buffer)
504506

505507
if (extension === 'html' || extension === 'htm') {
506-
return htmlToPlainText(buffer.toString('utf8'))
508+
return htmlToPlainText(text)
507509
}
508510

509-
return buffer.toString('utf8')
511+
return text
510512
}
511513

512514
/**

‎apps/sim/lib/copilot/vfs/file-reader.test.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import { randomFillSync } from 'node:crypto'
66
import { crc32 } from 'node:zlib'
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
88

9-
const { fetchWorkspaceFileBuffer } = vi.hoisted(() => ({
9+
const { fetchWorkspaceFileBuffer, mockParseBuffer } = vi.hoisted(() => ({
1010
fetchWorkspaceFileBuffer: vi.fn(),
11+
mockParseBuffer: vi.fn(),
1112
}))
1213

1314
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
1415
fetchWorkspaceFileBuffer,
1516
}))
17+
vi.mock('@/lib/file-parsers', () => ({
18+
parseBuffer: mockParseBuffer,
19+
}))
1620

1721
import {
1822
MAX_IMAGE_READ_BYTES,
@@ -21,6 +25,7 @@ import {
2125
MAX_TEXT_READ_BYTES,
2226
readFileRecord,
2327
} from '@/lib/copilot/vfs/file-reader'
28+
import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders'
2429
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
2530
import { MAX_TRANSCODE_INPUT_BYTES } from '@/lib/uploads/server/heic'
2631

@@ -201,3 +206,46 @@ describe('readFileRecord', () => {
201206
SHARP_TEST_TIMEOUT_MS
202207
)
203208
})
209+
210+
describe('readFileRecord parseable documents', () => {
211+
beforeEach(() => {
212+
vi.clearAllMocks()
213+
})
214+
215+
function documentRecord(name: string, type: string, size: number) {
216+
return { ...imageRecord(name, size, type), id: 'wf_doc' }
217+
}
218+
219+
it('returns the parsed text of a document', async () => {
220+
fetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('bytes'))
221+
mockParseBuffer.mockResolvedValue({
222+
content: 'Quarterly review\nSecond line',
223+
metadata: { extractionMethod: 'word-extractor' },
224+
})
225+
226+
const result = await readFileRecord(documentRecord('review.doc', 'application/msword', 5))
227+
228+
expect(result).toEqual({ content: 'Quarterly review\nSecond line', totalLines: 2 })
229+
})
230+
231+
/**
232+
* A parser that could only scrape bytes flags the result `degraded`; that must
233+
* reach the model as the could-not-parse placeholder, never as file content.
234+
*/
235+
it('reports degraded parser output as could-not-parse instead of handing it to the model', async () => {
236+
fetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('bytes'))
237+
mockParseBuffer.mockResolvedValue({
238+
content: '[Content_Types].xml _rels/.rels theme/theme/themeManager.xml',
239+
metadata: { degraded: true, warning: 'Basic text extraction used' },
240+
})
241+
242+
const result = await readFileRecord(
243+
documentRecord('deck.pptx', 'application/vnd.ms-powerpoint', 5)
244+
)
245+
246+
expect(result).toEqual(
247+
readPlaceholder.couldNotParse('deck.pptx', 'application/vnd.ms-powerpoint', 5)
248+
)
249+
expect(result?.content).not.toContain('[Content_Types].xml')
250+
})
251+
})

‎apps/sim/lib/copilot/vfs/file-reader.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ const TEXT_TYPES = new Set([
104104
'application/javascript',
105105
])
106106

107-
const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt'])
107+
const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx'])
108108

109109
export function isReadableFileType(contentType: string): boolean {
110110
return TEXT_TYPES.has(contentType) || contentType.startsWith('text/')
@@ -587,6 +587,10 @@ export async function readFileRecord(
587587
try {
588588
const { parseBuffer } = await import('@/lib/file-parsers')
589589
const result = await parseBuffer(fetched.buffer, ext)
590+
if (result.metadata?.degraded === true) {
591+
/** Scraped ZIP internals or placeholder prose, not the document's text. */
592+
throw new Error(result.metadata.warning ?? 'Parser returned degraded output')
593+
}
590594
const content = result.content || ''
591595
const lines = content.split('\n').length
592596
span.setAttributes({

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

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
1-
import { createReadStream, existsSync } from 'fs'
1+
import { existsSync } from 'fs'
2+
import { readFile } from 'fs/promises'
23
import { Readable } from 'stream'
34
import { createLogger } from '@sim/logger'
45
import { type Options, parse } from 'csv-parse'
56
import { FileParserError } from '@/lib/file-parsers/errors'
67
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
7-
import { sanitizeTextForUTF8, truncationNotice } from '@/lib/file-parsers/utils'
8+
import {
9+
type DecodedText,
10+
decodeTextBuffer,
11+
sanitizeTextForUTF8,
12+
truncationNotice,
13+
} from '@/lib/file-parsers/utils'
814

915
const logger = createLogger('CsvParser')
1016

1117
const CONFIG = {
1218
MAX_PREVIEW_ROWS: 1000, // Only keep first 1000 rows for preview
1319
MAX_SAMPLE_ROWS: 100, // Sample for metadata
1420
MAX_ERRORS: 100, // Stop after 100 errors
15-
STREAM_CHUNK_SIZE: 16384, // 16KB chunks for streaming
1621
}
1722

1823
export class CsvParser implements FileParser {
24+
/**
25+
* Reads the whole file before parsing rather than streaming 16 KB chunks:
26+
* encoding detection needs the complete byte sequence (a BOM-less UTF-16 or
27+
* Windows-1252 file cannot be recognized per chunk, and a multi-byte UTF-8
28+
* sequence split across chunk boundaries would be misread). The upload size
29+
* caps already bound the file, and `parseBuffer` — the production path —
30+
* always held the full buffer.
31+
*/
1932
async parseFile(filePath: string): Promise<FileParseResult> {
2033
if (!filePath) {
2134
throw new Error('No file path provided')
@@ -25,11 +38,7 @@ export class CsvParser implements FileParser {
2538
throw new Error(`File not found: ${filePath}`)
2639
}
2740

28-
const stream = createReadStream(filePath, {
29-
highWaterMark: CONFIG.STREAM_CHUNK_SIZE,
30-
})
31-
32-
return this.parseStream(stream)
41+
return this.parseBuffer(await readFile(filePath))
3342
}
3443

3544
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
@@ -38,14 +47,18 @@ export class CsvParser implements FileParser {
3847
`Parsing CSV buffer, size: ${bufferSize} bytes (${(bufferSize / 1024 / 1024).toFixed(2)} MB)`
3948
)
4049

50+
const decoded = decodeTextBuffer(buffer)
4151
const stream = new Readable({ read() {} })
42-
stream.push(buffer)
52+
stream.push(decoded.text)
4353
stream.push(null)
4454

45-
return this.parseStream(stream)
55+
return this.parseStream(stream, decoded)
4656
}
4757

48-
private parseStream(inputStream: NodeJS.ReadableStream): Promise<FileParseResult> {
58+
private parseStream(
59+
inputStream: NodeJS.ReadableStream,
60+
decoded: DecodedText
61+
): Promise<FileParseResult> {
4962
return new Promise((resolve, reject) => {
5063
let rowCount = 0
5164
let errorCount = 0
@@ -145,6 +158,8 @@ export class CsvParser implements FileParser {
145158
errors: errors.slice(0, 10),
146159
truncated: rowCount > CONFIG.MAX_PREVIEW_ROWS,
147160
sampledData: sampledRows,
161+
encoding: decoded.encoding,
162+
...(decoded.warning ? { warning: decoded.warning } : {}),
148163
},
149164
})
150165
}

0 commit comments

Comments
 (0)