From f71a47718c6641344de98b6fd21585e3d9365063 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 18:47:36 +0200 Subject: [PATCH 1/2] feat(web): import themes from local .vsix extension packages A paid or private VS Code theme the user already owns has no import path: the dialog only accepts loose JSON files, and Open VSX only carries open-source extensions. Extract the VSIX/ZIP machinery from openVsxThemes.ts into vsixThemePackage.ts and reuse it for local files. A dropped or picked .vsix imports every contributed color theme as one collection, so re-importing the same extension offers an update instead of piling up copies. Local packages skip the registry-only gates (license allowlist, checksum) but keep every archive-safety limit. The desktop picker lists .vsix and sends package bytes base64-encoded over IPC. --- apps/desktop/src/ipc/methods/window.ts | 21 +- .../settings/ThemeImportDialog.test.ts | 34 +- .../components/settings/ThemeImportDialog.tsx | 167 +++++- apps/web/src/openVsxThemes.ts | 445 +-------------- apps/web/src/vsixThemePackage.test.ts | 145 +++++ apps/web/src/vsixThemePackage.ts | 519 ++++++++++++++++++ packages/contracts/src/ipc.ts | 8 + 7 files changed, 903 insertions(+), 436 deletions(-) create mode 100644 apps/web/src/vsixThemePackage.test.ts create mode 100644 apps/web/src/vsixThemePackage.ts diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index edae8394302c..08b235958fad 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -326,6 +326,10 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; +/** Extension packages carry icons and screenshots, so they get the same cap + * the renderer applies to a downloaded VSIX. */ +const PICKED_THEME_PACKAGE_MAX_BYTES = 20 * 1024 * 1024; + export const pickThemeFiles = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PICK_THEME_FILES_CHANNEL, payload: Schema.Undefined, @@ -345,7 +349,11 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ const paths = yield* dialog.pickFiles({ owner: yield* electronWindow.focusedMainOrFirst, defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(), - filters: [{ name: "JSON", extensions: ["json"] }], + filters: [ + { name: "Themes", extensions: ["json", "vsix"] }, + { name: "JSON", extensions: ["json"] }, + { name: "Extension package", extensions: ["vsix"] }, + ], multiple: true, }); if (paths.length === 0) { @@ -353,12 +361,21 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ } return yield* Effect.forEach(paths, (filePath) => { const name = path.basename(filePath); + const isPackage = name.toLowerCase().endsWith(".vsix"); return Effect.gen(function* () { const info = yield* fileSystem.stat(filePath); const size = Number(info.size); - if (size > PICKED_THEME_FILE_MAX_BYTES) { + const limit = isPackage ? PICKED_THEME_PACKAGE_MAX_BYTES : PICKED_THEME_FILE_MAX_BYTES; + if (size > limit) { return { name, size, text: "" } satisfies PickedThemeFile; } + // A package is binary, so it crosses the bridge base64-encoded; the + // renderer unzips it and never looks at `text`. + if (isPackage) { + const bytes = yield* fileSystem.readFile(filePath); + const contentBase64 = Buffer.from(bytes).toString("base64"); + return { name, size, text: "", contentBase64 } satisfies PickedThemeFile; + } const text = yield* fileSystem.readFileString(filePath); return { name, size, text } satisfies PickedThemeFile; }).pipe( diff --git a/apps/web/src/components/settings/ThemeImportDialog.test.ts b/apps/web/src/components/settings/ThemeImportDialog.test.ts index 6cd51e9b77ae..03777d73a3d8 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.test.ts +++ b/apps/web/src/components/settings/ThemeImportDialog.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { describeOversizedThemeFile, MAX_THEME_FILE_BYTES } from "./ThemeImportDialog"; +import { + describeOversizedThemeFile, + describeOversizedThemePackage, + isThemePackageName, + MAX_THEME_FILE_BYTES, +} from "./ThemeImportDialog"; +import { MAX_VSIX_BYTES } from "../../vsixThemePackage"; describe("theme import size guard", () => { it("accepts anything a theme file could plausibly be", () => { @@ -19,3 +25,29 @@ describe("theme import size guard", () => { expect(describeOversizedThemeFile(MAX_THEME_FILE_BYTES + 1)).toContain("256 KB"); }); }); + +describe("theme package size guard", () => { + it("accepts a package up to the VSIX limit", () => { + for (const bytes of [0, MAX_THEME_FILE_BYTES + 1, MAX_VSIX_BYTES]) { + expect(describeOversizedThemePackage(bytes)).toBeNull(); + } + }); + + it("rejects a package past the VSIX limit and names both sizes", () => { + const message = describeOversizedThemePackage(64 * 1024 * 1024); + expect(message).toContain("64.0 MB"); + expect(message).toContain("20.0 MB"); + }); +}); + +describe("theme package detection", () => { + it("recognizes .vsix regardless of case", () => { + expect(isThemePackageName("dracula-pro.vsix")).toBe(true); + expect(isThemePackageName("Dracula-Pro.VSIX")).toBe(true); + }); + + it("leaves theme JSON to the file importer", () => { + expect(isThemePackageName("dracula.json")).toBe(false); + expect(isThemePackageName("vsix")).toBe(false); + }); +}); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index 891294960028..7c0f9330847a 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -4,9 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "../../lib/utils"; import { getCustomThemes, + getStoredCustomThemeCollection, installCustomTheme, parseThemeFile, removeCustomTheme, + replaceCustomThemeCollection, THEME_FILE_VERSION, updateCustomTheme, type ThemeDefinition, @@ -18,6 +20,7 @@ import { parseVsCodeThemeFile, resolveThemeLabelCollisions, } from "../../vscodeThemeImport"; +import { importVsixThemeFile, MAX_VSIX_BYTES } from "../../vsixThemePackage"; import { Alert } from "../ui/alert"; import { Button } from "../ui/button"; import { Dialog, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog"; @@ -47,6 +50,17 @@ export function describeOversizedThemeFile(bytes: number): string | null { return `That file is ${formatByteSize(bytes)}. Theme files are only a few KB, so this one was not read (limit ${formatByteSize(MAX_THEME_FILE_BYTES)}).`; } +/** Extension packages ship icons and screenshots, so they get the larger cap + * an Open VSX download uses instead of the loose-file one. */ +export function describeOversizedThemePackage(bytes: number): string | null { + if (bytes <= MAX_VSIX_BYTES) return null; + return `That extension package is ${formatByteSize(bytes)}, past the ${formatByteSize(MAX_VSIX_BYTES)} import limit, so it was not read.`; +} + +export function isThemePackageName(name: string): boolean { + return name.toLowerCase().endsWith(".vsix"); +} + function escapeJsonHtml(value: string): string { return value.replace( /[&<>"']/g, @@ -140,8 +154,37 @@ function ThemeJsonEditor({ ); } -/** What the import pipeline needs from a file; DOM File satisfies it. */ -type ImportableThemeFile = { name: string; size: number; text: () => Promise }; +/** What the import pipeline needs from a file; DOM File satisfies it. + * `bytes` is only read for extension packages, which are binary. */ +type ImportableThemeFile = { + name: string; + size: number; + text: () => Promise; + bytes?: () => Promise; +}; + +function decodeBase64(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +function importableThemeFile(file: File): ImportableThemeFile { + return { + name: file.name, + size: file.size, + text: () => file.text(), + bytes: async () => new Uint8Array(await file.arrayBuffer()), + }; +} + +/** An extension package waiting on an update-or-cancel decision because its + * collection is already installed. */ +type PendingThemePackage = { + label: string; + collectionId: string; + themes: ReadonlyArray; + installedCollection: ReadonlyArray; +}; export function ThemeImportDialog({ open, @@ -164,6 +207,7 @@ export function ThemeImportDialog({ // Imports whose id is already installed wait here for an update-or-copy // decision instead of failing. const [conflicts, setConflicts] = useState | null>(null); + const [pendingPackage, setPendingPackage] = useState(null); const importRequestRef = useRef(0); useEffect(() => { @@ -177,6 +221,7 @@ export function ThemeImportDialog({ setError(null); setIsReading(false); setConflicts(null); + setPendingPackage(null); }, [open]); const readThemeFile = useCallback(async (file: ImportableThemeFile) => { @@ -263,13 +308,92 @@ export function ThemeImportDialog({ [onImportedMany, onOpenChange], ); + // A package installs as one collection so a later import of the same + // extension replaces its variants instead of piling up copies. + const installThemePackage = useCallback( + (pending: PendingThemePackage) => { + try { + const imported = replaceCustomThemeCollection(pending.collectionId, pending.themes, { + expectedCollection: pending.installedCollection, + }); + setPendingPackage(null); + onImportedMany(imported, { updated: pending.installedCollection.length > 0 }); + onOpenChange(false); + } catch (cause) { + setPendingPackage(null); + setError( + cause instanceof Error ? cause.message : "That extension package could not be installed.", + ); + } + }, + [onImportedMany, onOpenChange], + ); + + const readThemePackage = useCallback( + async (file: ImportableThemeFile) => { + // Check the size before unzipping: expanding a huge archive is what + // would lock the UI. + const oversized = describeOversizedThemePackage(file.size); + if (oversized) { + setError(oversized); + return; + } + + const requestId = ++importRequestRef.current; + setIsReading(true); + setError(null); + try { + const bytes = await file.bytes?.(); + if (requestId !== importRequestRef.current) return; + if (!bytes || bytes.byteLength === 0) { + setError("Could not read that extension package."); + return; + } + const themes = await importVsixThemeFile({ name: file.name, bytes }); + if (requestId !== importRequestRef.current) return; + const collection = themes[0]?.collection; + if (!collection) throw new Error("That extension has no compatible color themes."); + const installedCollection = getStoredCustomThemeCollection(collection.id); + const pending = { + label: collection.label, + collectionId: collection.id, + themes, + installedCollection, + }; + setFileName(file.name); + // An extension already installed under this collection waits for an + // explicit update: replacing it drops local edits. + if (installedCollection.length > 0) setPendingPackage(pending); + else installThemePackage(pending); + } catch (cause) { + if (requestId !== importRequestRef.current) return; + setError( + cause instanceof Error ? cause.message : "That extension package could not be imported.", + ); + } finally { + if (requestId === importRequestRef.current) setIsReading(false); + } + }, + [installThemePackage], + ); + const readThemeFiles = useCallback( (files: ReadonlyArray) => { if (files.length === 0) return; + if (files.some((file) => isThemePackageName(file.name))) { + // A package expands into a whole collection with its own update + // prompt, so it imports on its own rather than inside a batch. + if (files.length > 1) { + setError("Import one .vsix extension package at a time."); + return; + } + void readThemePackage(files[0]!); + return; + } if (files.length === 1) void readThemeFile(files[0]!); else void readThemeBatch(files); }, - [readThemeBatch, readThemeFile], + [readThemeBatch, readThemeFile, readThemePackage], ); // On desktop the native picker opens in ~/.vscode/extensions (when it @@ -285,6 +409,9 @@ export function ThemeImportDialog({ name: file.name, size: file.size, text: () => Promise.resolve(file.text), + ...(file.contentBase64 === undefined + ? {} + : { bytes: () => Promise.resolve(decodeBase64(file.contentBase64!)) }), })), ); }); @@ -297,7 +424,7 @@ export function ThemeImportDialog({ (event: ChangeEvent) => { const files = [...(event.currentTarget.files ?? [])]; event.currentTarget.value = ""; - readThemeFiles(files); + readThemeFiles(files.map(importableThemeFile)); }, [readThemeFiles], ); @@ -306,7 +433,7 @@ export function ThemeImportDialog({ (event: DragEvent) => { event.preventDefault(); setIsDropTarget(false); - readThemeFiles([...event.dataTransfer.files]); + readThemeFiles([...event.dataTransfer.files].map(importableThemeFile)); }, [readThemeFiles], ); @@ -464,7 +591,7 @@ export function ThemeImportDialog({ const fileInput = ( ); + if (pendingPackage) { + return ( +
+
+

+ “{pendingPackage.label}” is already installed +

+

+ Updating replaces its installed variants, including any local edits. Variants + no longer in the package will be removed. +

+
+
+ + + +
+
+ ); + } if (conflicts) { return (
@@ -525,7 +678,7 @@ export function ThemeImportDialog({

Theme file

- {fileName ?? "Drop T3 Code or VS Code .json files"} + {fileName ?? "Drop T3 Code or VS Code .json files, or a .vsix extension"}

{chooseButton()} diff --git a/apps/web/src/openVsxThemes.ts b/apps/web/src/openVsxThemes.ts index 8b03d84869b5..a855af93bd7f 100644 --- a/apps/web/src/openVsxThemes.ts +++ b/apps/web/src/openVsxThemes.ts @@ -1,30 +1,24 @@ import { sha256 } from "@noble/hashes/sha2"; -import JSZip from "jszip"; -import { parse, type ParseError } from "jsonc-parser"; import type { ThemeDefinition } from "./themePalette"; import { - isVsCodeThemeFile, - pairVsCodeThemes, - parseVsCodeThemeFile, - resolveThemeLabelCollisions, -} from "./vscodeThemeImport"; + isRecord, + MAX_THEMES_PER_EXTENSION, + MAX_VSIX_BYTES, + openThemePackage, + parseJsoncObject, + readPackagedManifest, + shortHash, + themeContributions, + themesFromPackage, + type ThemePackageIdentity, +} from "./vsixThemePackage"; const OPEN_VSX_SEARCH_URL = "https://open-vsx.org/api/-/search"; -const MAX_VSIX_BYTES = 20 * 1024 * 1024; const MAX_SEARCH_BYTES = 512 * 1024; const MAX_DETAIL_BYTES = 256 * 1024; const MAX_MANIFEST_BYTES = 256 * 1024; const SEARCH_REQUEST_TIMEOUT_MS = 10_000; -const MAX_THEME_BYTES = 256 * 1024; -const MAX_ZIP_ENTRIES = 5_000; -const MAX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024; -const MAX_COMPRESSION_RATIO = 200; -const MAX_THEMES_PER_EXTENSION = 40; -const MAX_INCLUDE_DEPTH = 8; -const MAX_PACKAGE_PATH_LENGTH = 1_024; -const MAX_COLOR_VALUE_LENGTH = 128; -const MAX_RESOLVED_THEME_FILES = MAX_THEMES_PER_EXTENSION * MAX_INCLUDE_DEPTH; const SUPPORTED_LICENSES = new Set([ "0BSD", "Apache-2.0", @@ -36,50 +30,6 @@ const SUPPORTED_LICENSES = new Set([ "MPL-2.0", "Unlicense", ]); -const USED_WORKBENCH_COLORS = new Set([ - "activityBar.background", - "activityBarBadge.background", - "badge.background", - "button.background", - "button.foreground", - "contrastBorder", - "descriptionForeground", - "disabledForeground", - "dropdown.background", - "dropdown.border", - "editor.background", - "editor.foreground", - "editor.selectionBackground", - "editorCursor.foreground", - "editorError.foreground", - "editorGroup.border", - "editorPane.background", - "editorWarning.foreground", - "editorWidget.background", - "errorForeground", - "focusBorder", - "foreground", - "input.border", - "input.placeholderForeground", - "list.activeSelectionBackground", - "list.hoverBackground", - "list.inactiveSelectionBackground", - "menu.background", - "panel.background", - "panel.border", - "progressBar.background", - "quickInput.background", - "scrollbarSlider.background", - "sideBar.background", - "sideBar.border", - "sideBar.foreground", - "terminal.background", - "terminal.foreground", - "terminal.selectionBackground", - "terminalCursor.foreground", - "textCodeBlock.background", - "textLink.foreground", -]); export type OpenVsxThemeSort = "downloadCount" | "rating" | "timestamp" | "relevance"; @@ -104,23 +54,6 @@ export type OpenVsxThemeSearchOptions = { sortBy?: OpenVsxThemeSort; }; -type ThemeContribution = { label?: unknown; uiTheme?: unknown; path?: unknown }; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function shortHash(value: string): string { - return [...sha256(new TextEncoder().encode(value))] - .slice(0, 6) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - -function openVsxThemeId(extensionId: string, source: string): string { - return `ovx-theme-${shortHash(`${extensionId}:${source}`)}`; -} - function openVsxCollectionId(extensionId: string): string { const normalized = `open-vsx:${extensionId.toLowerCase()}`; return /^[a-z0-9][a-z0-9.:-]{0,127}$/.test(normalized) @@ -156,13 +89,6 @@ function publicSourceUrl(value: unknown): string | null { } } -function themeContributions(manifest: Record): ThemeContribution[] { - const contributes = isRecord(manifest.contributes) ? manifest.contributes : null; - return Array.isArray(contributes?.themes) - ? (contributes.themes.filter(isRecord) as ThemeContribution[]) - : []; -} - function manifestLicenseMatches(manifest: Record, license: string): boolean { return ( typeof manifest.license === "string" && @@ -321,256 +247,6 @@ export async function searchOpenVsxThemes( return completedDetails.flatMap((result) => (result.value ? [result.value] : [])).slice(0, 8); } -function parseJsoncObject(source: string, description: string): Record { - const errors: ParseError[] = []; - const value: unknown = parse(source, errors, { allowTrailingComma: true }); - if (errors.length > 0 || !isRecord(value)) throw new Error(`${description} is not valid JSON.`); - return value; -} - -function sanitizeThemeObject(value: Record): Record { - const colors: Record = {}; - if (isRecord(value.colors)) { - for (const [key, color] of Object.entries(value.colors)) { - if ( - USED_WORKBENCH_COLORS.has(key) && - typeof color === "string" && - color.length <= MAX_COLOR_VALUE_LENGTH - ) { - colors[key] = color; - } - } - } - return { - ...(typeof value.include === "string" ? { include: value.include } : {}), - colors, - }; -} - -function normalizePackagePath(path: string, relativeTo = "extension/"): string { - if ( - path.length > MAX_PACKAGE_PATH_LENGTH || - path.includes("\0") || - path.startsWith("/") || - /^[a-zA-Z]:/.test(path) - ) { - throw new Error("Theme path is not a safe relative package path."); - } - const normalizedInput = path.replaceAll("\\", "/"); - const baseSegments = relativeTo.split("/").slice(0, -1); - const segments = baseSegments; - for (const segment of normalizedInput.split("/")) { - if (!segment || segment === ".") continue; - if (segment === "..") { - if (segments.length <= 1) throw new Error("Theme path escapes the extension package."); - segments.pop(); - continue; - } - segments.push(segment); - } - if (segments[0] !== "extension") segments.unshift("extension"); - return segments.join("/"); -} - -function contributionType(uiTheme: unknown): string | null { - if (uiTheme === "vs") return "light"; - if (uiTheme === "vs-dark") return "dark"; - if (uiTheme === "hc-black" || uiTheme === "hc-light") return uiTheme; - return null; -} - -type ZipEntrySizes = { - uncompressedSize?: unknown; -}; - -type InspectableZipObject = JSZip.JSZipObject & { - _data?: ZipEntrySizes; - unsafeOriginalName?: string; - internalStream?: (type: "uint8array") => JSZip.JSZipStreamHelper; -}; - -function inspectZipDirectory(bytes: Uint8Array): Uint8Array { - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const minimumOffset = Math.max(0, bytes.byteLength - 65_557); - let endOffset = bytes.byteLength - 22; - while ( - endOffset >= minimumOffset && - (view.getUint32(endOffset, true) !== 0x06054b50 || - endOffset + 22 + view.getUint16(endOffset + 20, true) !== bytes.byteLength) - ) { - endOffset -= 1; - } - if (endOffset < minimumOffset) throw new Error("That extension package has no ZIP directory."); - - const directorySize = view.getUint32(endOffset + 12, true); - const directoryOffset = view.getUint32(endOffset + 16, true); - const directoryEnd = directoryOffset + directorySize; - if (directoryEnd !== endOffset || directoryEnd > bytes.byteLength) { - throw new Error("That extension package has an invalid ZIP directory."); - } - - let entryCount = 0; - let totalUncompressed = 0; - let offset = directoryOffset; - while (offset < directoryEnd) { - if (offset + 46 > directoryEnd || view.getUint32(offset, true) !== 0x02014b50) { - throw new Error("That extension package has an invalid ZIP directory."); - } - entryCount += 1; - if (entryCount > MAX_ZIP_ENTRIES) { - throw new Error("That extension package has too many files."); - } - const compressed = view.getUint32(offset + 20, true); - const uncompressed = view.getUint32(offset + 24, true); - if (compressed === 0xffffffff || uncompressed === 0xffffffff) { - throw new Error("That extension package has unsupported ZIP64 metadata."); - } - totalUncompressed += uncompressed; - if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) { - throw new Error("That extension package expands beyond the safe import limit."); - } - if ( - uncompressed > 0 && - (compressed === 0 || uncompressed / compressed > MAX_COMPRESSION_RATIO) - ) { - throw new Error("That extension package has an unsafe compression ratio."); - } - const nameLength = view.getUint16(offset + 28, true); - const extraLength = view.getUint16(offset + 30, true); - const commentLength = view.getUint16(offset + 32, true); - offset += 46 + nameLength + extraLength + commentLength; - } - if (offset !== directoryEnd) - throw new Error("That extension package has an invalid ZIP directory."); - - const commentLength = view.getUint16(endOffset + 20, true); - if (commentLength === 0) return bytes; - - // JSZip mistakes EOCD-like bytes inside an archive comment for the real EOCD. - // The comment is not needed for theme import, so remove it before parsing. - const withoutComment = bytes.slice(0, endOffset + 22); - withoutComment[endOffset + 20] = 0; - withoutComment[endOffset + 21] = 0; - return withoutComment; -} - -function inspectZip(zip: JSZip): void { - const entries = Object.values(zip.files) as InspectableZipObject[]; - if (entries.length > MAX_ZIP_ENTRIES) - throw new Error("That extension package has too many files."); - - for (const entry of entries) { - if (entry.unsafeOriginalName) normalizePackagePath(entry.unsafeOriginalName); - } -} - -async function readZipText( - zip: JSZip, - path: string, - description: string, - signal?: AbortSignal, -): Promise { - signal?.throwIfAborted(); - const file = zip.file(path) as InspectableZipObject | null; - if (!file) throw new Error(`${description} is missing from the extension package.`); - if (typeof file._data?.uncompressedSize !== "number" || !file.internalStream) { - throw new Error(`${description} has unreadable size metadata.`); - } - if (file._data.uncompressedSize > MAX_THEME_BYTES) { - throw new Error(`${description} is too large.`); - } - - return new Promise((resolve, reject) => { - const chunks: Uint8Array[] = []; - let byteLength = 0; - let settled = false; - const stream = file.internalStream!("uint8array"); - const cleanup = () => signal?.removeEventListener("abort", handleAbort); - const handleAbort = () => { - if (settled) return; - settled = true; - stream.pause(); - cleanup(); - reject(signal?.reason); - }; - signal?.addEventListener("abort", handleAbort, { once: true }); - stream - .on("data", (chunk) => { - if (settled) return; - byteLength += chunk.byteLength; - if (byteLength > MAX_THEME_BYTES) { - settled = true; - stream.pause(); - cleanup(); - reject(new Error(`${description} is too large.`)); - return; - } - chunks.push(chunk); - }) - .on("error", (cause) => { - if (settled) return; - settled = true; - cleanup(); - reject(cause); - }) - .on("end", () => { - if (settled) return; - settled = true; - cleanup(); - const bytes = new Uint8Array(byteLength); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - resolve(new TextDecoder().decode(bytes)); - }) - .resume(); - }); -} - -async function loadThemeObject( - zip: JSZip, - path: string, - cache: Map>, - budget: { files: number }, - ancestors: ReadonlySet = new Set(), - signal?: AbortSignal, -): Promise> { - signal?.throwIfAborted(); - if (ancestors.size >= MAX_INCLUDE_DEPTH) throw new Error("Theme includes are nested too deeply."); - if (ancestors.has(path)) throw new Error("Theme includes contain a cycle."); - const cached = cache.get(path); - if (cached) return cached; - budget.files += 1; - if (budget.files > MAX_RESOLVED_THEME_FILES) { - throw new Error("That extension references too many theme files."); - } - - const value = sanitizeThemeObject( - parseJsoncObject(await readZipText(zip, path, path, signal), path), - ); - if (typeof value.include !== "string") { - cache.set(path, value); - return value; - } - - const includePath = normalizePackagePath(value.include, path); - const nextAncestors = new Set(ancestors); - nextAncestors.add(path); - const base = await loadThemeObject(zip, includePath, cache, budget, nextAncestors, signal); - const resolved = { - ...base, - ...value, - colors: { - ...(isRecord(base.colors) ? base.colors : {}), - ...(isRecord(value.colors) ? value.colors : {}), - }, - }; - cache.set(path, resolved); - return resolved; -} - async function readCappedResponse( response: Response, limit: number, @@ -666,22 +342,9 @@ export async function importOpenVsxThemeExtension( throw new Error("That Open VSX theme failed its integrity check."); } signal?.throwIfAborted(); - let zip: JSZip; - try { - const inspectedPackageBytes = inspectZipDirectory(packageBytes); - zip = await JSZip.loadAsync(inspectedPackageBytes); - signal?.throwIfAborted(); - inspectZip(zip); - } catch (cause) { - if (signal?.aborted) signal.throwIfAborted(); - if (cause instanceof Error && cause.message.startsWith("That extension package")) throw cause; - throw new Error("That Open VSX extension package could not be opened.", { cause }); - } + const zip = await openThemePackage(packageBytes, signal); - const packagedManifest = parseJsoncObject( - await readZipText(zip, "extension/package.json", "Extension manifest", signal), - "Extension manifest", - ); + const packagedManifest = await readPackagedManifest(zip, signal); if ( typeof packagedManifest.publisher !== "string" || packagedManifest.publisher.toLowerCase() !== extension.publisher.toLowerCase() || @@ -695,82 +358,12 @@ export async function importOpenVsxThemeExtension( if (!manifestLicenseMatches(packagedManifest, extension.license)) { throw new Error("That extension package does not match its advertised license."); } - const contributions = themeContributions(packagedManifest); - if (contributions.length === 0) throw new Error("That extension does not contain color themes."); - if (contributions.length > MAX_THEMES_PER_EXTENSION) { - throw new Error("That extension contains too many color themes to import safely."); - } - const parsed: Array<{ theme: ThemeDefinition; sourceName: string; sourcePath: string }> = []; - const failures: string[] = []; - const themeCache = new Map>(); - const themeBudget = { files: 0 }; - for (const contribution of contributions) { - signal?.throwIfAborted(); - if (typeof contribution.path !== "string") { - failures.push("theme path is missing"); - continue; - } - try { - const path = normalizePackagePath(contribution.path); - const themeValue = await loadThemeObject( - zip, - path, - themeCache, - themeBudget, - new Set(), - signal, - ); - const type = contributionType(contribution.uiTheme); - const label = - typeof contribution.label === "string" && contribution.label.trim() - ? contribution.label.trim() - : extension.name; - const decorated = { - ...themeValue, - displayName: label, - ...(type ? { type } : {}), - }; - if (!isVsCodeThemeFile(decorated)) throw new Error("not a VS Code color theme"); - parsed.push({ - theme: parseVsCodeThemeFile(decorated), - sourceName: path.split("/").at(-1)!, - sourcePath: path, - }); - } catch (cause) { - signal?.throwIfAborted(); - failures.push(cause instanceof Error ? cause.message : "theme could not be read"); - } - } - if (failures.length > 0) { - throw new Error("One or more color themes in that extension could not be imported safely."); - } - if (parsed.length === 0) { - throw new Error("That extension has no compatible color themes."); - } - const extensionId = extension.id.toLowerCase(); - const sourcePathCounts = new Map(); - for (const { sourcePath } of parsed) { - sourcePathCounts.set(sourcePath, (sourcePathCounts.get(sourcePath) ?? 0) + 1); - } - const sourcePathOccurrences = new Map(); - const sourceIdentities = parsed.map(({ sourcePath }) => { - if (sourcePathCounts.get(sourcePath) === 1) return sourcePath; - const occurrence = sourcePathOccurrences.get(sourcePath) ?? 0; - sourcePathOccurrences.set(sourcePath, occurrence + 1); - return occurrence === 0 ? sourcePath : `${sourcePath}\0${occurrence}`; - }); - const resolved = resolveThemeLabelCollisions(parsed).map((theme, index) => ({ - ...theme, - id: openVsxThemeId(extensionId, sourceIdentities[index]!), - })); - const paired = pairVsCodeThemes(resolved, { - pairedId: (light, dark) => openVsxThemeId(extensionId, [light.id, dark.id].sort().join(":")), - }); - const themes = resolveThemeLabelCollisions(paired.map((theme) => ({ theme }))); - const collection = { - id: extension.collectionId, - label: extension.name.slice(0, 48), + const identity: ThemePackageIdentity = { + idPrefix: "ovx-theme", + key: extension.id.toLowerCase(), + name: extension.name, + collection: { id: extension.collectionId, label: extension.name.slice(0, 48) }, }; - return themes.map((theme) => ({ ...theme, collection })); + return themesFromPackage(zip, packagedManifest, identity, signal); } diff --git a/apps/web/src/vsixThemePackage.test.ts b/apps/web/src/vsixThemePackage.test.ts new file mode 100644 index 000000000000..c82dcd542974 --- /dev/null +++ b/apps/web/src/vsixThemePackage.test.ts @@ -0,0 +1,145 @@ +import JSZip from "jszip"; +import { describe, expect, it } from "vite-plus/test"; + +import { getThemeColorsForMode, themeColorToHex } from "./themePalette"; +import { importVsixThemeFile, MAX_VSIX_BYTES } from "./vsixThemePackage"; + +const DARK_THEME = JSON.stringify({ + colors: { "editor.background": "#111111", "editor.foreground": "#eeeeee" }, +}); +const LIGHT_THEME = JSON.stringify({ + colors: { "editor.background": "#fafafa", "editor.foreground": "#222222" }, +}); + +function draculaProManifest(overrides: Record = {}) { + return { + name: "theme-dracula-pro", + displayName: "Dracula Pro", + version: "2.2.2", + publisher: "dracula-theme-pro", + // Paid themes are not open source. A local file is the user's own copy, + // so the license gate that applies to Open VSX must not apply here. + license: "proprietary", + contributes: { + themes: [ + { label: "Dracula Pro", uiTheme: "vs-dark", path: "./theme/dracula-pro.json" }, + { + label: "Dracula Pro (Alucard)", + uiTheme: "vs", + path: "./theme/dracula-pro-alucard.json", + }, + ], + }, + ...overrides, + }; +} + +async function vsixBytes(manifest: Record): Promise { + const zip = new JSZip(); + // A real .vsix carries OPC metadata outside `extension/` plus assets the + // import ignores. + zip.file("extension.vsixmanifest", ""); + zip.file("[Content_Types].xml", ""); + zip.file("extension/README.md", "# Dracula Pro"); + zip.file("extension/package.json", JSON.stringify(manifest)); + zip.file("extension/theme/dracula-pro.json", DARK_THEME); + zip.file("extension/theme/dracula-pro-alucard.json", LIGHT_THEME); + return new Uint8Array(await zip.generateAsync({ type: "uint8array" })); +} + +describe("local .vsix theme import", () => { + it("imports a proprietary package as one collection with stable ids", async () => { + const bytes = await vsixBytes(draculaProManifest()); + + const themes = await importVsixThemeFile({ name: "dracula-pro.vsix", bytes }); + + expect(themes).toHaveLength(2); + expect(themes.map((theme) => theme.label)).toEqual(["Dracula Pro", "Dracula Pro (Alucard)"]); + expect( + themes.every( + (theme) => theme.collection?.id === "local-vsix:dracula-theme-pro.theme-dracula-pro", + ), + ).toBe(true); + expect(themes.every((theme) => theme.collection?.label === "Dracula Pro")).toBe(true); + // A local install must never collide with the same extension installed + // from Open VSX, so it carries its own id prefix. + expect(themes.every((theme) => /^vsix-theme-[0-9a-f]{12}$/.test(theme.id))).toBe(true); + expect(new Set(themes.map((theme) => theme.id)).size).toBe(2); + expect(themeColorToHex(themes[0]!.colors.canvas)).toBe("#111111"); + expect(themes[1]!.appearance).toBe("light"); + + // Re-importing the same package under a different file name reuses the + // manifest identity, so an update replaces rather than duplicates. + const reimported = await importVsixThemeFile({ name: "dracula-pro-2.2.2.vsix", bytes }); + expect(reimported.map((theme) => theme.id)).toEqual(themes.map((theme) => theme.id)); + }); + + it("pairs light and dark variants that share a name", async () => { + const bytes = await vsixBytes( + draculaProManifest({ + displayName: "Demo", + contributes: { + themes: [ + { label: "Demo Dark", uiTheme: "vs-dark", path: "./theme/dracula-pro.json" }, + { label: "Demo Light", uiTheme: "vs", path: "./theme/dracula-pro-alucard.json" }, + ], + }, + }), + ); + + const themes = await importVsixThemeFile({ name: "demo.vsix", bytes }); + + expect(themes).toHaveLength(1); + expect(themeColorToHex(getThemeColorsForMode(themes[0]!, "light")!.canvas)).toBe("#fafafa"); + expect(themeColorToHex(getThemeColorsForMode(themes[0]!, "dark")!.canvas)).toBe("#111111"); + }); + + it("falls back to the file name when the manifest has no identity", async () => { + const manifest = draculaProManifest(); + Reflect.deleteProperty(manifest, "publisher"); + Reflect.deleteProperty(manifest, "name"); + Reflect.deleteProperty(manifest, "displayName"); + const bytes = await vsixBytes(manifest); + + const themes = await importVsixThemeFile({ name: "my-theme-pack.vsix", bytes }); + + expect(themes[0]!.collection?.id).toBe("local-vsix:my-theme-pack"); + expect(themes[0]!.collection?.label).toBe("My Theme Pack"); + }); + + it("rejects packages without color themes", async () => { + const manifest = draculaProManifest({ contributes: { commands: [] } }); + const bytes = await vsixBytes(manifest); + + await expect(importVsixThemeFile({ name: "empty.vsix", bytes })).rejects.toThrow( + "does not contain color themes", + ); + }); + + it("rejects a contribution whose theme file is missing", async () => { + const bytes = await vsixBytes( + draculaProManifest({ + contributes: { themes: [{ label: "Gone", path: "./theme/missing.json" }] }, + }), + ); + + await expect(importVsixThemeFile({ name: "broken.vsix", bytes })).rejects.toThrow( + "could not be imported safely", + ); + }); + + it("rejects a file that is not a ZIP archive", async () => { + await expect( + importVsixThemeFile({ name: "notes.vsix", bytes: new Uint8Array([1, 2, 3]) }), + ).rejects.toThrow("extension package"); + }); + + it("rejects an oversized package before opening it", async () => { + await expect( + importVsixThemeFile({ + name: "huge.vsix", + bytes: new Uint8Array(MAX_VSIX_BYTES + 1), + }), + ).rejects.toThrow("too large to import safely"); + }); +}); diff --git a/apps/web/src/vsixThemePackage.ts b/apps/web/src/vsixThemePackage.ts new file mode 100644 index 000000000000..415924b4597d --- /dev/null +++ b/apps/web/src/vsixThemePackage.ts @@ -0,0 +1,519 @@ +import { sha256 } from "@noble/hashes/sha2"; +import JSZip from "jszip"; +import { parse, type ParseError } from "jsonc-parser"; + +import type { ThemeCollection, ThemeDefinition } from "./themePalette"; +import { + humanizeThemeName, + isVsCodeThemeFile, + pairVsCodeThemes, + parseVsCodeThemeFile, + resolveThemeLabelCollisions, +} from "./vscodeThemeImport"; + +export const MAX_VSIX_BYTES = 20 * 1024 * 1024; +const MAX_THEME_BYTES = 256 * 1024; +const MAX_ZIP_ENTRIES = 5_000; +const MAX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024; +const MAX_COMPRESSION_RATIO = 200; +export const MAX_THEMES_PER_EXTENSION = 40; +const MAX_INCLUDE_DEPTH = 8; +const MAX_PACKAGE_PATH_LENGTH = 1_024; +const MAX_COLOR_VALUE_LENGTH = 128; +const MAX_RESOLVED_THEME_FILES = MAX_THEMES_PER_EXTENSION * MAX_INCLUDE_DEPTH; +const USED_WORKBENCH_COLORS = new Set([ + "activityBar.background", + "activityBarBadge.background", + "badge.background", + "button.background", + "button.foreground", + "contrastBorder", + "descriptionForeground", + "disabledForeground", + "dropdown.background", + "dropdown.border", + "editor.background", + "editor.foreground", + "editor.selectionBackground", + "editorCursor.foreground", + "editorError.foreground", + "editorGroup.border", + "editorPane.background", + "editorWarning.foreground", + "editorWidget.background", + "errorForeground", + "focusBorder", + "foreground", + "input.border", + "input.placeholderForeground", + "list.activeSelectionBackground", + "list.hoverBackground", + "list.inactiveSelectionBackground", + "menu.background", + "panel.background", + "panel.border", + "progressBar.background", + "quickInput.background", + "scrollbarSlider.background", + "sideBar.background", + "sideBar.border", + "sideBar.foreground", + "terminal.background", + "terminal.foreground", + "terminal.selectionBackground", + "terminalCursor.foreground", + "textCodeBlock.background", + "textLink.foreground", +]); + +type ThemeContribution = { label?: unknown; uiTheme?: unknown; path?: unknown }; + +/** Where a package came from, so ids stay stable per source and two installs + * of the same extension from different sources cannot collide. */ +export type ThemePackageIdentity = { + /** Prefix for generated theme ids, one per import source. */ + idPrefix: string; + /** Stable key the ids hash from, usually `publisher.name`. */ + key: string; + /** Fallback label for contributions that ship without one. */ + name: string; + collection: ThemeCollection; +}; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function shortHash(value: string): string { + return [...sha256(new TextEncoder().encode(value))] + .slice(0, 6) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export function parseJsoncObject(source: string, description: string): Record { + const errors: ParseError[] = []; + const value: unknown = parse(source, errors, { allowTrailingComma: true }); + if (errors.length > 0 || !isRecord(value)) throw new Error(`${description} is not valid JSON.`); + return value; +} + +export function themeContributions(manifest: Record): ThemeContribution[] { + const contributes = isRecord(manifest.contributes) ? manifest.contributes : null; + return Array.isArray(contributes?.themes) + ? (contributes.themes.filter(isRecord) as ThemeContribution[]) + : []; +} + +function sanitizeThemeObject(value: Record): Record { + const colors: Record = {}; + if (isRecord(value.colors)) { + for (const [key, color] of Object.entries(value.colors)) { + if ( + USED_WORKBENCH_COLORS.has(key) && + typeof color === "string" && + color.length <= MAX_COLOR_VALUE_LENGTH + ) { + colors[key] = color; + } + } + } + return { + ...(typeof value.include === "string" ? { include: value.include } : {}), + colors, + }; +} + +function normalizePackagePath(path: string, relativeTo = "extension/"): string { + if ( + path.length > MAX_PACKAGE_PATH_LENGTH || + path.includes("\0") || + path.startsWith("/") || + /^[a-zA-Z]:/.test(path) + ) { + throw new Error("Theme path is not a safe relative package path."); + } + const normalizedInput = path.replaceAll("\\", "/"); + const baseSegments = relativeTo.split("/").slice(0, -1); + const segments = baseSegments; + for (const segment of normalizedInput.split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") { + if (segments.length <= 1) throw new Error("Theme path escapes the extension package."); + segments.pop(); + continue; + } + segments.push(segment); + } + if (segments[0] !== "extension") segments.unshift("extension"); + return segments.join("/"); +} + +function contributionType(uiTheme: unknown): string | null { + if (uiTheme === "vs") return "light"; + if (uiTheme === "vs-dark") return "dark"; + if (uiTheme === "hc-black" || uiTheme === "hc-light") return uiTheme; + return null; +} + +type ZipEntrySizes = { + uncompressedSize?: unknown; +}; + +type InspectableZipObject = JSZip.JSZipObject & { + _data?: ZipEntrySizes; + unsafeOriginalName?: string; + internalStream?: (type: "uint8array") => JSZip.JSZipStreamHelper; +}; + +function inspectZipDirectory(bytes: Uint8Array): Uint8Array { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const minimumOffset = Math.max(0, bytes.byteLength - 65_557); + let endOffset = bytes.byteLength - 22; + while ( + endOffset >= minimumOffset && + (view.getUint32(endOffset, true) !== 0x06054b50 || + endOffset + 22 + view.getUint16(endOffset + 20, true) !== bytes.byteLength) + ) { + endOffset -= 1; + } + if (endOffset < minimumOffset) throw new Error("That extension package has no ZIP directory."); + + const directorySize = view.getUint32(endOffset + 12, true); + const directoryOffset = view.getUint32(endOffset + 16, true); + const directoryEnd = directoryOffset + directorySize; + if (directoryEnd !== endOffset || directoryEnd > bytes.byteLength) { + throw new Error("That extension package has an invalid ZIP directory."); + } + + let entryCount = 0; + let totalUncompressed = 0; + let offset = directoryOffset; + while (offset < directoryEnd) { + if (offset + 46 > directoryEnd || view.getUint32(offset, true) !== 0x02014b50) { + throw new Error("That extension package has an invalid ZIP directory."); + } + entryCount += 1; + if (entryCount > MAX_ZIP_ENTRIES) { + throw new Error("That extension package has too many files."); + } + const compressed = view.getUint32(offset + 20, true); + const uncompressed = view.getUint32(offset + 24, true); + if (compressed === 0xffffffff || uncompressed === 0xffffffff) { + throw new Error("That extension package has unsupported ZIP64 metadata."); + } + totalUncompressed += uncompressed; + if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) { + throw new Error("That extension package expands beyond the safe import limit."); + } + if ( + uncompressed > 0 && + (compressed === 0 || uncompressed / compressed > MAX_COMPRESSION_RATIO) + ) { + throw new Error("That extension package has an unsafe compression ratio."); + } + const nameLength = view.getUint16(offset + 28, true); + const extraLength = view.getUint16(offset + 30, true); + const commentLength = view.getUint16(offset + 32, true); + offset += 46 + nameLength + extraLength + commentLength; + } + if (offset !== directoryEnd) + throw new Error("That extension package has an invalid ZIP directory."); + + const commentLength = view.getUint16(endOffset + 20, true); + if (commentLength === 0) return bytes; + + // JSZip mistakes EOCD-like bytes inside an archive comment for the real EOCD. + // The comment is not needed for theme import, so remove it before parsing. + const withoutComment = bytes.slice(0, endOffset + 22); + withoutComment[endOffset + 20] = 0; + withoutComment[endOffset + 21] = 0; + return withoutComment; +} + +function inspectZip(zip: JSZip): void { + const entries = Object.values(zip.files) as InspectableZipObject[]; + if (entries.length > MAX_ZIP_ENTRIES) + throw new Error("That extension package has too many files."); + + for (const entry of entries) { + if (entry.unsafeOriginalName) normalizePackagePath(entry.unsafeOriginalName); + } +} + +async function readZipText( + zip: JSZip, + path: string, + description: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const file = zip.file(path) as InspectableZipObject | null; + if (!file) throw new Error(`${description} is missing from the extension package.`); + if (typeof file._data?.uncompressedSize !== "number" || !file.internalStream) { + throw new Error(`${description} has unreadable size metadata.`); + } + if (file._data.uncompressedSize > MAX_THEME_BYTES) { + throw new Error(`${description} is too large.`); + } + + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = []; + let byteLength = 0; + let settled = false; + const stream = file.internalStream!("uint8array"); + const cleanup = () => signal?.removeEventListener("abort", handleAbort); + const handleAbort = () => { + if (settled) return; + settled = true; + stream.pause(); + cleanup(); + reject(signal?.reason); + }; + signal?.addEventListener("abort", handleAbort, { once: true }); + stream + .on("data", (chunk) => { + if (settled) return; + byteLength += chunk.byteLength; + if (byteLength > MAX_THEME_BYTES) { + settled = true; + stream.pause(); + cleanup(); + reject(new Error(`${description} is too large.`)); + return; + } + chunks.push(chunk); + }) + .on("error", (cause) => { + if (settled) return; + settled = true; + cleanup(); + reject(cause); + }) + .on("end", () => { + if (settled) return; + settled = true; + cleanup(); + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + resolve(new TextDecoder().decode(bytes)); + }) + .resume(); + }); +} + +async function loadThemeObject( + zip: JSZip, + path: string, + cache: Map>, + budget: { files: number }, + ancestors: ReadonlySet = new Set(), + signal?: AbortSignal, +): Promise> { + signal?.throwIfAborted(); + if (ancestors.size >= MAX_INCLUDE_DEPTH) throw new Error("Theme includes are nested too deeply."); + if (ancestors.has(path)) throw new Error("Theme includes contain a cycle."); + const cached = cache.get(path); + if (cached) return cached; + budget.files += 1; + if (budget.files > MAX_RESOLVED_THEME_FILES) { + throw new Error("That extension references too many theme files."); + } + + const value = sanitizeThemeObject( + parseJsoncObject(await readZipText(zip, path, path, signal), path), + ); + if (typeof value.include !== "string") { + cache.set(path, value); + return value; + } + + const includePath = normalizePackagePath(value.include, path); + const nextAncestors = new Set(ancestors); + nextAncestors.add(path); + const base = await loadThemeObject(zip, includePath, cache, budget, nextAncestors, signal); + const resolved = { + ...base, + ...value, + colors: { + ...(isRecord(base.colors) ? base.colors : {}), + ...(isRecord(value.colors) ? value.colors : {}), + }, + }; + cache.set(path, resolved); + return resolved; +} + +/** Opens VSIX bytes after the ZIP metadata has been checked for the shapes + * that make an archive unsafe to expand. */ +export async function openThemePackage( + packageBytes: Uint8Array, + signal?: AbortSignal, +): Promise { + try { + const inspectedPackageBytes = inspectZipDirectory(packageBytes); + const zip = await JSZip.loadAsync(inspectedPackageBytes); + signal?.throwIfAborted(); + inspectZip(zip); + return zip; + } catch (cause) { + if (signal?.aborted) signal.throwIfAborted(); + if (cause instanceof Error && cause.message.startsWith("That extension package")) throw cause; + throw new Error("That extension package could not be opened.", { cause }); + } +} + +export async function readPackagedManifest( + zip: JSZip, + signal?: AbortSignal, +): Promise> { + return parseJsoncObject( + await readZipText(zip, "extension/package.json", "Extension manifest", signal), + "Extension manifest", + ); +} + +/** Converts every color theme a package contributes into a theme collection. */ +export async function themesFromPackage( + zip: JSZip, + packagedManifest: Record, + identity: ThemePackageIdentity, + signal?: AbortSignal, +): Promise> { + const contributions = themeContributions(packagedManifest); + if (contributions.length === 0) throw new Error("That extension does not contain color themes."); + if (contributions.length > MAX_THEMES_PER_EXTENSION) { + throw new Error("That extension contains too many color themes to import safely."); + } + + const parsed: Array<{ theme: ThemeDefinition; sourceName: string; sourcePath: string }> = []; + const failures: string[] = []; + const themeCache = new Map>(); + const themeBudget = { files: 0 }; + for (const contribution of contributions) { + signal?.throwIfAborted(); + if (typeof contribution.path !== "string") { + failures.push("theme path is missing"); + continue; + } + try { + const path = normalizePackagePath(contribution.path); + const themeValue = await loadThemeObject( + zip, + path, + themeCache, + themeBudget, + new Set(), + signal, + ); + const type = contributionType(contribution.uiTheme); + const label = + typeof contribution.label === "string" && contribution.label.trim() + ? contribution.label.trim() + : identity.name; + const decorated = { + ...themeValue, + displayName: label, + ...(type ? { type } : {}), + }; + if (!isVsCodeThemeFile(decorated)) throw new Error("not a VS Code color theme"); + parsed.push({ + theme: parseVsCodeThemeFile(decorated), + sourceName: path.split("/").at(-1)!, + sourcePath: path, + }); + } catch (cause) { + signal?.throwIfAborted(); + failures.push(cause instanceof Error ? cause.message : "theme could not be read"); + } + } + if (failures.length > 0) { + throw new Error("One or more color themes in that extension could not be imported safely."); + } + if (parsed.length === 0) { + throw new Error("That extension has no compatible color themes."); + } + + const themeId = (source: string) => + `${identity.idPrefix}-${shortHash(`${identity.key}:${source}`)}`; + const sourcePathCounts = new Map(); + for (const { sourcePath } of parsed) { + sourcePathCounts.set(sourcePath, (sourcePathCounts.get(sourcePath) ?? 0) + 1); + } + const sourcePathOccurrences = new Map(); + const sourceIdentities = parsed.map(({ sourcePath }) => { + if (sourcePathCounts.get(sourcePath) === 1) return sourcePath; + const occurrence = sourcePathOccurrences.get(sourcePath) ?? 0; + sourcePathOccurrences.set(sourcePath, occurrence + 1); + return occurrence === 0 ? sourcePath : `${sourcePath}\0${occurrence}`; + }); + const resolved = resolveThemeLabelCollisions(parsed).map((theme, index) => ({ + ...theme, + id: themeId(sourceIdentities[index]!), + })); + const paired = pairVsCodeThemes(resolved, { + pairedId: (light, dark) => themeId([light.id, dark.id].sort().join(":")), + }); + const themes = resolveThemeLabelCollisions(paired.map((theme) => ({ theme }))); + return themes.map((theme) => ({ ...theme, collection: identity.collection })); +} + +function collectionId(prefix: string, key: string): string { + const normalized = `${prefix}:${key}`; + return /^[a-z0-9][a-z0-9.:-]{0,127}$/.test(normalized) + ? normalized + : `${prefix}:${shortHash(key)}`; +} + +/** Identity for a package the user picked off their own disk. The manifest is + * the only source of truth here, so a manifest without a publisher falls back + * to the file name. */ +function localPackageIdentity( + packagedManifest: Record, + fileName: string, +): ThemePackageIdentity { + const publisher = + typeof packagedManifest.publisher === "string" ? packagedManifest.publisher.trim() : ""; + const name = typeof packagedManifest.name === "string" ? packagedManifest.name.trim() : ""; + const displayName = + typeof packagedManifest.displayName === "string" ? packagedManifest.displayName.trim() : ""; + const baseName = fileName.replace(/\.vsix$/i, "") || "extension"; + const key = (publisher && name ? `${publisher}.${name}` : baseName).toLowerCase(); + const label = + [displayName, name, baseName] + .map((candidate) => humanizeThemeName(candidate).slice(0, 48)) + .find((candidate) => candidate.length > 0) ?? "Extension themes"; + return { + idPrefix: "vsix-theme", + key, + name: label, + collection: { id: collectionId("local-vsix", key), label }, + }; +} + +export type VsixThemeFile = { name: string; bytes: Uint8Array }; + +/** Imports a .vsix the user picked locally. Unlike an Open VSX install there + * is no registry metadata to check it against, so the packaged manifest is + * trusted for identity and the license gate does not apply: the user already + * has the file. */ +export async function importVsixThemeFile( + file: VsixThemeFile, + signal?: AbortSignal, +): Promise> { + if (file.bytes.byteLength > MAX_VSIX_BYTES) { + throw new Error("That extension package is too large to import safely."); + } + const zip = await openThemePackage(file.bytes, signal); + const packagedManifest = await readPackagedManifest(zip, signal); + return themesFromPackage( + zip, + packagedManifest, + localPackageIdentity(packagedManifest, file.name), + signal, + ); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e753596f3d33..98a3073a67f2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -454,12 +454,20 @@ export interface PickedThemeFile { name: string; size: number; text: string; + /** + * Base64 contents, sent only for binary packages such as `.vsix`. `text` is + * empty for those, so the renderer reads whichever field the file kind + * needs. Binary crosses the bridge encoded, matching how preview frames + * travel from the main process. + */ + contentBase64?: string; } export const PickedThemeFileSchema = Schema.Struct({ name: Schema.String, size: Schema.Number, text: Schema.String, + contentBase64: Schema.optional(Schema.String), }); export interface DesktopWslDistro { From b02e5cb3fbb5b8693daf65be35db848a102a15d2 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 18:57:38 +0200 Subject: [PATCH 2/2] fix(desktop): enforce the theme package cap while reading A .vsix could grow between stat and readFile, pulling an arbitrarily large archive into main-process memory before the renderer rejected it. Read through a bounded loop that stops past the cap instead. --- apps/desktop/src/ipc/methods/window.ts | 41 ++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 08b235958fad..11d706c29bd8 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -17,6 +17,7 @@ import { isCommandAvailable } from "@t3tools/shared/shell"; import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import type { PlatformError } from "effect/PlatformError"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -330,6 +331,37 @@ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; * the renderer applies to a downloaded VSIX. */ const PICKED_THEME_PACKAGE_MAX_BYTES = 20 * 1024 * 1024; +/** Reads at most `limit` bytes. The cap is enforced while reading, not by a + * prior stat, so a file that grows between the size check and the read can + * never pull more than the cap into memory. Returns None past the limit. */ +const readCappedFile = ( + fileSystem: FileSystem.FileSystem, + filePath: string, + limit: number, +): Effect.Effect, PlatformError> => + Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem.open(filePath); + const chunks: Uint8Array[] = []; + let byteLength = 0; + while (byteLength <= limit) { + const chunk = yield* file.readAlloc(64 * 1024); + if (Option.isNone(chunk) || chunk.value.byteLength === 0) { + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const part of chunks) { + bytes.set(part, offset); + offset += part.byteLength; + } + return Option.some(bytes); + } + chunks.push(chunk.value); + byteLength += chunk.value.byteLength; + } + return Option.none(); + }), + ); + export const pickThemeFiles = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PICK_THEME_FILES_CHANNEL, payload: Schema.Undefined, @@ -372,8 +404,13 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ // A package is binary, so it crosses the bridge base64-encoded; the // renderer unzips it and never looks at `text`. if (isPackage) { - const bytes = yield* fileSystem.readFile(filePath); - const contentBase64 = Buffer.from(bytes).toString("base64"); + const bytes = yield* readCappedFile(fileSystem, filePath, limit); + if (Option.isNone(bytes)) { + // Grew past the cap after stat; report a size the renderer + // rejects as oversized. + return { name, size: limit + 1, text: "" } satisfies PickedThemeFile; + } + const contentBase64 = Buffer.from(bytes.value).toString("base64"); return { name, size, text: "", contentBase64 } satisfies PickedThemeFile; } const text = yield* fileSystem.readFileString(filePath);