diff --git a/packages/cli/src/commands/results/pack.ts b/packages/cli/src/commands/results/pack.ts index e5d36099e27..2d4e09aa8e0 100644 --- a/packages/cli/src/commands/results/pack.ts +++ b/packages/cli/src/commands/results/pack.ts @@ -4,6 +4,7 @@ import { realpath } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { exit } from "node:process"; +import { formatByteSize } from "@allurereport/core-api"; import AdmZip from "adm-zip"; import { Command, Option } from "clipanion"; import { glob } from "glob"; @@ -38,27 +39,6 @@ export class ResultsPackCommand extends Command { description: "The working directory for the command to run (default: current working directory)", }); - /** - * Formats a size in bytes to a human-readable string with appropriate unit (B, KB, MB, GB) - * @param bytes - */ - #formatSize(bytes: number): string { - const units = ["bytes", "KB", "MB", "GB"]; - let size = bytes; - let unitIndex = 0; - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - - if (bytes === 0) { - return "0 bytes"; - } - - return unitIndex === 0 ? `${Math.round(size)} ${units[unitIndex]}` : `${size.toFixed(2)} ${units[unitIndex]}`; - } - async execute() { const cwd = await realpath(this.cwd ?? process.cwd()); const resultsDir = (this.resultsDir ?? "./**/allure-results").replace(/[\\/]$/, ""); @@ -116,7 +96,7 @@ export class ResultsPackCommand extends Command { console.log(green(`Archive created successfully: ${outputPath}`)); console.log( - green(`Total size: ${this.#formatSize(stats.size)}. ${resultsFiles.size} results files have been collected`), + green(`Total size: ${formatByteSize(stats.size)}. ${resultsFiles.size} results files have been collected`), ); } catch (err) { console.log(red(`Error creating archive: ${(err as Error).message}`)); diff --git a/packages/core-api/src/index.ts b/packages/core-api/src/index.ts index 60e37c148bc..94f90d7b080 100644 --- a/packages/core-api/src/index.ts +++ b/packages/core-api/src/index.ts @@ -28,3 +28,4 @@ export * from "./utils/strings.js"; export * from "./utils/dictionary.js"; export * from "./utils/path.js"; export * from "./utils/url.js"; +export * from "./utils/size.js"; diff --git a/packages/core-api/src/utils/size.ts b/packages/core-api/src/utils/size.ts new file mode 100644 index 00000000000..e2e54b70840 --- /dev/null +++ b/packages/core-api/src/utils/size.ts @@ -0,0 +1,73 @@ +/** Per-attachment raw size at or above which single-file mode writes externally. */ +export const HEAVY_ATTACHMENT_BYTES = 1_048_576; // 1 MiB + +/** Soft warning when single-file HTML is this large or larger. */ +export const LARGE_REPORT_WARN_BYTES = 50 * 1024 * 1024; // 50 MiB + +/** Stronger warning when single-file HTML is this large or larger. */ +export const LARGE_REPORT_SEVERE_BYTES = 100 * 1024 * 1024; // 100 MiB + +/** + * Formats a size in bytes to a human-readable string (B, KB, MB, GB) using 1024-based units. + */ +export const formatByteSize = (bytes: number): string => { + const units = ["bytes", "KB", "MB", "GB"]; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + if (bytes === 0) { + return "0 bytes"; + } + + return unitIndex === 0 ? `${Math.round(size)} ${units[unitIndex]}` : `${size.toFixed(2)} ${units[unitIndex]}`; +}; + +/** + * Whether an attachment raw size should be treated as heavy for single-file hybrid mode. + * Missing / non-finite sizes are not heavy (caller should re-check after reading the buffer). + */ +export const isHeavyAttachment = ( + sizeBytes: number | undefined | null, + thresholdBytes: number = HEAVY_ATTACHMENT_BYTES, +): boolean => { + if (sizeBytes == null || !Number.isFinite(sizeBytes)) { + return false; + } + + return sizeBytes >= thresholdBytes; +}; + +/** Rough base64-in-HTML size estimate (raw * 4/3). */ +export const estimateBase64EmbeddedSize = (rawBytes: number): number => Math.ceil((rawBytes * 4) / 3); + +/** + * Warn when a single-file HTML payload is large enough to hurt load performance. + * Does not throw; hard generation failures remain the caller's RangeError handling. + */ +export const warnIfLargeSingleFileReport = ( + htmlByteLength: number, + log: (message: string) => void = console.warn, +): void => { + if (htmlByteLength >= LARGE_REPORT_SEVERE_BYTES) { + log( + `Single-file report is very large (${formatByteSize(htmlByteLength)}). ` + + `Performance issues are expected beyond ~${formatByteSize(LARGE_REPORT_SEVERE_BYTES)}. ` + + "Prefer multi-file mode or ensure heavy attachments are written as external files. " + + "Serve the report over HTTP (e.g. `allure open`) rather than opening the HTML via file://.", + ); + return; + } + + if (htmlByteLength >= LARGE_REPORT_WARN_BYTES) { + log( + `Single-file report is large (${formatByteSize(htmlByteLength)}). ` + + `Load performance may degrade beyond ~${formatByteSize(LARGE_REPORT_WARN_BYTES)}. ` + + "Consider multi-file mode for reports with heavy attachments.", + ); + } +}; diff --git a/packages/core-api/test/utils/size.test.ts b/packages/core-api/test/utils/size.test.ts new file mode 100644 index 00000000000..9bef144f497 --- /dev/null +++ b/packages/core-api/test/utils/size.test.ts @@ -0,0 +1,88 @@ +import { epic, feature, label, story } from "allure-js-commons"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + HEAVY_ATTACHMENT_BYTES, + LARGE_REPORT_SEVERE_BYTES, + LARGE_REPORT_WARN_BYTES, + estimateBase64EmbeddedSize, + formatByteSize, + isHeavyAttachment, + warnIfLargeSingleFileReport, +} from "../../src/index.js"; + +beforeEach(async () => { + await epic("coverage"); + await feature("report-data-model"); + await story("size"); + await label("coverage", "report-data-model"); +}); + +describe("formatByteSize", () => { + it("formats zero and sub-kilobyte sizes", () => { + expect(formatByteSize(0)).toBe("0 bytes"); + expect(formatByteSize(512)).toBe("512 bytes"); + }); + + it("formats KB, MB, and GB with 1024-based units", () => { + expect(formatByteSize(1024)).toBe("1.00 KB"); + expect(formatByteSize(HEAVY_ATTACHMENT_BYTES)).toBe("1.00 MB"); + expect(formatByteSize(LARGE_REPORT_WARN_BYTES)).toBe("50.00 MB"); + expect(formatByteSize(1024 ** 3)).toBe("1.00 GB"); + }); +}); + +describe("isHeavyAttachment", () => { + it("returns false for missing or non-finite sizes", () => { + expect(isHeavyAttachment(undefined)).toBe(false); + expect(isHeavyAttachment(null)).toBe(false); + expect(isHeavyAttachment(Number.NaN)).toBe(false); + }); + + it("uses the default 1 MiB threshold", () => { + expect(isHeavyAttachment(HEAVY_ATTACHMENT_BYTES - 1)).toBe(false); + expect(isHeavyAttachment(HEAVY_ATTACHMENT_BYTES)).toBe(true); + }); + + it("accepts a custom threshold", () => { + expect(isHeavyAttachment(100, 100)).toBe(true); + expect(isHeavyAttachment(99, 100)).toBe(false); + }); +}); + +describe("estimateBase64EmbeddedSize", () => { + it("estimates 4/3 expansion", () => { + expect(estimateBase64EmbeddedSize(3)).toBe(4); + expect(estimateBase64EmbeddedSize(HEAVY_ATTACHMENT_BYTES)).toBe(Math.ceil((HEAVY_ATTACHMENT_BYTES * 4) / 3)); + }); +}); + +describe("warnIfLargeSingleFileReport", () => { + it("does not warn below the soft threshold", () => { + const log = vi.fn(); + + warnIfLargeSingleFileReport(LARGE_REPORT_WARN_BYTES - 1, log); + + expect(log).not.toHaveBeenCalled(); + }); + + it("warns at the soft threshold", () => { + const log = vi.fn(); + + warnIfLargeSingleFileReport(LARGE_REPORT_WARN_BYTES, log); + + expect(log).toHaveBeenCalledTimes(1); + expect(log.mock.calls[0][0]).toContain("is large"); + expect(log.mock.calls[0][0]).toContain("50.00 MB"); + }); + + it("uses the severe message at the hard threshold", () => { + const log = vi.fn(); + + warnIfLargeSingleFileReport(LARGE_REPORT_SEVERE_BYTES, log); + + expect(log).toHaveBeenCalledTimes(1); + expect(log.mock.calls[0][0]).toContain("very large"); + expect(log.mock.calls[0][0]).toContain("100.00 MB"); + }); +}); diff --git a/packages/plugin-awesome/README.md b/packages/plugin-awesome/README.md index b31d48760e5..7efdef727a9 100644 --- a/packages/plugin-awesome/README.md +++ b/packages/plugin-awesome/README.md @@ -50,7 +50,7 @@ The plugin accepts the following options: | Option | Description | Type | Default | |----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------|-------------------------------| | `reportName` | Name of the report | `string` | `Allure Report` | -| `singleFile` | Writes the report as a single `index.html` file | `boolean` | `false` | +| `singleFile` | Writes the report as a single `index.html` file. Attachments of **1 MiB or larger** are written next to the report under `data/attachments/` (hybrid mode) so the HTML stays loadable; open via `allure open` / HTTP rather than `file://` when external attachments exist. The CLI warns if the HTML exceeds ~50–100 MiB. | `boolean` | `false` | | `logo` | Path to the logo image | `string` | `null` | | `theme` | Default color theme of the report | `light \| dark \| auto` | `auto` OS theme | | `reportLanguage` | Default language of the report | `string` | OS language | diff --git a/packages/plugin-awesome/src/generators.ts b/packages/plugin-awesome/src/generators.ts index c0f09a35f2a..fd0600323aa 100644 --- a/packages/plugin-awesome/src/generators.ts +++ b/packages/plugin-awesome/src/generators.ts @@ -20,10 +20,13 @@ import { stringifyForInlineScript, createScriptTag, createStylesLinkTag, + HEAVY_ATTACHMENT_BYTES, incrementStatistic, + isHeavyAttachment, joinPosixPath, nullsLast, ordinal, + warnIfLargeSingleFileReport, } from "@allurereport/core-api"; import type { AllureStore, @@ -520,12 +523,37 @@ export const generateStatistic = async ( } }; +export type GenerateAttachmentsFilesOptions = { + /** + * When set, attachments at or above the heavy threshold are written here + * (sibling files) instead of into the in-memory single-file embed map. + */ + externalWriter?: AwesomeDataWriter; + /** + * Raw size threshold for hybrid single-file externalization. + * @default HEAVY_ATTACHMENT_BYTES (1 MiB) + */ + heavyAttachmentBytes?: number; +}; + +export type GenerateAttachmentsFilesResult = { + byId: Map; + /** Attachments written next to the report instead of embedded in index.html */ + externalCount: number; + externalBytes: number; +}; + export const generateAttachmentsFiles = async ( writer: AwesomeDataWriter, attachmentLinks: AttachmentLink[], contentFunction: (id: string) => Promise, -) => { + options: GenerateAttachmentsFilesOptions = {}, +): Promise => { + const { externalWriter, heavyAttachmentBytes = HEAVY_ATTACHMENT_BYTES } = options; const result = new Map(); + let externalCount = 0; + let externalBytes = 0; + for (const { id, ext, ...link } of attachmentLinks) { if (link.missed) { continue; @@ -536,10 +564,31 @@ export const generateAttachmentsFiles = async ( continue; } const src = `${id}${ext}`; - await writer.writeAttachment(src, content); + let targetWriter = writer; + + if (externalWriter) { + const sizeFromMeta = + "contentLength" in link && typeof link.contentLength === "number" ? link.contentLength : undefined; + let size = sizeFromMeta ?? content.getContentLength?.(); + + // Metadata can be missing or wrong; verify against the buffer when needed. + if (!isHeavyAttachment(size, heavyAttachmentBytes)) { + const buffer = await content.asBuffer(); + size = buffer?.byteLength ?? size; + } + + if (isHeavyAttachment(size, heavyAttachmentBytes)) { + targetWriter = externalWriter; + externalCount += 1; + externalBytes += size ?? 0; + } + } + + await targetWriter.writeAttachment(src, content); result.set(id, src); } - return result; + + return { byId: result, externalCount, externalBytes }; }; export const generateHistoryDataPoints = async (writer: AwesomeDataWriter, store: AllureStore) => { @@ -562,6 +611,11 @@ export const generateGlobals = async ( globalErrors?: PluginGlobalError[]; globalErrorsByEnv?: Record; contentFunction: (id: string) => Promise; + /** + * When set, heavy global attachments are written here instead of the in-memory embed map. + */ + externalWriter?: AwesomeDataWriter; + heavyAttachmentBytes?: number; }, ) => { const { @@ -582,6 +636,8 @@ export const generateGlobals = async ( globals.exitCode = globalExitCode; } + const { externalWriter, heavyAttachmentBytes = HEAVY_ATTACHMENT_BYTES } = payload; + for (const attachment of globalAttachments) { const src = `${attachment.id}${attachment.ext}`; const content = await contentFunction(attachment.id); @@ -590,7 +646,25 @@ export const generateGlobals = async ( continue; } - await writer.writeAttachment(src, content); + let targetWriter = writer; + + if (externalWriter) { + let size = + "contentLength" in attachment && typeof attachment.contentLength === "number" + ? attachment.contentLength + : content.getContentLength(); + + if (!isHeavyAttachment(size, heavyAttachmentBytes)) { + const buffer = await content.asBuffer(); + size = buffer?.byteLength ?? size; + } + + if (isHeavyAttachment(size, heavyAttachmentBytes)) { + targetWriter = externalWriter; + } + } + + await targetWriter.writeAttachment(src, content); globals.attachments.push(attachment); } @@ -736,7 +810,13 @@ export const generateStaticFiles = async ( singleFile: payload.singleFile, }); - await reportFiles.addFile("index.html", Buffer.from(html, "utf8")); + const htmlBuffer = Buffer.from(html, "utf8"); + + if (payload.singleFile) { + warnIfLargeSingleFileReport(htmlBuffer.byteLength); + } + + await reportFiles.addFile("index.html", htmlBuffer); } catch (err) { if (err instanceof RangeError) { // eslint-disable-next-line no-console diff --git a/packages/plugin-awesome/src/plugin.ts b/packages/plugin-awesome/src/plugin.ts index 0492b1a1b1c..934eb129226 100644 --- a/packages/plugin-awesome/src/plugin.ts +++ b/packages/plugin-awesome/src/plugin.ts @@ -1,4 +1,10 @@ -import { incrementStatistic, type EnvironmentItem, type Statistic, joinPosixPath } from "@allurereport/core-api"; +import { + formatByteSize, + incrementStatistic, + type EnvironmentItem, + type Statistic, + joinPosixPath, +} from "@allurereport/core-api"; import { type AllureStore, type Plugin, @@ -66,6 +72,8 @@ const statisticByTestResults = async ( export class AwesomePlugin implements Plugin { #writer: AwesomeDataWriter | undefined; + /** Sibling-file writer used for heavy attachments in single-file hybrid mode. */ + #externalWriter: AwesomeDataWriter | undefined; constructor(readonly options: AwesomePluginOptions = {}) {} @@ -194,7 +202,23 @@ export class AwesomePlugin implements Plugin { await generateEnvironmentJson(this.#writer!, environmentItems ?? []); if (attachments?.length) { - await generateAttachmentsFiles(this.#writer!, attachments, (id) => store.attachmentContentById(id)); + const attachmentResult = await generateAttachmentsFiles( + this.#writer!, + attachments, + (id) => store.attachmentContentById(id), + { + externalWriter: this.#externalWriter, + }, + ); + + if (singleFile && attachmentResult.externalCount > 0) { + // eslint-disable-next-line no-console + console.warn( + `Single-file report wrote ${attachmentResult.externalCount} heavy attachment(s) as external files ` + + `(${formatByteSize(attachmentResult.externalBytes)} raw). Serve the report over HTTP (e.g. \`allure open\`) ` + + "so attachments can be fetched next to index.html.", + ); + } } await generateQualityGateResults(this.#writer!, qualityGateResults); @@ -205,6 +229,7 @@ export class AwesomePlugin implements Plugin { globalErrorsByEnv, globalExitCode, contentFunction: (id) => store.attachmentContentById(id), + externalWriter: this.#externalWriter, }); const reportDataFiles = singleFile ? (this.#writer! as InMemoryReportDataWriter).reportFiles() : []; @@ -228,10 +253,13 @@ export class AwesomePlugin implements Plugin { if (singleFile) { this.#writer = new InMemoryReportDataWriter(); + // Heavy attachments are written beside index.html so the HTML stays lean. + this.#externalWriter = new ReportFileDataWriter(context.reportFiles); return; } this.#writer = new ReportFileDataWriter(context.reportFiles); + this.#externalWriter = undefined; await Promise.resolve(); }; diff --git a/packages/plugin-awesome/test/generators.test.ts b/packages/plugin-awesome/test/generators.test.ts index ceac01a9e63..3d462fc88b1 100644 --- a/packages/plugin-awesome/test/generators.test.ts +++ b/packages/plugin-awesome/test/generators.test.ts @@ -499,6 +499,67 @@ describe("generateAttachmentsFiles", () => { expect(writer.writeAttachment).toHaveBeenCalledTimes(1); expect(writer.writeAttachment).toHaveBeenCalledWith("written.txt", writtenContent); - expect(result).toEqual(new Map([["written", "written.txt"]])); + expect(result.byId).toEqual(new Map([["written", "written.txt"]])); + expect(result.externalCount).toBe(0); + expect(result.externalBytes).toBe(0); + }); + + it("writes heavy attachments to the external writer when provided", async () => { + const heavyContent = { + kind: "attachment", + getContentLength: () => 2 * 1024 * 1024, + asBuffer: async () => Buffer.alloc(2 * 1024 * 1024, 1), + } as unknown as ResultFile; + const lightContent = { + kind: "attachment", + getContentLength: () => 100, + asBuffer: async () => Buffer.from("small"), + } as unknown as ResultFile; + const writer: AwesomeDataWriter = { + writeData: vi.fn().mockResolvedValue(undefined), + writeWidget: vi.fn().mockResolvedValue(undefined), + writeTestCase: vi.fn().mockResolvedValue(undefined), + writeAttachment: vi.fn().mockResolvedValue(undefined), + }; + const externalWriter: AwesomeDataWriter = { + writeData: vi.fn().mockResolvedValue(undefined), + writeWidget: vi.fn().mockResolvedValue(undefined), + writeTestCase: vi.fn().mockResolvedValue(undefined), + writeAttachment: vi.fn().mockResolvedValue(undefined), + }; + const attachmentLinks: AttachmentLink[] = [ + { + id: "heavy", + ext: ".bin", + originalFileName: "heavy.bin", + name: "heavy", + missed: false, + used: true, + contentLength: 2 * 1024 * 1024, + }, + { + id: "light", + ext: ".txt", + originalFileName: "light.txt", + name: "light", + missed: false, + used: true, + contentLength: 100, + }, + ]; + + const result = await generateAttachmentsFiles( + writer, + attachmentLinks, + vi.fn(async (id: string) => (id === "heavy" ? heavyContent : lightContent)), + { externalWriter }, + ); + + expect(externalWriter.writeAttachment).toHaveBeenCalledTimes(1); + expect(externalWriter.writeAttachment).toHaveBeenCalledWith("heavy.bin", heavyContent); + expect(writer.writeAttachment).toHaveBeenCalledTimes(1); + expect(writer.writeAttachment).toHaveBeenCalledWith("light.txt", lightContent); + expect(result.externalCount).toBe(1); + expect(result.externalBytes).toBe(2 * 1024 * 1024); }); }); diff --git a/packages/plugin-awesome/test/plugin.test.ts b/packages/plugin-awesome/test/plugin.test.ts index fdf3db92088..28083ea64a4 100644 --- a/packages/plugin-awesome/test/plugin.test.ts +++ b/packages/plugin-awesome/test/plugin.test.ts @@ -932,6 +932,79 @@ describe("plugin", () => { expect(Object.keys(embeddedData).some((k) => k.startsWith("data/test-results/"))).toBe(true); }); + it("should write heavy single-file attachments as sibling files instead of embedding them", async () => { + const heavyBytes = Buffer.alloc(2 * 1024 * 1024, 7); + const lightBytes = Buffer.from("tiny-log"); + const heavyContent = { + getContentLength: () => heavyBytes.byteLength, + asBuffer: async () => heavyBytes, + writeTo: vi.fn(), + }; + const lightContent = { + getContentLength: () => lightBytes.byteLength, + asBuffer: async () => lightBytes, + writeTo: vi.fn(), + }; + const testResults: TestResult[] = [ + { + id: "tr-1", + name: "passed test", + status: "passed", + environment: "default", + labels: [], + }, + ] as TestResult[]; + const store = makeSingleFileStore(testResults); + + store.allAttachments = vi.fn().mockResolvedValue([ + { + id: "heavy", + ext: ".bin", + originalFileName: "heavy.bin", + name: "heavy", + missed: false, + used: true, + contentLength: heavyBytes.byteLength, + }, + { + id: "light", + ext: ".txt", + originalFileName: "light.txt", + name: "light", + missed: false, + used: true, + contentLength: lightBytes.byteLength, + }, + ]); + store.attachmentContentById = vi.fn(async (id: string) => (id === "heavy" ? heavyContent : lightContent)); + + const addedFiles = new Map(); + const reportFiles: ReportFiles = { + addFile: vi.fn(async (path: string, data: Buffer) => { + addedFiles.set(path, data); + return path; + }), + }; + const plugin = new AwesomePlugin({ singleFile: true }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await plugin.start(makeSingleFileContext(reportFiles)); + await plugin.done(makeSingleFileContext(reportFiles), store); + + const indexHtml = addedFiles.get("index.html")?.toString("utf-8") ?? ""; + const embeddedData = extractEmbeddedData(indexHtml); + + expect(addedFiles.has("data/attachments/heavy.bin")).toBe(true); + expect(addedFiles.get("data/attachments/heavy.bin")?.equals(heavyBytes)).toBe(true); + expect(embeddedData["data/attachments/heavy.bin"]).toBeUndefined(); + expect(embeddedData["data/attachments/light.txt"]).toBe(lightBytes.toString("base64")); + expect(warnSpy.mock.calls.some((call) => String(call[0]).includes("heavy attachment"))).toBe(true); + } finally { + warnSpy.mockRestore(); + } + }); + it("should include launch timing and allure2 executor metadata in report options", async () => { const testResults: TestResult[] = [ { diff --git a/packages/web-commons/src/data.ts b/packages/web-commons/src/data.ts index ed25c82528a..5e4caf3aaae 100644 --- a/packages/web-commons/src/data.ts +++ b/packages/web-commons/src/data.ts @@ -5,6 +5,25 @@ import { toPosixPath } from "@allurereport/core-api"; */ export const ALLURE_LIVE_RELOAD_HASH_STORAGE_KEY = "__allure_report_live_reload_hash__"; +/** + * Strip parameters and reject malformed / injection-prone MIME types. + * Attachment contentType comes from untrusted test results. + */ +export const sanitizeContentType = (contentType?: string): string => { + if (!contentType) { + return "application/octet-stream"; + } + + const base = contentType.split(";")[0]?.trim().toLowerCase() ?? ""; + + // type/subtype with limited token characters (RFC 6838-ish) + if (!/^[a-z0-9][a-z0-9!#$&\-^_.+]*\/[a-z0-9][a-z0-9!#$&\-^_.+]*$/i.test(base)) { + return "application/octet-stream"; + } + + return base; +}; + export const ensureReportDataReady = () => new Promise((resolve) => { const waitForReady = () => { @@ -44,18 +63,7 @@ export const loadReportData = async (name: string): Promise => { }); }; -export const reportDataUrl = async ( - path: string, - contentType: string = "application/octet-stream", - params?: { bustCache: boolean }, -) => { - if (globalThis.allureReportData) { - const [dataKey] = path.split("?"); - const value = await loadReportData(dataKey); - - return `data:${contentType};base64,${value}`; - } - +const relativeReportUrl = (path: string, params?: { bustCache: boolean }) => { const baseEl = globalThis.document.head.querySelector("base")?.href ?? "https://localhost"; const url = new URL(path, baseEl); const liveReloadHash = globalThis.localStorage.getItem(ALLURE_LIVE_RELOAD_HASH_STORAGE_KEY); @@ -69,9 +77,39 @@ export const reportDataUrl = async ( url.searchParams.set("v", cacheKey); } + // Never allow absolute attachment/report data URLs from untrusted path inputs. + // `new URL(path, base)` keeps path relative to the report base when path is relative. return url.toString(); }; +export const reportDataUrl = async ( + path: string, + contentType: string = "application/octet-stream", + params?: { bustCache: boolean }, +) => { + const safeContentType = sanitizeContentType(contentType); + + // Reject absolute / scheme-based paths so hybrid fallback cannot be turned into open redirects. + if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(path) || path.startsWith("//")) { + throw new Error(`Refusing absolute report data path: ${path}`); + } + + if (globalThis.allureReportData) { + const [dataKey] = path.split("?"); + + try { + const value = await loadReportData(dataKey); + + return `data:${safeContentType};base64,${value}`; + } catch { + // Hybrid single-file mode: heavy attachments may live as sibling files next to index.html. + // Fall through to a relative fetch under the report base. + } + } + + return relativeReportUrl(path, params); +}; + export class ReportFetchError extends Error { constructor( message: string, @@ -88,13 +126,16 @@ export const fetchReportJsonData = async (path: string, params?: { bustCache: try { url = await reportDataUrl(path, undefined, params); - } catch { - // In single-file mode loadReportData throws a plain Error when a key is absent. - // Convert to ReportFetchError(404) so callers behave the same as in multi-file mode. - throw new ReportFetchError( - `Failed to fetch ${path}: data not found`, - new Response(null, { status: 404, statusText: "Not Found" }), - ); + } catch (error) { + // Absolute paths are rejected; convert to a 404-style fetch error for callers. + if (error instanceof Error && /absolute report data path/i.test(error.message)) { + throw new ReportFetchError( + `Failed to fetch ${path}: invalid path`, + new Response(null, { status: 404, statusText: "Not Found" }), + ); + } + + throw error; } const res = await globalThis.fetch(url); diff --git a/packages/web-commons/test/data.test.ts b/packages/web-commons/test/data.test.ts index 2e62768b488..ac0f9dff0ce 100644 --- a/packages/web-commons/test/data.test.ts +++ b/packages/web-commons/test/data.test.ts @@ -1,7 +1,13 @@ import { epic, feature, label, story } from "allure-js-commons"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ReportFetchError, fetchReportJsonData, loadReportData } from "../src/data.js"; +import { + ReportFetchError, + fetchReportJsonData, + loadReportData, + reportDataUrl, + sanitizeContentType, +} from "../src/data.js"; beforeEach(async () => { await epic("coverage"); @@ -83,6 +89,8 @@ describe("fetchReportJsonData", () => { (globalThis as any).allureReportDataReady = true; (globalThis as any).allureReportData = {}; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 404, statusText: "Not Found" })); + const error = await fetchReportJsonData("widgets/tree-filters.json").catch((e) => e); expect(error).toBeInstanceOf(ReportFetchError); @@ -103,3 +111,47 @@ describe("fetchReportJsonData", () => { expect(result.tags).toEqual(["smoke"]); }); }); + +describe("sanitizeContentType", () => { + it("defaults missing and malformed types to application/octet-stream", () => { + expect(sanitizeContentType(undefined)).toBe("application/octet-stream"); + expect(sanitizeContentType("")).toBe("application/octet-stream"); + expect(sanitizeContentType("text/html;base64,PHN2Zy")).toBe("text/html"); + expect(sanitizeContentType("text/html; charset=utf-8")).toBe("text/html"); + expect(sanitizeContentType("image/png