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"; 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 = 2_000;const MAX_UNCOMPRESSED_BYTES = 50 * 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", "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", "ISC", "MIT", "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"; export type OpenVsxThemeExtension = { id: string; collectionId: string; name: string; publisher: string; description: string; downloadCount: number; iconUrl: string | null; sourceUrl: string | null; manifestUrl: string; sha256Url: string; vsixUrl: string; version: string; license: string;}; export type OpenVsxThemeSearchOptions = { signal?: AbortSignal; sortBy?: OpenVsxThemeSort;}; type ThemeContribution = { label?: unknown; uiTheme?: unknown; path?: unknown }; function isRecord(value: unknown): value is Record<string, unknown> { 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) ? normalized : `open-vsx:${shortHash(extensionId)}`;} function trustedOpenVsxUrl(value: unknown): string | null { if (typeof value !== "string") return null; try { const url = new URL(value); return url.protocol === "https:" && url.hostname.toLowerCase() === "open-vsx.org" ? url.toString() : null; } catch { return null; }} function publicSourceUrl(value: unknown): string | null { const rawValue = typeof value === "string" ? value : isRecord(value) && typeof value.url === "string" ? value.url : null; if (!rawValue) return null; try { const url = new URL(rawValue); return url.protocol === "https:" && !url.username && !url.password ? url.toString() : null; } catch { return null; }} function themeContributions(manifest: Record<string, unknown>): ThemeContribution[] { const contributes = isRecord(manifest.contributes) ? manifest.contributes : null; return Array.isArray(contributes?.themes) ? (contributes.themes.filter(isRecord) as ThemeContribution[]) : [];} function manifestLicenseMatches(manifest: Record<string, unknown>, license: string): boolean { return ( typeof manifest.license === "string" && manifest.license.trim().toLowerCase() === license.toLowerCase() );} function extensionFromDetail(value: unknown): OpenVsxThemeExtension | null { if (!isRecord(value) || !isRecord(value.files)) { throw new Error("Open VSX returned malformed theme details."); } const namespace = typeof value.namespace === "string" ? value.namespace.trim() : ""; const extensionName = typeof value.name === "string" ? value.name.trim() : ""; const displayName = (typeof value.displayName === "string" ? value.displayName.trim() : "") || extensionName; const version = typeof value.version === "string" ? value.version.trim() : ""; const license = typeof value.license === "string" ? value.license.trim() : ""; const manifestUrl = trustedOpenVsxUrl(value.files.manifest); const sha256Url = trustedOpenVsxUrl(value.files.sha256); const vsixUrl = trustedOpenVsxUrl(value.files.download); if (!namespace || !extensionName || !version || !manifestUrl || !sha256Url || !vsixUrl) { throw new Error("Open VSX returned malformed theme details."); } if (!SUPPORTED_LICENSES.has(license)) return null; const id = `${namespace}.${extensionName}`; return { id, collectionId: openVsxCollectionId(id), name: displayName, publisher: namespace, description: typeof value.description === "string" ? value.description : "", downloadCount: typeof value.downloadCount === "number" && Number.isFinite(value.downloadCount) ? value.downloadCount : 0, iconUrl: trustedOpenVsxUrl(value.files.icon), sourceUrl: publicSourceUrl(value.repository) ?? publicSourceUrl(value.homepage) ?? publicSourceUrl(value.url), manifestUrl, sha256Url, vsixUrl, version, license, };} async function withSearchTimeout<T>( operation: (signal: AbortSignal) => Promise<T>, parentSignal?: AbortSignal,): Promise<T> { const controller = new AbortController(); const abort = () => controller.abort(); if (parentSignal?.aborted) abort(); else parentSignal?.addEventListener("abort", abort, { once: true }); const timeout = setTimeout(abort, SEARCH_REQUEST_TIMEOUT_MS); try { return await operation(controller.signal); } catch (cause) { if (controller.signal.aborted && !parentSignal?.aborted) { throw new Error("Open VSX took too long to respond.", { cause }); } throw cause; } finally { clearTimeout(timeout); parentSignal?.removeEventListener("abort", abort); }} export async function searchOpenVsxThemes( query: string, { signal, sortBy = "downloadCount" }: OpenVsxThemeSearchOptions = {},): Promise<OpenVsxThemeExtension[]> { const searchText = query.trim(); if (!searchText) return []; const url = new URL(OPEN_VSX_SEARCH_URL); url.searchParams.set("query", searchText); url.searchParams.set("category", "Themes"); url.searchParams.set("sortBy", sortBy); url.searchParams.set("sortOrder", "desc"); // Ask for a few extras because results without a supported SPDX license // are intentionally omitted. url.searchParams.set("size", "16"); const value = await withSearchTimeout(async (requestSignal) => { const response = await fetch(url, { signal: requestSignal }); if (!response.ok) throw new Error("Open VSX search is unavailable right now."); const searchBytes = await readCappedResponse( response, MAX_SEARCH_BYTES, "Open VSX returned an unexpectedly large response.", ); try { return JSON.parse(new TextDecoder().decode(searchBytes)) as unknown; } catch { throw new Error("Open VSX returned an unreadable response."); } }, signal); if (!isRecord(value) || !Array.isArray(value.extensions)) { throw new Error("Open VSX returned an unreadable search response."); } const identities = value.extensions.flatMap((candidate): Array<[string, string]> => { if (!isRecord(candidate)) return []; const namespace = typeof candidate.namespace === "string" ? candidate.namespace : ""; const name = typeof candidate.name === "string" ? candidate.name : ""; return namespace && name ? [[namespace, name]] : []; }); const details = await Promise.allSettled( identities.slice(0, 16).map(([namespace, name]) => withSearchTimeout(async (requestSignal) => { const detailUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`; const detailResponse = await fetch(detailUrl, { signal: requestSignal }); if (!detailResponse.ok) throw new Error("Open VSX theme details are unavailable."); const detailBytes = await readCappedResponse( detailResponse, MAX_DETAIL_BYTES, "Open VSX returned an unexpectedly large detail response.", ); try { const extension = extensionFromDetail(JSON.parse(new TextDecoder().decode(detailBytes))); if (!extension) return null; const [manifestResponse, packageResponse] = await Promise.all([ fetch(extension.manifestUrl, { signal: requestSignal }), fetch(extension.vsixUrl, { method: "HEAD", signal: requestSignal }), ]); if (!manifestResponse.ok) throw new Error("manifest unavailable"); if (!packageResponse.ok) return null; const packageLength = Number(packageResponse.headers.get("content-length")); if (Number.isFinite(packageLength) && packageLength > MAX_VSIX_BYTES) { return null; } const manifestBytes = await readCappedResponse( manifestResponse, MAX_MANIFEST_BYTES, "Open VSX returned an unexpectedly large manifest.", ); const manifest = parseJsoncObject( new TextDecoder().decode(manifestBytes), "Extension manifest", ); return themeContributions(manifest).length > 0 && manifestLicenseMatches(manifest, extension.license) ? extension : null; } catch { throw new Error("Open VSX returned unreadable theme details."); } }, signal), ), ); if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError"); const completedDetails = details.filter((result) => result.status === "fulfilled"); if (identities.length > 0 && completedDetails.length === 0) { throw new Error("Open VSX theme details are unavailable right now."); } return completedDetails.flatMap((result) => (result.value ? [result.value] : [])).slice(0, 8);} function parseJsoncObject(source: string, description: string): Record<string, unknown> { 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<string, unknown>): Record<string, unknown> { const colors: Record<string, string> = {}; 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<Uint8Array>;}; 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<string> { 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;Lines 1–500 of 776. Load more
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"; 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 = 2_000; const MAX_UNCOMPRESSED_BYTES = 50 * 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", "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", "ISC", "MIT", "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"; export type OpenVsxThemeExtension = { id: string; collectionId: string; name: string; publisher: string; description: string; downloadCount: number; iconUrl: string | null; sourceUrl: string | null; manifestUrl: string; sha256Url: string; vsixUrl: string; version: string; license: string; }; export type OpenVsxThemeSearchOptions = { signal?: AbortSignal; sortBy?: OpenVsxThemeSort; }; type ThemeContribution = { label?: unknown; uiTheme?: unknown; path?: unknown }; function isRecord(value: unknown): value is Record<string, unknown> { 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) ? normalized : `open-vsx:${shortHash(extensionId)}`; } function trustedOpenVsxUrl(value: unknown): string | null { if (typeof value !== "string") return null; try { const url = new URL(value); return url.protocol === "https:" && url.hostname.toLowerCase() === "open-vsx.org" ? url.toString() : null; } catch { return null; } } function publicSourceUrl(value: unknown): string | null { const rawValue = typeof value === "string" ? value : isRecord(value) && typeof value.url === "string" ? value.url : null; if (!rawValue) return null; try { const url = new URL(rawValue); return url.protocol === "https:" && !url.username && !url.password ? url.toString() : null; } catch { return null; } } function themeContributions(manifest: Record<string, unknown>): ThemeContribution[] { const contributes = isRecord(manifest.contributes) ? manifest.contributes : null; return Array.isArray(contributes?.themes) ? (contributes.themes.filter(isRecord) as ThemeContribution[]) : []; } function manifestLicenseMatches(manifest: Record<string, unknown>, license: string): boolean { return ( typeof manifest.license === "string" && manifest.license.trim().toLowerCase() === license.toLowerCase() ); } function extensionFromDetail(value: unknown): OpenVsxThemeExtension | null { if (!isRecord(value) || !isRecord(value.files)) { throw new Error("Open VSX returned malformed theme details."); } const namespace = typeof value.namespace === "string" ? value.namespace.trim() : ""; const extensionName = typeof value.name === "string" ? value.name.trim() : ""; const displayName = (typeof value.displayName === "string" ? value.displayName.trim() : "") || extensionName; const version = typeof value.version === "string" ? value.version.trim() : ""; const license = typeof value.license === "string" ? value.license.trim() : ""; const manifestUrl = trustedOpenVsxUrl(value.files.manifest); const sha256Url = trustedOpenVsxUrl(value.files.sha256); const vsixUrl = trustedOpenVsxUrl(value.files.download); if (!namespace || !extensionName || !version || !manifestUrl || !sha256Url || !vsixUrl) { throw new Error("Open VSX returned malformed theme details."); } if (!SUPPORTED_LICENSES.has(license)) return null; const id = `${namespace}.${extensionName}`; return { id, collectionId: openVsxCollectionId(id), name: displayName, publisher: namespace, description: typeof value.description === "string" ? value.description : "", downloadCount: typeof value.downloadCount === "number" && Number.isFinite(value.downloadCount) ? value.downloadCount : 0, iconUrl: trustedOpenVsxUrl(value.files.icon), sourceUrl: publicSourceUrl(value.repository) ?? publicSourceUrl(value.homepage) ?? publicSourceUrl(value.url), manifestUrl, sha256Url, vsixUrl, version, license, }; } async function withSearchTimeout<T>( operation: (signal: AbortSignal) => Promise<T>, parentSignal?: AbortSignal, ): Promise<T> { const controller = new AbortController(); const abort = () => controller.abort(); if (parentSignal?.aborted) abort(); else parentSignal?.addEventListener("abort", abort, { once: true }); const timeout = setTimeout(abort, SEARCH_REQUEST_TIMEOUT_MS); try { return await operation(controller.signal); } catch (cause) { if (controller.signal.aborted && !parentSignal?.aborted) { throw new Error("Open VSX took too long to respond.", { cause }); } throw cause; } finally { clearTimeout(timeout); parentSignal?.removeEventListener("abort", abort); } } export async function searchOpenVsxThemes( query: string, { signal, sortBy = "downloadCount" }: OpenVsxThemeSearchOptions = {}, ): Promise<OpenVsxThemeExtension[]> { const searchText = query.trim(); if (!searchText) return []; const url = new URL(OPEN_VSX_SEARCH_URL); url.searchParams.set("query", searchText); url.searchParams.set("category", "Themes"); url.searchParams.set("sortBy", sortBy); url.searchParams.set("sortOrder", "desc"); // Ask for a few extras because results without a supported SPDX license // are intentionally omitted. url.searchParams.set("size", "16"); const value = await withSearchTimeout(async (requestSignal) => { const response = await fetch(url, { signal: requestSignal }); if (!response.ok) throw new Error("Open VSX search is unavailable right now."); const searchBytes = await readCappedResponse( response, MAX_SEARCH_BYTES, "Open VSX returned an unexpectedly large response.", ); try { return JSON.parse(new TextDecoder().decode(searchBytes)) as unknown; } catch { throw new Error("Open VSX returned an unreadable response."); } }, signal); if (!isRecord(value) || !Array.isArray(value.extensions)) { throw new Error("Open VSX returned an unreadable search response."); } const identities = value.extensions.flatMap((candidate): Array<[string, string]> => { if (!isRecord(candidate)) return []; const namespace = typeof candidate.namespace === "string" ? candidate.namespace : ""; const name = typeof candidate.name === "string" ? candidate.name : ""; return namespace && name ? [[namespace, name]] : []; }); const details = await Promise.allSettled( identities.slice(0, 16).map(([namespace, name]) => withSearchTimeout(async (requestSignal) => { const detailUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`; const detailResponse = await fetch(detailUrl, { signal: requestSignal }); if (!detailResponse.ok) throw new Error("Open VSX theme details are unavailable."); const detailBytes = await readCappedResponse( detailResponse, MAX_DETAIL_BYTES, "Open VSX returned an unexpectedly large detail response.", ); try { const extension = extensionFromDetail(JSON.parse(new TextDecoder().decode(detailBytes))); if (!extension) return null; const [manifestResponse, packageResponse] = await Promise.all([ fetch(extension.manifestUrl, { signal: requestSignal }), fetch(extension.vsixUrl, { method: "HEAD", signal: requestSignal }), ]); if (!manifestResponse.ok) throw new Error("manifest unavailable"); if (!packageResponse.ok) return null; const packageLength = Number(packageResponse.headers.get("content-length")); if (Number.isFinite(packageLength) && packageLength > MAX_VSIX_BYTES) { return null; } const manifestBytes = await readCappedResponse( manifestResponse, MAX_MANIFEST_BYTES, "Open VSX returned an unexpectedly large manifest.", ); const manifest = parseJsoncObject( new TextDecoder().decode(manifestBytes), "Extension manifest", ); return themeContributions(manifest).length > 0 && manifestLicenseMatches(manifest, extension.license) ? extension : null; } catch { throw new Error("Open VSX returned unreadable theme details."); } }, signal), ), ); if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError"); const completedDetails = details.filter((result) => result.status === "fulfilled"); if (identities.length > 0 && completedDetails.length === 0) { throw new Error("Open VSX theme details are unavailable right now."); } return completedDetails.flatMap((result) => (result.value ? [result.value] : [])).slice(0, 8); } function parseJsoncObject(source: string, description: string): Record<string, unknown> { 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<string, unknown>): Record<string, unknown> { const colors: Record<string, string> = {}; 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<Uint8Array>; }; 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<string> { 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;