unsloth/studio/frontend/tests/data-uri.test.ts
oobabooga a763affd4d
Desktop: fix remaining file downloads (#7710)
* Studio: fix remaining desktop downloads

* Studio: percent-decode data URIs to raw octets

decodeURIComponent() requires the escapes to form valid UTF-8, so a
legitimate binary data URI such as data:audio/wav,%FF%00%80 threw
URIError instead of decoding to [255, 0, 128]. The audio download
button previously handed the URI straight to an anchor and let the
browser decode it, so routing it through decodeDataUri regressed that
path in Chromium, Firefox and WebKit alike.

Decode percent escapes byte-wise, and leave an invalid escape as a
literal character, which is what all three engines do.

* Studio: percent-decode a base64 data URI payload before atob

A base64 payload can carry its own percent escapes, so
data:audio/wav;base64,SGVsbG8%3D reached atob() as the literal string
and threw InvalidCharacterError. Chromium, Firefox and WebKit all
percent-decode the payload first and decode that URI successfully, so
downloadUrl and urlToBlob rejected media the browser accepts.

Percent-decoding now runs for both branches, which is also the order
the data URL processor uses.

* Studio: decode data URIs in bulk and match the browser marker rules

Three corrections to the decoder:

Encoding literal spans one character at a time cost 20.8s for a 20 MiB
image, which is the attachment cap, so Download and Copy blocked the UI
for that long. Literal runs are now encoded in one call and an
unescaped payload skips the scan entirely: 20 MiB goes 20814ms to
100ms.

Only a trailing ;base64 marks a base64 payload. A segment named base64
elsewhere is an ordinary parameter, so
data:text/plain;base64;charset=utf-8,SGVsbG8= stays literal text and
data:base64,SGVsbG8= is not base64 at all.

A fragment belongs to the URL, so data:text/plain,abc#frag is abc. An
escaped %23 stays payload. A media type without a slash falls back to
the RFC 2397 default instead of being reported as base64.

Expected values for all 26 cases were taken from Chromium, Firefox and
WebKit, which agree on every one.

* Studio: decode into one buffer and accept a mixed-case data scheme

The chunked decoder allocated a Uint8Array per escape run and per
literal span, so an encoded SVG whose escapes alternate with literals
still cost 2556ms and 462 MiB of heap for a 5 MiB payload. Everything
now goes through one growable buffer written with encodeInto, and the
scan reads char codes instead of slicing a two character string per
position: the same payload is 241ms and 7 MiB.

URL schemes are case-insensitive and all three engines render
DATA:image/png;base64,..., but the guards in downloadUrl and urlToBlob
only matched lowercase, so such a URL took the fetch path and the
desktop CSP refused it. Both now share isDataUri.

* Studio: drop URL tabs and newlines before parsing a data URI

The URL parser removes every ASCII tab and newline from its input
before parsing, so data:text/plain;base64\n, really is base64. Firefox
and WebKit do this and decode the payload; the end-anchored marker test
did not, so the base64 text was saved instead of the bytes the element
renders. Chromium keeps the whitespace for a data URL handed to fetch,
so this follows the standard and the other two.

Only raw characters are removed. An escaped %0A stays payload. The
strip is guarded by a test so a payload without whitespace is not
copied: 20 MiB of base64 stays at 90ms.

* Studio: trim leading and trailing C0 controls before the scheme check

The URL parser strips any leading and trailing C0 control or space
before it removes tabs and newlines, so " data:image/png;base64,..."
is a data URL. All three engines render it and decode it, but the
scheme guard only handled tabs and newlines, so such a URL took the
fetch path and the desktop CSP refused it.

Both normalisation steps now run in order behind one helper. Trimming
walks in from each end, so it stays O(1) on a large payload: 20 MiB of
base64 is unchanged at 100ms. A space inside the payload is still
content.

* Studio: match the data scheme in place instead of in a fixed window

isDataUri sliced a 32 character prefix and normalised that, so a URL
carrying 28 or more leading controls hid the scheme from the check and
took the fetch path. All three engines decode 30 leading spaces or 40
leading NULs, so the window was a guess about input rather than a
property of the format.

The scheme is now matched in place: skip the leading run, then compare
five characters, skipping tabs and newlines between them because the
URL parser removes those anywhere. A space is not removed, so da ta:
stays invalid, which all three engines agree on. Cost is the leading
run plus five characters, with no bound to get wrong.

---------

Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
2026-08-02 00:45:40 -07:00

243 lines
8.1 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import test from "node:test";
import { decodeDataUri, isDataUri } from "../src/lib/data-uri.ts";
const INVALID_DATA_URI_RE = /Invalid data URI/;
const DEFAULT_MIME = "text/plain;charset=US-ASCII";
test("decodes base64 data URIs with their media type", () => {
const decoded = decodeDataUri("data:audio/wav;base64,AAH6/w==");
assert.equal(decoded.mimeType, "audio/wav");
assert.deepEqual(Array.from(decoded.bytes), [0, 1, 250, 255]);
});
test("preserves commas in percent-encoded data URI payloads", () => {
const decoded = decodeDataUri("data:text/plain,hello,world%20again");
assert.equal(decoded.mimeType, "text/plain");
assert.equal(new TextDecoder().decode(decoded.bytes), "hello,world again");
});
test("uses the RFC default media type when it is omitted", () => {
const decoded = decodeDataUri("data:,plain%20text");
assert.equal(decoded.mimeType, "text/plain;charset=US-ASCII");
assert.equal(new TextDecoder().decode(decoded.bytes), "plain text");
});
test("rejects data URIs without a payload separator", () => {
assert.throws(
() => decodeDataUri("data:image/png;base64"),
INVALID_DATA_URI_RE,
);
});
// The expectations below were taken from Chromium, Firefox and WebKit, which
// all agree: percent-decoding a data URI is byte-oriented, not UTF-8 text.
test("decodes percent escapes that are not valid UTF-8", () => {
// decodeURIComponent() throws URIError on these; a browser returns the octets.
assert.deepEqual(
Array.from(decodeDataUri("data:audio/wav,%FF%00%80").bytes),
[255, 0, 128],
);
assert.deepEqual(
Array.from(decodeDataUri("data:application/octet-stream,%FF").bytes),
[255],
);
});
test("leaves malformed percent escapes as literal characters", () => {
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain,%G0").bytes),
[37, 71, 48],
);
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain,abc%").bytes),
[97, 98, 99, 37],
);
});
test("does not treat a base64x parameter as base64", () => {
// The old `/;base64/i` matched inside `;base64x`; the anchored form must not.
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain;base64x,QUJD").bytes),
[81, 85, 74, 68],
);
});
test("percent-decodes a base64 payload before decoding it", () => {
// atob() would throw InvalidCharacterError on the escapes.
assert.deepEqual(
Array.from(decodeDataUri("data:audio/wav;base64,SGVsbG8%3D").bytes),
[72, 101, 108, 108, 111],
);
assert.deepEqual(
Array.from(decodeDataUri("data:audio/wav;base64,AAH6%2Fw%3D%3D").bytes),
[0, 1, 250, 255],
);
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain;base64,QUJ%44").bytes),
[65, 66, 67],
);
});
test("treats base64 as the marker only when it ends the metadata", () => {
// A mid-metadata `base64` segment is an ordinary parameter.
assert.deepEqual(
Array.from(
decodeDataUri("data:text/plain;base64;charset=utf-8,SGVsbG8=").bytes,
),
[83, 71, 86, 115, 98, 71, 56, 61],
);
assert.deepEqual(
Array.from(decodeDataUri("data:base64,SGVsbG8=").bytes),
[83, 71, 86, 115, 98, 71, 56, 61],
);
assert.deepEqual(
Array.from(
decodeDataUri("data:text/plain;charset=utf-8;base64,SGVsbG8=").bytes,
),
[72, 101, 108, 108, 111],
);
});
test("ignores a URL fragment", () => {
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain,abc#frag").bytes),
[97, 98, 99],
);
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain;base64,SGVsbG8=#frag").bytes),
[72, 101, 108, 108, 111],
);
// An escaped hash is payload, not a fragment.
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain,abc%23hash").bytes),
[97, 98, 99, 35, 104, 97, 115, 104],
);
});
test("falls back to the default media type when there is no slash", () => {
assert.equal(decodeDataUri("data:base64,SGVsbG8=").mimeType, DEFAULT_MIME);
assert.equal(decodeDataUri("data:;base64,AAA=").mimeType, DEFAULT_MIME);
assert.equal(
decodeDataUri("data:image/png;base64,QUJD").mimeType,
"image/png",
);
});
test("decodes a large base64 payload without stalling", () => {
// The 20 MiB attachment cap must not take seconds of blocked UI.
const payload = btoa("x".repeat(3 * 1024 * 1024));
const started = Date.now();
const decoded = decodeDataUri(`data:image/png;base64,${payload}`);
assert.equal(decoded.bytes.length, 3 * 1024 * 1024);
assert.ok(
Date.now() - started < 2000,
`decoding took ${Date.now() - started}ms`,
);
});
test("treats the data scheme case-insensitively", () => {
// URL schemes are case-insensitive and all three engines render DATA:.
assert.ok(isDataUri("DATA:image/png;base64,QUJD"));
assert.ok(isDataUri("Data:image/png;base64,QUJD"));
assert.ok(isDataUri("data:image/png;base64,QUJD"));
assert.ok(!isDataUri("https://example.com/a.png"));
assert.deepEqual(
Array.from(decodeDataUri("DATA:text/plain;base64,QUJD").bytes),
[65, 66, 67],
);
});
test("decodes an escape-heavy payload without stalling", () => {
// Encoded SVG text alternates literals and escapes, which used to allocate
// a separate array per run.
const source = "a%20".repeat(400000);
const started = Date.now();
const decoded = decodeDataUri(`data:image/svg+xml,${source}`);
assert.equal(decoded.bytes.length, 800000);
assert.equal(decoded.bytes[0], 97);
assert.equal(decoded.bytes[1], 32);
assert.ok(
Date.now() - started < 2000,
`decoding took ${Date.now() - started}ms`,
);
});
test("removes URL tabs and newlines the way the URL parser does", () => {
// Firefox and WebKit strip these before parsing, per the URL standard.
// Chromium keeps them for a data: URL passed to fetch, so this follows the
// standard and the majority.
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain;base64\n,SGVsbG8=").bytes),
[72, 101, 108, 108, 111],
);
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain;base64\t,SGVsbG8=").bytes),
[72, 101, 108, 108, 111],
);
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain;bas\ne64,SGVsbG8=").bytes),
[72, 101, 108, 108, 111],
);
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain,ab\ncd").bytes),
[97, 98, 99, 100],
);
// An escaped newline is payload, not URL whitespace.
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain,ab%0Acd").bytes),
[97, 98, 10, 99, 100],
);
});
test("trims leading and trailing C0 controls and spaces", () => {
// All three engines render ` data:image/png;...` and decode these.
assert.ok(isDataUri(" data:text/plain,abc"));
assert.ok(isDataUri("\u0000data:text/plain,abc"));
assert.ok(isDataUri(" DATA:text/plain,abc"));
for (const uri of [
" data:text/plain,abc",
" data:text/plain,abc",
"\u0000data:text/plain,abc",
"\u001fdata:text/plain,abc",
"data:text/plain,abc ",
]) {
assert.deepEqual(Array.from(decodeDataUri(uri).bytes), [97, 98, 99], uri);
}
// A space inside the payload is content, not URL whitespace.
assert.deepEqual(
Array.from(decodeDataUri("data:text/plain,a bc").bytes),
[97, 32, 98, 99],
);
});
test("detects the scheme past any number of leading controls", () => {
// All three engines decode these; a fixed-size prefix window could not.
const lead = [" ".repeat(30), "\u0000".repeat(40), " \u0000 \t"];
for (const prefix of lead) {
assert.ok(
isDataUri(`${prefix}data:text/plain,abc`),
JSON.stringify(prefix),
);
assert.deepEqual(
Array.from(decodeDataUri(`${prefix}data:text/plain,abc`).bytes),
[97, 98, 99],
);
}
// Tabs and newlines are removed inside the scheme too.
assert.ok(isDataUri("da\nta:text/plain,abc"));
assert.ok(isDataUri("da\tta:text/plain,abc"));
// A space is not removed, so this is not a data URL in any engine.
assert.ok(!isDataUri("da ta:text/plain,abc"));
assert.ok(!isDataUri("https://example.com/a.png"));
assert.ok(!isDataUri("dat"));
assert.ok(!isDataUri(""));
});