diff --git a/packages/ui/src/components/viewers/MultipartViewer.test.tsx b/packages/ui/src/components/viewers/MultipartViewer.test.tsx new file mode 100644 index 000000000..72ec73721 --- /dev/null +++ b/packages/ui/src/components/viewers/MultipartViewer.test.tsx @@ -0,0 +1,122 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test, vi } from "vite-plus/test"; +import { + GMAIL_BATCH_BODY, + GMAIL_BATCH_BOUNDARY, + GMAIL_BATCH_CONTENT_TYPE, +} from "../../lib/multipart.fixtures"; +import { MAX_MULTIPART_DEPTH, MultipartViewer } from "./MultipartViewer"; + +// Stand-ins that show which viewer a part went to, without the editor or the tab machinery +vi.mock("./TextViewer", () => ({ + TextViewer: ({ text, language }: { text: string; language?: string }) => ( +
+      {text}
+    
+ ), +})); +vi.mock("./HexdumpViewer", () => ({ + HexdumpViewer: ({ data }: { data: Uint8Array }) => ( +
{data.length} bytes
+ ), +})); +vi.mock("../tabs/Tabs", () => ({ + Tabs: ({ tabs, children }: { tabs: { label: string }[]; children: ReactNode }) => ( +
+ {tabs.map((t) => ( + + ))} + {children} +
+ ), + TabContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../HeadersTable", () => ({ + HeadersTable: ({ headers }: { headers: { name: string; value: string }[] }) => ( +
+ {headers.map((h) => ( +
+ {h.name}: {h.value} +
+ ))} +
+ ), +})); + +const bytes = (s: string) => new TextEncoder().encode(s); + +describe("MultipartViewer", () => { + test("renders Gmail's batch response as a nested HTTP response", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).not.toContain("Failed to parse"); + expect(markup).toContain(">response-gtlr_43499"); + expect(markup).toContain("200"); + expect(markup).toContain("OK"); + expect(markup).toContain("Vary: X-Origin"); + expect(markup).toContain('data-viewer="text" data-language="json"'); + expect(markup).toContain(""messagesTotal": 23545"); + expect(markup).not.toContain(GMAIL_BATCH_BOUNDARY); + }); + + test("renders a multipart part recursively", () => { + const body = [ + "--outer", + 'Content-Type: multipart/alternative; boundary="inner"', + "", + "--inner", + "Content-Type: text/plain", + "", + "deep", + "--inner--", + "--outer--", + ].join("\r\n"); + const markup = renderToStaticMarkup(); + expect(markup).toContain('data-viewer="text"'); + expect(markup).toContain(">deep"); + expect(markup).not.toContain("--inner"); + }); + + test("shows multipart nested past the limit raw, with a note", () => { + // Six levels, each a multipart part holding the next, the innermost a text part + let body = ["--l6", "Content-Type: text/plain", "", "innermost", "--l6--"].join("\r\n"); + for (let level = 5; level >= 1; level--) { + body = [ + `--l${level}`, + `Content-Type: multipart/mixed; boundary=l${level + 1}`, + "", + body, + `--l${level}--`, + ].join("\r\n"); + } + const markup = renderToStaticMarkup(); + expect(markup).toContain(`nested more than ${MAX_MULTIPART_DEPTH} levels deep`); + // Level five is shown as its raw text, so its own delimiters are still in it + expect(markup).toContain('data-viewer="text" data-language="text"'); + expect(markup).toContain("--l5"); + expect(markup).not.toContain(">innermost"); + }); + + test("shows a binary part as a hexdump", () => { + const head = bytes("--b\r\nContent-Type: application/octet-stream\r\n\r\n"); + const tail = bytes("\r\n--b--"); + const data = new Uint8Array([...head, 0x00, 0x01, 0xff, 0xfe, ...tail]); + const markup = renderToStaticMarkup(); + expect(markup).toContain('data-viewer="hexdump">4 bytes'); + }); + + test("notes a body cut off before its closing delimiter", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("ends without its closing boundary"); + expect(markup).toContain(">partial"); + }); +}); diff --git a/packages/ui/src/components/viewers/MultipartViewer.tsx b/packages/ui/src/components/viewers/MultipartViewer.tsx index 50a176537..0e5b5a42d 100644 --- a/packages/ui/src/components/viewers/MultipartViewer.tsx +++ b/packages/ui/src/components/viewers/MultipartViewer.tsx @@ -1,12 +1,24 @@ -import { type MultipartPart, parseMultipart } from "@mjackson/multipart-parser"; import type { ReactNode } from "react"; import { useMemo } from "react"; import { languageFromContentType } from "../../lib/contentType"; +import { + boundaryFromContentType, + headerValue, + type HttpMessage, + isProbablyBinary, + type MultipartParseResult, + type MultipartPart, + parseHttpMessage, + parseMultipart, +} from "../../lib/multipart"; import { Banner } from "../Banner"; +import { HeadersTable } from "../HeadersTable"; import { Icon } from "../Icon"; +import { HttpStatusTagRaw } from "../tags/HttpStatusTag"; import { TabContent, type TabContentProps, Tabs } from "../tabs/Tabs"; import { AudioViewer } from "./AudioViewer"; import { CsvViewer } from "./CsvViewer"; +import { HexdumpViewer } from "./HexdumpViewer"; import { ImageViewer } from "./ImageViewer"; import { SvgViewer } from "./SvgViewer"; import { TextViewer, type TextViewerProps } from "./TextViewer"; @@ -15,6 +27,7 @@ import { WebPageViewer } from "./WebPageViewer"; export interface MultipartViewerProps { data: Uint8Array; + /** The boundary, bare or as cut from a `Content-Type` header (quotes and later parameters are dropped) */ boundary: string; idPrefix?: string; /** Renders a PDF part, which shows as text without it */ @@ -24,35 +37,47 @@ export interface MultipartViewerProps { ErrorBoundary?: TabContentProps["ErrorBoundary"]; } -export function MultipartViewer({ +/** How many multipart levels show as tabs; a part nested deeper shows raw, with a note */ +export const MAX_MULTIPART_DEPTH = 4; + +type ViewerOptions = Pick< + MultipartViewerProps, + "renderPdf" | "textViewerProps" | "ErrorBoundary" +> & { + /** The multipart levels shown so far, this one included */ + depth: number; +}; + +export function MultipartViewer(props: MultipartViewerProps) { + return ; +} + +function MultipartParts({ data, boundary, idPrefix = "multipart", - renderPdf, - textViewerProps, - ErrorBoundary, -}: MultipartViewerProps) { - const parseResult = useMemo(() => { + ...options +}: MultipartViewerProps & { depth: number }) { + // Parsed once per bytes and boundary, however often this renders + const parseResult = useMemo< + { result: MultipartParseResult; error: null } | { result: null; error: string } + >(() => { try { - const maxFileSize = 1024 * 1024 * 10; // 10MB - const parsed = parseMultipart(data, { boundary, maxFileSize }); - const parts = Array.from(parsed); - return { parts, error: null }; + return { result: parseMultipart(data, boundary), error: null }; } catch (err) { - return { parts: [], error: err instanceof Error ? err.message : String(err) }; + return { result: null, error: err instanceof Error ? err.message : String(err) }; } }, [data, boundary]); - const { parts, error } = parseResult; - - if (error) { + if (parseResult.error != null) { return ( - Failed to parse multipart data: {error} + Failed to parse multipart data: {parseResult.error} ); } + const { parts, complete } = parseResult.result; if (parts.length === 0) { return ( @@ -61,20 +86,21 @@ export function MultipartViewer({ ); } - return ( + const tabs = ( ({ - label: part.name ?? "", + label: partLabel(part, i), value: tabValue(part, i), rightSlot: - part.filename && part.headers.contentType.mediaType?.startsWith("image/") ? ( + part.filename && part.mediaType?.startsWith("image/") ? (
@@ -86,59 +112,182 @@ export function MultipartViewer({ {parts.map((part, i) => ( - + ))}
); + + if (complete) return tabs; + return ( +
+ + The body ends without its closing boundary; showing the parts that arrived. + + {tabs} +
+ ); } function Part({ part, + idPrefix, + ...options +}: { part: MultipartPart; idPrefix: string } & ViewerOptions) { + if (part.mediaType === "application/http") { + const message = parseHttpMessage(part.body); + if (message != null) { + return ; + } + } + return ( + + ); +} + +/** An `application/http` part, as in Google and OData batches: an HTTP message of its own */ +function HttpMessagePart({ + message, + idPrefix, + ...options +}: { message: HttpMessage; idPrefix: string } & ViewerOptions) { + return ( +
+
+ {message.status != null ? ( + + ) : ( + + {message.method} {message.url} + + )} +
+ + +
+ ); +} + +/** A body by its own content type: the viewer the response pane would pick for it */ +function PartBody({ + body, + contentType, + idPrefix, + ...options +}: { + body: Uint8Array; + contentType: string | null; + idPrefix: string; +} & ViewerOptions) { + const mediaType = contentType?.split(";")[0]?.trim().toLowerCase() || null; + + if (body.length === 0) { + return
Empty
; + } + + const nestedBoundary = mediaType?.startsWith("multipart/") + ? boundaryFromContentType(contentType) + : null; + if (nestedBoundary != null) { + if (options.depth < MAX_MULTIPART_DEPTH) { + // The nested body is a view into the outer one's bytes, parsed only when shown + return ( + + ); + } + return ( +
+ + Multipart nested more than {MAX_MULTIPART_DEPTH} levels deep; shown raw. + + +
+ ); + } + + return ; +} + +/** A body that isn't multipart, decoded only here, where a viewer needs its text */ +function LeafBody({ + body, + contentType, + mediaType, + raw, renderPdf, textViewerProps, -}: { part: MultipartPart } & Pick) { - const mimeType = part.headers.contentType.mediaType ?? null; - const contentTypeHeader = part.headers.get("content-type"); +}: { + body: Uint8Array; + contentType: string | null; + mediaType: string | null; + /** Text or a hexdump, whatever the content type says */ + raw?: boolean; +} & ViewerOptions) { + const { content, detectedLanguage, binary } = useMemo(() => { + const binary = isBinaryPart(mediaType, body); + const content = new TextDecoder().decode(body); + const detectedLanguage = raw ? "text" : languageFromContentType(contentType, content); + return { content, detectedLanguage, binary }; + }, [body, contentType, mediaType, raw]); - const { uint8Array, content, detectedLanguage } = useMemo(() => { - const uint8Array = new Uint8Array(part.arrayBuffer); - const content = new TextDecoder().decode(part.arrayBuffer); - const detectedLanguage = languageFromContentType(contentTypeHeader, content); - return { uint8Array, content, detectedLanguage }; - }, [part, contentTypeHeader]); + if (raw) { + return binary ? ( + + ) : ( + + ); + } - if (mimeType?.match(/^image\/svg/i)) { + if (mediaType?.match(/^image\/svg/i)) { return ; } - if (mimeType?.match(/^image/i)) { - return ; + if (mediaType?.match(/^image/i)) { + return ; } - if (mimeType?.match(/^audio/i)) { - return ; + if (mediaType?.match(/^audio/i)) { + return ; } - if (mimeType?.match(/^video/i)) { - return ; + if (mediaType?.match(/^video/i)) { + return ; } - if (mimeType?.match(/csv|tab-separated/i)) { + if (mediaType?.match(/csv|tab-separated/i)) { return ; } - if (mimeType?.match(/^text\/html/i) || detectedLanguage === "html") { + if (mediaType?.match(/^text\/html/i) || (!binary && detectedLanguage === "html")) { return ; } - if (mimeType?.match(/pdf/i) && renderPdf != null) { - return renderPdf(uint8Array); + if (mediaType?.match(/pdf/i) && renderPdf != null) { + return renderPdf(body); + } + + if (binary) { + return ; } return ( @@ -146,6 +295,25 @@ function Part({ ); } +/** Text types are shown as text even with odd bytes; anything else is binary if its bytes say so */ +function isBinaryPart(mediaType: string | null, body: Uint8Array): boolean { + if ( + mediaType != null && + /^text\/|json|xml|javascript|yaml|csv|x-www-form-urlencoded/.test(mediaType) + ) { + return false; + } + return isProbablyBinary(body); +} + +function partLabel(part: MultipartPart, i: number): string { + return part.name || part.filename || part.contentId || `Part ${i + 1}`; +} + function tabValue(part: MultipartPart, i: number) { return `${part.name ?? ""}::${i}`; } + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.slice().buffer; +} diff --git a/packages/ui/src/lib/multipart.fixtures.ts b/packages/ui/src/lib/multipart.fixtures.ts new file mode 100644 index 000000000..dd8e92ff3 --- /dev/null +++ b/packages/ui/src/lib/multipart.fixtures.ts @@ -0,0 +1,34 @@ +// Multipart bodies for the parser and viewer tests + +/** Gmail's batch response, as captured: a blank line, then one `application/http` part */ +export const GMAIL_BATCH_BOUNDARY = "batch_8Js-Xj0pJXhmxHz6uTLe9MxOxKn8BPHl"; +export const GMAIL_BATCH_CONTENT_TYPE = `multipart/mixed; boundary=${GMAIL_BATCH_BOUNDARY}`; +export const GMAIL_BATCH_JSON = [ + "{", + ' "id": "IMPORTANT",', + ' "name": "IMPORTANT",', + ' "messageListVisibility": "hide",', + ' "labelListVisibility": "labelShow",', + ' "type": "system",', + ' "messagesTotal": 23545,', + ' "messagesUnread": 12,', + ' "threadsTotal": 19203,', + ' "threadsUnread": 10', + "}", +].join("\n"); +export const GMAIL_BATCH_BODY = [ + "", + `--${GMAIL_BATCH_BOUNDARY}`, + "Content-Type: application/http", + "Content-ID: response-gtlr_43499", + "", + "HTTP/1.1 200 OK", + "Content-Type: application/json; charset=UTF-8", + "Vary: Origin", + "Vary: X-Origin", + "Vary: Referer", + "", + GMAIL_BATCH_JSON, + `--${GMAIL_BATCH_BOUNDARY}--`, + "", +].join("\r\n"); diff --git a/packages/ui/src/lib/multipart.test.ts b/packages/ui/src/lib/multipart.test.ts new file mode 100644 index 000000000..95bd6bbe1 --- /dev/null +++ b/packages/ui/src/lib/multipart.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + boundaryFromContentType, + headerValue, + isProbablyBinary, + normalizeBoundary, + parseHttpMessage, + parseMultipart, +} from "./multipart"; +import { + GMAIL_BATCH_BODY, + GMAIL_BATCH_BOUNDARY, + GMAIL_BATCH_CONTENT_TYPE, +} from "./multipart.fixtures"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const bytes = (s: string) => encoder.encode(s); +const text = (b: Uint8Array) => decoder.decode(b); + +describe("parseMultipart", () => { + test("parses Gmail's batch response, skipping the blank line before the first boundary", () => { + const boundary = boundaryFromContentType(GMAIL_BATCH_CONTENT_TYPE)!; + const { parts, complete } = parseMultipart(bytes(GMAIL_BATCH_BODY), boundary); + + expect(complete).toBe(true); + expect(parts).toHaveLength(1); + const part = parts[0]!; + expect(part.mediaType).toBe("application/http"); + expect(part.contentId).toBe("response-gtlr_43499"); + + const message = parseHttpMessage(part.body)!; + expect(message.status).toBe(200); + expect(message.statusReason).toBe("OK"); + expect(message.headers.filter((h) => h.name === "Vary").map((h) => h.value)).toEqual([ + "Origin", + "X-Origin", + "Referer", + ]); + expect(headerValue(message.headers, "content-type")).toBe("application/json; charset=UTF-8"); + expect(JSON.parse(text(message.body))).toMatchObject({ id: "IMPORTANT", messagesTotal: 23545 }); + }); + + test("skips a text preamble and ignores the epilogue", () => { + const body = [ + "This is a multi-part message in MIME format.", + "--b", + 'Content-Disposition: form-data; name="a"', + "", + "one", + "--b", + 'Content-Disposition: form-data; name="b"', + "", + "two", + "--b--", + "This is the epilogue. --b", + ].join("\r\n"); + const { parts, complete } = parseMultipart(bytes(body), "b"); + expect(complete).toBe(true); + expect(parts.map((p) => [p.name, text(p.body)])).toEqual([ + ["a", "one"], + ["b", "two"], + ]); + }); + + test("shows the parts that arrived when the closing delimiter is missing", () => { + const body = ["--b", "Content-Type: text/plain", "", "one", "--b", "", "two cut sh"].join( + "\r\n", + ); + const { parts, complete } = parseMultipart(bytes(body), "b"); + expect(complete).toBe(false); + expect(parts.map((p) => text(p.body))).toEqual(["one", "two cut sh"]); + expect(parts[1]!.headers).toEqual([]); + }); + + test("tolerates a missing final line break and LF-only line endings", () => { + const body = ["--b", "Content-Type: text/plain", "", "one", "--b--"].join("\n"); + const { parts, complete } = parseMultipart(bytes(body), "b"); + expect(complete).toBe(true); + expect(text(parts[0]!.body)).toBe("one"); + }); + + test("parses a multipart part nested inside a part", () => { + const inner = ["--inner", "Content-Type: text/plain", "", "deep", "--inner--"].join("\r\n"); + const body = [ + "--outer", + 'Content-Type: multipart/alternative; boundary="inner"', + "", + inner, + "--outer", + "Content-Type: text/plain", + "", + "shallow", + "--outer--", + ].join("\r\n"); + const outer = parseMultipart(bytes(body), "outer"); + expect(outer.parts).toHaveLength(2); + const nested = outer.parts[0]!; + expect(nested.mediaType).toBe("multipart/alternative"); + const innerResult = parseMultipart(nested.body, boundaryFromContentType(nested.contentType)!); + expect(innerResult.complete).toBe(true); + expect(text(innerResult.parts[0]!.body)).toBe("deep"); + expect(text(outer.parts[1]!.body)).toBe("shallow"); + }); + + test("keeps a binary part's bytes exactly", () => { + const png = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff, 0x0d, 0x0a, + ]); + const head = bytes( + [ + "--b", + 'Content-Disposition: form-data; name="file"; filename="a.bin"', + "Content-Type: application/octet-stream", + "", + "", + ].join("\r\n"), + ); + const tail = bytes("\r\n--b--\r\n"); + const body = new Uint8Array([...head, ...png, ...tail]); + const { parts } = parseMultipart(body, "b"); + expect(parts[0]!.filename).toBe("a.bin"); + expect(Array.from(parts[0]!.body)).toEqual(Array.from(png)); + expect(isProbablyBinary(parts[0]!.body)).toBe(true); + }); + + test("handles quoted boundaries with special characters", () => { + const boundary = "a'b(c)+_,-./:=? d"; + const contentType = `multipart/form-data; boundary="${boundary}"; charset=utf-8`; + expect(boundaryFromContentType(contentType)).toBe(boundary); + // What the panes cut from the header after `boundary=` + expect(normalizeBoundary(`"${boundary}"; charset=utf-8`)).toBe(boundary); + expect(normalizeBoundary("simple; charset=utf-8")).toBe("simple"); + + const body = [`--${boundary}`, "", "x", `--${boundary}--`].join("\r\n"); + const { parts } = parseMultipart(bytes(body), `"${boundary}"`); + expect(parts.map((p) => text(p.body))).toEqual(["x"]); + }); + + test("does not take a longer boundary for this one", () => { + const body = ["--b", "", "x", "--bb", "still x", "--b--"].join("\r\n"); + const { parts } = parseMultipart(bytes(body), "b"); + expect(parts.map((p) => text(p.body))).toEqual(["x\r\n--bb\r\nstill x"]); + }); + + test("takes a line for a delimiter only when the whole line is one", () => { + const body = [ + "--b", + "", + "x", + "--b-suffix", + "--b--suffix", + "still x", + "--b ", + "", + "y", + "--b-- ", + "epilogue", + ].join("\r\n"); + const { parts, complete } = parseMultipart(bytes(body), "b"); + expect(complete).toBe(true); + expect(parts.map((p) => text(p.body))).toEqual([ + "x\r\n--b-suffix\r\n--b--suffix\r\nstill x", + "y", + ]); + }); + + test("reads quoted and extended disposition parameters", () => { + const body = [ + "--b", + `Content-Disposition: form-data; name="a;b"; filename*=UTF-8''r%C3%A9sum%C3%A9.txt`, + "", + "x", + "--b--", + ].join("\r\n"); + const { parts } = parseMultipart(bytes(body), "b"); + expect(parts[0]!.name).toBe("a;b"); + expect(parts[0]!.filename).toBe("résumé.txt"); + }); + + test("reads UTF-8 names, quoted semicolons and a filename in its declared charset", () => { + const body = [ + "--b", + 'Content-Disposition: form-data; filename="a; name=wrong"; name="café"', + "", + "x", + "--b", + "Content-Disposition: attachment; filename*=ISO-8859-1''caf%E9.txt", + "", + "y", + "--b--", + ].join("\r\n"); + const { parts } = parseMultipart(bytes(body), "b"); + expect(parts[0]!.name).toBe("café"); + expect(parts[0]!.filename).toBe("a; name=wrong"); + expect(parts[1]!.filename).toBe("café.txt"); + }); + + test("keeps every byte of a part cut off before its delimiter", () => { + const { parts } = parseMultipart(bytes("--b\r\n\r\nhello\r\n"), "b"); + expect(text(parts[0]!.body)).toBe("hello\r\n"); + }); + + test("keeps a quoted boundary's leading space, however often it's normalized", () => { + const contentType = 'multipart/mixed; boundary=" b"'; + const boundary = boundaryFromContentType(contentType)!; + expect(boundary).toBe(" b"); + expect(normalizeBoundary(boundary)).toBe(" b"); + const { parts } = parseMultipart(bytes("-- b\r\n\r\nx\r\n-- b--"), boundary); + expect(parts.map((p) => text(p.body))).toEqual(["x"]); + }); + + test("throws when the boundary never appears", () => { + expect(() => parseMultipart(bytes("nothing here"), "b")).toThrow(/No "--b" delimiter/); + }); + + test("parses the same bytes once", () => { + const data = bytes(GMAIL_BATCH_BODY); + expect(parseMultipart(data, GMAIL_BATCH_BOUNDARY)).toBe( + parseMultipart(data, GMAIL_BATCH_BOUNDARY), + ); + }); +}); + +describe("parseHttpMessage", () => { + test("parses a request line, as in an OData batch", () => { + const message = parseHttpMessage( + bytes("GET /people(1) HTTP/1.1\r\nAccept: application/json\r\n\r\n"), + )!; + expect(message.method).toBe("GET"); + expect(message.url).toBe("/people(1)"); + expect(message.status).toBeNull(); + expect(message.body).toHaveLength(0); + }); + + test("returns null for something that isn't HTTP", () => { + expect(parseHttpMessage(bytes("hello world"))).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/multipart.ts b/packages/ui/src/lib/multipart.ts new file mode 100644 index 000000000..f726d7f30 --- /dev/null +++ b/packages/ui/src/lib/multipart.ts @@ -0,0 +1,386 @@ +/** + * A forgiving multipart parser for viewing bodies, not for validating them. It follows RFC 2046 + * but shows whatever it can: a preamble before the first delimiter and an epilogue after the + * last are skipped, LF line endings are accepted as well as CRLF, and a body cut off before its + * closing delimiter still gives the parts that arrived. + */ + +export interface MultipartHeader { + name: string; + value: string; +} + +export interface MultipartPart { + headers: MultipartHeader[]; + /** The part's `Content-Type` header as sent, or null when it has none */ + contentType: string | null; + /** The lowercased type and subtype of `contentType`, like `application/json` */ + mediaType: string | null; + /** The `name` parameter of `Content-Disposition` */ + name: string | null; + /** The `filename` parameter of `Content-Disposition` */ + filename: string | null; + /** The `Content-ID` header, without angle brackets */ + contentId: string | null; + body: Uint8Array; +} + +export interface MultipartParseResult { + parts: MultipartPart[]; + /** Whether the closing `--boundary--` delimiter was found */ + complete: boolean; +} + +export interface HttpMessage { + /** `HTTP/1.1 200 OK` or `GET /path HTTP/1.1`, as sent */ + startLine: string; + /** The status code, for a response */ + status: number | null; + /** The reason phrase, for a response */ + statusReason: string | null; + /** The method, for a request */ + method: string | null; + /** The target, for a request */ + url: string | null; + headers: MultipartHeader[]; + body: Uint8Array; +} + +const CR = 0x0d; +const LF = 0x0a; +const DASH = 0x2d; +const SPACE = 0x20; +const TAB = 0x09; + +const encoder = new TextEncoder(); +const latin1 = new TextDecoder("latin1"); +const utf8 = new TextDecoder("utf-8", { fatal: true }); + +/** Results already parsed, by the bytes and boundary they came from, for as long as those bytes live */ +const parsed = new WeakMap>(); + +/** + * The boundary from a `Content-Type` header, or from a value cut out of one, without quotes or + * the parameters that follow it. Null when there is none. + */ +export function boundaryFromContentType(contentType: string | null | undefined): string | null { + if (contentType == null) return null; + const boundary = headerParams(contentType).get("boundary"); + return boundary == null || boundary === "" ? null : boundary; +} + +/** + * A boundary as it appears after `--`: surrounding quotes removed, escapes undone, and anything + * after an unquoted `;` dropped, so a value like `"a b"; charset=x` gives `a b`. A boundary can't + * hold `"` or `;`, so normalizing one that already is gives it back unchanged, leading spaces and + * all. + */ +export function normalizeBoundary(boundary: string | null | undefined): string | null { + if (boundary == null) return null; + let value = boundary; + if (value.trimStart().startsWith('"')) { + const end = /^"((?:[^"\\]|\\.)*)"/.exec(value.trimStart()); + value = end != null ? end[1]!.replace(/\\(.)/g, "$1") : value.trimStart().slice(1); + } else { + // Trailing whitespace isn't part of a boundary (RFC 2046) + value = value.split(";")[0]!.trimEnd(); + } + return value === "" ? null : value; +} + +/** Parses a multipart body, made once for the same bytes and boundary */ +export function parseMultipart(data: Uint8Array, rawBoundary: string): MultipartParseResult { + const boundary = normalizeBoundary(rawBoundary) ?? rawBoundary; + let byBoundary = parsed.get(data); + const cached = byBoundary?.get(boundary); + if (cached != null) return cached; + + const result = parseMultipartUncached(data, boundary); + if (byBoundary == null) { + byBoundary = new Map(); + parsed.set(data, byBoundary); + } + byBoundary.set(boundary, result); + return result; +} + +function parseMultipartUncached(data: Uint8Array, boundary: string): MultipartParseResult { + const delimiter = encoder.encode(`--${boundary}`); + + // The first delimiter starts the body or a line; anything before it is preamble + let start = findDelimiter(data, delimiter, 0); + if (start < 0) { + throw new Error(`No "--${boundary}" delimiter found`); + } + + const parts: MultipartPart[] = []; + for (;;) { + let pos = start + delimiter.length; + + // `--boundary--` closes the body; whatever follows is epilogue + if (data[pos] === DASH && data[pos + 1] === DASH) { + return { parts, complete: true }; + } + + // Transport padding, then the line break that ends the delimiter line + while (data[pos] === SPACE || data[pos] === TAB) pos++; + if (data[pos] === CR) pos++; + if (data[pos] === LF) pos++; + + if (pos >= data.length) { + return { parts, complete: false }; + } + + const next = findDelimiter(data, delimiter, pos); + if (next < 0) { + // Cut off before the next delimiter: the rest is the last part, as far as it got + parts.push(parsePart(data.subarray(pos))); + return { parts, complete: false }; + } + + // The line break before a delimiter belongs to the delimiter, not the part + const end = next === pos ? pos : lineStart(data, next); + parts.push(parsePart(data.subarray(pos, end))); + start = next; + } +} + +/** + * Where the next delimiter at the start of a line begins, at or after `from`, or -1. A match must + * be followed by `--`, whitespace, a line break or the end of the data, so a longer boundary that + * merely starts with this one isn't taken for it. + */ +function findDelimiter(data: Uint8Array, delimiter: Uint8Array, from: number): number { + let i = data.indexOf(delimiter[0]!, from); + while (i >= 0 && i + delimiter.length <= data.length) { + if ( + (i === 0 || data[i - 1] === LF) && + matchesAt(data, delimiter, i) && + endsDelimiter(data, i + delimiter.length) + ) { + return i; + } + i = data.indexOf(delimiter[0]!, i + 1); + } + return -1; +} + +function matchesAt(data: Uint8Array, needle: Uint8Array, at: number): boolean { + for (let j = 0; j < needle.length; j++) { + if (data[at + j] !== needle[j]) return false; + } + return true; +} + +/** + * Whether the rest of the line after a boundary completes a delimiter: an optional `--` for the + * closing one, optional transport padding, then a line break or the end of the data. So with + * boundary `b`, neither `--b-suffix` nor `--b--suffix` is a delimiter. + */ +function endsDelimiter(data: Uint8Array, at: number): boolean { + let pos = at; + if (data[pos] === DASH && data[pos + 1] === DASH) pos += 2; + while (data[pos] === SPACE || data[pos] === TAB) pos++; + if (pos >= data.length) return true; + if (data[pos] === LF) return true; + return data[pos] === CR && (pos + 1 >= data.length || data[pos + 1] === LF); +} + +/** The start of the line break before `at` (CRLF or LF), or `at` when there is none */ +function lineStart(data: Uint8Array, at: number): number { + if (at > 0 && data[at - 1] === LF) { + return at > 1 && data[at - 2] === CR ? at - 2 : at - 1; + } + return at; +} + +function parsePart(bytes: Uint8Array): MultipartPart { + const { headers, body } = splitHeaders(bytes); + const contentType = headerValue(headers, "content-type"); + const disposition = headerParams(headerValue(headers, "content-disposition") ?? ""); + const contentId = headerValue(headers, "content-id"); + const extendedFilename = disposition.get("filename*"); + return { + headers, + contentType, + mediaType: mediaTypeOf(contentType), + name: disposition.get("name") ?? null, + filename: + extendedFilename != null + ? decodeExtendedValue(extendedFilename) + : (disposition.get("filename") ?? null), + contentId: contentId == null ? null : contentId.replace(/^<(.*)>$/, "$1"), + body, + }; +} + +/** + * Splits a header block from the body after it, at the first blank line. A part that starts with + * a blank line has no headers. + */ +function splitHeaders(bytes: Uint8Array): { headers: MultipartHeader[]; body: Uint8Array } { + let pos = 0; + const lines: string[] = []; + for (;;) { + const lf = bytes.indexOf(LF, pos); + const lineEnd = lf < 0 ? bytes.length : lf; + const contentEnd = lineEnd > pos && bytes[lineEnd - 1] === CR ? lineEnd - 1 : lineEnd; + if (contentEnd === pos) { + // A blank line ends the headers; without one, it was all headers and no body + pos = lf < 0 ? bytes.length : lf + 1; + break; + } + const line = decodeHeaderLine(bytes.subarray(pos, contentEnd)); + if (lines.length === 0 && !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+\s*:/.test(line)) { + // Not a header, so a part sent without its blank line: all of it is body + return { headers: [], body: bytes }; + } + lines.push(line); + if (lf < 0) { + pos = bytes.length; + break; + } + pos = lf + 1; + } + + const headers: MultipartHeader[] = []; + for (const line of lines) { + const last = headers[headers.length - 1]; + if ((line.startsWith(" ") || line.startsWith("\t")) && last != null) { + // A folded line continues the header before it + last.value = `${last.value} ${line.trim()}`; + continue; + } + const colon = line.indexOf(":"); + if (colon <= 0) continue; + headers.push({ name: line.slice(0, colon).trim(), value: line.slice(colon + 1).trim() }); + } + return { headers, body: bytes.subarray(pos) }; +} + +/** + * Parses an `application/http` part: a request or response line, its headers and its body. Null + * when the bytes don't start with one. + */ +export function parseHttpMessage(bytes: Uint8Array): HttpMessage | null { + // Leading blank lines are tolerated, as HTTP/1.1 servers do + let start = 0; + while (bytes[start] === CR || bytes[start] === LF) start++; + const lf = bytes.indexOf(LF, start); + const lineEnd = lf < 0 ? bytes.length : lf; + const startLine = latin1 + .decode( + bytes.subarray(start, lineEnd > start && bytes[lineEnd - 1] === CR ? lineEnd - 1 : lineEnd), + ) + .trim(); + + const response = /^(HTTP\/\d(?:\.\d)?)\s+(\d{3})(?:\s+(.*))?$/i.exec(startLine); + const request = /^([A-Z]+)\s+(\S+)(?:\s+HTTP\/\d(?:\.\d)?)?$/.exec(startLine); + if (response == null && request == null) return null; + + const rest = lf < 0 ? new Uint8Array() : bytes.subarray(lf + 1); + const { headers, body } = splitHeaders(rest); + return { + startLine, + status: response != null ? Number(response[2]) : null, + statusReason: response?.[3]?.trim() || null, + method: response == null ? (request?.[1] ?? null) : null, + url: response == null ? (request?.[2] ?? null) : null, + headers, + body, + }; +} + +/** A header line as UTF-8, as browsers send form field names, or Latin-1 when it isn't valid UTF-8 */ +function decodeHeaderLine(bytes: Uint8Array): string { + try { + return utf8.decode(bytes); + } catch { + return latin1.decode(bytes); + } +} + +export function headerValue(headers: MultipartHeader[], name: string): string | null { + const lower = name.toLowerCase(); + return headers.find((h) => h.name.toLowerCase() === lower)?.value ?? null; +} + +function mediaTypeOf(contentType: string | null): string | null { + if (contentType == null) return null; + const essence = contentType.split(";")[0]!.trim().toLowerCase(); + return essence === "" ? null : essence; +} + +/** + * The parameters after a header's first `;`, like `form-data; name="a"; filename="b.txt"`, by + * lowercased name and unquoted. A quoted value may hold `;` and `=` without ending it. + */ +function headerParams(header: string): Map { + const params = new Map(); + let i = header.indexOf(";"); + if (i < 0) return params; + while (i < header.length) { + // Past the `;` and whitespace, then the name up to `=` or the next `;` + while (i < header.length && (header[i] === ";" || header[i] === " " || header[i] === "\t")) i++; + const nameStart = i; + while (i < header.length && header[i] !== "=" && header[i] !== ";") i++; + const name = header.slice(nameStart, i).trim().toLowerCase(); + if (header[i] !== "=") continue; + i++; + while (header[i] === " " || header[i] === "\t") i++; + let value = ""; + if (header[i] === '"') { + i++; + while (i < header.length && header[i] !== '"') { + if (header[i] === "\\" && i + 1 < header.length) i++; + value += header[i]; + i++; + } + // Past the closing quote and anything up to the next parameter + while (i < header.length && header[i] !== ";") i++; + } else { + const valueStart = i; + while (i < header.length && header[i] !== ";") i++; + value = header.slice(valueStart, i).trim(); + } + if (name !== "" && !params.has(name)) params.set(name, value); + } + return params; +} + +/** An RFC 5987 extended value, `charset'language'percent-encoded`, decoded in its charset */ +function decodeExtendedValue(value: string): string { + const ext = /^([^']*)'[^']*'(.*)$/.exec(value); + if (ext == null) return value; + const encoded = ext[2]!; + const bytes: number[] = []; + for (let i = 0; i < encoded.length; i++) { + const hex = encoded.slice(i + 1, i + 3); + if (encoded[i] === "%" && /^[0-9a-f]{2}$/i.test(hex)) { + bytes.push(parseInt(hex, 16)); + i += 2; + } else { + bytes.push(encoded.charCodeAt(i) & 0xff); + } + } + try { + return new TextDecoder(ext[1]!.trim() || "utf-8").decode(new Uint8Array(bytes)); + } catch { + return encoded; + } +} + +/** Whether bytes look binary: not valid UTF-8, or holding NUL or other control bytes */ +export function isProbablyBinary(bytes: Uint8Array): boolean { + const sample = bytes.subarray(0, 8192); + for (const b of sample) { + if (b === 0) return true; + if (b < 0x20 && b !== TAB && b !== LF && b !== CR && b !== 0x0c && b !== 0x1b) return true; + } + try { + new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return false; + } catch { + return true; + } +}