diff --git a/bun.lock b/bun.lock index c4680f50..01e25f9e 100644 --- a/bun.lock +++ b/bun.lock @@ -182,6 +182,7 @@ "cron-parser": "^5.5.0", "dompurify": "^3.2.7", "express": "^5.1.0", + "fflate": "^0.8.3", "fuse.js": "^7.1.0", "ghostty-web": "^0.4.0", "heic2any": "^0.0.4", @@ -2000,6 +2001,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index f35bdbd7..36e26ffa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -58,6 +58,7 @@ "cron-parser": "^5.5.0", "dompurify": "^3.2.7", "express": "^5.1.0", + "fflate": "^0.8.3", "fuse.js": "^7.1.0", "ghostty-web": "^0.4.0", "heic2any": "^0.0.4", diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 62262663..747bdc86 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -48,12 +48,15 @@ So: | `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists | | `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state | | `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes | +| `document-attachments.ts` | Bounded Office/OpenDocument extraction, document text serialization, embedded-image extraction, and positional citations | DOCX, PPTX, XLSX, ODT, ODP, and ODS chat attachments | | `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state | | `selection-store.ts` | Model/agent/variant selections | App UI state | | `voice-store.ts` | Voice state | App UI state | Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection. +Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 2,000,000 characters. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready. + ## Session list rules ### Directory bootstrap scheduling diff --git a/packages/ui/src/sync/attachment-files.test.ts b/packages/ui/src/sync/attachment-files.test.ts index ab4b6a49..0e74ce9d 100644 --- a/packages/ui/src/sync/attachment-files.test.ts +++ b/packages/ui/src/sync/attachment-files.test.ts @@ -16,6 +16,7 @@ describe("attachment file preparation", () => { for (const extension of [ "diff", "patch", "ipynb", "jsonl", "ndjson", "har", "svg", "drawio", "vue", "svelte", "php", "cs", "kt", "swift", "lua", "dart", "tf", "hcl", "proto", + "docx", "pptx", "xlsx", "odt", "odp", "ods", ]) { expect(ACCEPTED_ATTACHMENT_EXTENSIONS.includes(extension)).toBe(true) expect(ATTACHMENT_ACCEPT.includes(`.${extension}`)).toBe(true) diff --git a/packages/ui/src/sync/attachment-files.ts b/packages/ui/src/sync/attachment-files.ts index 01fbb129..26d40957 100644 --- a/packages/ui/src/sync/attachment-files.ts +++ b/packages/ui/src/sync/attachment-files.ts @@ -6,6 +6,12 @@ const ACCEPTED_ATTACHMENT_TYPES = [ "image/heic", "image/heif", "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.spreadsheet", "text/*", "application/json", "application/ld+json", @@ -27,6 +33,7 @@ const ACCEPTED_ATTACHMENT_TYPES = [ ".cts", ".dart", ".diff", + ".docx", ".drawio", ".env", ".erl", @@ -64,9 +71,13 @@ const ACCEPTED_ATTACHMENT_TYPES = [ ".mjs", ".mts", ".ndjson", + ".odp", + ".ods", + ".odt", ".patch", ".php", ".proto", + ".pptx", ".ps1", ".py", ".r", @@ -88,6 +99,7 @@ const ACCEPTED_ATTACHMENT_TYPES = [ ".txt", ".vue", ".xml", + ".xlsx", ".yaml", ".yml", ".zig", @@ -104,6 +116,12 @@ const PICKER_MIME_EXTENSIONS = new Map([ ["image/heic", "heic"], ["image/heif", "heif"], ["application/pdf", "pdf"], + ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"], + ["application/vnd.openxmlformats-officedocument.presentationml.presentation", "pptx"], + ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"], + ["application/vnd.oasis.opendocument.text", "odt"], + ["application/vnd.oasis.opendocument.presentation", "odp"], + ["application/vnd.oasis.opendocument.spreadsheet", "ods"], ["application/json", "json"], ["application/ld+json", "jsonld"], ["application/toml", "toml"], @@ -157,11 +175,12 @@ const TEXT_MIMES = new Set([ "image/svg+xml", ]) const ATTACHMENT_SAMPLE_BYTES = 4096 +const DOCUMENT_EXTENSIONS = new Set(["docx", "pptx", "xlsx", "odt", "odp", "ods"]) const REDACTED = "[REDACTED]" const OMITTED = "[OMITTED BY OPENCHAMBER]" const SENSITIVE_NAMES = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[-_]?key|client[-_]?secret|password|secret|access[-_]?token|refresh[-_]?token|id[-_]?token|token)$/i -export type PreparedAttachmentFile = { +type PreparedAttachmentFile = { file: File mimeType: string } @@ -341,3 +360,26 @@ export const prepareAttachmentFile = ( if (typeof mime === "string") return { file, mimeType: mime } return mime?.then((mimeType) => mimeType ? { file, mimeType } : undefined) } + +export const prepareAttachmentFiles = ( + file: File, + reservedFilenames: Iterable = [], +): PreparedAttachmentFile[] | Promise | undefined => { + if (!DOCUMENT_EXTENSIONS.has(extensionOf(file.name))) { + const prepared = prepareAttachmentFile(file) + if (prepared instanceof Promise) return prepared.then((output) => output ? [output] : undefined) + return prepared ? [prepared] : undefined + } + + return import("./document-attachments").then(async ({ extractDocumentAttachments }) => { + const extracted = await extractDocumentAttachments(file, reservedFilenames) + if (!extracted) return + const prepared: PreparedAttachmentFile[] = [{ file: extracted.textFile, mimeType: "text/plain" }] + for (const image of extracted.images) { + const output = await prepareAttachmentFile(image) + if (!output || !output.mimeType.startsWith("image/")) return + prepared.push(output) + } + return prepared + }) +} diff --git a/packages/ui/src/sync/document-attachments.test.ts b/packages/ui/src/sync/document-attachments.test.ts new file mode 100644 index 00000000..37d5bd7e --- /dev/null +++ b/packages/ui/src/sync/document-attachments.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "bun:test" +import { strToU8, zipSync } from "fflate" +import { extractDocumentAttachments } from "./document-attachments" + +const zippedFile = (name: string, entries: Record) => new File([ + zipSync(Object.fromEntries(Object.entries(entries).map(([path, value]) => [ + path, + typeof value === "string" ? strToU8(value) : value, + ]))), +], name) + +const relationships = (items: Array<{ id: string; target: string; type?: string }>) => ` + + ${items.map((item) => ``).join("")} + +` + +const pngBytes = (suffix = 0) => new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, suffix]) +const jpegBytes = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0]) +const webpBytes = new Uint8Array([ + 0x52, 0x49, 0x46, 0x46, 0x04, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, +]) + +describe("document attachment extraction", () => { + test("extracts DOCX text and preserves inline image citations", async () => { + const file = zippedFile("report.docx", { + "word/document.xml": ` + + + Before image + + After image + + `, + "word/_rels/document.xml.rels": relationships([{ id: "rId1", target: "media/image1.png" }]), + "word/media/image1.png": pngBytes(), + }) + + const result = await extractDocumentAttachments(file) + const text = await result?.textFile.text() ?? "" + + expect(text.includes("Before image\n\n[report-image-1.png]\n\nAfter image")).toBe(true) + expect(result?.textFile.name).toBe("report.docx") + expect(result?.textFile.type.startsWith("text/plain")).toBe(true) + expect(result?.images).toHaveLength(1) + expect(result?.images[0]?.name).toBe("report-image-1.png") + expect(result?.images[0]?.type).toBe("image/png") + + const deduplicated = await extractDocumentAttachments(file, ["report-image-1.png"]) + expect((await deduplicated?.textFile.text())?.includes("[report-image-2.png]")).toBe(true) + expect(deduplicated?.images[0]?.name).toBe("report-image-2.png") + }) + + test("extracts PPTX slide text, notes, and pictures", async () => { + const file = zippedFile("deck.pptx", { + "ppt/slides/slide1.xml": ` + + Slide title + + `, + "ppt/slides/_rels/slide1.xml.rels": relationships([ + { id: "rIdImage", target: "../media/image1.jpeg" }, + { id: "rIdNotes", target: "../notesSlides/notesSlide1.xml", type: "http://example/notesSlide" }, + ]), + "ppt/notesSlides/notesSlide1.xml": `Speaker note`, + "ppt/media/image1.jpeg": jpegBytes, + }) + + const result = await extractDocumentAttachments(file) + const text = await result?.textFile.text() ?? "" + + expect(text.includes("## Slide 1")).toBe(true) + expect(text.includes("Slide title")).toBe(true) + expect(text.includes("[deck-image-1.jpg]")).toBe(true) + expect(text.includes("### Slide 1 notes\n\nSpeaker note")).toBe(true) + expect(result?.images[0]?.name).toBe("deck-image-1.jpg") + }) + + test("extracts XLSX cell values and anchors pictures to cells", async () => { + const file = zippedFile("budget.xlsx", { + "xl/workbook.xml": ``, + "xl/_rels/workbook.xml.rels": relationships([{ id: "rIdSheet", target: "worksheets/sheet1.xml" }]), + "xl/sharedStrings.xml": `Revenue`, + "xl/worksheets/sheet1.xml": ` + 042`, + "xl/worksheets/_rels/sheet1.xml.rels": relationships([{ id: "rIdDrawing", target: "../drawings/drawing1.xml" }]), + "xl/drawings/drawing1.xml": ` + 12`, + "xl/drawings/_rels/drawing1.xml.rels": relationships([{ id: "rIdImage", target: "../media/image1.webp" }]), + "xl/media/image1.webp": webpBytes, + }) + + const result = await extractDocumentAttachments(file) + const text = await result?.textFile.text() ?? "" + + expect(text.includes("## Sheet: Summary")).toBe(true) + expect(text.includes("A1: Revenue | B1: 42")).toBe(true) + expect(text.includes("Image at B3: [budget-image-1.webp]")).toBe(true) + expect(result?.images[0]?.name).toBe("budget-image-1.webp") + }) + + test("extracts OpenDocument text, presentations, spreadsheets, and image positions", async () => { + const image = pngBytes() + const odt = zippedFile("notes.odt", { + "content.xml": `HeadingHello worldAfter image`, + "Pictures/photo.png": image, + }) + const odp = zippedFile("slides.odp", { + "content.xml": `Welcome`, + }) + const ods = zippedFile("table.ods", { + "content.xml": `NameValue`, + "Pictures/chart.png": image, + }) + + const odtResult = await extractDocumentAttachments(odt) + const odpResult = await extractDocumentAttachments(odp) + const odsResult = await extractDocumentAttachments(ods) + + expect((await odtResult?.textFile.text())?.includes("Heading\n\nHello world\n\n[notes-image-1.png]\n\nAfter image")).toBe(true) + expect(odtResult?.images).toHaveLength(1) + expect((await odpResult?.textFile.text())?.includes("## Slide: Intro\n\nWelcome")).toBe(true) + expect((await odsResult?.textFile.text())?.includes("## Sheet: Data\n\n[table-image-1.png]\n\nName | Value")).toBe(true) + expect(odsResult?.images).toHaveLength(1) + }) + + test("rejects unsafe archive paths", async () => { + const file = zippedFile("unsafe.docx", { + "../word/document.xml": `Unsafe`, + }) + await expect(extractDocumentAttachments(file)).rejects.toThrow("unsafe file path") + }) + + test("rejects archives over the entry-count limit", async () => { + const entries = Object.fromEntries(Array.from({ length: 5_001 }, (_, index) => [ + `metadata/entry-${index}.xml`, + "", + ])) + + await expect(extractDocumentAttachments(zippedFile("too-many.docx", entries))).rejects.toThrow("too many files") + }) + + test("bounds embedded image count and marks omitted images in document text", async () => { + const imageTags = Array.from({ length: 51 }, (_, index) => ``).join("") + const relationshipItems = Array.from({ length: 51 }, (_, index) => ({ + id: `rId${index}`, + target: `media/image${index}.png`, + })) + const entries: Record = { + "word/document.xml": `${imageTags}`, + "word/_rels/document.xml.rels": relationships(relationshipItems), + } + for (let index = 0; index < 51; index += 1) entries[`word/media/image${index}.png`] = pngBytes(index) + + const result = await extractDocumentAttachments(zippedFile("gallery.docx", entries)) + const text = await result?.textFile.text() ?? "" + + expect(result?.images).toHaveLength(50) + expect(text.includes("[Embedded image omitted by attachment limits: image50.png]")).toBe(true) + }) + + test("omits unsupported and spoofed embedded image content", async () => { + const file = zippedFile("unsafe-images.docx", { + "word/document.xml": ` + + + + `, + "word/_rels/document.xml.rels": relationships([ + { id: "svg", target: "media/image.svg" }, + { id: "fakePng", target: "media/fake.png" }, + ]), + "word/media/image.svg": ``, + "word/media/fake.png": new Uint8Array([1, 2, 3]), + }) + + const result = await extractDocumentAttachments(file) + const text = await result?.textFile.text() ?? "" + + expect(result?.images).toEqual([]) + expect(text.includes("[Unsupported embedded image omitted: image.svg]")).toBe(true) + expect(text.includes("[Invalid embedded image omitted: fake.png]")).toBe(true) + }) + + test("bounds expanded ODF spaces", async () => { + const file = zippedFile("spaces.odt", { + "content.xml": `BeforeAfter`, + }) + + const result = await extractDocumentAttachments(file) + const text = await result?.textFile.text() ?? "" + + expect(text.includes("[Additional spaces omitted]After")).toBe(true) + expect(text.length).toBeLessThan(1_000) + }) + + test("does not retain images whose citations fall beyond the text limit", async () => { + const file = zippedFile("long.docx", { + "word/document.xml": `${"x".repeat(2_000_100)}`, + "word/_rels/document.xml.rels": relationships([{ id: "image", target: "media/image.png" }]), + "word/media/image.png": pngBytes(), + }) + + const result = await extractDocumentAttachments(file) + const text = await result?.textFile.text() ?? "" + + expect(text.length <= 2_000_000).toBe(true) + expect(text.endsWith("[Document text truncated by OpenChamber]\n")).toBe(true) + expect(text.includes("[long-image-1.png]")).toBe(false) + expect(result?.images).toEqual([]) + }) +}) diff --git a/packages/ui/src/sync/document-attachments.ts b/packages/ui/src/sync/document-attachments.ts new file mode 100644 index 00000000..4372f9ed --- /dev/null +++ b/packages/ui/src/sync/document-attachments.ts @@ -0,0 +1,596 @@ +import { unzip, unzipSync, type UnzipFileInfo, type Unzipped } from "fflate" + +const MAX_ARCHIVE_BYTES = 20 * 1024 * 1024 +const MAX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024 +const MAX_ENTRY_BYTES = 25 * 1024 * 1024 +const MAX_XML_ENTRY_BYTES = 8 * 1024 * 1024 +const MAX_ARCHIVE_ENTRIES = 5_000 +const MAX_EMBEDDED_IMAGES = 50 +const MAX_EMBEDDED_IMAGE_BYTES = 20 * 1024 * 1024 +const MAX_EMBEDDED_IMAGES_BYTES = 40 * 1024 * 1024 +const MAX_EXTRACTED_TEXT_CHARS = 2_000_000 +const MAX_ODF_SPACES_PER_ELEMENT = 100 +const TEXT_TRUNCATION_NOTICE = "\n\n[Document text truncated by OpenChamber]\n" + +const OFFICE_EXTENSIONS = new Set(["docx", "pptx", "xlsx", "odt", "odp", "ods"]) +const IMAGE_MIMES = new Map([ + ["png", "image/png"], + ["jpg", "image/jpeg"], + ["jpeg", "image/jpeg"], + ["gif", "image/gif"], + ["webp", "image/webp"], +]) + +type Relationship = { target: string; type: string } +type Relationships = Map + +type ExtractedDocumentAttachments = { + textFile: File + images: File[] +} + +const extensionOf = (name: string): string => { + const index = name.lastIndexOf(".") + return index === -1 ? "" : name.slice(index + 1).toLowerCase() +} + +const basenameWithoutExtension = (name: string): string => { + const basename = name.replace(/\\/g, "/").split("/").pop() || "document" + const index = basename.lastIndexOf(".") + return (index > 0 ? basename.slice(0, index) : basename).replace(/[^a-zA-Z0-9._-]+/g, "-") || "document" +} + +const normalizeArchivePath = (path: string): string | undefined => { + const segments: string[] = [] + for (const segment of path.replace(/\\/g, "/").split("/")) { + if (!segment || segment === ".") continue + if (segment === "..") { + if (segments.length === 0) return + segments.pop() + continue + } + segments.push(segment) + } + return segments.join("/") +} + +const resolveArchivePath = (sourcePath: string, target: string): string | undefined => { + if (target.startsWith("/")) return normalizeArchivePath(target.slice(1)) + const sourceDirectory = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/") + 1) : "" + return normalizeArchivePath(`${sourceDirectory}${target}`) +} + +const relationshipsPath = (sourcePath: string): string => { + const index = sourcePath.lastIndexOf("/") + const directory = index === -1 ? "" : sourcePath.slice(0, index + 1) + const filename = sourcePath.slice(index + 1) + return `${directory}_rels/${filename}.rels` +} + +const decodeXmlCodePoint = (code: string, radix: number): string => { + const value = Number.parseInt(code, radix) + if (value < 0 || value > 0x10FFFF || (value >= 0xD800 && value <= 0xDFFF)) return "�" + return String.fromCodePoint(value) +} + +const decodeXml = (value: string): string => value + .replace(/&#x([0-9a-f]+);/gi, (_, code: string) => decodeXmlCodePoint(code, 16)) + .replace(/&#([0-9]+);/g, (_, code: string) => decodeXmlCodePoint(code, 10)) + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&") + +const attribute = (tag: string, name: string): string | undefined => { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const match = tag.match(new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i")) + return decodeXml(match?.[1] ?? match?.[2] ?? "") || undefined +} + +const tagBlocks = (xml: string, tag: string): string[] => { + const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + return Array.from(xml.matchAll(new RegExp(`<${escaped}\\b[^>]*>[\\s\\S]*?<\\/${escaped}>`, "gi")), (match) => match[0]) +} + +const textDecoder = new TextDecoder() +const xml = (archive: Unzipped, path: string): string => { + const bytes = archive[path] + return bytes ? textDecoder.decode(bytes) : "" +} + +const parseRelationships = (archive: Unzipped, sourcePath: string): Relationships => { + const result: Relationships = new Map() + const source = xml(archive, relationshipsPath(sourcePath)) + for (const match of source.matchAll(/]*\/?\s*>/gi)) { + const id = attribute(match[0], "Id") + const target = attribute(match[0], "Target") + if (!id || !target) continue + result.set(id, { target, type: attribute(match[0], "Type") ?? "" }) + } + return result +} + +const isControlCharacter = (character: string): boolean => { + const code = character.charCodeAt(0) + return code <= 0x1F || code === 0x7F +} + +const hasControlCharacters = (value: string): boolean => Array.from(value).some(isControlCharacter) + +const embeddedImageLabel = (path: string): string => { + const basename = path.replace(/\\/g, "/").split("/").pop() || "embedded image" + return Array.from(basename.slice(0, 200), (character) => { + if (character === "[" || character === "]") return "_" + return isControlCharacter(character) ? "_" : character + }).join("") +} + +const hasBytes = (bytes: Uint8Array, expected: number[]): boolean => expected.every((value, index) => bytes[index] === value) + +const hasAscii = (bytes: Uint8Array, offset: number, expected: string): boolean => { + if (bytes.byteLength < offset + expected.length) return false + for (let index = 0; index < expected.length; index += 1) { + if (bytes[offset + index] !== expected.charCodeAt(index)) return false + } + return true +} + +const hasValidImageSignature = (bytes: Uint8Array, extension: string): boolean => { + switch (extension) { + case "png": + return hasBytes(bytes, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + case "jpg": + case "jpeg": + return hasBytes(bytes, [0xFF, 0xD8, 0xFF]) + case "gif": + return hasAscii(bytes, 0, "GIF87a") || hasAscii(bytes, 0, "GIF89a") + case "webp": + return hasAscii(bytes, 0, "RIFF") && hasAscii(bytes, 8, "WEBP") + default: + return false + } +} + +class EmbeddedImages { + private readonly files: File[] = [] + private readonly filenames = new Map() + private count = 0 + private imageBytes = 0 + private readonly reservedFilenames: Set + + constructor( + private readonly archive: Unzipped, + private readonly documentName: string, + reservedFilenames: Iterable, + ) { + this.reservedFilenames = new Set(Array.from(reservedFilenames, (filename) => filename.toLowerCase())) + } + + citation(path: string | undefined): string { + if (!path) return "[Embedded image reference could not be resolved]" + const normalized = normalizeArchivePath(path) + if (!normalized) return "[Unsafe embedded image path omitted]" + const existing = this.filenames.get(normalized) + if (existing) return `[${existing}]` + + const bytes = this.archive[normalized] + const extension = extensionOf(normalized) + const mime = IMAGE_MIMES.get(extension) + const label = embeddedImageLabel(normalized) + if (!bytes || !mime) return `[Unsupported embedded image omitted: ${label}]` + if (!hasValidImageSignature(bytes, extension)) return `[Invalid embedded image omitted: ${label}]` + if ( + this.files.length >= MAX_EMBEDDED_IMAGES + || bytes.byteLength > MAX_EMBEDDED_IMAGE_BYTES + || this.imageBytes + bytes.byteLength > MAX_EMBEDDED_IMAGES_BYTES + ) { + return `[Embedded image omitted by attachment limits: ${label}]` + } + + const outputExtension = extension === "jpeg" ? "jpg" : extension + let filename: string + do { + this.count += 1 + filename = `${basenameWithoutExtension(this.documentName)}-image-${this.count}.${outputExtension}` + } while (this.reservedFilenames.has(filename.toLowerCase())) + this.reservedFilenames.add(filename.toLowerCase()) + this.filenames.set(normalized, filename) + this.files.push(new File([bytes], filename, { type: mime })) + this.imageBytes += bytes.byteLength + return `[${filename}]` + } + + all(): File[] { + return this.files + } +} + +const relationshipTarget = (sourcePath: string, relationships: Relationships, id: string | undefined): string | undefined => { + if (!id) return + const relationship = relationships.get(id) + return relationship ? resolveArchivePath(sourcePath, relationship.target) : undefined +} + +const inlineText = ( + block: string, + sourcePath: string, + relationships: Relationships, + images: EmbeddedImages, +): string => { + const pieces: string[] = [] + const tokenPattern = /<(?:w:t|a:t|text:span)\b[^>]*>([\s\S]*?)<\/(?:w:t|a:t|text:span)>|<(?:w:tab|text:tab)\b[^>]*\/?>|<(?:w:br|a:br|text:line-break)\b[^>]*\/?>|<(?:a:blip|v:imagedata)\b[^>]*>|]*>/gi + for (const match of block.matchAll(tokenPattern)) { + if (match[1] !== undefined) { + pieces.push(decodeXml(match[1]).replace(/<[^>]+>/g, "")) + continue + } + if (/tab/i.test(match[0])) { + pieces.push("\t") + continue + } + if (/br|line-break/i.test(match[0])) { + pieces.push("\n") + continue + } + const relationshipId = attribute(match[0], "r:embed") ?? attribute(match[0], "r:id") + const directPath = attribute(match[0], "xlink:href") + const target = directPath + ? resolveArchivePath(sourcePath, directPath) + : relationshipTarget(sourcePath, relationships, relationshipId) + pieces.push(`\n${images.citation(target)}\n`) + } + return pieces.join("").replace(/[ \t]+\n/g, "\n").trim() +} + +const paragraphs = ( + source: string, + paragraphTag: string, + sourcePath: string, + relationships: Relationships, + images: EmbeddedImages, +): string[] => tagBlocks(source, paragraphTag) + .map((block) => inlineText(block, sourcePath, relationships, images)) + .filter(Boolean) + +const extractDocx = (archive: Unzipped, images: EmbeddedImages): string | undefined => { + const documentPath = "word/document.xml" + const documentXml = xml(archive, documentPath) + if (!documentXml) return + const sections = ["# Document", ...paragraphs(documentXml, "w:p", documentPath, parseRelationships(archive, documentPath), images)] + + const extras = Object.keys(archive) + .filter((path) => /^word\/(?:header|footer)\d+\.xml$/i.test(path)) + .sort() + for (const path of extras) { + const content = paragraphs(xml(archive, path), "w:p", path, parseRelationships(archive, path), images) + if (content.length > 0) sections.push(`## ${path.includes("header") ? "Header" : "Footer"}`, ...content) + } + return `${sections.join("\n\n")}\n` +} + +const numberedPaths = (archive: Unzipped, pattern: RegExp): string[] => Object.keys(archive) + .filter((path) => pattern.test(path)) + .sort((left, right) => { + const leftNumber = Number(left.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0) + const rightNumber = Number(right.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0) + return leftNumber - rightNumber + }) + +const extractPptx = (archive: Unzipped, images: EmbeddedImages): string | undefined => { + const slidePaths = numberedPaths(archive, /^ppt\/slides\/slide\d+\.xml$/i) + if (slidePaths.length === 0) return + const sections: string[] = ["# Presentation"] + + slidePaths.forEach((slidePath, index) => { + const relationships = parseRelationships(archive, slidePath) + const content = Array.from( + xml(archive, slidePath).matchAll(/]*>[\s\S]*?<\/a:p>|]*>[\s\S]*?<\/p:pic>/gi), + (match) => inlineText(match[0], slidePath, relationships, images), + ).filter(Boolean) + sections.push(`## Slide ${index + 1}`, ...(content.length > 0 ? content : ["[Empty slide]"])) + + const notesRelationship = Array.from(relationships.values()).find((relationship) => relationship.type.endsWith("/notesSlide")) + const notesPath = notesRelationship ? resolveArchivePath(slidePath, notesRelationship.target) : undefined + if (!notesPath) return + const notes = paragraphs(xml(archive, notesPath), "a:p", notesPath, parseRelationships(archive, notesPath), images) + if (notes.length > 0) sections.push(`### Slide ${index + 1} notes`, ...notes) + }) + return `${sections.join("\n\n")}\n` +} + +const columnName = (index: number): string => { + let value = index + 1 + let result = "" + while (value > 0) { + value -= 1 + result = String.fromCharCode(65 + (value % 26)) + result + value = Math.floor(value / 26) + } + return result +} + +const cellValue = (cell: string, sharedStrings: string[]): string => { + const type = attribute(cell.match(/^]*>/i)?.[0] ?? "", "t") + if (type === "inlineStr") { + return Array.from(cell.matchAll(/]*>([\s\S]*?)<\/t>/gi), (match) => decodeXml(match[1]).replace(/<[^>]+>/g, "")).join("") + } + const value = cell.match(/]*>([\s\S]*?)<\/v>/i)?.[1] ?? "" + if (type === "s") return sharedStrings[Number(value)] ?? "" + if (type === "b") return value === "1" ? "TRUE" : "FALSE" + return decodeXml(value) +} + +const drawingCitations = ( + archive: Unzipped, + worksheetPath: string, + images: EmbeddedImages, +): string[] => { + const worksheetXml = xml(archive, worksheetPath) + const worksheetRelationships = parseRelationships(archive, worksheetPath) + const output: string[] = [] + for (const drawing of worksheetXml.matchAll(/]*r:id=(?:"([^"]+)"|'([^']+)')[^>]*\/?\s*>/gi)) { + const drawingPath = relationshipTarget(worksheetPath, worksheetRelationships, drawing[1] ?? drawing[2]) + if (!drawingPath) continue + const drawingXml = xml(archive, drawingPath) + const drawingRelationships = parseRelationships(archive, drawingPath) + for (const anchor of drawingXml.matchAll(/]*>([\s\S]*?)<\/xdr:(?:oneCellAnchor|twoCellAnchor)>/gi)) { + const content = anchor[1] + const column = Number(content.match(/(\d+)<\/xdr:col>/i)?.[1] ?? 0) + const row = Number(content.match(/(\d+)<\/xdr:row>/i)?.[1] ?? 0) + const imageId = content.match(/]*r:embed=(?:"([^"]+)"|'([^']+)')[^>]*>/i) + const target = relationshipTarget(drawingPath, drawingRelationships, imageId?.[1] ?? imageId?.[2]) + output.push(`Image at ${columnName(column)}${row + 1}: ${images.citation(target)}`) + } + } + return output +} + +const extractXlsx = (archive: Unzipped, images: EmbeddedImages): string | undefined => { + const workbookPath = "xl/workbook.xml" + const workbookXml = xml(archive, workbookPath) + if (!workbookXml) return + const workbookRelationships = parseRelationships(archive, workbookPath) + const sharedStrings = tagBlocks(xml(archive, "xl/sharedStrings.xml"), "si") + .map((item) => Array.from(item.matchAll(/]*>([\s\S]*?)<\/t>/gi), (match) => decodeXml(match[1]).replace(/<[^>]+>/g, "")).join("")) + const sections: string[] = ["# Workbook"] + + for (const sheet of workbookXml.matchAll(/]*\/?\s*>/gi)) { + const name = attribute(sheet[0], "name") ?? "Sheet" + const relationshipId = attribute(sheet[0], "r:id") + const worksheetPath = relationshipTarget(workbookPath, workbookRelationships, relationshipId) + if (!worksheetPath) continue + sections.push(`## Sheet: ${name}`) + + const rows: string[] = [] + for (const row of tagBlocks(xml(archive, worksheetPath), "row")) { + const cells = Array.from(row.matchAll(/]*>[\s\S]*?<\/c>/gi), (match) => { + const tag = match[0].match(/^]*>/i)?.[0] ?? "" + const reference = attribute(tag, "r") ?? "?" + return `${reference}: ${cellValue(match[0], sharedStrings)}` + }).filter((value) => !value.endsWith(": ")) + if (cells.length > 0) rows.push(cells.join(" | ")) + } + sections.push(...(rows.length > 0 ? rows : ["[Empty sheet]"]), ...drawingCitations(archive, worksheetPath, images)) + } + return `${sections.join("\n\n")}\n` +} + +const expandOdfSpaces = (tag: string): string => { + const rawCount = attribute(tag, "text:c") + if (!rawCount) return " " + + const count = Number(rawCount) + if (Number.isSafeInteger(count) && count > 0 && count <= MAX_ODF_SPACES_PER_ELEMENT) return " ".repeat(count) + + const omitted = Number.isSafeInteger(count) && count > MAX_ODF_SPACES_PER_ELEMENT + ? `${count - MAX_ODF_SPACES_PER_ELEMENT} additional spaces omitted` + : "Additional spaces omitted" + return `${" ".repeat(MAX_ODF_SPACES_PER_ELEMENT)}[${omitted}]` +} + +const odfInlineText = (source: string, sourcePath: string, images: EmbeddedImages): string => source + .replace(/]*>/gi, (tag) => { + const target = resolveArchivePath(sourcePath, attribute(tag, "xlink:href") ?? "") + return `\n${images.citation(target)}\n` + }) + .replace(/]*\/?\s*>/gi, "\t") + .replace(/]*\/?\s*>/gi, "\n") + .replace(/]*\/?\s*>/gi, expandOdfSpaces) + .replace(/<[^>]+>/g, "") + +const odfContent = (source: string, sourcePath: string, images: EmbeddedImages): string[] => { + const output: string[] = [] + const contentPattern = /]*>[\s\S]*?<\/text:\1>|]*>/gi + for (const match of source.matchAll(contentPattern)) { + const content = /^ { + const contentPath = "content.xml" + const content = xml(archive, contentPath) + if (!content) return + return `${["# Document", ...odfContent(content, contentPath, images)].join("\n\n")}\n` +} + +const extractOdp = (archive: Unzipped, images: EmbeddedImages): string | undefined => { + const contentPath = "content.xml" + const content = xml(archive, contentPath) + if (!content) return + const sections = ["# Presentation"] + const pages = tagBlocks(content, "draw:page") + pages.forEach((page, index) => { + const openTag = page.match(/^]*>/i)?.[0] ?? "" + const name = attribute(openTag, "draw:name") ?? String(index + 1) + sections.push(`## Slide: ${name}`, ...odfContent(page, contentPath, images)) + }) + return `${sections.join("\n\n")}\n` +} + +const extractOds = (archive: Unzipped, images: EmbeddedImages): string | undefined => { + const contentPath = "content.xml" + const content = xml(archive, contentPath) + if (!content) return + const sections = ["# Workbook"] + for (const table of tagBlocks(content, "table:table")) { + const openTag = table.match(/^]*>/i)?.[0] ?? "" + sections.push(`## Sheet: ${attribute(openTag, "table:name") ?? "Sheet"}`) + for (const shapes of tagBlocks(table, "table:shapes")) { + sections.push(...odfContent(shapes, contentPath, images)) + } + for (const row of tagBlocks(table, "table:table-row")) { + const cells = tagBlocks(row, "table:table-cell") + .map((cell) => odfContent(cell, contentPath, images).join(" ")) + if (cells.some(Boolean)) sections.push(cells.join(" | ")) + } + } + return `${sections.join("\n\n")}\n` +} + +const createArchiveEntryValidator = () => { + let entries = 0 + let uncompressedBytes = 0 + + return (info: UnzipFileInfo): void => { + entries += 1 + uncompressedBytes += info.originalSize + if (entries > MAX_ARCHIVE_ENTRIES) throw new Error("Document contains too many files") + if (info.originalSize > MAX_ENTRY_BYTES) throw new Error("Document contains an oversized file") + if (/\.(?:xml|rels)$/i.test(info.name) && info.originalSize > MAX_XML_ENTRY_BYTES) { + throw new Error("Document contains XML that is too large to process safely") + } + if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) throw new Error("Document expands beyond the 100 MB safety limit") + const normalized = normalizeArchivePath(info.name) + const canonicalName = info.name.endsWith("/") ? info.name.slice(0, -1) : info.name + if ( + !normalized + || normalized !== canonicalName + || info.name.includes("\\") + || /^[a-z]:/i.test(info.name) + || hasControlCharacters(info.name) + ) { + throw new Error("Document contains an unsafe file path") + } + } +} + +const shouldExtractArchiveEntry = (info: UnzipFileInfo): boolean => { + const extension = extensionOf(info.name) + return extension === "xml" || extension === "rels" || IMAGE_MIMES.has(extension) +} + +const validateArchiveMetadata = async (data: Uint8Array): Promise => { + const validateArchiveEntry = createArchiveEntryValidator() + if ("Bun" in globalThis) { + unzipSync(data, { filter: (info) => { + validateArchiveEntry(info) + return false + } }) + return + } + + await new Promise((resolve, reject) => { + unzip(data, { filter: (info) => { + validateArchiveEntry(info) + return false + } }, (error) => { + if (error) reject(error) + else resolve() + }) + }) +} + +const validateExtractedArchive = (archive: Unzipped): void => { + let uncompressedBytes = 0 + for (const [path, bytes] of Object.entries(archive)) { + uncompressedBytes += bytes.byteLength + if (bytes.byteLength > MAX_ENTRY_BYTES) throw new Error("Document contains an oversized file") + if (/\.(?:xml|rels)$/i.test(path) && bytes.byteLength > MAX_XML_ENTRY_BYTES) { + throw new Error("Document contains XML that is too large to process safely") + } + if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) throw new Error("Document expands beyond the 100 MB safety limit") + } +} + +const unzipDocument = async (file: File): Promise => { + if (file.size > MAX_ARCHIVE_BYTES) throw new Error("Document exceeds the 20 MB attachment limit") + const data = new Uint8Array(await file.arrayBuffer()) + await validateArchiveMetadata(data) + + // Bun's browser-style Blob workers do not reliably execute fflate's async decoder. + // Production browser runtimes use the worker-backed path below. + const archive = "Bun" in globalThis + ? unzipSync(data, { filter: shouldExtractArchiveEntry }) + : await new Promise((resolve, reject) => { + unzip(data, { filter: shouldExtractArchiveEntry }, (error, result) => { + if (error) reject(error) + else resolve(result) + }) + }) + validateExtractedArchive(archive) + return archive +} + +const extractDocumentText = (extension: string, archive: Unzipped, images: EmbeddedImages): string | undefined => { + switch (extension) { + case "docx": + return extractDocx(archive, images) + case "pptx": + return extractPptx(archive, images) + case "xlsx": + return extractXlsx(archive, images) + case "odt": + return extractOdt(archive, images) + case "odp": + return extractOdp(archive, images) + case "ods": + return extractOds(archive, images) + default: + return undefined + } +} + +const boundExtractedText = (text: string, imageFilenames: Set): string => { + if (text.length <= MAX_EXTRACTED_TEXT_CHARS) return text + + let end = MAX_EXTRACTED_TEXT_CHARS - TEXT_TRUNCATION_NOTICE.length + const lastOpenBracket = text.lastIndexOf("[", end - 1) + const lastCloseBracket = text.lastIndexOf("]", end - 1) + if (lastOpenBracket > lastCloseBracket) { + const nextCloseBracket = text.indexOf("]", lastOpenBracket) + const candidate = nextCloseBracket === -1 ? "" : text.slice(lastOpenBracket + 1, nextCloseBracket) + if (imageFilenames.has(candidate)) end = lastOpenBracket + } + return `${text.slice(0, end)}${TEXT_TRUNCATION_NOTICE}` +} + +const citedImageFilenames = (text: string): Set => { + const filenames = new Set() + for (const match of text.matchAll(/\[([^\]\r\n]+)\]/g)) filenames.add(match[1]) + return filenames +} + +export const extractDocumentAttachments = async ( + file: File, + reservedFilenames: Iterable = [], +): Promise => { + const extension = extensionOf(file.name) + if (!OFFICE_EXTENSIONS.has(extension)) return + const archive = await unzipDocument(file) + const images = new EmbeddedImages(archive, file.name, reservedFilenames) + const text = extractDocumentText(extension, archive, images) + if (!text) return + const extractedImages = images.all() + const imageFilenames = new Set(extractedImages.map((image) => image.name)) + const boundedText = boundExtractedText(text, imageFilenames) + const citations = citedImageFilenames(boundedText) + return { + textFile: new File([boundedText], file.name, { type: "text/plain" }), + images: extractedImages.filter((image) => citations.has(image.name)), + } +} diff --git a/packages/ui/src/sync/input-store.test.ts b/packages/ui/src/sync/input-store.test.ts index 12426600..db6a0740 100644 --- a/packages/ui/src/sync/input-store.test.ts +++ b/packages/ui/src/sync/input-store.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test" +import { strToU8, zipSync } from "fflate" import { useInputStore } from "./input-store" class MockFileReader { @@ -41,6 +42,14 @@ const rejectReader = (reader: MockFileReader) => { reader.onerror?.call(reader as unknown as FileReader, {} as ProgressEvent) } +const waitForReaderCount = async (count: number) => { + for (let attempt = 0; attempt < 100 && pendingReaders.length < count; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)) + } +} + +const pngBytes = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + describe("input-store attachments", () => { beforeEach(() => { pendingReaders.length = 0 @@ -212,4 +221,59 @@ describe("input-store attachments", () => { expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("image/webp") expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe("data:image/webp;base64,AQID") }) + + testWithMockFileReader("adds extracted document text and referenced images atomically", async () => { + const archive = zipSync({ + "word/document.xml": strToU8(`Diagram`), + "word/_rels/document.xml.rels": strToU8(``), + "word/media/image.png": pngBytes, + }) + const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "design.docx")) + + await waitForReaderCount(1) + expect(pendingReaders).toHaveLength(1) + resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=") + await waitForReaderCount(2) + expect(pendingReaders).toHaveLength(2) + expect(useInputStore.getState().attachedFiles).toEqual([]) + resolveReader(pendingReaders[1], "data:image/png;base64,AQID") + + expect(await addPromise).toBe(true) + expect(useInputStore.getState().attachedFiles.map((attachment) => ({ + filename: attachment.filename, + mimeType: attachment.mimeType, + }))).toEqual([ + { filename: "design.docx", mimeType: "text/plain" }, + { filename: "design-image-1.png", mimeType: "image/png" }, + ]) + }) + + testWithMockFileReader("regenerates document image names when the composer changes during preparation", async () => { + const archive = zipSync({ + "word/document.xml": strToU8(``), + "word/_rels/document.xml.rels": strToU8(``), + "word/media/image.png": pngBytes, + }) + const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "design.docx")) + + await waitForReaderCount(1) + resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=") + await waitForReaderCount(2) + useInputStore.getState().addVSCodeFileAttachment("/workspace/design-image-1.png", "design-image-1.png", 1) + resolveReader(pendingReaders[1], "data:image/png;base64,AQID") + + await waitForReaderCount(3) + resolveReader(pendingReaders[2], "data:text/plain;base64,RG9jdW1lbnQ=") + await waitForReaderCount(4) + resolveReader(pendingReaders[3], "data:image/png;base64,AQID") + + expect(await addPromise).toBe(true) + expect(useInputStore.getState().attachedFiles.map((attachment) => attachment.filename)).toEqual([ + "design-image-1.png", + "design.docx", + "design-image-2.png", + ]) + const textAttachment = useInputStore.getState().attachedFiles[1] + expect((await textAttachment?.file.text())?.includes("[design-image-2.png]")).toBe(true) + }) }) diff --git a/packages/ui/src/sync/input-store.ts b/packages/ui/src/sync/input-store.ts index 3876378e..1f923198 100644 --- a/packages/ui/src/sync/input-store.ts +++ b/packages/ui/src/sync/input-store.ts @@ -5,9 +5,10 @@ import { create } from "zustand" import type { AttachedFile } from "@/stores/types/sessionTypes" -import { prepareAttachmentFile } from "./attachment-files" +import { prepareAttachmentFiles } from "./attachment-files" const FILE_URI_PREFIX = "file://" +const MAX_ATTACHMENT_PREPARATION_ATTEMPTS = 3 const pendingVSCodeSelectionKeys = new Set() let attachmentReadGeneration = 0 @@ -35,6 +36,12 @@ const toFileUrl = (filepath: string): string => { const getVSCodeSelectionKey = (path: string, filename: string): string => `${path}\u0000${filename}` +const hasGeneratedFilenameCollision = (filenames: string[], attachedFiles: AttachedFile[]): boolean => { + if (filenames.length === 0) return false + const attachedFilenames = new Set(attachedFiles.map((attachment) => attachment.filename.toLowerCase())) + return filenames.some((filename) => attachedFilenames.has(filename.toLowerCase())) +} + const readFileAsDataUrl = (file: File, mime: string): Promise => new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { @@ -157,30 +164,41 @@ export const useInputStore = create()((set, get) => ({ }, addAttachedFile: async (file: File) => { - const id = `${Date.now()}-${Math.random().toString(36).slice(2)}` const generation = attachmentReadGeneration - const preparedOrPending = prepareAttachmentFile(file) - // Keep the synchronous preparation path synchronous so FileReader starts before this action yields. - const prepared = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending - if (!prepared) return false - let dataUrl: string - try { - dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType) - } catch { - return false + for (let attempt = 0; attempt < MAX_ATTACHMENT_PREPARATION_ATTEMPTS; attempt += 1) { + const reservedFilenames = get().attachedFiles.map((attachment) => attachment.filename) + const preparedOrPending = prepareAttachmentFiles(file, reservedFilenames) + const preparedFiles = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending + if (!preparedFiles || preparedFiles.length === 0 || generation !== attachmentReadGeneration) return false + + const generatedFilenames = preparedFiles.slice(1).map((prepared) => prepared.file.name) + if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue + + const attachedFiles: AttachedFile[] = [] + for (const prepared of preparedFiles) { + let dataUrl: string + try { + dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType) + } catch { + return false + } + if (!dataUrl || generation !== attachmentReadGeneration) return false + attachedFiles.push({ + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + file: prepared.file, + dataUrl, + mimeType: prepared.mimeType, + filename: prepared.file.name, + size: prepared.file.size, + source: "local", + }) + } + + if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue + set((state) => ({ attachedFiles: [...state.attachedFiles, ...attachedFiles] })) + return true } - if (!dataUrl || generation !== attachmentReadGeneration) return false - const attached: AttachedFile = { - id, - file: prepared.file, - dataUrl, - mimeType: prepared.mimeType, - filename: prepared.file.name, - size: prepared.file.size, - source: "local", - } - set((s) => ({ attachedFiles: [...s.attachedFiles, attached] })) - return true + return false }, removeAttachedFile: (id) => diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 0479c22d..00de98d8 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -39,6 +39,8 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t - dropped-file parsing and attachment reading - models metadata fetch helper +The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`. + - `bridge-localfs-proxy-runtime.ts` - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers. diff --git a/packages/vscode/src/webviewHtml.test.ts b/packages/vscode/src/webviewHtml.test.ts new file mode 100644 index 00000000..a7c411b6 --- /dev/null +++ b/packages/vscode/src/webviewHtml.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, test } from 'node:test'; + +const source = readFileSync(new URL('./webviewHtml.ts', import.meta.url), 'utf8'); + +describe('VS Code webview content security policy', () => { + test('allows blob URLs for workers without allowing blob scripts', () => { + const workerSource = source.match(/const workerSrc = ([^\n]+);/)?.[1] ?? ''; + const scriptSource = source.match(/const scriptSrc = ([^\n]+);/)?.[1] ?? ''; + + assert.match(workerSource, /'blob:'/); + assert.doesNotMatch(scriptSource, /'blob:'/); + assert.match(source, /worker-src \$\{workerSrc\}/); + }); +}); diff --git a/packages/vscode/src/webviewHtml.ts b/packages/vscode/src/webviewHtml.ts index 197739d6..51dd18d1 100644 --- a/packages/vscode/src/webviewHtml.ts +++ b/packages/vscode/src/webviewHtml.ts @@ -68,7 +68,9 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { const connectSrc = uniqueTokens(['*', 'ws:', 'wss:', 'http:', 'https:', devServerOrigin]); const imgSrc = uniqueTokens([webview.cspSource, 'data:', 'https:', devServerOrigin]); const fontSrc = uniqueTokens([webview.cspSource, 'data:', devServerOrigin]); - const workerSrc = uniqueTokens([webview.cspSource, devServerOrigin]); + // fflate's async browser inflater creates blob-backed workers. Keep blob: + // scoped to worker-src so document decompression works without allowing blob scripts. + const workerSrc = uniqueTokens([webview.cspSource, 'blob:', devServerOrigin]); const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);