Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions packages/sie_ts_sdk/src/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,61 @@ export async function toImageBytes(input: ImageInput): Promise<Uint8Array> {

// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One behavior suggestion: data:;base64, now returns an empty Uint8Array and succeeds. That zero-byte "image" then goes out over the wire as { data: , format: "jpeg" } and fails somewhere in the server, far from the actual mistake. Empty payload should throw too.

}

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 (possibly empty), 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
* 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);
try {
return decodeURIComponent(payload);
} catch {
throw new Error("Malformed data URL: payload has invalid percent-encoding");
}
}

/**
* Convert base64 string to Uint8Array.
*/
Expand Down
57 changes: 57 additions & 0 deletions packages/sie_ts_sdk/tests/images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,63 @@ 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=";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two regression cases are a good start. If we switch to a small parser, could we also cover an uppercase scheme/BASE64 marker, percent-escaped padding such as %3D, and one explicit empty-payload case? Those are the boundaries where the current regex falls through into the raw-base64 path, and the tests would make the intended behavior clear.

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("returns an empty array for an empty base64 data URL payload", async () => {
const dataUrl = "data:;base64,";
const result = await toImageBytes(dataUrl);

expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBe(0);
});

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",
Expand Down