diff --git a/packages/sie_ts_sdk/src/images.ts b/packages/sie_ts_sdk/src/images.ts index 1e94817f7..8e6f64185 100644 --- a/packages/sie_ts_sdk/src/images.ts +++ b/packages/sie_ts_sdk/src/images.ts @@ -79,19 +79,67 @@ export async function toImageBytes(input: ImageInput): Promise { // Base64 string or data URL if (typeof input === "string") { - // Check if it's a data URL - const dataUrlMatch = input.match(/^data:[^;]+;base64,(.+)$/); - if (dataUrlMatch?.[1]) { - return base64ToBytes(dataUrlMatch[1]); - } - - // Assume it's raw base64 - return base64ToBytes(input); + // A `data:` URL is parsed structurally by parseBase64DataUrl; a plain + // base64 string (which can never begin with "data:", since ":" is not a + // base64 character) is decoded as-is. + const payload = parseBase64DataUrl(input); + return base64ToBytes(payload ?? input); } throw new Error(`Unsupported image input type: ${typeof input}`); } +/** + * Extract the base64 payload from a `data:` URL. + * + * Rather than encoding the RFC 2397 grammar in a regex, this walks the URL + * structurally: everything before the first comma is the header, and the + * `;`-delimited metadata's final segment must be the `base64` marker. The + * scheme and marker are matched case-insensitively, and the payload is + * percent-decoded (so escaped characters such as `%3D` padding are restored) + * before it reaches the base64 decoder. + * + * @returns the base64 payload, or `undefined` when `input` is not a `data:` URL + * at all — in which case the caller treats it as a raw base64 string. + * @throws if `input` is a `data:` URL that is malformed (no payload delimiter, + * an empty payload, or invalid percent-encoding) or is not base64-encoded, so + * such inputs fail loudly instead of silently corrupting in the base64 decoder. + */ +function parseBase64DataUrl(input: string): string | undefined { + if (!/^data:/i.test(input)) { + return undefined; + } + + const comma = input.indexOf(","); + if (comma === -1) { + throw new Error("Malformed data URL: missing ',' delimiter between metadata and payload"); + } + + const metadata = input.slice("data:".length, comma); + const params = metadata.split(";"); + // split(";") always yields at least one element, but the compiler can't prove + // it under noUncheckedIndexedAccess; optional chaining keeps this type-safe and + // still treats a missing marker as "not base64". + const marker = params.at(-1); + if (marker?.toLowerCase() !== "base64") { + throw new Error("Unsupported data URL: only base64-encoded payloads are supported"); + } + + const payload = input.slice(comma + 1); + let decoded: string; + try { + decoded = decodeURIComponent(payload); + } catch { + throw new Error("Malformed data URL: payload has invalid percent-encoding"); + } + if (decoded === "") { + // A zero-byte payload decodes to an empty image that only fails deep in the + // server, far from the mistake; reject it at the SDK boundary instead. + throw new Error("Malformed data URL: empty base64 payload"); + } + return decoded; +} + /** * Convert base64 string to Uint8Array. */ diff --git a/packages/sie_ts_sdk/tests/images.test.ts b/packages/sie_ts_sdk/tests/images.test.ts index 769fe3796..4eaa5e103 100644 --- a/packages/sie_ts_sdk/tests/images.test.ts +++ b/packages/sie_ts_sdk/tests/images.test.ts @@ -51,6 +51,61 @@ describe("toImageBytes", () => { expect(new TextDecoder().decode(result)).toBe("test"); }); + it("decodes a data URL whose media type carries a parameter", async () => { + // Valid per RFC 2397: the media type may be followed by ";param=value" + // (e.g. charset) before ";base64,". "Hello" base64-encoded. + const dataUrl = "data:image/svg+xml;charset=utf-8;base64,SGVsbG8="; + const result = await toImageBytes(dataUrl); + + expect(result).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result)).toBe("Hello"); + }); + + it("decodes a data URL with an omitted media type", async () => { + // RFC 2397 permits an empty media type (defaults to text/plain). + const dataUrl = "data:;base64,SGVsbG8="; + const result = await toImageBytes(dataUrl); + + expect(result).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result)).toBe("Hello"); + }); + + it("decodes a data URL with an uppercase scheme and BASE64 marker", async () => { + // RFC 2397: the scheme and the "base64" marker are case-insensitive. + const dataUrl = "DATA:image/png;BASE64,SGVsbG8="; + const result = await toImageBytes(dataUrl); + + expect(result).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result)).toBe("Hello"); + }); + + it("percent-decodes the payload before base64 decoding", async () => { + // The "=" padding can arrive percent-escaped as "%3D". + const dataUrl = "data:image/png;base64,SGVsbG8%3D"; + const result = await toImageBytes(dataUrl); + + expect(result).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result)).toBe("Hello"); + }); + + it("throws a clear error for an empty base64 data URL payload", async () => { + // A zero-byte payload would otherwise produce an empty image that only fails + // deep in the server; it must fail loudly at the SDK boundary instead. + await expect(toImageBytes("data:;base64,")).rejects.toThrow("empty base64 payload"); + }); + + it("throws a clear error for a data URL that is not base64-encoded", async () => { + // A non-base64 data URL cannot yield image bytes; it must fail loudly + // instead of being handed to the base64 decoder. + await expect(toImageBytes("data:text/plain,Hello")).rejects.toThrow( + "only base64-encoded payloads are supported", + ); + }); + + it("throws a clear error for a data URL missing the payload delimiter", async () => { + await expect(toImageBytes("data:image/png;base64")).rejects.toThrow("missing ',' delimiter"); + }); + it("throws for unsupported input type", async () => { await expect(toImageBytes(123 as unknown as Uint8Array)).rejects.toThrow( "Unsupported image input type",