Skip to content

Commit f3ef845

Browse files
authored
feat(codemode): add atob, btoa, and crypto.randomUUID (#48290)
1 parent 08ff211 commit f3ef845

6 files changed

Lines changed: 298 additions & 0 deletions

File tree

packages/codemode/interpreter-support.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,14 @@ ultimate source of truth.
364364
`entries`, `toString`, and `size`.
365365
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
366366

367+
## Web platform helpers
368+
369+
- [x] `atob` and `btoa` with forgiving-base64 decoding and WebIDL string conversion; invalid input throws an Error
370+
named `InvalidCharacterError`, since there is no `DOMException`.
371+
- [x] `crypto.randomUUID()`.
372+
- [ ] `crypto.getRandomValues` and `crypto.subtle`, `TextEncoder`/`TextDecoder`, and `Blob`: these need a binary
373+
value type, which the JSON-like data model does not have yet.
374+
367375
## Errors and diagnostics
368376

369377
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with

packages/codemode/src/interpreter/globals.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { regexpGlobal } from "../stdlib/regexp.js"
1111
import { stringGlobal } from "../stdlib/string.js"
1212
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
1313
import { coercion, errorConstructors } from "../stdlib/value.js"
14+
import { atobGlobal, btoaGlobal, cryptoGlobal } from "../stdlib/web.js"
1415
import { ToolReference } from "../tool-runtime.js"
1516
import { errorGlobal } from "./errors.js"
1617
import { HostFunction } from "./host.js"
@@ -71,5 +72,8 @@ export const globals = <R>(host: Host<R>): ReadonlyArray<readonly [string, unkno
7172
["encodeURIComponent", uriGlobal("encodeURIComponent")],
7273
["decodeURI", uriGlobal("decodeURI")],
7374
["decodeURIComponent", uriGlobal("decodeURIComponent")],
75+
["atob", atobGlobal],
76+
["btoa", btoaGlobal],
77+
["crypto", cryptoGlobal],
7478
...[...errorConstructors].map((name) => [name, errorGlobal(name, host.runner)] as const),
7579
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { HostNamespace, sync } from "../interpreter/host.js"
2+
import { InterpreterRuntimeError } from "../interpreter/model.js"
3+
import { coerceToString } from "./value.js"
4+
5+
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
6+
const base64 = (name: "atob" | "btoa") =>
7+
sync(name, (args, node) => {
8+
if (args.length === 0) {
9+
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
10+
}
11+
const input = coerceToString(args[0])
12+
try {
13+
return name === "atob" ? atob(input) : btoa(input)
14+
} catch {
15+
throw new InterpreterRuntimeError("The string contains invalid characters.", node).as("InvalidCharacterError")
16+
}
17+
})
18+
19+
export const atobGlobal = base64("atob")
20+
export const btoaGlobal = base64("btoa")
21+
22+
export const cryptoGlobal = new HostNamespace("crypto", {
23+
randomUUID: sync("crypto.randomUUID", () => crypto.randomUUID()),
24+
})

packages/codemode/test/LICENSE.wpt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# The 3-Clause BSD License
2+
3+
Copyright © web-platform-tests contributors
4+
5+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
6+
7+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
8+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10+
11+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
[
2+
["", []],
3+
["abcd", [105, 183, 29]],
4+
[" abcd", [105, 183, 29]],
5+
["abcd ", [105, 183, 29]],
6+
[" abcd===", null],
7+
["abcd=== ", null],
8+
["abcd ===", null],
9+
["a", null],
10+
["ab", [105]],
11+
["abc", [105, 183]],
12+
["abcde", null],
13+
["𐀀", null],
14+
["=", null],
15+
["==", null],
16+
["===", null],
17+
["====", null],
18+
["=====", null],
19+
["a=", null],
20+
["a==", null],
21+
["a===", null],
22+
["a====", null],
23+
["a=====", null],
24+
["ab=", null],
25+
["ab==", [105]],
26+
["ab===", null],
27+
["ab====", null],
28+
["ab=====", null],
29+
["abc=", [105, 183]],
30+
["abc==", null],
31+
["abc===", null],
32+
["abc====", null],
33+
["abc=====", null],
34+
["abcd=", null],
35+
["abcd==", null],
36+
["abcd===", null],
37+
["abcd====", null],
38+
["abcd=====", null],
39+
["abcde=", null],
40+
["abcde==", null],
41+
["abcde===", null],
42+
["abcde====", null],
43+
["abcde=====", null],
44+
["=a", null],
45+
["=a=", null],
46+
["a=b", null],
47+
["a=b=", null],
48+
["ab=c", null],
49+
["ab=c=", null],
50+
["abc=d", null],
51+
["abc=d=", null],
52+
["ab\u000Bcd", null],
53+
["ab\u3000cd", null],
54+
["ab\u3001cd", null],
55+
["ab\tcd", [105, 183, 29]],
56+
["ab\ncd", [105, 183, 29]],
57+
["ab\fcd", [105, 183, 29]],
58+
["ab\rcd", [105, 183, 29]],
59+
["ab cd", [105, 183, 29]],
60+
["ab\u00a0cd", null],
61+
["ab\t\n\f\r cd", [105, 183, 29]],
62+
[" \t\n\f\r ab\t\n\f\r cd\t\n\f\r ", [105, 183, 29]],
63+
["ab\t\n\f\r =\t\n\f\r =\t\n\f\r ", [105]],
64+
["A", null],
65+
["/A", [252]],
66+
["//A", [255, 240]],
67+
["///A", [255, 255, 192]],
68+
["////A", null],
69+
["/", null],
70+
["A/", [3]],
71+
["AA/", [0, 15]],
72+
["AAAA/", null],
73+
["AAA/", [0, 0, 63]],
74+
["\u0000nonsense", null],
75+
["abcd\u0000nonsense", null],
76+
["YQ", [97]],
77+
["YR", [97]],
78+
["~~", null],
79+
["..", null],
80+
["--", null],
81+
["__", null]
82+
]
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Portions adapted from web-platform-tests at revision 863077959ca8c1a7ceecfbe2534b75d2527b9013:
3+
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
4+
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
5+
* - WebCryptoAPI/randomUUID.https.any.js
6+
*
7+
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
8+
*
9+
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check on `error.name`: CodeMode has no
10+
* DOMException, so the name is carried on a plain Error.
11+
*/
12+
import { describe, expect, test } from "bun:test"
13+
import { Effect } from "effect"
14+
import { CodeMode } from "../src/index.js"
15+
16+
const base64Cases = (await Bun.file(new URL("./fixtures/wpt-base64.json", import.meta.url)).json()) as Array<
17+
[string, Array<number> | null]
18+
>
19+
20+
const value = async (code: string) => {
21+
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
22+
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
23+
return result.value
24+
}
25+
26+
// The reference encoder from base64.any.js, run inside the interpreter so btoa is checked against
27+
// an independent implementation rather than against the host's btoa.
28+
const referenceEncoder = `
29+
function btoaLookup(idx) {
30+
if (idx < 26) return String.fromCharCode(idx + "A".charCodeAt(0))
31+
if (idx < 52) return String.fromCharCode(idx - 26 + "a".charCodeAt(0))
32+
if (idx < 62) return String.fromCharCode(idx - 52 + "0".charCodeAt(0))
33+
if (idx == 62) return "+"
34+
if (idx == 63) return "/"
35+
}
36+
function mybtoa(s) {
37+
s = String(s)
38+
for (var i = 0; i < s.length; i++) if (s.charCodeAt(i) > 255) return "INVALID_CHARACTER_ERR"
39+
var out = ""
40+
for (var i = 0; i < s.length; i += 3) {
41+
var groupsOfSix = [undefined, undefined, undefined, undefined]
42+
groupsOfSix[0] = s.charCodeAt(i) >> 2
43+
groupsOfSix[1] = (s.charCodeAt(i) & 0x03) << 4
44+
if (s.length > i + 1) {
45+
groupsOfSix[1] |= s.charCodeAt(i + 1) >> 4
46+
groupsOfSix[2] = (s.charCodeAt(i + 1) & 0x0f) << 2
47+
}
48+
if (s.length > i + 2) {
49+
groupsOfSix[2] |= s.charCodeAt(i + 2) >> 6
50+
groupsOfSix[3] = s.charCodeAt(i + 2) & 0x3f
51+
}
52+
for (var j = 0; j < groupsOfSix.length; j++) {
53+
out += typeof groupsOfSix[j] == "undefined" ? "=" : btoaLookup(groupsOfSix[j])
54+
}
55+
}
56+
return out
57+
}
58+
function testBtoa(input) {
59+
var expected = mybtoa(input)
60+
if (expected === "INVALID_CHARACTER_ERR") {
61+
try { btoa(input) } catch (error) { return error.name === "InvalidCharacterError" ? "ok" : error.name }
62+
return "did not throw"
63+
}
64+
if (btoa(input) !== expected) return "btoa mismatch"
65+
if (atob(btoa(input)) !== String(input)) return "roundtrip mismatch"
66+
return "ok"
67+
}
68+
`
69+
70+
describe("btoa WPT parity (html/webappapis/atob/base64.any.js)", () => {
71+
test("every input encodes like the reference encoder and round-trips through atob", async () => {
72+
expect(
73+
await value(`
74+
${referenceEncoder}
75+
var tests = ["עברית", "", "ab", "abc", "abcd", "abcde", "\\xff\\xff\\xc0", "\\0a", "a\\0b",
76+
undefined, null, 7, 12, 1.5, true, false, NaN, +Infinity, -Infinity, 0, -0]
77+
for (var i = 0; i < 258; i++) tests.push(String.fromCharCode(i))
78+
tests.push(String.fromCharCode(10000), String.fromCharCode(65534), String.fromCharCode(65535))
79+
tests.push(String.fromCharCode(0xd800, 0xdc00))
80+
var everything = ""
81+
for (var i = 0; i < 256; i++) everything += String.fromCharCode(i)
82+
tests.push(everything)
83+
return tests.map(testBtoa).filter((outcome) => outcome !== "ok")
84+
`),
85+
).toEqual([])
86+
})
87+
})
88+
89+
describe("atob WPT parity (fetch/data-urls/resources/base64.json)", () => {
90+
const idlCases: Array<[unknown, Array<number> | null]> = [
91+
[undefined, null],
92+
[null, [158, 233, 101]],
93+
[7, null],
94+
[12, [215]],
95+
[1.5, null],
96+
[true, [182, 187]],
97+
[false, null],
98+
[NaN, [53, 163]],
99+
[Infinity, [34, 119, 226, 158, 43, 114]],
100+
[-Infinity, null],
101+
[0, null],
102+
[-0, null],
103+
]
104+
105+
test(`${base64Cases.length} forgiving-base64 inputs decode to the expected bytes or throw InvalidCharacterError`, async () => {
106+
expect(
107+
await value(`
108+
const cases = ${JSON.stringify(base64Cases)}
109+
return cases.flatMap(([input, output]) => {
110+
try {
111+
const result = atob(input)
112+
if (output === null) return [[input, "expected throw"]]
113+
const bytes = Array.from({ length: result.length }, (_, i) => result.charCodeAt(i))
114+
return JSON.stringify(bytes) === JSON.stringify(output) ? [] : [[input, bytes]]
115+
} catch (error) {
116+
return output === null && error.name === "InvalidCharacterError" ? [] : [[input, error.name]]
117+
}
118+
})
119+
`),
120+
).toEqual([])
121+
})
122+
123+
test("WebIDL argument conversion stringifies non-string inputs", async () => {
124+
const literal = (input: unknown) =>
125+
Object.is(input, -0)
126+
? "-0"
127+
: typeof input === "number" || input === undefined
128+
? String(input)
129+
: JSON.stringify(input)
130+
expect(
131+
await value(`
132+
const cases = [${idlCases.map(([input, output]) => `[${literal(input)}, ${JSON.stringify(output)}]`).join(",")}]
133+
return cases.flatMap(([input, output]) => {
134+
try {
135+
const result = atob(input)
136+
if (output === null) return [[String(input), "expected throw"]]
137+
// The source loop checks only the listed prefix of the decoded bytes.
138+
const bytes = output.map((_, i) => result.charCodeAt(i))
139+
return JSON.stringify(bytes) === JSON.stringify(output) ? [] : [[String(input), bytes]]
140+
} catch (error) {
141+
return output === null && error.name === "InvalidCharacterError" ? [] : [[String(input), error.name]]
142+
}
143+
})
144+
`),
145+
).toEqual([])
146+
})
147+
})
148+
149+
describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)", () => {
150+
test("namespace format, version, and variant bits over 256 iterations without collision", async () => {
151+
expect(
152+
await value(`
153+
const uuids = new Set()
154+
const randomUUID = () => {
155+
const uuid = crypto.randomUUID()
156+
if (uuids.has(uuid)) throw new Error("uuid collision " + uuid)
157+
uuids.add(uuid)
158+
return uuid
159+
}
160+
const UUIDRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
161+
let format = true, version = true, variant = true
162+
for (let i = 0; i < 256; i++) format = format && UUIDRegex.test(randomUUID())
163+
for (let i = 0; i < 256; i++) version = version && (parseInt(randomUUID().split("-")[2].slice(0, 2), 16) & 0b11110000) === 0b01000000
164+
for (let i = 0; i < 256; i++) variant = variant && (parseInt(randomUUID().split("-")[3].slice(0, 2), 16) & 0b11000000) === 0b10000000
165+
return [format, version, variant, uuids.size]
166+
`),
167+
).toEqual([true, true, true, 768])
168+
})
169+
})

0 commit comments

Comments
 (0)