| 1 | import { sha256 } from "@noble/hashes/sha2"; |
| 2 | import JSZip from "jszip"; |
| 3 | import { parse, type ParseError } from "jsonc-parser"; |
| 4 | |
| 5 | import type { ThemeDefinition } from "./themePalette"; |
| 6 | import { |
| 7 | isVsCodeThemeFile, |
| 8 | pairVsCodeThemes, |
| 9 | parseVsCodeThemeFile, |
| 10 | resolveThemeLabelCollisions, |
| 11 | } from "./vscodeThemeImport"; |
| 12 | |
| 13 | const OPEN_VSX_SEARCH_URL = "https://open-vsx.org/api/-/search"; |
| 14 | const MAX_VSIX_BYTES = 20 * 1024 * 1024; |
| 15 | const MAX_SEARCH_BYTES = 512 * 1024; |
| 16 | const MAX_DETAIL_BYTES = 256 * 1024; |
| 17 | const MAX_MANIFEST_BYTES = 256 * 1024; |
| 18 | const SEARCH_REQUEST_TIMEOUT_MS = 10_000; |
| 19 | const MAX_THEME_BYTES = 256 * 1024; |
| 20 | const MAX_ZIP_ENTRIES = 5_000; |
| 21 | const MAX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024; |
| 22 | const MAX_COMPRESSION_RATIO = 200; |
| 23 | const MAX_THEMES_PER_EXTENSION = 40; |
| 24 | const MAX_INCLUDE_DEPTH = 8; |
| 25 | const MAX_PACKAGE_PATH_LENGTH = 1_024; |
| 26 | const MAX_COLOR_VALUE_LENGTH = 128; |
| 27 | const MAX_RESOLVED_THEME_FILES = MAX_THEMES_PER_EXTENSION * MAX_INCLUDE_DEPTH; |
| 28 | const SUPPORTED_LICENSES = new Set([ |
| 29 | "0BSD", |
| 30 | "Apache-2.0", |
| 31 | "BSD-2-Clause", |
| 32 | "BSD-3-Clause", |
| 33 | "CC0-1.0", |
| 34 | "ISC", |
| 35 | "MIT", |
| 36 | "MPL-2.0", |
| 37 | "Unlicense", |
| 38 | ]); |
| 39 | const USED_WORKBENCH_COLORS = new Set([ |
| 40 | "activityBar.background", |
| 41 | "activityBarBadge.background", |
| 42 | "badge.background", |
| 43 | "button.background", |
| 44 | "button.foreground", |
| 45 | "contrastBorder", |
| 46 | "descriptionForeground", |
| 47 | "disabledForeground", |
| 48 | "dropdown.background", |
| 49 | "dropdown.border", |
| 50 | "editor.background", |
| 51 | "editor.foreground", |
| 52 | "editor.findMatchBackground", |
| 53 | "editor.findMatchForeground", |
| 54 | "editor.findMatchHighlightBackground", |
| 55 | "editor.findMatchHighlightForeground", |
| 56 | "editor.selectionBackground", |
| 57 | "editorCursor.foreground", |
| 58 | "editorError.foreground", |
| 59 | "editorGroup.border", |
| 60 | "editorPane.background", |
| 61 | "editorWarning.foreground", |
| 62 | "editorWidget.background", |
| 63 | "errorForeground", |
| 64 | "focusBorder", |
| 65 | "foreground", |
| 66 | "input.border", |
| 67 | "input.placeholderForeground", |
| 68 | "list.activeSelectionBackground", |
| 69 | "list.hoverBackground", |
| 70 | "list.inactiveSelectionBackground", |
| 71 | "menu.background", |
| 72 | "panel.background", |
| 73 | "panel.border", |
| 74 | "progressBar.background", |
| 75 | "quickInput.background", |
| 76 | "scrollbarSlider.background", |
| 77 | "sideBar.background", |
| 78 | "sideBar.border", |
| 79 | "sideBar.foreground", |
| 80 | "terminal.background", |
| 81 | "terminal.foreground", |
| 82 | "terminal.selectionBackground", |
| 83 | "terminalCursor.foreground", |
| 84 | "textCodeBlock.background", |
| 85 | "textLink.foreground", |
| 86 | ]); |
| 87 | |
| 88 | export type OpenVsxThemeSort = "downloadCount" | "rating" | "timestamp" | "relevance"; |
| 89 | |
| 90 | export type OpenVsxThemeExtension = { |
| 91 | id: string; |
| 92 | collectionId: string; |
| 93 | name: string; |
| 94 | publisher: string; |
| 95 | description: string; |
| 96 | downloadCount: number; |
| 97 | iconUrl: string | null; |
| 98 | sourceUrl: string | null; |
| 99 | manifestUrl: string; |
| 100 | sha256Url: string; |
| 101 | vsixUrl: string; |
| 102 | version: string; |
| 103 | license: string; |
| 104 | }; |
| 105 | |
| 106 | export type OpenVsxThemeSearchOptions = { |
| 107 | signal?: AbortSignal; |
| 108 | sortBy?: OpenVsxThemeSort; |
| 109 | }; |
| 110 | |
| 111 | type ThemeContribution = { label?: unknown; uiTheme?: unknown; path?: unknown }; |
| 112 | |
| 113 | function isRecord(value: unknown): value is Record<string, unknown> { |
| 114 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 115 | } |
| 116 | |
| 117 | function shortHash(value: string): string { |
| 118 | return [...sha256(new TextEncoder().encode(value))] |
| 119 | .slice(0, 6) |
| 120 | .map((byte) => byte.toString(16).padStart(2, "0")) |
| 121 | .join(""); |
| 122 | } |
| 123 | |
| 124 | function openVsxThemeId(extensionId: string, source: string): string { |
| 125 | return `ovx-theme-${shortHash(`${extensionId}:${source}`)}`; |
| 126 | } |
| 127 | |
| 128 | function openVsxCollectionId(extensionId: string): string { |
| 129 | const normalized = `open-vsx:${extensionId.toLowerCase()}`; |
| 130 | return /^[a-z0-9][a-z0-9.:-]{0,127}$/.test(normalized) |
| 131 | ? normalized |
| 132 | : `open-vsx:${shortHash(extensionId)}`; |
| 133 | } |
| 134 | |
| 135 | function trustedOpenVsxUrl(value: unknown): string | null { |
| 136 | if (typeof value !== "string") return null; |
| 137 | try { |
| 138 | const url = new URL(value); |
| 139 | return url.protocol === "https:" && url.hostname.toLowerCase() === "open-vsx.org" |
| 140 | ? url.toString() |
| 141 | : null; |
| 142 | } catch { |
| 143 | return null; |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | function publicSourceUrl(value: unknown): string | null { |
| 148 | const rawValue = |
| 149 | typeof value === "string" |
| 150 | ? value |
| 151 | : isRecord(value) && typeof value.url === "string" |
| 152 | ? value.url |
| 153 | : null; |
| 154 | if (!rawValue) return null; |
| 155 | try { |
| 156 | const url = new URL(rawValue); |
| 157 | return url.protocol === "https:" && !url.username && !url.password ? url.toString() : null; |
| 158 | } catch { |
| 159 | return null; |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | function themeContributions(manifest: Record<string, unknown>): ThemeContribution[] { |
| 164 | const contributes = isRecord(manifest.contributes) ? manifest.contributes : null; |
| 165 | return Array.isArray(contributes?.themes) |
| 166 | ? (contributes.themes.filter(isRecord) as ThemeContribution[]) |
| 167 | : []; |
| 168 | } |
| 169 | |
| 170 | function manifestLicenseMatches(manifest: Record<string, unknown>, license: string): boolean { |
| 171 | return ( |
| 172 | typeof manifest.license === "string" && |
| 173 | manifest.license.trim().toLowerCase() === license.toLowerCase() |
| 174 | ); |
| 175 | } |
| 176 | |
| 177 | function extensionFromDetail(value: unknown): OpenVsxThemeExtension | null { |
| 178 | if (!isRecord(value) || !isRecord(value.files)) { |
| 179 | throw new Error("Open VSX returned malformed theme details."); |
| 180 | } |
| 181 | const namespace = typeof value.namespace === "string" ? value.namespace.trim() : ""; |
| 182 | const extensionName = typeof value.name === "string" ? value.name.trim() : ""; |
| 183 | const displayName = |
| 184 | (typeof value.displayName === "string" ? value.displayName.trim() : "") || extensionName; |
| 185 | const version = typeof value.version === "string" ? value.version.trim() : ""; |
| 186 | const license = typeof value.license === "string" ? value.license.trim() : ""; |
| 187 | const manifestUrl = trustedOpenVsxUrl(value.files.manifest); |
| 188 | const sha256Url = trustedOpenVsxUrl(value.files.sha256); |
| 189 | const vsixUrl = trustedOpenVsxUrl(value.files.download); |
| 190 | if (!namespace || !extensionName || !version || !manifestUrl || !sha256Url || !vsixUrl) { |
| 191 | throw new Error("Open VSX returned malformed theme details."); |
| 192 | } |
| 193 | if (!SUPPORTED_LICENSES.has(license)) return null; |
| 194 | const id = `${namespace}.${extensionName}`; |
| 195 | return { |
| 196 | id, |
| 197 | collectionId: openVsxCollectionId(id), |
| 198 | name: displayName, |
| 199 | publisher: namespace, |
| 200 | description: typeof value.description === "string" ? value.description : "", |
| 201 | downloadCount: |
| 202 | typeof value.downloadCount === "number" && Number.isFinite(value.downloadCount) |
| 203 | ? value.downloadCount |
| 204 | : 0, |
| 205 | iconUrl: trustedOpenVsxUrl(value.files.icon), |
| 206 | sourceUrl: |
| 207 | publicSourceUrl(value.repository) ?? |
| 208 | publicSourceUrl(value.homepage) ?? |
| 209 | publicSourceUrl(value.url), |
| 210 | manifestUrl, |
| 211 | sha256Url, |
| 212 | vsixUrl, |
| 213 | version, |
| 214 | license, |
| 215 | }; |
| 216 | } |
| 217 | |
| 218 | async function withSearchTimeout<T>( |
| 219 | operation: (signal: AbortSignal) => Promise<T>, |
| 220 | parentSignal?: AbortSignal, |
| 221 | ): Promise<T> { |
| 222 | const controller = new AbortController(); |
| 223 | const abort = () => controller.abort(); |
| 224 | if (parentSignal?.aborted) abort(); |
| 225 | else parentSignal?.addEventListener("abort", abort, { once: true }); |
| 226 | const timeout = setTimeout(abort, SEARCH_REQUEST_TIMEOUT_MS); |
| 227 | try { |
| 228 | return await operation(controller.signal); |
| 229 | } catch (cause) { |
| 230 | if (controller.signal.aborted && !parentSignal?.aborted) { |
| 231 | throw new Error("Open VSX took too long to respond.", { cause }); |
| 232 | } |
| 233 | throw cause; |
| 234 | } finally { |
| 235 | clearTimeout(timeout); |
| 236 | parentSignal?.removeEventListener("abort", abort); |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | export async function searchOpenVsxThemes( |
| 241 | query: string, |
| 242 | { signal, sortBy = "downloadCount" }: OpenVsxThemeSearchOptions = {}, |
| 243 | ): Promise<OpenVsxThemeExtension[]> { |
| 244 | const searchText = query.trim(); |
| 245 | if (!searchText) return []; |
| 246 | const url = new URL(OPEN_VSX_SEARCH_URL); |
| 247 | url.searchParams.set("query", searchText); |
| 248 | url.searchParams.set("category", "Themes"); |
| 249 | url.searchParams.set("sortBy", sortBy); |
| 250 | url.searchParams.set("sortOrder", "desc"); |
| 251 | // Ask for a few extras because results without a supported SPDX license |
| 252 | // are intentionally omitted. |
| 253 | url.searchParams.set("size", "16"); |
| 254 | const value = await withSearchTimeout(async (requestSignal) => { |
| 255 | const response = await fetch(url, { signal: requestSignal }); |
| 256 | if (!response.ok) throw new Error("Open VSX search is unavailable right now."); |
| 257 | const searchBytes = await readCappedResponse( |
| 258 | response, |
| 259 | MAX_SEARCH_BYTES, |
| 260 | "Open VSX returned an unexpectedly large response.", |
| 261 | ); |
| 262 | try { |
| 263 | return JSON.parse(new TextDecoder().decode(searchBytes)) as unknown; |
| 264 | } catch { |
| 265 | throw new Error("Open VSX returned an unreadable response."); |
| 266 | } |
| 267 | }, signal); |
| 268 | if (!isRecord(value) || !Array.isArray(value.extensions)) { |
| 269 | throw new Error("Open VSX returned an unreadable search response."); |
| 270 | } |
| 271 | const identities = value.extensions.flatMap((candidate): Array<[string, string]> => { |
| 272 | if (!isRecord(candidate)) return []; |
| 273 | const namespace = typeof candidate.namespace === "string" ? candidate.namespace : ""; |
| 274 | const name = typeof candidate.name === "string" ? candidate.name : ""; |
| 275 | return namespace && name ? [[namespace, name]] : []; |
| 276 | }); |
| 277 | const details = await Promise.allSettled( |
| 278 | identities.slice(0, 16).map(([namespace, name]) => |
| 279 | withSearchTimeout(async (requestSignal) => { |
| 280 | const detailUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`; |
| 281 | const detailResponse = await fetch(detailUrl, { signal: requestSignal }); |
| 282 | if (!detailResponse.ok) throw new Error("Open VSX theme details are unavailable."); |
| 283 | const detailBytes = await readCappedResponse( |
| 284 | detailResponse, |
| 285 | MAX_DETAIL_BYTES, |
| 286 | "Open VSX returned an unexpectedly large detail response.", |
| 287 | ); |
| 288 | try { |
| 289 | const extension = extensionFromDetail(JSON.parse(new TextDecoder().decode(detailBytes))); |
| 290 | if (!extension) return null; |
| 291 | const [manifestResponse, packageResponse] = await Promise.all([ |
| 292 | fetch(extension.manifestUrl, { signal: requestSignal }), |
| 293 | fetch(extension.vsixUrl, { method: "HEAD", signal: requestSignal }), |
| 294 | ]); |
| 295 | if (!manifestResponse.ok) throw new Error("manifest unavailable"); |
| 296 | if (!packageResponse.ok) return null; |
| 297 | const packageLength = Number(packageResponse.headers.get("content-length")); |
| 298 | if (Number.isFinite(packageLength) && packageLength > MAX_VSIX_BYTES) { |
| 299 | return null; |
| 300 | } |
| 301 | const manifestBytes = await readCappedResponse( |
| 302 | manifestResponse, |
| 303 | MAX_MANIFEST_BYTES, |
| 304 | "Open VSX returned an unexpectedly large manifest.", |
| 305 | ); |
| 306 | const manifest = parseJsoncObject( |
| 307 | new TextDecoder().decode(manifestBytes), |
| 308 | "Extension manifest", |
| 309 | ); |
| 310 | return themeContributions(manifest).length > 0 && |
| 311 | manifestLicenseMatches(manifest, extension.license) |
| 312 | ? extension |
| 313 | : null; |
| 314 | } catch { |
| 315 | throw new Error("Open VSX returned unreadable theme details."); |
| 316 | } |
| 317 | }, signal), |
| 318 | ), |
| 319 | ); |
| 320 | if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError"); |
| 321 | const completedDetails = details.filter((result) => result.status === "fulfilled"); |
| 322 | if (identities.length > 0 && completedDetails.length === 0) { |
| 323 | throw new Error("Open VSX theme details are unavailable right now."); |
| 324 | } |
| 325 | return completedDetails.flatMap((result) => (result.value ? [result.value] : [])).slice(0, 8); |
| 326 | } |
| 327 | |
| 328 | function parseJsoncObject(source: string, description: string): Record<string, unknown> { |
| 329 | const errors: ParseError[] = []; |
| 330 | const value: unknown = parse(source, errors, { allowTrailingComma: true }); |
| 331 | if (errors.length > 0 || !isRecord(value)) throw new Error(`${description} is not valid JSON.`); |
| 332 | return value; |
| 333 | } |
| 334 | |
| 335 | function sanitizeThemeObject(value: Record<string, unknown>): Record<string, unknown> { |
| 336 | const colors: Record<string, string> = {}; |
| 337 | if (isRecord(value.colors)) { |
| 338 | for (const [key, color] of Object.entries(value.colors)) { |
| 339 | if ( |
| 340 | USED_WORKBENCH_COLORS.has(key) && |
| 341 | typeof color === "string" && |
| 342 | color.length <= MAX_COLOR_VALUE_LENGTH |
| 343 | ) { |
| 344 | colors[key] = color; |
| 345 | } |
| 346 | } |
| 347 | } |
| 348 | return { |
| 349 | ...(typeof value.include === "string" ? { include: value.include } : {}), |
| 350 | colors, |
| 351 | }; |
| 352 | } |
| 353 | |
| 354 | function normalizePackagePath(path: string, relativeTo = "extension/"): string { |
| 355 | if ( |
| 356 | path.length > MAX_PACKAGE_PATH_LENGTH || |
| 357 | path.includes("\0") || |
| 358 | path.startsWith("/") || |
| 359 | /^[a-zA-Z]:/.test(path) |
| 360 | ) { |
| 361 | throw new Error("Theme path is not a safe relative package path."); |
| 362 | } |
| 363 | const normalizedInput = path.replaceAll("\\", "/"); |
| 364 | const baseSegments = relativeTo.split("/").slice(0, -1); |
| 365 | const segments = baseSegments; |
| 366 | for (const segment of normalizedInput.split("/")) { |
| 367 | if (!segment || segment === ".") continue; |
| 368 | if (segment === "..") { |
| 369 | if (segments.length <= 1) throw new Error("Theme path escapes the extension package."); |
| 370 | segments.pop(); |
| 371 | continue; |
| 372 | } |
| 373 | segments.push(segment); |
| 374 | } |
| 375 | if (segments[0] !== "extension") segments.unshift("extension"); |
| 376 | return segments.join("/"); |
| 377 | } |
| 378 | |
| 379 | function contributionType(uiTheme: unknown): string | null { |
| 380 | if (uiTheme === "vs") return "light"; |
| 381 | if (uiTheme === "vs-dark") return "dark"; |
| 382 | if (uiTheme === "hc-black" || uiTheme === "hc-light") return uiTheme; |
| 383 | return null; |
| 384 | } |
| 385 | |
| 386 | type ZipEntrySizes = { |
| 387 | uncompressedSize?: unknown; |
| 388 | }; |
| 389 | |
| 390 | type InspectableZipObject = JSZip.JSZipObject & { |
| 391 | _data?: ZipEntrySizes; |
| 392 | unsafeOriginalName?: string; |
| 393 | internalStream?: (type: "uint8array") => JSZip.JSZipStreamHelper<Uint8Array>; |
| 394 | }; |
| 395 | |
| 396 | function inspectZipDirectory(bytes: Uint8Array): Uint8Array { |
| 397 | const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); |
| 398 | const minimumOffset = Math.max(0, bytes.byteLength - 65_557); |
| 399 | let endOffset = bytes.byteLength - 22; |
| 400 | while ( |
| 401 | endOffset >= minimumOffset && |
| 402 | (view.getUint32(endOffset, true) !== 0x06054b50 || |
| 403 | endOffset + 22 + view.getUint16(endOffset + 20, true) !== bytes.byteLength) |
| 404 | ) { |
| 405 | endOffset -= 1; |
| 406 | } |
| 407 | if (endOffset < minimumOffset) throw new Error("That extension package has no ZIP directory."); |
| 408 | |
| 409 | const directorySize = view.getUint32(endOffset + 12, true); |
| 410 | const directoryOffset = view.getUint32(endOffset + 16, true); |
| 411 | const directoryEnd = directoryOffset + directorySize; |
| 412 | if (directoryEnd !== endOffset || directoryEnd > bytes.byteLength) { |
| 413 | throw new Error("That extension package has an invalid ZIP directory."); |
| 414 | } |
| 415 | |
| 416 | let entryCount = 0; |
| 417 | let totalUncompressed = 0; |
| 418 | let offset = directoryOffset; |
| 419 | while (offset < directoryEnd) { |
| 420 | if (offset + 46 > directoryEnd || view.getUint32(offset, true) !== 0x02014b50) { |
| 421 | throw new Error("That extension package has an invalid ZIP directory."); |
| 422 | } |
| 423 | entryCount += 1; |
| 424 | if (entryCount > MAX_ZIP_ENTRIES) { |
| 425 | throw new Error("That extension package has too many files."); |
| 426 | } |
| 427 | const compressed = view.getUint32(offset + 20, true); |
| 428 | const uncompressed = view.getUint32(offset + 24, true); |
| 429 | if (compressed === 0xffffffff || uncompressed === 0xffffffff) { |
| 430 | throw new Error("That extension package has unsupported ZIP64 metadata."); |
| 431 | } |
| 432 | totalUncompressed += uncompressed; |
| 433 | if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) { |
| 434 | throw new Error("That extension package expands beyond the safe import limit."); |
| 435 | } |
| 436 | if ( |
| 437 | uncompressed > 0 && |
| 438 | (compressed === 0 || uncompressed / compressed > MAX_COMPRESSION_RATIO) |
| 439 | ) { |
| 440 | throw new Error("That extension package has an unsafe compression ratio."); |
| 441 | } |
| 442 | const nameLength = view.getUint16(offset + 28, true); |
| 443 | const extraLength = view.getUint16(offset + 30, true); |
| 444 | const commentLength = view.getUint16(offset + 32, true); |
| 445 | offset += 46 + nameLength + extraLength + commentLength; |
| 446 | } |
| 447 | if (offset !== directoryEnd) |
| 448 | throw new Error("That extension package has an invalid ZIP directory."); |
| 449 | |
| 450 | const commentLength = view.getUint16(endOffset + 20, true); |
| 451 | if (commentLength === 0) return bytes; |
| 452 | |
| 453 | // JSZip mistakes EOCD-like bytes inside an archive comment for the real EOCD. |
| 454 | // The comment is not needed for theme import, so remove it before parsing. |
| 455 | const withoutComment = bytes.slice(0, endOffset + 22); |
| 456 | withoutComment[endOffset + 20] = 0; |
| 457 | withoutComment[endOffset + 21] = 0; |
| 458 | return withoutComment; |
| 459 | } |
| 460 | |
| 461 | function inspectZip(zip: JSZip): void { |
| 462 | const entries = Object.values(zip.files) as InspectableZipObject[]; |
| 463 | if (entries.length > MAX_ZIP_ENTRIES) |
| 464 | throw new Error("That extension package has too many files."); |
| 465 | |
| 466 | for (const entry of entries) { |
| 467 | if (entry.unsafeOriginalName) normalizePackagePath(entry.unsafeOriginalName); |
| 468 | } |
| 469 | } |
| 470 | |
| 471 | async function readZipText( |
| 472 | zip: JSZip, |
| 473 | path: string, |
| 474 | description: string, |
| 475 | signal?: AbortSignal, |
| 476 | ): Promise<string> { |
| 477 | signal?.throwIfAborted(); |
| 478 | const file = zip.file(path) as InspectableZipObject | null; |
| 479 | if (!file) throw new Error(`${description} is missing from the extension package.`); |
| 480 | if (typeof file._data?.uncompressedSize !== "number" || !file.internalStream) { |
| 481 | throw new Error(`${description} has unreadable size metadata.`); |
| 482 | } |
| 483 | if (file._data.uncompressedSize > MAX_THEME_BYTES) { |
| 484 | throw new Error(`${description} is too large.`); |
| 485 | } |
| 486 | |
| 487 | return new Promise((resolve, reject) => { |
| 488 | const chunks: Uint8Array[] = []; |
| 489 | let byteLength = 0; |
| 490 | let settled = false; |
| 491 | const stream = file.internalStream!("uint8array"); |
| 492 | const cleanup = () => signal?.removeEventListener("abort", handleAbort); |
| 493 | const handleAbort = () => { |
| 494 | if (settled) return; |
| 495 | settled = true; |
| 496 | stream.pause(); |
| 497 | cleanup(); |
| 498 | reject(signal?.reason); |
| 499 | }; |
| 500 | signal?.addEventListener("abort", handleAbort, { once: true }); |
| 501 | stream |
| 502 | .on("data", (chunk) => { |
| 503 | if (settled) return; |
| 504 | byteLength += chunk.byteLength; |
| 505 | if (byteLength > MAX_THEME_BYTES) { |
| 506 | settled = true; |
| 507 | stream.pause(); |
| 508 | cleanup(); |
| 509 | reject(new Error(`${description} is too large.`)); |
| 510 | return; |
| 511 | } |
| 512 | chunks.push(chunk); |
| 513 | }) |
| 514 | .on("error", (cause) => { |
| 515 | if (settled) return; |
| 516 | settled = true; |
| 517 | cleanup(); |
| 518 | reject(cause); |
| 519 | }) |
| 520 | .on("end", () => { |
| 521 | if (settled) return; |
| 522 | settled = true; |
| 523 | cleanup(); |
| 524 | const bytes = new Uint8Array(byteLength); |
| 525 | let offset = 0; |
| 526 | for (const chunk of chunks) { |
| 527 | bytes.set(chunk, offset); |
| 528 | offset += chunk.byteLength; |
| 529 | } |
| 530 | resolve(new TextDecoder().decode(bytes)); |
| 531 | }) |
| 532 | .resume(); |
| 533 | }); |
| 534 | } |
| 535 | |
| 536 | async function loadThemeObject( |
| 537 | zip: JSZip, |
| 538 | path: string, |
| 539 | cache: Map<string, Record<string, unknown>>, |
| 540 | budget: { files: number }, |
| 541 | ancestors: ReadonlySet<string> = new Set(), |
| 542 | signal?: AbortSignal, |
| 543 | ): Promise<Record<string, unknown>> { |
| 544 | signal?.throwIfAborted(); |
| 545 | if (ancestors.size >= MAX_INCLUDE_DEPTH) throw new Error("Theme includes are nested too deeply."); |
| 546 | if (ancestors.has(path)) throw new Error("Theme includes contain a cycle."); |
| 547 | const cached = cache.get(path); |
| 548 | if (cached) return cached; |
| 549 | budget.files += 1; |
| 550 | if (budget.files > MAX_RESOLVED_THEME_FILES) { |
| 551 | throw new Error("That extension references too many theme files."); |
| 552 | } |
| 553 | |
| 554 | const value = sanitizeThemeObject( |
| 555 | parseJsoncObject(await readZipText(zip, path, path, signal), path), |
| 556 | ); |
| 557 | if (typeof value.include !== "string") { |
| 558 | cache.set(path, value); |
| 559 | return value; |
| 560 | } |
| 561 | |
| 562 | const includePath = normalizePackagePath(value.include, path); |
| 563 | const nextAncestors = new Set(ancestors); |
| 564 | nextAncestors.add(path); |
| 565 | const base = await loadThemeObject(zip, includePath, cache, budget, nextAncestors, signal); |
| 566 | const resolved = { |
| 567 | ...base, |
| 568 | ...value, |
| 569 | colors: { |
| 570 | ...(isRecord(base.colors) ? base.colors : {}), |
| 571 | ...(isRecord(value.colors) ? value.colors : {}), |
| 572 | }, |
| 573 | }; |
| 574 | cache.set(path, resolved); |
| 575 | return resolved; |
| 576 | } |
| 577 | |
| 578 | async function readCappedResponse( |
| 579 | response: Response, |
| 580 | limit: number, |
| 581 | tooLargeMessage: string, |
| 582 | ): Promise<Uint8Array> { |
| 583 | const contentLength = response.headers.get("content-length"); |
| 584 | if (contentLength && Number(contentLength) > limit) throw new Error(tooLargeMessage); |
| 585 | if (!response.body) { |
| 586 | const bytes = new Uint8Array(await response.arrayBuffer()); |
| 587 | if (bytes.byteLength > limit) throw new Error(tooLargeMessage); |
| 588 | return bytes; |
| 589 | } |
| 590 | |
| 591 | const reader = response.body.getReader(); |
| 592 | const chunks: Uint8Array[] = []; |
| 593 | let byteLength = 0; |
| 594 | try { |
| 595 | while (true) { |
| 596 | const { done, value } = await reader.read(); |
| 597 | if (done) break; |
| 598 | byteLength += value.byteLength; |
| 599 | if (byteLength > limit) { |
| 600 | await reader.cancel(); |
| 601 | throw new Error(tooLargeMessage); |
| 602 | } |
| 603 | chunks.push(value); |
| 604 | } |
| 605 | } finally { |
| 606 | reader.releaseLock(); |
| 607 | } |
| 608 | |
| 609 | const result = new Uint8Array(byteLength); |
| 610 | let offset = 0; |
| 611 | for (const chunk of chunks) { |
| 612 | result.set(chunk, offset); |
| 613 | offset += chunk.byteLength; |
| 614 | } |
| 615 | return result; |
| 616 | } |
| 617 | |
| 618 | async function fetchPackage(url: string, signal?: AbortSignal): Promise<Uint8Array> { |
| 619 | const response = await fetch(url, signal ? { signal } : {}); |
| 620 | if (!response.ok) throw new Error("That Open VSX theme could not be downloaded."); |
| 621 | return readCappedResponse( |
| 622 | response, |
| 623 | MAX_VSIX_BYTES, |
| 624 | "That theme extension is too large to import safely.", |
| 625 | ); |
| 626 | } |
| 627 | |
| 628 | export async function importOpenVsxThemeExtension( |
| 629 | extension: OpenVsxThemeExtension, |
| 630 | signal?: AbortSignal, |
| 631 | ): Promise<ReadonlyArray<ThemeDefinition>> { |
| 632 | const manifestResponse = await fetch(extension.manifestUrl, signal ? { signal } : {}); |
| 633 | if (!manifestResponse.ok) throw new Error("That Open VSX extension has no readable manifest."); |
| 634 | const manifestBytes = await readCappedResponse( |
| 635 | manifestResponse, |
| 636 | MAX_MANIFEST_BYTES, |
| 637 | "That Open VSX extension manifest is too large.", |
| 638 | ); |
| 639 | const manifest = parseJsoncObject(new TextDecoder().decode(manifestBytes), "Extension manifest"); |
| 640 | const advertisedContributions = themeContributions(manifest); |
| 641 | if (advertisedContributions.length === 0) { |
| 642 | throw new Error("That extension does not contain color themes."); |
| 643 | } |
| 644 | if (advertisedContributions.length > MAX_THEMES_PER_EXTENSION) { |
| 645 | throw new Error("That extension contains too many color themes to import safely."); |
| 646 | } |
| 647 | |
| 648 | const packageBytes = await fetchPackage(extension.vsixUrl, signal); |
| 649 | signal?.throwIfAborted(); |
| 650 | const checksumResponse = await fetch(extension.sha256Url, signal ? { signal } : {}); |
| 651 | if (!checksumResponse.ok) throw new Error("That Open VSX theme has no readable checksum."); |
| 652 | const expectedChecksum = new TextDecoder() |
| 653 | .decode( |
| 654 | await readCappedResponse( |
| 655 | checksumResponse, |
| 656 | 256, |
| 657 | "That Open VSX checksum response is invalid.", |
| 658 | ), |
| 659 | ) |
| 660 | .trim() |
| 661 | .split(/\s+/)[0]; |
| 662 | if (!expectedChecksum || !/^[a-f\d]{64}$/i.test(expectedChecksum)) { |
| 663 | throw new Error("That Open VSX theme has an invalid checksum."); |
| 664 | } |
| 665 | signal?.throwIfAborted(); |
| 666 | const actualChecksum = [...sha256(packageBytes)] |
| 667 | .map((byte) => byte.toString(16).padStart(2, "0")) |
| 668 | .join(""); |
| 669 | if (actualChecksum.toLowerCase() !== expectedChecksum.toLowerCase()) { |
| 670 | throw new Error("That Open VSX theme failed its integrity check."); |
| 671 | } |
| 672 | signal?.throwIfAborted(); |
| 673 | let zip: JSZip; |
| 674 | try { |
| 675 | const inspectedPackageBytes = inspectZipDirectory(packageBytes); |
| 676 | zip = await JSZip.loadAsync(inspectedPackageBytes); |
| 677 | signal?.throwIfAborted(); |
| 678 | inspectZip(zip); |
| 679 | } catch (cause) { |
| 680 | if (signal?.aborted) signal.throwIfAborted(); |
| 681 | if (cause instanceof Error && cause.message.startsWith("That extension package")) throw cause; |
| 682 | throw new Error("That Open VSX extension package could not be opened.", { cause }); |
| 683 | } |
| 684 | |
| 685 | const packagedManifest = parseJsoncObject( |
| 686 | await readZipText(zip, "extension/package.json", "Extension manifest", signal), |
| 687 | "Extension manifest", |
| 688 | ); |
| 689 | if ( |
| 690 | typeof packagedManifest.publisher !== "string" || |
| 691 | packagedManifest.publisher.toLowerCase() !== extension.publisher.toLowerCase() || |
| 692 | typeof packagedManifest.name !== "string" || |
| 693 | `${packagedManifest.publisher}.${packagedManifest.name}`.toLowerCase() !== |
| 694 | extension.id.toLowerCase() || |
| 695 | packagedManifest.version !== extension.version |
| 696 | ) { |
| 697 | throw new Error("That extension package does not match the selected Open VSX theme."); |
| 698 | } |
| 699 | if (!manifestLicenseMatches(packagedManifest, extension.license)) { |
| 700 | throw new Error("That extension package does not match its advertised license."); |
| 701 | } |
| 702 | const contributions = themeContributions(packagedManifest); |
| 703 | if (contributions.length === 0) throw new Error("That extension does not contain color themes."); |
| 704 | if (contributions.length > MAX_THEMES_PER_EXTENSION) { |
| 705 | throw new Error("That extension contains too many color themes to import safely."); |
| 706 | } |
| 707 | |
| 708 | const parsed: Array<{ theme: ThemeDefinition; sourceName: string; sourcePath: string }> = []; |
| 709 | const failures: string[] = []; |
| 710 | const themeCache = new Map<string, Record<string, unknown>>(); |
| 711 | const themeBudget = { files: 0 }; |
| 712 | for (const contribution of contributions) { |
| 713 | signal?.throwIfAborted(); |
| 714 | if (typeof contribution.path !== "string") { |
| 715 | failures.push("theme path is missing"); |
| 716 | continue; |
| 717 | } |
| 718 | try { |
| 719 | const path = normalizePackagePath(contribution.path); |
| 720 | const themeValue = await loadThemeObject( |
| 721 | zip, |
| 722 | path, |
| 723 | themeCache, |
| 724 | themeBudget, |
| 725 | new Set(), |
| 726 | signal, |
| 727 | ); |
| 728 | const type = contributionType(contribution.uiTheme); |
| 729 | const label = |
| 730 | typeof contribution.label === "string" && contribution.label.trim() |
| 731 | ? contribution.label.trim() |
| 732 | : extension.name; |
| 733 | const decorated = { |
| 734 | ...themeValue, |
| 735 | displayName: label, |
| 736 | ...(type ? { type } : {}), |
| 737 | }; |
| 738 | if (!isVsCodeThemeFile(decorated)) throw new Error("not a VS Code color theme"); |
| 739 | parsed.push({ |
| 740 | theme: parseVsCodeThemeFile(decorated), |
| 741 | sourceName: path.split("/").at(-1)!, |
| 742 | sourcePath: path, |
| 743 | }); |
| 744 | } catch (cause) { |
| 745 | signal?.throwIfAborted(); |
| 746 | failures.push(cause instanceof Error ? cause.message : "theme could not be read"); |
| 747 | } |
| 748 | } |
| 749 | if (failures.length > 0) { |
| 750 | throw new Error("One or more color themes in that extension could not be imported safely."); |
| 751 | } |
| 752 | if (parsed.length === 0) { |
| 753 | throw new Error("That extension has no compatible color themes."); |
| 754 | } |
| 755 | const extensionId = extension.id.toLowerCase(); |
| 756 | const sourcePathCounts = new Map<string, number>(); |
| 757 | for (const { sourcePath } of parsed) { |
| 758 | sourcePathCounts.set(sourcePath, (sourcePathCounts.get(sourcePath) ?? 0) + 1); |
| 759 | } |
| 760 | const sourcePathOccurrences = new Map<string, number>(); |
| 761 | const sourceIdentities = parsed.map(({ sourcePath }) => { |
| 762 | if (sourcePathCounts.get(sourcePath) === 1) return sourcePath; |
| 763 | const occurrence = sourcePathOccurrences.get(sourcePath) ?? 0; |
| 764 | sourcePathOccurrences.set(sourcePath, occurrence + 1); |
| 765 | return occurrence === 0 ? sourcePath : `${sourcePath}\0${occurrence}`; |
| 766 | }); |
| 767 | const resolved = resolveThemeLabelCollisions(parsed).map((theme, index) => ({ |
| 768 | ...theme, |
| 769 | id: openVsxThemeId(extensionId, sourceIdentities[index]!), |
| 770 | })); |
| 771 | const paired = pairVsCodeThemes(resolved, { |
| 772 | pairedId: (light, dark) => openVsxThemeId(extensionId, [light.id, dark.id].sort().join(":")), |
| 773 | }); |
| 774 | const themes = resolveThemeLabelCollisions(paired.map((theme) => ({ theme }))); |
| 775 | const collection = { |
| 776 | id: extension.collectionId, |
| 777 | label: extension.name.slice(0, 48), |
| 778 | }; |
| 779 | return themes.map((theme) => ({ ...theme, collection })); |
| 780 | } |