Skip to content

Commit 48f7308

Browse files
waleedlatif1claude
andcommitted
fix(parsers): close audit findings on byte sniffing, decoding and legacy formats
Routing and the KB pipeline: - A non-PDF named .pdf (HTML error page, plain text) is rejected as a permanent `invalid_file` in `readEmbeddedPdfText` instead of being indexed as the "text layer" or sent to OCR to fail terminally; the document processor logs `detectedType`/`warning` at info with the filename (no document id is in scope in that module). - The HTML override now applies only to `.txt` and `.md` (a `.md` opening with `<!DOCTYPE html>` is deliberately treated as HTML); an HTML document under csv/json/jsonl/yaml is `invalid_format`. - RTF (`{\rtf` at offset 0) is a sniffed kind and is `unsupported_type` under any extension, so control words are never indexed as prose. - `%PDF-` is searched through the first KiB only under a declared `.pdf`; elsewhere it must be at offset 0 (after BOM/whitespace), so a `.txt` that mentions the magic string stays text. - NUL bytes in a declared text file keep the text route: the decoder handles UTF-16/Windows-1252 and the sanitizer strips stray NULs. Recognised containers under a text extension are still refused. - `resolveParserExtension` throws `FileParserError('unsupported_type')`, so stored `.ppt` documents dead-letter as permanent instead of burning the retry budget as transient. - Workspace-file "get content" (`internal/file/operations.ts`) treats `degraded` output as a parse failure like the other two tool paths. Decoding: - Windows-1252 uses the runtime `TextDecoder` when a module-init self-test proves the label is real (Bun 1.3.14), else a one-pass table decode into UTF-16 code units. 100 MB of C1 bytes: 379 ms / +200 MB native, 286 ms / +401 MB table (was ~4 s / +5.4 GB). - Connectors sftp, s3, databricks, google-drive and bitbucket decode through `decodeTextBuffer` (bitbucket previously skipped non-UTF-8 files; it now indexes them decoded). Connectors hash source revisions (blob sha, etag, rev), not decoded text, so there is no mass re-sync: documents indexed earlier with mojibake stay as they are until the source changes. Legacy .doc: text boxes are a sixth extracted section; word-extractor's raw `RangeError` text is replaced by the stable "This .doc file could not be read". Behaviour notes: UTF-32 input is not recognised and decodes as Windows-1252; BOM-less UTF-16 whose code units are mostly non-ASCII (CJK) does not match the NUL-layout heuristic and also falls to Windows-1252 — the warning now says the file may use another encoding. Stale `.ppt` mentions removed from the files-audit OpenAPI description (regenerated), the Box representation list, `OFFICE_REPAIR_EXTENSIONS` and two TSDoc blocks; the package.json re-sort from the previous commit is reverted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 542d925 commit 48f7308

25 files changed

Lines changed: 375 additions & 68 deletions

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@
745745
"get": {
746746
"operationId": "readFileText",
747747
"summary": "Read File Text",
748-
"description": "Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, including some legacy `.doc` and `.ppt` results; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.",
748+
"description": "Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.",
749749
"x-sim-operation": "files.read_content",
750750
"x-oauth-scope": "api:read",
751751
"tags": ["Files"],

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,7 @@ describe('bitbucket getDocument', () => {
774774
expect(doc?.skippedReason).toMatch(/Binary/)
775775
})
776776

777-
it('surfaces non-UTF-8 source as skipped instead of indexing replacement characters', async () => {
777+
it('decodes non-UTF-8 source as Windows-1252 instead of skipping or indexing replacement characters', async () => {
778778
mockApi([
779779
[
780780
/\/src\/[a-f0-9]+\/latin1\.txt$/,
@@ -784,8 +784,9 @@ describe('bitbucket getDocument', () => {
784784

785785
const doc = await bitbucketConnector.getDocument(ACCESS_TOKEN, CONFIG, 'file:latin1.txt', {})
786786

787-
expect(doc?.skippedReason).toMatch(/Non-UTF-8/)
788-
expect(doc?.content).toBe('')
787+
expect(doc?.skippedReason).toBeUndefined()
788+
expect(doc?.content).toContain('café')
789+
expect(doc?.content).not.toContain('\uFFFD')
789790
})
790791

791792
it('returns null for a file the ref no longer carries', async () => {

‎apps/sim/connectors/bitbucket/bitbucket.ts‎

Lines changed: 2 additions & 8 deletions
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 { bitbucketConnectorMeta } from '@/connectors/bitbucket/meta'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -81,7 +82,6 @@ const BINARY_SNIFF_BYTES = 8000
8182
*/
8283
const MAX_TREE_DEPTH = 5
8384
const BINARY_SKIP_REASON = 'Binary file was not indexed'
84-
const NON_UTF8_SKIP_REASON = 'Non-UTF-8 file was not indexed'
8585
/**
8686
* Bitbucket answers a raw read of an LFS-managed file with a 301 to Atlassian's
8787
* media services platform. The connector deliberately surfaces the file as
@@ -1240,13 +1240,7 @@ export const bitbucketConnector: ConnectorConfig = {
12401240
return markSkipped(stub, BINARY_SKIP_REASON)
12411241
}
12421242

1243-
let text: string
1244-
try {
1245-
text = new TextDecoder('utf-8', { fatal: true }).decode(buffer)
1246-
} catch {
1247-
logger.info('Skipping non-UTF-8 Bitbucket file', { path })
1248-
return markSkipped(stub, NON_UTF8_SKIP_REASON)
1249-
}
1243+
const text = decodeTextBuffer(buffer).text
12501244

12511245
const body = composeBody(stub.title, text)
12521246
if (!body.trim()) return null

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ const REPRESENTATION_EXTENSIONS = new Set([
133133
'odt',
134134
'otp',
135135
'pdf',
136-
'ppt',
137136
'pptx',
138137
'rtf',
139138
'vi',

‎apps/sim/connectors/databricks/databricks.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { truncate } from '@sim/utils/string'
44
import { validateDatabricksWorkspaceHost } from '@/lib/core/security/input-validation'
5+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
56
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
67
import {
78
DATABRICKS_CONTENT_TYPES,
@@ -586,7 +587,7 @@ async function exportNotebook(
586587
return { skippedReason: sizeLimitSkipReason(CONNECTOR_MAX_FILE_BYTES) }
587588
}
588589

589-
return { content: decoded.toString('utf8') }
590+
return { content: decodeTextBuffer(decoded).text }
590591
}
591592

592593
/**

‎apps/sim/connectors/google-drive/google-drive.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { isPlainRecord } from '@sim/utils/object'
44
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
5+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
56
import {
67
type DrivePermission,
78
driveFileAcl,
@@ -194,10 +195,10 @@ async function fetchFilePayload(accessToken: string, file: DriveFile): Promise<F
194195
},
195196
}
196197
}
197-
return { content: bytes.toString('utf8'), mimeType: 'text/plain' }
198+
return { content: decodeTextBuffer(bytes).text, mimeType: 'text/plain' }
198199
}
199200
if (file.mimeType === 'text/html') {
200-
const html = (await downloadFile(accessToken, file.id)).toString('utf8')
201+
const html = decodeTextBuffer(await downloadFile(accessToken, file.id)).text
201202
return { content: htmlToPlainText(html), mimeType: 'text/plain' }
202203
}
203204
const raw = rawFileType(file)
@@ -210,7 +211,7 @@ async function fetchFilePayload(accessToken: string, file: DriveFile): Promise<F
210211
}
211212
if (isSupportedTextFile(file.mimeType)) {
212213
return {
213-
content: (await downloadFile(accessToken, file.id)).toString('utf8'),
214+
content: decodeTextBuffer(await downloadFile(accessToken, file.id)).text,
214215
mimeType: 'text/plain',
215216
}
216217
}

‎apps/sim/connectors/s3/s3.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isLoopbackHostname } from '@sim/security/ssrf'
44
import { getErrorMessage, toError } from '@sim/utils/errors'
55
import { truncate } from '@sim/utils/string'
66
import { isHosted } from '@/lib/core/config/env-flags'
7+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
78
import { secureFetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
89
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
910
import { s3ConnectorMeta } from '@/connectors/s3/meta'
@@ -658,7 +659,7 @@ export const s3Connector: ConnectorConfig = {
658659
sizeLimitSkipReason(MAX_FILE_SIZE)
659660
)
660661
}
661-
const raw = body.toString('utf-8')
662+
const raw = decodeTextBuffer(body).text
662663
const content = MARKUP_EXTENSIONS.has(getExtension(key)) ? htmlToPlainText(raw) : raw
663664
if (!content.trim()) return null
664665

‎apps/sim/connectors/sftp/sftp.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { type Attributes, type Client, type SFTPWrapper, utils as ssh2Utils } from 'ssh2'
44
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
5+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
56
import {
67
createSftpConnection,
78
getFileType,
@@ -779,7 +780,7 @@ export const sftpConnector: ConnectorConfig = {
779780
return markSkipped(stub, 'File appears to be binary and was not indexed')
780781
}
781782

782-
const raw = buffer.toString('utf-8')
783+
const raw = decodeTextBuffer(buffer).text
783784
const content = HTML_EXTENSIONS.has(getExtension(remotePath)) ? htmlToPlainText(raw) : raw
784785
if (!content.trim()) return null
785786

‎apps/sim/lib/api/contracts/v2/openapi/files-audit.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ const declaredRoutes = [
393393
operationId: 'readFileText',
394394
summary: 'Read File Text',
395395
description:
396-
'Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, including some legacy `.doc` and `.ppt` results; `truncated: true` indicates a parser limit.',
396+
'Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.',
397397
errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'],
398398
success: { description: 'The extracted text and its extraction-quality flags.' },
399399
}),

‎apps/sim/lib/file-parsers/doc-parser.test.ts‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface WordSections {
3737
footers?: string
3838
footnotes?: string
3939
endnotes?: string
40+
textboxes?: string
4041
}
4142

4243
/** The accessor surface of word-extractor's `Document`, with empty sections by default. */
@@ -48,7 +49,7 @@ function wordDocument(sections: WordSections) {
4849
getFootnotes: () => sections.footnotes ?? '',
4950
getEndnotes: () => sections.endnotes ?? '',
5051
getAnnotations: () => '',
51-
getTextboxes: () => '',
52+
getTextboxes: () => sections.textboxes ?? '',
5253
}
5354
}
5455

@@ -169,6 +170,7 @@ describe('DocParser.parseBuffer', () => {
169170
footers: 'Page footer',
170171
footnotes: '',
171172
endnotes: 'An endnote',
173+
textboxes: 'Pull quote in a text box',
172174
})
173175
)
174176

@@ -177,7 +179,7 @@ describe('DocParser.parseBuffer', () => {
177179
expect(mockWordExtract).toHaveBeenCalledOnce()
178180
expect(mockParseOfficeText).not.toHaveBeenCalled()
179181
expect(result.content).toBe(
180-
'Body paragraph “quoted”\n\nRunning header\n\nPage footer\n\nAn endnote'
182+
'Body paragraph “quoted”\n\nRunning header\n\nPage footer\n\nAn endnote\n\nPull quote in a text box'
181183
)
182184
expect(result.metadata).toMatchObject({
183185
extractionMethod: 'word-extractor',
@@ -200,18 +202,26 @@ describe('DocParser.parseBuffer', () => {
200202
expect((error as Error).message).toMatch(/Word 6\/95/)
201203
})
202204

203-
it('maps any other word-extractor failure to invalid_format with the cause retained', async () => {
204-
const libraryError = new Error('Invalid Short Sector Allocation Table')
205-
mockWordExtract.mockRejectedValue(libraryError)
206-
207-
const error = await new DocParser()
208-
.parseBuffer(buildLegacyOleDoc())
209-
.catch((caught: unknown) => caught)
210-
211-
expect(error).toBeInstanceOf(FileParserError)
212-
expect(error).toMatchObject({ code: 'invalid_format' })
213-
expect((error as FileParserError).cause).toBe(libraryError)
214-
})
205+
it.each([
206+
new Error('Invalid Short Sector Allocation Table'),
207+
new RangeError('Attempt to access memory outside buffer bounds'),
208+
])(
209+
'maps any other word-extractor failure to a stable invalid_format message with the cause retained: %s',
210+
async (libraryError) => {
211+
mockWordExtract.mockRejectedValue(libraryError)
212+
213+
const error = await new DocParser()
214+
.parseBuffer(buildLegacyOleDoc())
215+
.catch((caught: unknown) => caught)
216+
217+
expect(error).toBeInstanceOf(FileParserError)
218+
expect(error).toMatchObject({
219+
code: 'invalid_format',
220+
message: 'This .doc file could not be read',
221+
})
222+
expect((error as FileParserError).cause).toBe(libraryError)
223+
}
224+
)
215225

216226
it('reports a legacy .doc with no text as no_extractable_text', async () => {
217227
mockWordExtract.mockResolvedValue(wordDocument({ body: ' \n' }))

0 commit comments

Comments
 (0)