diff --git a/apps/desktop/src/main/browser-agent/url-guard.test.ts b/apps/desktop/src/main/browser-agent/url-guard.test.ts index 8544e149b90..e50d51eb6b7 100644 --- a/apps/desktop/src/main/browser-agent/url-guard.test.ts +++ b/apps/desktop/src/main/browser-agent/url-guard.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +// url-guard pulls in @/main/navigation, which imports electron. +vi.mock('electron', () => import('@/test/electron-mock')) + const { mockLookup } = vi.hoisted(() => ({ mockLookup: vi.fn() })) // The real resolveHostAddresses runs; only the resolver under it is mocked, so diff --git a/apps/desktop/src/main/csp.test.ts b/apps/desktop/src/main/csp.test.ts index 1319ca314fb..0bab8851199 100644 --- a/apps/desktop/src/main/csp.test.ts +++ b/apps/desktop/src/main/csp.test.ts @@ -1,4 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' + +// csp pulls in @/main/navigation, which imports electron. +vi.mock('electron', () => import('@/test/electron-mock')) + import { attachCspFallback, DEFAULT_DESKTOP_CSP } from '@/main/csp' type HeadersReceivedHandler = ( diff --git a/apps/desktop/src/main/telemetry-policy.test.ts b/apps/desktop/src/main/telemetry-policy.test.ts index 29b0cd2f6a1..c4d7ab876b0 100644 --- a/apps/desktop/src/main/telemetry-policy.test.ts +++ b/apps/desktop/src/main/telemetry-policy.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +// telemetry-policy pulls in @/main/navigation, which imports electron. +vi.mock('electron', () => import('@/test/electron-mock')) + import { shouldBlockRequest } from '@/main/telemetry-policy' describe('shouldBlockRequest', () => { diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index 8047cea0f0d..a6c047ec217 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -13,6 +13,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' +import { isHtmlComplexityError } from '@/lib/file-parsers/html-parser' import { isYamlComplexityError } from '@/lib/file-parsers/yaml-parser' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' @@ -1047,6 +1048,7 @@ async function handleGenericTextBuffer( // Fail closed on a resource-exhaustion rejection instead of silently // storing the crafted document as raw text. if (isYamlComplexityError(parserError)) throw parserError + if (isHtmlComplexityError(parserError)) throw parserError logger.warn('Specialized parser failed, falling back to generic parsing:', parserError) } diff --git a/apps/sim/lib/file-parsers/html-parser.test.ts b/apps/sim/lib/file-parsers/html-parser.test.ts new file mode 100644 index 00000000000..8b7fe57bdf5 --- /dev/null +++ b/apps/sim/lib/file-parsers/html-parser.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { HtmlComplexityError, HtmlParser } from '@/lib/file-parsers/html-parser' + +const parser = new HtmlParser() + +describe('HtmlParser', () => { + describe('resource limits', () => { + it('rejects a document above the input byte cap', async () => { + const sparse = Buffer.concat([ + Buffer.from('
'), + Buffer.alloc(32 * 1024 * 1024, 0x61), + Buffer.from('
'), + ]) + + await expect(parser.parseBuffer(sparse)).rejects.toThrow( + /above the maximum of 33554432 bytes/ + ) + }) + + it('rejects a tag-dense document above the markup-token cap', async () => { + const dense = Buffer.from(`${'a
'.repeat(300_000)}`) + + const error = await parser.parseBuffer(dense).catch((e) => e) + + expect(error).toBeInstanceOf(HtmlComplexityError) + expect(error.message).toMatch(/exceeds the maximum of 500000 markup tokens/) + }) + + it('accepts a byte-heavy document whose markup stays under the token cap', async () => { + const paragraph = `${'word '.repeat(200)}
` + const buffer = Buffer.from(`${paragraph.repeat(2000)}`) + + const result = await parser.parseBuffer(buffer) + + expect(result.content).toContain('word') + }) + + /** + * Deep nesting overflows the stack inside cheerio's own recursive `.text()`, + * which the pre-parse caps cannot predict. It still has to be classified as + * a resource rejection so callers fail closed rather than fall back to + * storing the document as raw text. + */ + it('preserves the error type through parseFile so callers still fail closed', async () => { + const dense = `${'a
'.repeat(300_000)}` + const path = join(tmpdir(), `html-parser-limits-${process.pid}.html`) + await writeFile(path, dense) + + try { + await expect(parser.parseFile(path)).rejects.toBeInstanceOf(HtmlComplexityError) + } finally { + await rm(path, { force: true }) + } + }) + + it('classifies a deep-nesting stack overflow as a complexity rejection', async () => { + const depth = 15_000 + const buffer = Buffer.from( + `${'Body text
` + + `| h |
|---|
| c |