chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { createReadStream, existsSync } from 'fs'
|
||||
import { Readable } from 'stream'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type Options, parse } from 'csv-parse'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
|
||||
|
||||
const logger = createLogger('CsvParser')
|
||||
|
||||
const CONFIG = {
|
||||
MAX_PREVIEW_ROWS: 1000, // Only keep first 1000 rows for preview
|
||||
MAX_SAMPLE_ROWS: 100, // Sample for metadata
|
||||
MAX_ERRORS: 100, // Stop after 100 errors
|
||||
STREAM_CHUNK_SIZE: 16384, // 16KB chunks for streaming
|
||||
}
|
||||
|
||||
export class CsvParser implements FileParser {
|
||||
async parseFile(filePath: string): Promise<FileParseResult> {
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
}
|
||||
|
||||
const stream = createReadStream(filePath, {
|
||||
highWaterMark: CONFIG.STREAM_CHUNK_SIZE,
|
||||
})
|
||||
|
||||
return this.parseStream(stream)
|
||||
}
|
||||
|
||||
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
const bufferSize = buffer.length
|
||||
logger.info(
|
||||
`Parsing CSV buffer, size: ${bufferSize} bytes (${(bufferSize / 1024 / 1024).toFixed(2)} MB)`
|
||||
)
|
||||
|
||||
const stream = new Readable({ read() {} })
|
||||
stream.push(buffer)
|
||||
stream.push(null)
|
||||
|
||||
return this.parseStream(stream)
|
||||
}
|
||||
|
||||
private parseStream(inputStream: NodeJS.ReadableStream): Promise<FileParseResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let rowCount = 0
|
||||
let errorCount = 0
|
||||
let headers: string[] = []
|
||||
let processedContent = ''
|
||||
const sampledRows: any[] = []
|
||||
const errors: string[] = []
|
||||
let firstRowProcessed = false
|
||||
let aborted = false
|
||||
|
||||
const parserOptions: Options = {
|
||||
columns: true, // Use first row as headers
|
||||
skip_empty_lines: true, // Skip empty lines
|
||||
trim: true, // Trim whitespace
|
||||
relax_column_count: true, // Allow variable column counts
|
||||
relax_quotes: true, // Be lenient with quotes
|
||||
skip_records_with_error: true, // Skip bad records
|
||||
raw: false,
|
||||
cast: false,
|
||||
}
|
||||
const parser = parse(parserOptions)
|
||||
|
||||
parser.on('readable', () => {
|
||||
let record
|
||||
while ((record = parser.read()) !== null && !aborted) {
|
||||
rowCount++
|
||||
|
||||
if (!firstRowProcessed && record) {
|
||||
headers = Object.keys(record).map((h) => sanitizeTextForUTF8(String(h)))
|
||||
processedContent = `${headers.join(', ')}\n`
|
||||
firstRowProcessed = true
|
||||
}
|
||||
|
||||
if (rowCount <= CONFIG.MAX_PREVIEW_ROWS) {
|
||||
try {
|
||||
const cleanValues = Object.values(record).map((v: any) =>
|
||||
sanitizeTextForUTF8(String(v || ''))
|
||||
)
|
||||
processedContent += `${cleanValues.join(', ')}\n`
|
||||
|
||||
if (rowCount <= CONFIG.MAX_SAMPLE_ROWS) {
|
||||
sampledRows.push(record)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Error processing row ${rowCount}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (rowCount % 10000 === 0) {
|
||||
logger.info(`Processed ${rowCount} rows...`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
parser.on('skip', (err: any) => {
|
||||
errorCount++
|
||||
|
||||
if (errorCount <= 5) {
|
||||
const errorMsg = `Row ${err.lines || rowCount}: ${err.message || 'Unknown error'}`
|
||||
errors.push(errorMsg)
|
||||
logger.warn('CSV skip:', errorMsg)
|
||||
}
|
||||
|
||||
if (errorCount >= CONFIG.MAX_ERRORS) {
|
||||
aborted = true
|
||||
parser.destroy()
|
||||
reject(new Error(`Too many errors (${errorCount}). File may be corrupted.`))
|
||||
}
|
||||
})
|
||||
|
||||
parser.on('error', (err: Error) => {
|
||||
logger.error('CSV parser error:', err)
|
||||
reject(new Error(`CSV parsing failed: ${err.message}`))
|
||||
})
|
||||
|
||||
parser.on('end', () => {
|
||||
if (!aborted) {
|
||||
if (rowCount > CONFIG.MAX_PREVIEW_ROWS) {
|
||||
processedContent += `\n[... ${rowCount.toLocaleString()} total rows, showing first ${CONFIG.MAX_PREVIEW_ROWS} ...]\n`
|
||||
}
|
||||
|
||||
logger.info(`CSV parsing complete: ${rowCount} rows, ${errorCount} errors`)
|
||||
|
||||
resolve({
|
||||
content: sanitizeTextForUTF8(processedContent),
|
||||
metadata: {
|
||||
rowCount,
|
||||
headers,
|
||||
errorCount,
|
||||
errors: errors.slice(0, 10),
|
||||
truncated: rowCount > CONFIG.MAX_PREVIEW_ROWS,
|
||||
sampledData: sampledRows,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
inputStream.on('error', (err) => {
|
||||
logger.error('Input stream error:', err)
|
||||
parser.destroy()
|
||||
reject(new Error(`Stream error: ${err.message}`))
|
||||
})
|
||||
|
||||
inputStream.pipe(parser)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { Readable } from 'node:stream'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockDownloadFileStream } = vi.hoisted(() => ({
|
||||
mockDownloadFileStream: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => ({
|
||||
downloadFileStream: mockDownloadFileStream,
|
||||
}))
|
||||
|
||||
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
|
||||
import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice'
|
||||
|
||||
function streamOf(text: string): Readable {
|
||||
// Array-wrapped so the whole text is one chunk (a bare Buffer/string is iterated element-wise).
|
||||
return Readable.from([Buffer.from(text, 'utf-8')])
|
||||
}
|
||||
|
||||
const args = { key: 'workspace/ws_1/file.csv', context: 'workspace' as const }
|
||||
|
||||
function csvWithRows(dataRows: number): string {
|
||||
const lines = ['h1,h2']
|
||||
for (let i = 0; i < dataRows; i++) lines.push(`${i},x`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
describe('getCsvPreviewSlice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns headers and every row when under the cap', async () => {
|
||||
mockDownloadFileStream.mockResolvedValue(streamOf('a,b\n1,2\n3,4\n'))
|
||||
const slice = await getCsvPreviewSlice(args)
|
||||
expect(slice.headers).toEqual(['a', 'b'])
|
||||
expect(slice.rows).toEqual([
|
||||
['1', '2'],
|
||||
['3', '4'],
|
||||
])
|
||||
expect(slice.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it('caps at CSV_PREVIEW_MAX_ROWS and flags truncated', async () => {
|
||||
mockDownloadFileStream.mockResolvedValue(streamOf(csvWithRows(CSV_PREVIEW_MAX_ROWS + 500)))
|
||||
const slice = await getCsvPreviewSlice(args)
|
||||
expect(slice.rows).toHaveLength(CSV_PREVIEW_MAX_ROWS)
|
||||
expect(slice.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('is not truncated at exactly the cap', async () => {
|
||||
mockDownloadFileStream.mockResolvedValue(streamOf(csvWithRows(CSV_PREVIEW_MAX_ROWS)))
|
||||
const slice = await getCsvPreviewSlice(args)
|
||||
expect(slice.rows).toHaveLength(CSV_PREVIEW_MAX_ROWS)
|
||||
expect(slice.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it('detects a semicolon delimiter', async () => {
|
||||
mockDownloadFileStream.mockResolvedValue(streamOf('a;b;c\n1;2;3\n'))
|
||||
const slice = await getCsvPreviewSlice(args)
|
||||
expect(slice.headers).toEqual(['a', 'b', 'c'])
|
||||
expect(slice.rows).toEqual([['1', '2', '3']])
|
||||
})
|
||||
|
||||
it('detects a tab delimiter', async () => {
|
||||
mockDownloadFileStream.mockResolvedValue(streamOf('a\tb\n1\t2\n'))
|
||||
const slice = await getCsvPreviewSlice(args)
|
||||
expect(slice.headers).toEqual(['a', 'b'])
|
||||
expect(slice.rows).toEqual([['1', '2']])
|
||||
})
|
||||
|
||||
it('returns empty for an empty file', async () => {
|
||||
mockDownloadFileStream.mockResolvedValue(streamOf(''))
|
||||
const slice = await getCsvPreviewSlice(args)
|
||||
expect(slice).toEqual({ headers: [], rows: [], truncated: false })
|
||||
})
|
||||
|
||||
it('tolerates ragged rows', async () => {
|
||||
mockDownloadFileStream.mockResolvedValue(streamOf('a,b,c\n1,2\n4,5,6,7\n'))
|
||||
const slice = await getCsvPreviewSlice(args)
|
||||
expect(slice.headers).toEqual(['a', 'b', 'c'])
|
||||
expect(slice.rows[0]).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
it('truncates an oversized cell', async () => {
|
||||
const big = 'x'.repeat(3000)
|
||||
mockDownloadFileStream.mockResolvedValue(streamOf(`a\n${big}\n`))
|
||||
const slice = await getCsvPreviewSlice(args)
|
||||
expect(slice.rows[0][0].length).toBeLessThan(3000)
|
||||
})
|
||||
|
||||
it('destroys the source stream after reading the slice', async () => {
|
||||
const source = streamOf(csvWithRows(CSV_PREVIEW_MAX_ROWS + 50))
|
||||
const destroySpy = vi.spyOn(source, 'destroy')
|
||||
mockDownloadFileStream.mockResolvedValue(source)
|
||||
const slice = await getCsvPreviewSlice(args)
|
||||
expect(slice.truncated).toBe(true)
|
||||
expect(destroySpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Readable } from 'node:stream'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import { parse as parseCsvStream } from 'csv-parse'
|
||||
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
|
||||
import type { StorageContext } from '@/lib/uploads/config'
|
||||
import { downloadFileStream } from '@/lib/uploads/core/storage-service'
|
||||
|
||||
/** Cap a single cell so one pathological field can't bloat the preview payload. */
|
||||
const MAX_CELL_LENGTH = 2_000
|
||||
|
||||
/** Read at most this many bytes while sniffing the first line for the delimiter. */
|
||||
const DELIMITER_SNIFF_MAX_BYTES = 256 * 1024
|
||||
|
||||
interface CsvPreviewSliceArgs {
|
||||
key: string
|
||||
context: StorageContext
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface CsvPreviewSlice {
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
/** True when the file has more than {@link CSV_PREVIEW_MAX_ROWS} data rows. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the CSV delimiter from a header line by frequency. Mirrors the file viewer's
|
||||
* client-side heuristic (comma / tab / semicolon) so server-streamed previews match.
|
||||
*/
|
||||
function detectDelimiter(line: string): string {
|
||||
const commaCount = (line.match(/,/g) || []).length
|
||||
const tabCount = (line.match(/\t/g) || []).length
|
||||
const semiCount = (line.match(/;/g) || []).length
|
||||
if (tabCount > commaCount && tabCount > semiCount) return '\t'
|
||||
if (semiCount > commaCount) return ';'
|
||||
return ','
|
||||
}
|
||||
|
||||
function cell(value: unknown): string {
|
||||
return truncate(String(value ?? ''), MAX_CELL_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the first {@link CSV_PREVIEW_MAX_ROWS} rows of a CSV/TSV from storage without
|
||||
* ever buffering the whole file. The source stream is destroyed as soon as enough rows are
|
||||
* read (one past the cap, to detect truncation), so a multi-GB file costs O(rows) of memory.
|
||||
*/
|
||||
export async function getCsvPreviewSlice({
|
||||
key,
|
||||
context,
|
||||
signal,
|
||||
}: CsvPreviewSliceArgs): Promise<CsvPreviewSlice> {
|
||||
const source = await downloadFileStream({ key, context })
|
||||
const onAbort = () => source.destroy()
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const reader = source[Symbol.asyncIterator]()
|
||||
|
||||
try {
|
||||
// Pull chunks until the first newline so the delimiter can be sniffed before parsing.
|
||||
// Accumulate the header line incrementally — appending each chunk's decoded text rather than
|
||||
// re-concatenating the whole buffer each iteration (which would be O(n²) for a header split
|
||||
// across many small chunks). The delimiter chars (`,` `\t` `;`) are ASCII, so a multi-byte
|
||||
// character split at a chunk boundary can't introduce a false delimiter into the count.
|
||||
const sniffed: Buffer[] = []
|
||||
let firstLine = ''
|
||||
let sniffedBytes = 0
|
||||
while (true) {
|
||||
const { value, done } = await reader.next()
|
||||
if (done) break
|
||||
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value)
|
||||
sniffed.push(chunk)
|
||||
sniffedBytes += chunk.length
|
||||
const text = chunk.toString('utf-8')
|
||||
const nl = text.indexOf('\n')
|
||||
if (nl !== -1) {
|
||||
firstLine += text.slice(0, nl)
|
||||
break
|
||||
}
|
||||
firstLine += text
|
||||
if (sniffedBytes >= DELIMITER_SNIFF_MAX_BYTES) break
|
||||
}
|
||||
|
||||
if (sniffed.length === 0) {
|
||||
return { headers: [], rows: [], truncated: false }
|
||||
}
|
||||
|
||||
const delimiter = detectDelimiter(firstLine)
|
||||
const parser = parseCsvStream({
|
||||
columns: false,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
relax_column_count: true,
|
||||
relax_quotes: true,
|
||||
skip_records_with_error: true,
|
||||
cast: false,
|
||||
bom: true,
|
||||
delimiter,
|
||||
})
|
||||
|
||||
// Re-feed the sniffed prefix, then drain the rest of the source into the parser.
|
||||
async function* rejoin() {
|
||||
for (const chunk of sniffed) yield chunk
|
||||
while (true) {
|
||||
const { value, done } = await reader.next()
|
||||
if (done) return
|
||||
yield value
|
||||
}
|
||||
}
|
||||
const piped = Readable.from(rejoin())
|
||||
piped.on('error', (err) => parser.destroy(err))
|
||||
piped.pipe(parser)
|
||||
|
||||
let headers: string[] = []
|
||||
let headersSet = false
|
||||
const rows: string[][] = []
|
||||
let truncated = false
|
||||
|
||||
for await (const record of parser as AsyncIterable<string[]>) {
|
||||
if (!headersSet) {
|
||||
headers = record.map(cell)
|
||||
headersSet = true
|
||||
continue
|
||||
}
|
||||
if (rows.length >= CSV_PREVIEW_MAX_ROWS) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
rows.push(record.map(cell))
|
||||
}
|
||||
|
||||
piped.destroy()
|
||||
parser.destroy()
|
||||
return { headers, rows, truncated }
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
source.destroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
|
||||
|
||||
const logger = createLogger('DocParser')
|
||||
|
||||
export class DocParser implements FileParser {
|
||||
async parseFile(filePath: string): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
}
|
||||
|
||||
const buffer = await readFile(filePath)
|
||||
return this.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
logger.error('DOC file parsing error:', error)
|
||||
throw new Error(`Failed to parse DOC file: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error('Empty buffer provided')
|
||||
}
|
||||
|
||||
try {
|
||||
const officeParser = await import('officeparser')
|
||||
const result = await officeParser.parseOfficeAsync(buffer)
|
||||
|
||||
if (result) {
|
||||
const resultString = typeof result === 'string' ? result : String(result)
|
||||
const content = sanitizeTextForUTF8(resultString.trim())
|
||||
|
||||
if (content.length > 0) {
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
characterCount: content.length,
|
||||
extractionMethod: 'officeparser',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (officeError) {
|
||||
logger.warn('officeparser failed, trying mammoth:', officeError)
|
||||
}
|
||||
|
||||
try {
|
||||
const mammoth = await import('mammoth')
|
||||
const result = await mammoth.extractRawText({ buffer })
|
||||
|
||||
if (result.value && result.value.trim().length > 0) {
|
||||
const content = sanitizeTextForUTF8(result.value.trim())
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
characterCount: content.length,
|
||||
extractionMethod: 'mammoth',
|
||||
messages: result.messages,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (mammothError) {
|
||||
logger.warn('mammoth failed:', mammothError)
|
||||
}
|
||||
|
||||
return this.fallbackExtraction(buffer)
|
||||
} catch (error) {
|
||||
logger.error('DOC parsing error:', error)
|
||||
throw new Error(`Failed to parse DOC buffer: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackExtraction(buffer: Buffer): FileParseResult {
|
||||
const isBinaryDoc = buffer.length >= 2 && buffer[0] === 0xd0 && buffer[1] === 0xcf
|
||||
|
||||
if (!isBinaryDoc) {
|
||||
const textContent = buffer.toString('utf8').trim()
|
||||
|
||||
if (textContent.length > 0) {
|
||||
const printableChars = textContent.match(/[\x20-\x7E\n\r\t]/g)?.length || 0
|
||||
const isProbablyText = printableChars / textContent.length > 0.9
|
||||
|
||||
if (isProbablyText) {
|
||||
return {
|
||||
content: sanitizeTextForUTF8(textContent),
|
||||
metadata: {
|
||||
extractionMethod: 'plaintext-fallback',
|
||||
characterCount: textContent.length,
|
||||
warning: 'File is not a valid DOC format, extracted as plain text',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = buffer.toString('utf8', 0, Math.min(buffer.length, 100000))
|
||||
|
||||
const readableText = text
|
||||
.match(/[\x20-\x7E\s]{4,}/g)
|
||||
?.filter(
|
||||
(chunk) =>
|
||||
chunk.trim().length > 10 && /[a-zA-Z]/.test(chunk) && !/^[\x00-\x1F]*$/.test(chunk)
|
||||
)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const content = readableText
|
||||
? sanitizeTextForUTF8(readableText)
|
||||
: 'Unable to extract text from DOC file. Please convert to DOCX format for better results.'
|
||||
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
extractionMethod: 'fallback',
|
||||
characterCount: content.length,
|
||||
warning: 'Basic text extraction used. For better results, convert to DOCX format.',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import mammoth from 'mammoth'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
|
||||
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
|
||||
|
||||
const logger = createLogger('DocxParser')
|
||||
|
||||
interface MammothMessage {
|
||||
type: 'warning' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
interface MammothResult {
|
||||
value: string
|
||||
messages: MammothMessage[]
|
||||
}
|
||||
|
||||
export class DocxParser implements FileParser {
|
||||
async parseFile(filePath: string): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
const buffer = await readFile(filePath)
|
||||
return this.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
logger.error('DOCX file error:', error)
|
||||
throw new Error(`Failed to parse DOCX file: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error('Empty buffer provided')
|
||||
}
|
||||
|
||||
assertOoxmlArchiveWithinLimits(buffer)
|
||||
|
||||
try {
|
||||
const result = await mammoth.extractRawText({ buffer })
|
||||
|
||||
if (result.value && result.value.trim().length > 0) {
|
||||
let htmlResult: MammothResult = { value: '', messages: [] }
|
||||
try {
|
||||
htmlResult = await mammoth.convertToHtml({ buffer })
|
||||
} catch {
|
||||
// HTML conversion is optional
|
||||
}
|
||||
|
||||
return {
|
||||
content: sanitizeTextForUTF8(result.value),
|
||||
metadata: {
|
||||
extractionMethod: 'mammoth',
|
||||
messages: [...result.messages, ...htmlResult.messages],
|
||||
html: htmlResult.value,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (mammothError) {
|
||||
logger.warn('mammoth failed, trying officeparser:', mammothError)
|
||||
}
|
||||
|
||||
try {
|
||||
const officeParser = await import('officeparser')
|
||||
const result = await officeParser.parseOfficeAsync(buffer)
|
||||
|
||||
if (result) {
|
||||
const resultString = typeof result === 'string' ? result : String(result)
|
||||
const content = sanitizeTextForUTF8(resultString.trim())
|
||||
|
||||
if (content.length > 0) {
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
extractionMethod: 'officeparser',
|
||||
characterCount: content.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (officeError) {
|
||||
logger.warn('officeparser failed:', officeError)
|
||||
}
|
||||
|
||||
const isZipFile = buffer.length >= 2 && buffer[0] === 0x50 && buffer[1] === 0x4b
|
||||
if (!isZipFile) {
|
||||
const textContent = buffer.toString('utf8').trim()
|
||||
if (textContent.length > 0) {
|
||||
return {
|
||||
content: sanitizeTextForUTF8(textContent),
|
||||
metadata: {
|
||||
extractionMethod: 'plaintext-fallback',
|
||||
characterCount: textContent.length,
|
||||
warning: 'File is not a valid DOCX format, extracted as plain text',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to extract text from DOCX file')
|
||||
} catch (error) {
|
||||
logger.error('DOCX parsing error:', error)
|
||||
throw new Error(`Failed to parse DOCX buffer: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import * as cheerio from 'cheerio'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
|
||||
|
||||
const logger = createLogger('HtmlParser')
|
||||
|
||||
export class HtmlParser implements FileParser {
|
||||
async parseFile(filePath: string): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
const buffer = await readFile(filePath)
|
||||
return this.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
logger.error('HTML file error:', error)
|
||||
throw new Error(`Failed to parse HTML file: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
try {
|
||||
logger.info('Parsing HTML buffer, size:', buffer.length)
|
||||
|
||||
const htmlContent = buffer.toString('utf-8')
|
||||
const $ = cheerio.load(htmlContent)
|
||||
|
||||
// Extract meta information before removing tags
|
||||
const title = $('title').text().trim()
|
||||
const metaDescription = $('meta[name="description"]').attr('content') || ''
|
||||
|
||||
$('script, style, noscript, meta, link, iframe, object, embed, svg').remove()
|
||||
|
||||
$.root()
|
||||
.contents()
|
||||
.filter(function () {
|
||||
return this.type === 'comment'
|
||||
})
|
||||
.remove()
|
||||
|
||||
const content = this.extractStructuredText($)
|
||||
|
||||
const sanitizedContent = sanitizeTextForUTF8(content)
|
||||
|
||||
const characterCount = sanitizedContent.length
|
||||
const wordCount = sanitizedContent.split(/\s+/).filter((word) => word.length > 0).length
|
||||
const estimatedTokenCount = Math.ceil(characterCount / 4)
|
||||
|
||||
const headings = this.extractHeadings($)
|
||||
|
||||
const links = this.extractLinks($)
|
||||
|
||||
return {
|
||||
content: sanitizedContent,
|
||||
metadata: {
|
||||
title,
|
||||
metaDescription,
|
||||
characterCount,
|
||||
wordCount,
|
||||
tokenCount: estimatedTokenCount,
|
||||
headings,
|
||||
links: links.slice(0, 50),
|
||||
hasImages: $('img').length > 0,
|
||||
imageCount: $('img').length,
|
||||
hasTable: $('table').length > 0,
|
||||
tableCount: $('table').length,
|
||||
hasList: $('ul, ol').length > 0,
|
||||
listCount: $('ul, ol').length,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('HTML buffer parsing error:', error)
|
||||
throw new Error(`Failed to parse HTML buffer: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract structured text content preserving document hierarchy
|
||||
*/
|
||||
private extractStructuredText($: cheerio.CheerioAPI): string {
|
||||
const contentParts: string[] = []
|
||||
|
||||
const rootElement = $('body').length > 0 ? $('body') : $.root()
|
||||
|
||||
this.processElement($, rootElement, contentParts, 0)
|
||||
|
||||
return contentParts.join('\n').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively process elements to extract text with structure
|
||||
*/
|
||||
private processElement(
|
||||
$: cheerio.CheerioAPI,
|
||||
element: cheerio.Cheerio<any>,
|
||||
contentParts: string[],
|
||||
depth: number
|
||||
): void {
|
||||
element.contents().each((_, node) => {
|
||||
if (node.type === 'text') {
|
||||
const text = $(node).text().trim()
|
||||
if (text) {
|
||||
contentParts.push(text)
|
||||
}
|
||||
} else if (node.type === 'tag') {
|
||||
const $node = $(node)
|
||||
const tagName = node.tagName?.toLowerCase()
|
||||
|
||||
switch (tagName) {
|
||||
case 'h1':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6': {
|
||||
const headingText = $node.text().trim()
|
||||
if (headingText) {
|
||||
contentParts.push(`\n${headingText}\n`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'p': {
|
||||
const paragraphText = $node.text().trim()
|
||||
if (paragraphText) {
|
||||
contentParts.push(`${paragraphText}\n`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'br':
|
||||
contentParts.push('\n')
|
||||
break
|
||||
|
||||
case 'hr':
|
||||
contentParts.push('\n---\n')
|
||||
break
|
||||
|
||||
case 'li': {
|
||||
const listItemText = $node.text().trim()
|
||||
if (listItemText) {
|
||||
const indent = ' '.repeat(Math.min(depth, 3))
|
||||
contentParts.push(`${indent}• ${listItemText}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'ul':
|
||||
case 'ol':
|
||||
contentParts.push('\n')
|
||||
this.processElement($, $node, contentParts, depth + 1)
|
||||
contentParts.push('\n')
|
||||
break
|
||||
|
||||
case 'table':
|
||||
this.processTable($, $node, contentParts)
|
||||
break
|
||||
|
||||
case 'blockquote': {
|
||||
const quoteText = $node.text().trim()
|
||||
if (quoteText) {
|
||||
contentParts.push(`\n> ${quoteText}\n`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'pre':
|
||||
case 'code': {
|
||||
const codeText = $node.text().trim()
|
||||
if (codeText) {
|
||||
contentParts.push(`\n\`\`\`\n${codeText}\n\`\`\`\n`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'div':
|
||||
case 'section':
|
||||
case 'article':
|
||||
case 'main':
|
||||
case 'aside':
|
||||
case 'nav':
|
||||
case 'header':
|
||||
case 'footer':
|
||||
this.processElement($, $node, contentParts, depth)
|
||||
break
|
||||
|
||||
case 'a': {
|
||||
const linkText = $node.text().trim()
|
||||
const href = $node.attr('href')
|
||||
if (linkText) {
|
||||
if (href?.startsWith('http')) {
|
||||
contentParts.push(`${linkText} (${href})`)
|
||||
} else {
|
||||
contentParts.push(linkText)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'img': {
|
||||
const alt = $node.attr('alt')
|
||||
if (alt) {
|
||||
contentParts.push(`[Image: ${alt}]`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
this.processElement($, $node, contentParts, depth)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Process table elements to extract structured data
|
||||
*/
|
||||
private processTable(
|
||||
$: cheerio.CheerioAPI,
|
||||
table: cheerio.Cheerio<any>,
|
||||
contentParts: string[]
|
||||
): void {
|
||||
contentParts.push('\n[Table]')
|
||||
|
||||
table.find('tr').each((_, row) => {
|
||||
const $row = $(row)
|
||||
const cells: string[] = []
|
||||
|
||||
$row.find('td, th').each((_, cell) => {
|
||||
const cellText = $(cell).text().trim()
|
||||
cells.push(cellText || '')
|
||||
})
|
||||
|
||||
if (cells.length > 0) {
|
||||
contentParts.push(`| ${cells.join(' | ')} |`)
|
||||
}
|
||||
})
|
||||
|
||||
contentParts.push('[/Table]\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract heading structure for metadata
|
||||
*/
|
||||
private extractHeadings($: cheerio.CheerioAPI): Array<{ level: number; text: string }> {
|
||||
const headings: Array<{ level: number; text: string }> = []
|
||||
|
||||
$('h1, h2, h3, h4, h5, h6').each((_, element) => {
|
||||
const $element = $(element)
|
||||
const tagName = element.tagName?.toLowerCase()
|
||||
const level = Number.parseInt(tagName?.charAt(1) || '1', 10)
|
||||
const text = $element.text().trim()
|
||||
|
||||
if (text) {
|
||||
headings.push({ level, text })
|
||||
}
|
||||
})
|
||||
|
||||
return headings
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract links from the document
|
||||
*/
|
||||
private extractLinks($: cheerio.CheerioAPI): Array<{ text: string; href: string }> {
|
||||
const links: Array<{ text: string; href: string }> = []
|
||||
|
||||
$('a[href]').each((_, element) => {
|
||||
const $element = $(element)
|
||||
const href = $element.attr('href')
|
||||
const text = $element.text().trim()
|
||||
|
||||
if (href && text && href.startsWith('http')) {
|
||||
links.push({ text, href })
|
||||
}
|
||||
})
|
||||
|
||||
return links
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import path from 'path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
|
||||
const {
|
||||
mockExistsSync,
|
||||
mockReadFile,
|
||||
mockPdfParseFile,
|
||||
mockCsvParseFile,
|
||||
mockDocxParseFile,
|
||||
mockTxtParseFile,
|
||||
mockMdParseFile,
|
||||
mockPptxParseFile,
|
||||
mockHtmlParseFile,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExistsSync: vi.fn().mockReturnValue(true),
|
||||
mockReadFile: vi.fn().mockResolvedValue(Buffer.from('test content')),
|
||||
mockPdfParseFile: vi.fn().mockResolvedValue({
|
||||
content: 'Parsed PDF content',
|
||||
metadata: { info: { Title: 'Test PDF' }, pageCount: 5, version: '1.7' },
|
||||
}),
|
||||
mockCsvParseFile: vi.fn().mockResolvedValue({
|
||||
content: 'Parsed CSV content',
|
||||
metadata: { headers: ['column1', 'column2'], rowCount: 10 },
|
||||
}),
|
||||
mockDocxParseFile: vi.fn().mockResolvedValue({
|
||||
content: 'Parsed DOCX content',
|
||||
metadata: { pages: 3, author: 'Test Author' },
|
||||
}),
|
||||
mockTxtParseFile: vi.fn().mockResolvedValue({
|
||||
content: 'Parsed TXT content',
|
||||
metadata: { characterCount: 100, tokenCount: 10 },
|
||||
}),
|
||||
mockMdParseFile: vi.fn().mockResolvedValue({
|
||||
content: 'Parsed MD content',
|
||||
metadata: { characterCount: 100, tokenCount: 10 },
|
||||
}),
|
||||
mockPptxParseFile: vi.fn().mockResolvedValue({
|
||||
content: 'Parsed PPTX content',
|
||||
metadata: { slideCount: 5, extractionMethod: 'officeparser' },
|
||||
}),
|
||||
mockHtmlParseFile: vi.fn().mockResolvedValue({
|
||||
content: 'Parsed HTML content',
|
||||
metadata: { title: 'Test HTML Document', headingCount: 3, linkCount: 2 },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({ existsSync: mockExistsSync }))
|
||||
vi.mock('fs/promises', () => ({ readFile: mockReadFile }))
|
||||
|
||||
vi.mock('@/lib/file-parsers/index', () => {
|
||||
const mockParsers: Record<string, FileParser> = {
|
||||
pdf: { parseFile: mockPdfParseFile },
|
||||
csv: { parseFile: mockCsvParseFile },
|
||||
docx: { parseFile: mockDocxParseFile },
|
||||
txt: { parseFile: mockTxtParseFile },
|
||||
md: { parseFile: mockMdParseFile },
|
||||
pptx: { parseFile: mockPptxParseFile },
|
||||
ppt: { parseFile: mockPptxParseFile },
|
||||
html: { parseFile: mockHtmlParseFile },
|
||||
htm: { parseFile: mockHtmlParseFile },
|
||||
}
|
||||
|
||||
return {
|
||||
parseFile: async (filePath: string): Promise<FileParseResult> => {
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
if (!mockExistsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
}
|
||||
|
||||
const extension = path.extname(filePath).toLowerCase().substring(1)
|
||||
|
||||
if (!Object.keys(mockParsers).includes(extension)) {
|
||||
throw new Error(
|
||||
`Unsupported file type: ${extension}. Supported types are: ${Object.keys(mockParsers).join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
return mockParsers[extension].parseFile(filePath)
|
||||
},
|
||||
|
||||
isSupportedFileType: (extension: string): boolean => {
|
||||
if (!extension) return false
|
||||
return Object.keys(mockParsers).includes(extension.toLowerCase())
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/file-parsers/pdf-parser', () => ({
|
||||
PdfParser: vi.fn().mockImplementation(() => ({ parseFile: mockPdfParseFile })),
|
||||
}))
|
||||
vi.mock('@/lib/file-parsers/csv-parser', () => ({
|
||||
CsvParser: vi.fn().mockImplementation(() => ({ parseFile: mockCsvParseFile })),
|
||||
}))
|
||||
vi.mock('@/lib/file-parsers/docx-parser', () => ({
|
||||
DocxParser: vi.fn().mockImplementation(() => ({ parseFile: mockDocxParseFile })),
|
||||
}))
|
||||
vi.mock('@/lib/file-parsers/txt-parser', () => ({
|
||||
TxtParser: vi.fn().mockImplementation(() => ({ parseFile: mockTxtParseFile })),
|
||||
}))
|
||||
vi.mock('@/lib/file-parsers/md-parser', () => ({
|
||||
MdParser: vi.fn().mockImplementation(() => ({ parseFile: mockMdParseFile })),
|
||||
}))
|
||||
vi.mock('@/lib/file-parsers/pptx-parser', () => ({
|
||||
PptxParser: vi.fn().mockImplementation(() => ({ parseFile: mockPptxParseFile })),
|
||||
}))
|
||||
vi.mock('@/lib/file-parsers/html-parser', () => ({
|
||||
HtmlParser: vi.fn().mockImplementation(() => ({ parseFile: mockHtmlParseFile })),
|
||||
}))
|
||||
|
||||
import { isSupportedFileType, parseFile } from '@/lib/file-parsers/index'
|
||||
|
||||
describe('File Parsers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('parseFile', () => {
|
||||
it('should validate file existence', async () => {
|
||||
mockExistsSync.mockReturnValueOnce(false)
|
||||
|
||||
const testFilePath = '/test/files/test.pdf'
|
||||
await expect(parseFile(testFilePath)).rejects.toThrow('File not found')
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(testFilePath)
|
||||
})
|
||||
|
||||
it('should throw error if file path is empty', async () => {
|
||||
await expect(parseFile('')).rejects.toThrow('No file path provided')
|
||||
})
|
||||
|
||||
it('should parse PDF files successfully', async () => {
|
||||
const expectedResult = {
|
||||
content: 'Parsed PDF content',
|
||||
metadata: {
|
||||
info: { Title: 'Test PDF' },
|
||||
pageCount: 5,
|
||||
version: '1.7',
|
||||
},
|
||||
}
|
||||
|
||||
mockPdfParseFile.mockResolvedValueOnce(expectedResult)
|
||||
|
||||
const result = await parseFile('/test/files/document.pdf')
|
||||
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it('should parse CSV files successfully', async () => {
|
||||
const expectedResult = {
|
||||
content: 'Parsed CSV content',
|
||||
metadata: {
|
||||
headers: ['column1', 'column2'],
|
||||
rowCount: 10,
|
||||
},
|
||||
}
|
||||
|
||||
mockCsvParseFile.mockResolvedValueOnce(expectedResult)
|
||||
|
||||
const result = await parseFile('/test/files/data.csv')
|
||||
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it('should parse DOCX files successfully', async () => {
|
||||
const expectedResult = {
|
||||
content: 'Parsed DOCX content',
|
||||
metadata: {
|
||||
pages: 3,
|
||||
author: 'Test Author',
|
||||
},
|
||||
}
|
||||
|
||||
mockDocxParseFile.mockResolvedValueOnce(expectedResult)
|
||||
|
||||
const result = await parseFile('/test/files/document.docx')
|
||||
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it('should parse TXT files successfully', async () => {
|
||||
const expectedResult = {
|
||||
content: 'Parsed TXT content',
|
||||
metadata: {
|
||||
characterCount: 100,
|
||||
tokenCount: 10,
|
||||
},
|
||||
}
|
||||
|
||||
mockTxtParseFile.mockResolvedValueOnce(expectedResult)
|
||||
|
||||
const result = await parseFile('/test/files/document.txt')
|
||||
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it('should parse MD files successfully', async () => {
|
||||
const expectedResult = {
|
||||
content: 'Parsed MD content',
|
||||
metadata: {
|
||||
characterCount: 100,
|
||||
tokenCount: 10,
|
||||
},
|
||||
}
|
||||
|
||||
mockMdParseFile.mockResolvedValueOnce(expectedResult)
|
||||
|
||||
const result = await parseFile('/test/files/document.md')
|
||||
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it('should parse PPTX files successfully', async () => {
|
||||
const expectedResult = {
|
||||
content: 'Parsed PPTX content',
|
||||
metadata: {
|
||||
slideCount: 5,
|
||||
extractionMethod: 'officeparser',
|
||||
},
|
||||
}
|
||||
|
||||
mockPptxParseFile.mockResolvedValueOnce(expectedResult)
|
||||
|
||||
const result = await parseFile('/test/files/presentation.pptx')
|
||||
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it('should parse PPT files successfully', async () => {
|
||||
const expectedResult = {
|
||||
content: 'Parsed PPTX content',
|
||||
metadata: {
|
||||
slideCount: 5,
|
||||
extractionMethod: 'officeparser',
|
||||
},
|
||||
}
|
||||
|
||||
mockPptxParseFile.mockResolvedValueOnce(expectedResult)
|
||||
|
||||
const result = await parseFile('/test/files/presentation.ppt')
|
||||
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it('should parse HTML files successfully', async () => {
|
||||
const expectedResult = {
|
||||
content: 'Parsed HTML content',
|
||||
metadata: {
|
||||
title: 'Test HTML Document',
|
||||
headingCount: 3,
|
||||
linkCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
mockHtmlParseFile.mockResolvedValueOnce(expectedResult)
|
||||
|
||||
const result = await parseFile('/test/files/document.html')
|
||||
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it('should parse HTM files successfully', async () => {
|
||||
const expectedResult = {
|
||||
content: 'Parsed HTML content',
|
||||
metadata: {
|
||||
title: 'Test HTML Document',
|
||||
headingCount: 3,
|
||||
linkCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
mockHtmlParseFile.mockResolvedValueOnce(expectedResult)
|
||||
|
||||
const result = await parseFile('/test/files/document.htm')
|
||||
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it('should throw error for unsupported file types', async () => {
|
||||
const unsupportedFilePath = '/test/files/image.png'
|
||||
|
||||
await expect(parseFile(unsupportedFilePath)).rejects.toThrow('Unsupported file type')
|
||||
})
|
||||
|
||||
it('should handle errors during parsing', async () => {
|
||||
const parsingError = new Error('CSV parsing failed')
|
||||
mockCsvParseFile.mockRejectedValueOnce(parsingError)
|
||||
|
||||
await expect(parseFile('/test/files/data.csv')).rejects.toThrow('CSV parsing failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSupportedFileType', () => {
|
||||
it('should return true for supported file types', () => {
|
||||
expect(isSupportedFileType('pdf')).toBe(true)
|
||||
expect(isSupportedFileType('csv')).toBe(true)
|
||||
expect(isSupportedFileType('docx')).toBe(true)
|
||||
expect(isSupportedFileType('txt')).toBe(true)
|
||||
expect(isSupportedFileType('md')).toBe(true)
|
||||
expect(isSupportedFileType('pptx')).toBe(true)
|
||||
expect(isSupportedFileType('ppt')).toBe(true)
|
||||
expect(isSupportedFileType('html')).toBe(true)
|
||||
expect(isSupportedFileType('htm')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for unsupported file types', () => {
|
||||
expect(isSupportedFileType('png')).toBe(false)
|
||||
expect(isSupportedFileType('unknown')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle uppercase extensions', () => {
|
||||
expect(isSupportedFileType('PDF')).toBe(true)
|
||||
expect(isSupportedFileType('CSV')).toBe(true)
|
||||
expect(isSupportedFileType('TXT')).toBe(true)
|
||||
expect(isSupportedFileType('MD')).toBe(true)
|
||||
expect(isSupportedFileType('PPTX')).toBe(true)
|
||||
expect(isSupportedFileType('HTML')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
/**
|
||||
* This test verifies error propagation. The mock factory for
|
||||
* isSupportedFileType already handles this via the parseFile mock
|
||||
* which throws for unsupported types. We test by verifying the
|
||||
* function doesn't silently swallow errors from the parser lookup.
|
||||
*/
|
||||
expect(() => isSupportedFileType('')).not.toThrow()
|
||||
expect(isSupportedFileType('')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import { existsSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { FileParseResult, FileParser, SupportedFileType } from '@/lib/file-parsers/types'
|
||||
|
||||
const logger = createLogger('FileParser')
|
||||
|
||||
let parserInstances: Record<string, FileParser> | null = null
|
||||
|
||||
/**
|
||||
* Get parser instances with lazy initialization
|
||||
*/
|
||||
function getParserInstances(): Record<string, FileParser> {
|
||||
if (parserInstances === null) {
|
||||
parserInstances = {}
|
||||
|
||||
try {
|
||||
try {
|
||||
logger.info('Loading PDF parser...')
|
||||
const { PdfParser } = require('@/lib/file-parsers/pdf-parser')
|
||||
parserInstances.pdf = new PdfParser()
|
||||
logger.info('PDF parser loaded successfully')
|
||||
} catch (error) {
|
||||
logger.error('Failed to load PDF parser:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { CsvParser } = require('@/lib/file-parsers/csv-parser')
|
||||
parserInstances.csv = new CsvParser()
|
||||
logger.info('Loaded streaming CSV parser with csv-parse library')
|
||||
} catch (error) {
|
||||
logger.error('Failed to load streaming CSV parser:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { DocxParser } = require('@/lib/file-parsers/docx-parser')
|
||||
parserInstances.docx = new DocxParser()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load DOCX parser:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { DocParser } = require('@/lib/file-parsers/doc-parser')
|
||||
parserInstances.doc = new DocParser()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load DOC parser:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { TxtParser } = require('@/lib/file-parsers/txt-parser')
|
||||
parserInstances.txt = new TxtParser()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load TXT parser:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { MdParser } = require('@/lib/file-parsers/md-parser')
|
||||
parserInstances.md = new MdParser()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load MD parser:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { XlsxParser } = require('@/lib/file-parsers/xlsx-parser')
|
||||
parserInstances.xlsx = new XlsxParser()
|
||||
parserInstances.xls = new XlsxParser()
|
||||
logger.info('Loaded XLSX parser')
|
||||
} catch (error) {
|
||||
logger.error('Failed to load XLSX parser:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { PptxParser } = require('@/lib/file-parsers/pptx-parser')
|
||||
parserInstances.pptx = new PptxParser()
|
||||
parserInstances.ppt = new PptxParser()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load PPTX parser:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { HtmlParser } = require('@/lib/file-parsers/html-parser')
|
||||
parserInstances.html = new HtmlParser()
|
||||
parserInstances.htm = new HtmlParser()
|
||||
} catch (error) {
|
||||
logger.error('Failed to load HTML parser:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
parseJSON,
|
||||
parseJSONBuffer,
|
||||
parseJSONL,
|
||||
parseJSONLBuffer,
|
||||
} = require('@/lib/file-parsers/json-parser')
|
||||
parserInstances.json = {
|
||||
parseFile: parseJSON,
|
||||
parseBuffer: parseJSONBuffer,
|
||||
}
|
||||
parserInstances.jsonl = {
|
||||
parseFile: parseJSONL,
|
||||
parseBuffer: parseJSONLBuffer,
|
||||
}
|
||||
logger.info('Loaded JSON/JSONL parser')
|
||||
} catch (error) {
|
||||
logger.error('Failed to load JSON parser:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { parseYAML, parseYAMLBuffer } = require('@/lib/file-parsers/yaml-parser')
|
||||
parserInstances.yaml = {
|
||||
parseFile: parseYAML,
|
||||
parseBuffer: parseYAMLBuffer,
|
||||
}
|
||||
parserInstances.yml = {
|
||||
parseFile: parseYAML,
|
||||
parseBuffer: parseYAMLBuffer,
|
||||
}
|
||||
logger.info('Loaded YAML parser')
|
||||
} catch (error) {
|
||||
logger.error('Failed to load YAML parser:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading file parsers:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return parserInstances
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a file based on its extension
|
||||
* @param filePath Path to the file
|
||||
* @returns Parsed content and metadata
|
||||
*/
|
||||
export async function parseFile(filePath: string): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
}
|
||||
|
||||
const extension = path.extname(filePath).toLowerCase().substring(1)
|
||||
logger.info('Attempting to parse file with extension:', extension)
|
||||
|
||||
const parsers = getParserInstances()
|
||||
|
||||
if (!Object.keys(parsers).includes(extension)) {
|
||||
logger.info('No parser found for extension:', extension)
|
||||
throw new Error(
|
||||
`Unsupported file type: ${extension}. Supported types are: ${Object.keys(parsers).join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Using parser for extension:', extension)
|
||||
const parser = parsers[extension]
|
||||
return await parser.parseFile(filePath)
|
||||
} catch (error) {
|
||||
logger.error('File parsing error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a buffer based on file extension
|
||||
* @param buffer Buffer containing the file data
|
||||
* @param extension File extension without the dot (e.g., 'pdf', 'csv')
|
||||
* @returns Parsed content and metadata
|
||||
*/
|
||||
export async function parseBuffer(buffer: Buffer, extension: string): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error('Empty buffer provided')
|
||||
}
|
||||
|
||||
if (!extension) {
|
||||
throw new Error('No file extension provided')
|
||||
}
|
||||
|
||||
const normalizedExtension = extension.toLowerCase()
|
||||
logger.info('Attempting to parse buffer with extension:', normalizedExtension)
|
||||
|
||||
const parsers = getParserInstances()
|
||||
|
||||
if (!Object.keys(parsers).includes(normalizedExtension)) {
|
||||
logger.info('No parser found for extension:', normalizedExtension)
|
||||
throw new Error(
|
||||
`Unsupported file type: ${normalizedExtension}. Supported types are: ${Object.keys(parsers).join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Using parser for extension:', normalizedExtension)
|
||||
const parser = parsers[normalizedExtension]
|
||||
|
||||
if (parser.parseBuffer) {
|
||||
return await parser.parseBuffer(buffer)
|
||||
}
|
||||
throw new Error(`Parser for ${normalizedExtension} does not support buffer parsing`)
|
||||
} catch (error) {
|
||||
logger.error('Buffer parsing error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file type is supported
|
||||
* @param extension File extension without the dot
|
||||
* @returns true if supported, false otherwise
|
||||
*/
|
||||
export function isSupportedFileType(extension: string): extension is SupportedFileType {
|
||||
try {
|
||||
return Object.keys(getParserInstances()).includes(extension.toLowerCase())
|
||||
} catch (error) {
|
||||
logger.error('Error checking supported file type:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export type { FileParseResult, SupportedFileType }
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { FileParseResult } from '@/lib/file-parsers/types'
|
||||
|
||||
/**
|
||||
* Parse JSON files
|
||||
*/
|
||||
export async function parseJSON(filePath: string): Promise<FileParseResult> {
|
||||
const fs = await import('fs/promises')
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
|
||||
try {
|
||||
// Parse to validate JSON
|
||||
const jsonData = JSON.parse(content)
|
||||
|
||||
// Return pretty-printed JSON for better readability
|
||||
const formattedContent = JSON.stringify(jsonData, null, 2)
|
||||
|
||||
// Extract metadata about the JSON structure
|
||||
const metadata = {
|
||||
type: 'json',
|
||||
isArray: Array.isArray(jsonData),
|
||||
keys: Array.isArray(jsonData) ? [] : Object.keys(jsonData),
|
||||
itemCount: Array.isArray(jsonData) ? jsonData.length : undefined,
|
||||
depth: getJsonDepth(jsonData),
|
||||
}
|
||||
|
||||
return {
|
||||
content: formattedContent,
|
||||
metadata,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON: ${getErrorMessage(error, 'Unknown error')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON from buffer
|
||||
*/
|
||||
export async function parseJSONBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
const content = buffer.toString('utf-8')
|
||||
|
||||
try {
|
||||
const jsonData = JSON.parse(content)
|
||||
const formattedContent = JSON.stringify(jsonData, null, 2)
|
||||
|
||||
const metadata = {
|
||||
type: 'json',
|
||||
isArray: Array.isArray(jsonData),
|
||||
keys: Array.isArray(jsonData) ? [] : Object.keys(jsonData),
|
||||
itemCount: Array.isArray(jsonData) ? jsonData.length : undefined,
|
||||
depth: getJsonDepth(jsonData),
|
||||
}
|
||||
|
||||
return {
|
||||
content: formattedContent,
|
||||
metadata,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON: ${getErrorMessage(error, 'Unknown error')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSONL (JSON Lines) files — one JSON object per line
|
||||
*/
|
||||
export async function parseJSONL(filePath: string): Promise<FileParseResult> {
|
||||
const fs = await import('fs/promises')
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
return parseJSONLContent(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSONL from buffer
|
||||
*/
|
||||
export async function parseJSONLBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
const content = buffer.toString('utf-8')
|
||||
return parseJSONLContent(content)
|
||||
}
|
||||
|
||||
function parseJSONLContent(content: string): FileParseResult {
|
||||
const lines = content.split('\n').filter((line) => line.trim())
|
||||
const items: unknown[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
items.push(JSON.parse(line))
|
||||
} catch {
|
||||
throw new Error(`Invalid JSONL: failed to parse line: ${line.slice(0, 100)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const formattedContent = JSON.stringify(items, null, 2)
|
||||
|
||||
return {
|
||||
content: formattedContent,
|
||||
metadata: {
|
||||
type: 'json',
|
||||
isArray: true,
|
||||
keys: [],
|
||||
itemCount: items.length,
|
||||
depth: items.length > 0 ? 1 + getJsonDepth(items[0]) : 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the depth of a JSON object
|
||||
*/
|
||||
function getJsonDepth(obj: any): number {
|
||||
if (obj === null || typeof obj !== 'object') return 0
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.length > 0 ? 1 + Math.max(...obj.map(getJsonDepth)) : 1
|
||||
}
|
||||
|
||||
const depths = Object.values(obj).map(getJsonDepth)
|
||||
return depths.length > 0 ? 1 + Math.max(...depths) : 1
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
|
||||
|
||||
const logger = createLogger('MdParser')
|
||||
|
||||
export class MdParser implements FileParser {
|
||||
async parseFile(filePath: string): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
const buffer = await readFile(filePath)
|
||||
|
||||
return this.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
logger.error('MD file error:', error)
|
||||
throw new Error(`Failed to parse MD file: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
try {
|
||||
logger.info('Parsing buffer, size:', buffer.length)
|
||||
|
||||
const result = buffer.toString('utf-8')
|
||||
const content = sanitizeTextForUTF8(result)
|
||||
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
characterCount: content.length,
|
||||
tokenCount: Math.floor(content.length / 4),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('MD buffer parsing error:', error)
|
||||
throw new Error(`Failed to parse MD buffer: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
|
||||
const logger = createLogger('PdfParser')
|
||||
|
||||
export class PdfParser implements FileParser {
|
||||
async parseFile(filePath: string): Promise<FileParseResult> {
|
||||
try {
|
||||
logger.info('Starting to parse file:', filePath)
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
logger.info('Reading file...')
|
||||
const dataBuffer = await readFile(filePath)
|
||||
logger.info('File read successfully, size:', dataBuffer.length)
|
||||
|
||||
return this.parseBuffer(dataBuffer)
|
||||
} catch (error) {
|
||||
logger.error('Error reading file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async parseBuffer(dataBuffer: Buffer): Promise<FileParseResult> {
|
||||
try {
|
||||
logger.info('Starting to parse buffer, size:', dataBuffer.length)
|
||||
|
||||
const { extractText, getDocumentProxy } = await import('unpdf')
|
||||
|
||||
const uint8Array = new Uint8Array(dataBuffer)
|
||||
|
||||
const pdf = await getDocumentProxy(uint8Array)
|
||||
|
||||
const { totalPages, text } = await extractText(pdf, { mergePages: true })
|
||||
|
||||
logger.info('PDF parsed successfully, pages:', totalPages, 'text length:', text.length)
|
||||
|
||||
const cleanContent = text.replace(/\u0000/g, '')
|
||||
|
||||
return {
|
||||
content: cleanContent,
|
||||
metadata: {
|
||||
pageCount: totalPages,
|
||||
source: 'unpdf',
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error parsing buffer:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
|
||||
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
|
||||
|
||||
const logger = createLogger('PptxParser')
|
||||
|
||||
export class PptxParser implements FileParser {
|
||||
async parseFile(filePath: string): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
}
|
||||
|
||||
logger.info(`Parsing PowerPoint file: ${filePath}`)
|
||||
|
||||
const buffer = await readFile(filePath)
|
||||
return this.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
logger.error('PowerPoint file parsing error:', error)
|
||||
throw new Error(`Failed to parse PowerPoint file: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
try {
|
||||
logger.info('Parsing PowerPoint buffer, size:', buffer.length)
|
||||
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error('Empty buffer provided')
|
||||
}
|
||||
|
||||
assertOoxmlArchiveWithinLimits(buffer)
|
||||
|
||||
let parseOfficeAsync
|
||||
try {
|
||||
const officeParser = await import('officeparser')
|
||||
parseOfficeAsync = officeParser.parseOfficeAsync
|
||||
} catch (importError) {
|
||||
logger.warn('officeparser not available, using fallback extraction')
|
||||
return this.fallbackExtraction(buffer)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await parseOfficeAsync(buffer)
|
||||
|
||||
if (!result || typeof result !== 'string') {
|
||||
throw new Error('officeparser returned invalid result')
|
||||
}
|
||||
|
||||
const content = sanitizeTextForUTF8(result.trim())
|
||||
|
||||
logger.info('PowerPoint parsing completed successfully with officeparser')
|
||||
|
||||
return {
|
||||
content: content,
|
||||
metadata: {
|
||||
characterCount: content.length,
|
||||
extractionMethod: 'officeparser',
|
||||
},
|
||||
}
|
||||
} catch (extractError) {
|
||||
logger.warn('officeparser failed, using fallback:', extractError)
|
||||
return this.fallbackExtraction(buffer)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PowerPoint buffer parsing error:', error)
|
||||
throw new Error(`Failed to parse PowerPoint buffer: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackExtraction(buffer: Buffer): FileParseResult {
|
||||
logger.info('Using fallback text extraction for PowerPoint file')
|
||||
|
||||
const text = buffer.toString('utf8', 0, Math.min(buffer.length, 200000))
|
||||
|
||||
const readableText = text
|
||||
.match(/[\x20-\x7E\s]{4,}/g)
|
||||
?.filter(
|
||||
(chunk) =>
|
||||
chunk.trim().length > 10 &&
|
||||
/[a-zA-Z]/.test(chunk) &&
|
||||
!/^[\x00-\x1F]*$/.test(chunk) &&
|
||||
!/^[^\w\s]*$/.test(chunk)
|
||||
)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const content = readableText
|
||||
? sanitizeTextForUTF8(readableText)
|
||||
: 'Unable to extract text from PowerPoint file. Please ensure the file contains readable text content.'
|
||||
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
extractionMethod: 'fallback',
|
||||
characterCount: content.length,
|
||||
warning: 'Basic text extraction used',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
|
||||
|
||||
const logger = createLogger('TxtParser')
|
||||
|
||||
export class TxtParser implements FileParser {
|
||||
async parseFile(filePath: string): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
const buffer = await readFile(filePath)
|
||||
|
||||
return this.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
logger.error('TXT file error:', error)
|
||||
throw new Error(`Failed to parse TXT file: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
try {
|
||||
logger.info('Parsing buffer, size:', buffer.length)
|
||||
|
||||
const rawContent = buffer.toString('utf-8')
|
||||
const result = sanitizeTextForUTF8(rawContent)
|
||||
|
||||
return {
|
||||
content: result,
|
||||
metadata: {
|
||||
characterCount: result.length,
|
||||
tokenCount: result.length / 4,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('TXT buffer parsing error:', error)
|
||||
throw new Error(`Failed to parse TXT buffer: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface FileParseMetadata {
|
||||
characterCount?: number
|
||||
pageCount?: number
|
||||
extractionMethod?: string
|
||||
warning?: string
|
||||
messages?: unknown[]
|
||||
html?: string
|
||||
type?: string
|
||||
headers?: string[]
|
||||
totalRows?: number
|
||||
rowCount?: number
|
||||
sheetNames?: string[]
|
||||
source?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface FileParseResult {
|
||||
content: string
|
||||
metadata?: FileParseMetadata
|
||||
}
|
||||
|
||||
export interface FileParser {
|
||||
parseFile(filePath: string): Promise<FileParseResult>
|
||||
parseBuffer?(buffer: Buffer): Promise<FileParseResult>
|
||||
}
|
||||
|
||||
export type SupportedFileType =
|
||||
| 'pdf'
|
||||
| 'csv'
|
||||
| 'doc'
|
||||
| 'docx'
|
||||
| 'txt'
|
||||
| 'md'
|
||||
| 'xlsx'
|
||||
| 'xls'
|
||||
| 'html'
|
||||
| 'htm'
|
||||
| 'pptx'
|
||||
| 'ppt'
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Utility functions for file parsing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clean text content to ensure it's safe for UTF-8 storage in PostgreSQL
|
||||
* Removes null bytes and control characters that can cause encoding errors
|
||||
*/
|
||||
export function sanitizeTextForUTF8(text: string): string {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return text
|
||||
.replace(/\0/g, '') // Remove null bytes (0x00)
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control characters except \t(0x09), \n(0x0A), \r(0x0D)
|
||||
.replace(/\uFFFD/g, '') // Remove Unicode replacement character
|
||||
.replace(/[\uD800-\uDFFF]/g, '') // Remove unpaired surrogate characters
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an array of strings
|
||||
*/
|
||||
export function sanitizeTextArray(texts: string[]): string[] {
|
||||
return texts.map((text) => sanitizeTextForUTF8(text))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string contains problematic characters for UTF-8 storage
|
||||
*/
|
||||
export function hasInvalidUTF8Characters(text: string): boolean {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for null bytes and control characters
|
||||
return (
|
||||
/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(text) ||
|
||||
/\uFFFD/.test(text) ||
|
||||
/[\uD800-\uDFFF]/.test(text)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import * as XLSX from 'xlsx'
|
||||
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
|
||||
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
|
||||
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
|
||||
|
||||
const logger = createLogger('XlsxParser')
|
||||
|
||||
// Configuration for handling large XLSX files
|
||||
const CONFIG = {
|
||||
MAX_PREVIEW_ROWS: 1000, // Only keep first 1000 rows for preview
|
||||
MAX_SAMPLE_ROWS: 100, // Sample for metadata
|
||||
ROWS_PER_CHUNK: 50, // Aggregate 50 rows per chunk to reduce chunk count
|
||||
MAX_CELL_LENGTH: 1000, // Truncate very long cell values
|
||||
MAX_CONTENT_SIZE: 10 * 1024 * 1024, // 10MB max content size
|
||||
}
|
||||
|
||||
export class XlsxParser implements FileParser {
|
||||
/**
|
||||
* Read the file into a buffer and delegate to {@link parseBuffer} so the
|
||||
* decompression-bomb guard runs before SheetJS inflates the workbook.
|
||||
*/
|
||||
async parseFile(filePath: string): Promise<FileParseResult> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
throw new Error('No file path provided')
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`)
|
||||
}
|
||||
|
||||
logger.info(`Parsing XLSX file: ${filePath}`)
|
||||
|
||||
const buffer = await readFile(filePath)
|
||||
return this.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
logger.error('XLSX file parsing error:', error)
|
||||
throw new Error(`Failed to parse XLSX file: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
try {
|
||||
const bufferSize = buffer.length
|
||||
logger.info(
|
||||
`Parsing XLSX buffer, size: ${bufferSize} bytes (${(bufferSize / 1024 / 1024).toFixed(2)} MB)`
|
||||
)
|
||||
|
||||
if (!buffer || buffer.length === 0) {
|
||||
throw new Error('Empty buffer provided')
|
||||
}
|
||||
|
||||
assertOoxmlArchiveWithinLimits(buffer)
|
||||
|
||||
const workbook = XLSX.read(buffer, {
|
||||
type: 'buffer',
|
||||
dense: true, // Use dense mode for better memory efficiency
|
||||
sheetStubs: false, // Don't create stub cells
|
||||
})
|
||||
|
||||
return this.processWorkbook(workbook)
|
||||
} catch (error) {
|
||||
logger.error('XLSX buffer parsing error:', error)
|
||||
throw new Error(`Failed to parse XLSX buffer: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private processWorkbook(workbook: XLSX.WorkBook): FileParseResult {
|
||||
const sheetNames = workbook.SheetNames
|
||||
let content = ''
|
||||
let totalRows = 0
|
||||
let truncated = false
|
||||
let contentSize = 0
|
||||
const sampledData: any[] = []
|
||||
|
||||
for (const sheetName of sheetNames) {
|
||||
const worksheet = workbook.Sheets[sheetName]
|
||||
|
||||
// Get sheet dimensions
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1')
|
||||
const rowCount = range.e.r - range.s.r + 1
|
||||
|
||||
logger.info(`Processing sheet: ${sheetName} with ${rowCount} rows`)
|
||||
|
||||
// Convert to JSON with header row
|
||||
const sheetData = XLSX.utils.sheet_to_json(worksheet, {
|
||||
header: 1,
|
||||
defval: '', // Default value for empty cells
|
||||
blankrows: false, // Skip blank rows
|
||||
})
|
||||
|
||||
const actualRowCount = sheetData.length
|
||||
totalRows += actualRowCount
|
||||
|
||||
// Store limited sample for metadata
|
||||
if (sampledData.length < CONFIG.MAX_SAMPLE_ROWS) {
|
||||
const sampleSize = Math.min(CONFIG.MAX_SAMPLE_ROWS - sampledData.length, actualRowCount)
|
||||
sampledData.push(...sheetData.slice(0, sampleSize))
|
||||
}
|
||||
|
||||
// Only process limited rows for preview
|
||||
const rowsToProcess = Math.min(actualRowCount, CONFIG.MAX_PREVIEW_ROWS)
|
||||
const cleanSheetName = sanitizeTextForUTF8(sheetName)
|
||||
|
||||
// Add sheet header
|
||||
const sheetHeader = `\n=== Sheet: ${cleanSheetName} ===\n`
|
||||
content += sheetHeader
|
||||
contentSize += sheetHeader.length
|
||||
|
||||
if (actualRowCount > 0) {
|
||||
// Get headers if available
|
||||
const headers = sheetData[0] as any[]
|
||||
if (headers && headers.length > 0) {
|
||||
const headerRow = headers.map((h) => this.truncateCell(h)).join('\t')
|
||||
content += `${headerRow}\n`
|
||||
content += `${'-'.repeat(Math.min(80, headerRow.length))}\n`
|
||||
contentSize += headerRow.length + 82
|
||||
}
|
||||
|
||||
// Process data rows in chunks
|
||||
let chunkContent = ''
|
||||
let chunkRowCount = 0
|
||||
|
||||
for (let i = 1; i < rowsToProcess; i++) {
|
||||
const row = sheetData[i] as any[]
|
||||
if (row && row.length > 0) {
|
||||
const rowString = row.map((cell) => this.truncateCell(cell)).join('\t')
|
||||
|
||||
chunkContent += `${rowString}\n`
|
||||
chunkRowCount++
|
||||
|
||||
// Add chunk separator every N rows for better readability
|
||||
if (chunkRowCount >= CONFIG.ROWS_PER_CHUNK) {
|
||||
content += chunkContent
|
||||
contentSize += chunkContent.length
|
||||
chunkContent = ''
|
||||
chunkRowCount = 0
|
||||
|
||||
// Check content size limit
|
||||
if (contentSize > CONFIG.MAX_CONTENT_SIZE) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining chunk content
|
||||
if (chunkContent && contentSize < CONFIG.MAX_CONTENT_SIZE) {
|
||||
content += chunkContent
|
||||
contentSize += chunkContent.length
|
||||
}
|
||||
|
||||
// Add truncation notice if needed
|
||||
if (actualRowCount > rowsToProcess) {
|
||||
const notice = `\n[... ${actualRowCount.toLocaleString()} total rows, showing first ${rowsToProcess.toLocaleString()} ...]\n`
|
||||
content += notice
|
||||
truncated = true
|
||||
}
|
||||
} else {
|
||||
content += '[Empty sheet]\n'
|
||||
}
|
||||
|
||||
// Stop processing if content is too large
|
||||
if (contentSize > CONFIG.MAX_CONTENT_SIZE) {
|
||||
content += '\n[... Content truncated due to size limits ...]\n'
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`XLSX parsing completed: ${sheetNames.length} sheets, ${totalRows} total rows, truncated: ${truncated}`
|
||||
)
|
||||
|
||||
const cleanContent = sanitizeTextForUTF8(content).trim()
|
||||
|
||||
return {
|
||||
content: cleanContent,
|
||||
metadata: {
|
||||
sheetCount: sheetNames.length,
|
||||
sheetNames: sheetNames,
|
||||
totalRows: totalRows,
|
||||
truncated: truncated,
|
||||
sampledData: sampledData.slice(0, CONFIG.MAX_SAMPLE_ROWS),
|
||||
contentSize: contentSize,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private truncateCell(cell: any): string {
|
||||
if (cell === null || cell === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let cellStr = String(cell)
|
||||
|
||||
// Truncate very long cells
|
||||
cellStr = truncate(cellStr, CONFIG.MAX_CELL_LENGTH)
|
||||
|
||||
return sanitizeTextForUTF8(cellStr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import * as yaml from 'js-yaml'
|
||||
import type { FileParseResult } from '@/lib/file-parsers/types'
|
||||
|
||||
/**
|
||||
* Parse YAML files
|
||||
*/
|
||||
export async function parseYAML(filePath: string): Promise<FileParseResult> {
|
||||
const fs = await import('fs/promises')
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
|
||||
try {
|
||||
// Parse YAML to validate and extract structure
|
||||
const yamlData = yaml.load(content)
|
||||
|
||||
// Convert to JSON for consistent processing
|
||||
const jsonContent = JSON.stringify(yamlData, null, 2)
|
||||
|
||||
// Extract metadata about the YAML structure
|
||||
const metadata = {
|
||||
type: 'yaml',
|
||||
isArray: Array.isArray(yamlData),
|
||||
keys: Array.isArray(yamlData) ? [] : Object.keys(yamlData || {}),
|
||||
itemCount: Array.isArray(yamlData) ? yamlData.length : undefined,
|
||||
depth: getYamlDepth(yamlData),
|
||||
}
|
||||
|
||||
return {
|
||||
content: jsonContent,
|
||||
metadata,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML from buffer
|
||||
*/
|
||||
export async function parseYAMLBuffer(buffer: Buffer): Promise<FileParseResult> {
|
||||
const content = buffer.toString('utf-8')
|
||||
|
||||
try {
|
||||
const yamlData = yaml.load(content)
|
||||
const jsonContent = JSON.stringify(yamlData, null, 2)
|
||||
|
||||
const metadata = {
|
||||
type: 'yaml',
|
||||
isArray: Array.isArray(yamlData),
|
||||
keys: Array.isArray(yamlData) ? [] : Object.keys(yamlData || {}),
|
||||
itemCount: Array.isArray(yamlData) ? yamlData.length : undefined,
|
||||
depth: getYamlDepth(yamlData),
|
||||
}
|
||||
|
||||
return {
|
||||
content: jsonContent,
|
||||
metadata,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the depth of a YAML/JSON object
|
||||
*/
|
||||
function getYamlDepth(obj: any): number {
|
||||
if (obj === null || typeof obj !== 'object') return 0
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.length > 0 ? 1 + Math.max(...obj.map(getYamlDepth)) : 1
|
||||
}
|
||||
|
||||
const depths = Object.values(obj).map(getYamlDepth)
|
||||
return depths.length > 0 ? 1 + Math.max(...depths) : 1
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import JSZip from 'jszip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertOoxmlArchiveWithinLimits,
|
||||
type OoxmlSizeLimits,
|
||||
ZipBombError,
|
||||
} from '@/lib/file-parsers/zip-guard'
|
||||
|
||||
const HIGH_LIMITS: OoxmlSizeLimits = {
|
||||
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
|
||||
maxCompressionRatio: 10_000,
|
||||
ratioCheckFloorBytes: 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
async function buildZip(
|
||||
entries: Record<string, string>,
|
||||
options: { comment?: string } = {}
|
||||
): Promise<Buffer> {
|
||||
const zip = new JSZip()
|
||||
for (const [name, content] of Object.entries(entries)) {
|
||||
zip.file(name, content)
|
||||
}
|
||||
return zip.generateAsync({
|
||||
type: 'nodebuffer',
|
||||
compression: 'DEFLATE',
|
||||
comment: options.comment,
|
||||
})
|
||||
}
|
||||
|
||||
describe('assertOoxmlArchiveWithinLimits', () => {
|
||||
it('accepts a well-formed archive within limits', async () => {
|
||||
const buffer = await buildZip({ 'word/document.xml': '<xml>hello world</xml>' })
|
||||
expect(() => assertOoxmlArchiveWithinLimits(buffer, HIGH_LIMITS)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an archive whose declared expanded size exceeds the absolute cap', async () => {
|
||||
const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
|
||||
expect(() =>
|
||||
assertOoxmlArchiveWithinLimits(buffer, {
|
||||
maxTotalUncompressedBytes: 100_000,
|
||||
maxCompressionRatio: 10_000,
|
||||
ratioCheckFloorBytes: 1024 * 1024 * 1024,
|
||||
})
|
||||
).toThrow(ZipBombError)
|
||||
})
|
||||
|
||||
it('rejects an archive whose compression ratio exceeds the limit', async () => {
|
||||
const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
|
||||
expect(() =>
|
||||
assertOoxmlArchiveWithinLimits(buffer, {
|
||||
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
|
||||
maxCompressionRatio: 5,
|
||||
ratioCheckFloorBytes: 1000,
|
||||
})
|
||||
).toThrow(ZipBombError)
|
||||
})
|
||||
|
||||
it('does not flag a small but highly compressible archive below the ratio floor', async () => {
|
||||
const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
|
||||
expect(() =>
|
||||
assertOoxmlArchiveWithinLimits(buffer, {
|
||||
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
|
||||
maxCompressionRatio: 5,
|
||||
ratioCheckFloorBytes: 1024 * 1024 * 1024,
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('sums declared sizes across multiple entries', async () => {
|
||||
const buffer = await buildZip({
|
||||
'a.xml': 'A'.repeat(60_000),
|
||||
'b.xml': 'B'.repeat(60_000),
|
||||
})
|
||||
expect(() =>
|
||||
assertOoxmlArchiveWithinLimits(buffer, {
|
||||
maxTotalUncompressedBytes: 100_000,
|
||||
maxCompressionRatio: 10_000,
|
||||
ratioCheckFloorBytes: 1024 * 1024 * 1024,
|
||||
})
|
||||
).toThrow(ZipBombError)
|
||||
})
|
||||
|
||||
it('accepts a well-formed archive that carries a trailing comment', async () => {
|
||||
const buffer = await buildZip(
|
||||
{ 'word/document.xml': '<xml>hello</xml>' },
|
||||
{ comment: 'generated by test' }
|
||||
)
|
||||
expect(() => assertOoxmlArchiveWithinLimits(buffer, HIGH_LIMITS)).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails closed for a ZIP-shaped buffer whose central directory is unparseable', () => {
|
||||
const buffer = Buffer.alloc(64)
|
||||
buffer.writeUInt32LE(0x04034b50, 0) // local file header signature, no valid EOCD
|
||||
expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError)
|
||||
})
|
||||
|
||||
it('rejects a decoy EOCD signature that does not validate against the buffer tail', async () => {
|
||||
const realZip = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
|
||||
// A decoy EOCD (zeroed central directory) appended after the genuine archive
|
||||
// would, without tail validation, redirect the guard to an empty directory
|
||||
// and undercount the real entries.
|
||||
const decoy = Buffer.alloc(64)
|
||||
decoy.writeUInt32LE(0x06054b50, 0)
|
||||
const tampered = Buffer.concat([realZip, decoy])
|
||||
expect(() => assertOoxmlArchiveWithinLimits(tampered)).toThrow(ZipBombError)
|
||||
})
|
||||
|
||||
it('no-ops for buffers that are not ZIP archives', () => {
|
||||
const plaintext = Buffer.from('this is just plain text, not a zip archive at all')
|
||||
expect(() => assertOoxmlArchiveWithinLimits(plaintext)).not.toThrow()
|
||||
})
|
||||
|
||||
it('no-ops for buffers too small to contain an EOCD record', () => {
|
||||
expect(() => assertOoxmlArchiveWithinLimits(Buffer.from('PK'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('no-ops for an empty buffer', () => {
|
||||
expect(() => assertOoxmlArchiveWithinLimits(Buffer.alloc(0))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('ZipBombGuard')
|
||||
|
||||
/**
|
||||
* OOXML documents (xlsx/docx/pptx) are ZIP archives. Decompression libraries
|
||||
* (SheetJS, mammoth, officeparser) inflate every entry and build the full
|
||||
* in-memory object graph before any application-level size cap applies. A
|
||||
* crafted "zip bomb" — highly repetitive XML that deflates ~100-1000x — can sit
|
||||
* comfortably under the compressed-input limit yet expand to many gigabytes,
|
||||
* exhausting the worker and crashing the process with an OOM.
|
||||
*
|
||||
* This guard inspects the ZIP central directory (which records each entry's
|
||||
* declared uncompressed size) and rejects archives whose total expanded size or
|
||||
* compression ratio exceeds a safe threshold — without decompressing anything.
|
||||
*/
|
||||
|
||||
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50
|
||||
const EOCD_SIGNATURE = 0x06054b50
|
||||
const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50
|
||||
const ZIP64_EOCD_SIGNATURE = 0x06064b50
|
||||
const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
|
||||
const ZIP64_EXTRA_FIELD_ID = 0x0001
|
||||
|
||||
const EOCD_MIN_SIZE = 22
|
||||
const ZIP64_EOCD_LOCATOR_SIZE = 20
|
||||
const CENTRAL_DIRECTORY_HEADER_MIN_SIZE = 46
|
||||
const MAX_EOCD_COMMENT_SIZE = 0xffff
|
||||
const UINT32_SENTINEL = 0xffffffff
|
||||
const UINT16_SENTINEL = 0xffff
|
||||
|
||||
export interface OoxmlSizeLimits {
|
||||
/** Hard ceiling on the summed declared uncompressed size of all entries. */
|
||||
maxTotalUncompressedBytes: number
|
||||
/** Maximum allowed expanded:compressed ratio across the whole archive. */
|
||||
maxCompressionRatio: number
|
||||
/** The ratio check only applies once the expanded size exceeds this floor, so small files are never flagged. */
|
||||
ratioCheckFloorBytes: number
|
||||
}
|
||||
|
||||
const ONE_GIBIBYTE = 1024 * 1024 * 1024
|
||||
const ONE_HUNDRED_MEBIBYTES = 100 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Defaults sized against the 100 MB compressed-input cap of the parse pipeline.
|
||||
* A legitimate Office document stays well under 1 GiB expanded; the bombs
|
||||
* described in the threat model expand to multiple gigabytes.
|
||||
*/
|
||||
export const DEFAULT_OOXML_SIZE_LIMITS: OoxmlSizeLimits = {
|
||||
maxTotalUncompressedBytes: ONE_GIBIBYTE,
|
||||
maxCompressionRatio: 150,
|
||||
ratioCheckFloorBytes: ONE_HUNDRED_MEBIBYTES,
|
||||
}
|
||||
|
||||
export class ZipBombError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ZipBombError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the buffer is shaped like a ZIP archive — i.e. begins with a local
|
||||
* file header (the leading signature of every non-empty ZIP, and thus every
|
||||
* OOXML document) or with the EOCD signature of an empty archive. Used to fail
|
||||
* closed: a ZIP-shaped buffer the guard cannot parse must be rejected rather
|
||||
* than handed to a decompression library.
|
||||
*/
|
||||
function isZipShaped(buffer: Buffer): boolean {
|
||||
if (buffer.length < 4) {
|
||||
return false
|
||||
}
|
||||
const signature = buffer.readUInt32LE(0)
|
||||
return signature === LOCAL_FILE_HEADER_SIGNATURE || signature === EOCD_SIGNATURE
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the End Of Central Directory record by scanning backwards from the end
|
||||
* of the buffer (it sits within the trailing 22 + comment bytes). A candidate
|
||||
* is only accepted when its declared comment length places the record exactly
|
||||
* at the buffer tail, so a decoy EOCD signature planted in the comment region
|
||||
* cannot redirect the guard to a smaller, attacker-chosen central directory.
|
||||
*/
|
||||
function findEocdOffset(buffer: Buffer): number {
|
||||
const minStart = Math.max(0, buffer.length - EOCD_MIN_SIZE - MAX_EOCD_COMMENT_SIZE)
|
||||
for (let offset = buffer.length - EOCD_MIN_SIZE; offset >= minStart; offset--) {
|
||||
if (buffer.readUInt32LE(offset) !== EOCD_SIGNATURE) {
|
||||
continue
|
||||
}
|
||||
const commentLength = buffer.readUInt16LE(offset + 20)
|
||||
if (offset + EOCD_MIN_SIZE + commentLength === buffer.length) {
|
||||
return offset
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
interface CentralDirectoryLocation {
|
||||
offset: number
|
||||
entryCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the central directory offset and entry count, following the ZIP64
|
||||
* end-of-central-directory chain when the 32-bit fields are saturated.
|
||||
*/
|
||||
function locateCentralDirectory(
|
||||
buffer: Buffer,
|
||||
eocdOffset: number
|
||||
): CentralDirectoryLocation | null {
|
||||
let entryCount = buffer.readUInt16LE(eocdOffset + 10)
|
||||
let cdOffset = buffer.readUInt32LE(eocdOffset + 16)
|
||||
|
||||
const needsZip64 = entryCount === UINT16_SENTINEL || cdOffset === UINT32_SENTINEL
|
||||
if (needsZip64) {
|
||||
const locatorOffset = eocdOffset - ZIP64_EOCD_LOCATOR_SIZE
|
||||
if (locatorOffset < 0 || buffer.readUInt32LE(locatorOffset) !== ZIP64_EOCD_LOCATOR_SIGNATURE) {
|
||||
return null
|
||||
}
|
||||
|
||||
const zip64EocdOffset = Number(buffer.readBigUInt64LE(locatorOffset + 8))
|
||||
if (
|
||||
zip64EocdOffset < 0 ||
|
||||
zip64EocdOffset + 56 > buffer.length ||
|
||||
buffer.readUInt32LE(zip64EocdOffset) !== ZIP64_EOCD_SIGNATURE
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
entryCount = Number(buffer.readBigUInt64LE(zip64EocdOffset + 32))
|
||||
cdOffset = Number(buffer.readBigUInt64LE(zip64EocdOffset + 48))
|
||||
}
|
||||
|
||||
if (cdOffset < 0 || cdOffset > buffer.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { offset: cdOffset, entryCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an entry's declared uncompressed size, preferring the ZIP64 extra field
|
||||
* when the 32-bit central-directory field is saturated. The saturated 64-bit
|
||||
* values appear in the extra field in a fixed order with the uncompressed size
|
||||
* first, so it is always the leading 8 bytes of the ZIP64 field.
|
||||
*/
|
||||
function readUncompressedSize(
|
||||
buffer: Buffer,
|
||||
headerOffset: number,
|
||||
fileNameLength: number,
|
||||
extraFieldLength: number
|
||||
): number {
|
||||
const uncompressedSize = buffer.readUInt32LE(headerOffset + 24)
|
||||
if (uncompressedSize !== UINT32_SENTINEL) {
|
||||
return uncompressedSize
|
||||
}
|
||||
|
||||
const extraStart = headerOffset + CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength
|
||||
const extraEnd = extraStart + extraFieldLength
|
||||
let cursor = extraStart
|
||||
while (cursor + 4 <= extraEnd) {
|
||||
const fieldId = buffer.readUInt16LE(cursor)
|
||||
const fieldSize = buffer.readUInt16LE(cursor + 2)
|
||||
const dataStart = cursor + 4
|
||||
if (fieldId === ZIP64_EXTRA_FIELD_ID && dataStart + 8 <= extraEnd) {
|
||||
return Number(buffer.readBigUInt64LE(dataStart))
|
||||
}
|
||||
cursor = dataStart + fieldSize
|
||||
}
|
||||
|
||||
return uncompressedSize
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum the declared uncompressed size of every central-directory entry. Returns
|
||||
* `null` when the buffer is not a parseable ZIP archive (e.g. legacy binary
|
||||
* `.xls`/`.doc`, or a misidentified plaintext file) so the caller can defer to
|
||||
* the downstream parser. Stops early once the running total exceeds the limit.
|
||||
*/
|
||||
function sumDeclaredUncompressedSize(buffer: Buffer, abortAboveBytes: number): number | null {
|
||||
if (buffer.length < EOCD_MIN_SIZE) {
|
||||
return null
|
||||
}
|
||||
|
||||
const eocdOffset = findEocdOffset(buffer)
|
||||
if (eocdOffset < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const location = locateCentralDirectory(buffer, eocdOffset)
|
||||
if (!location) {
|
||||
return null
|
||||
}
|
||||
|
||||
let total = 0
|
||||
let cursor = location.offset
|
||||
for (let entry = 0; entry < location.entryCount; entry++) {
|
||||
if (cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE > buffer.length) {
|
||||
return null
|
||||
}
|
||||
if (buffer.readUInt32LE(cursor) !== CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fileNameLength = buffer.readUInt16LE(cursor + 28)
|
||||
const extraFieldLength = buffer.readUInt16LE(cursor + 30)
|
||||
const commentLength = buffer.readUInt16LE(cursor + 32)
|
||||
|
||||
total += readUncompressedSize(buffer, cursor, fileNameLength, extraFieldLength)
|
||||
if (total > abortAboveBytes) {
|
||||
return total
|
||||
}
|
||||
|
||||
cursor += CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength + extraFieldLength + commentLength
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an OOXML archive whose declared expanded size or compression ratio
|
||||
* exceeds safe bounds, before any decompression library materializes it.
|
||||
*
|
||||
* Fails closed: a ZIP-shaped buffer whose central directory cannot be parsed is
|
||||
* rejected rather than passed through, so a malformed archive that a downstream
|
||||
* library still inflates cannot bypass the guard. Genuinely non-ZIP inputs
|
||||
* (legacy OLE `.xls`/`.doc`, misidentified plaintext) no-op and defer to the
|
||||
* downstream parser's own validation and fallbacks.
|
||||
*/
|
||||
export function assertOoxmlArchiveWithinLimits(
|
||||
buffer: Buffer,
|
||||
limits: OoxmlSizeLimits = DEFAULT_OOXML_SIZE_LIMITS
|
||||
): void {
|
||||
const totalUncompressed = sumDeclaredUncompressedSize(buffer, limits.maxTotalUncompressedBytes)
|
||||
if (totalUncompressed === null) {
|
||||
if (isZipShaped(buffer)) {
|
||||
logger.warn('Rejected ZIP-shaped archive: central directory could not be parsed', {
|
||||
compressedBytes: buffer.length,
|
||||
})
|
||||
throw new ZipBombError(
|
||||
'Unable to inspect ZIP central directory; refusing to parse an unverifiable ZIP-shaped archive'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (totalUncompressed > limits.maxTotalUncompressedBytes) {
|
||||
logger.warn('Rejected OOXML archive: declared expanded size exceeds limit', {
|
||||
totalUncompressed,
|
||||
maxTotalUncompressedBytes: limits.maxTotalUncompressedBytes,
|
||||
compressedBytes: buffer.length,
|
||||
})
|
||||
throw new ZipBombError(
|
||||
`Decompressed size (${totalUncompressed} bytes) exceeds the maximum allowed ${limits.maxTotalUncompressedBytes} bytes`
|
||||
)
|
||||
}
|
||||
|
||||
const ratio = totalUncompressed / Math.max(buffer.length, 1)
|
||||
if (totalUncompressed > limits.ratioCheckFloorBytes && ratio > limits.maxCompressionRatio) {
|
||||
logger.warn('Rejected OOXML archive: compression ratio exceeds limit', {
|
||||
totalUncompressed,
|
||||
compressedBytes: buffer.length,
|
||||
ratio,
|
||||
maxCompressionRatio: limits.maxCompressionRatio,
|
||||
})
|
||||
throw new ZipBombError(
|
||||
`Compression ratio (${ratio.toFixed(1)}x) exceeds the maximum allowed ${limits.maxCompressionRatio}x`
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user