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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+154
View File
@@ -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()
}
}
+130
View File
@@ -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.',
},
}
}
}
+110
View File
@@ -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}`)
}
}
}
+283
View File
@@ -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
}
}
+337
View File
@@ -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)
})
})
})
+221
View File
@@ -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 }
+118
View File
@@ -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
}
+43
View File
@@ -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}`)
}
}
}
+55
View File
@@ -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
}
}
}
+109
View File
@@ -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',
},
}
}
}
+43
View File
@@ -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}`)
}
}
}
+39
View File
@@ -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'
+42
View File
@@ -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)
)
}
+207
View File
@@ -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)
}
}
+76
View File
@@ -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
}
+123
View File
@@ -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()
})
})
+270
View File
@@ -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`
)
}
}