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
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions core/wintertc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ rust-version.workspace = true

[dependencies]
boa_engine.workspace = true
base64.workspace = true

[dev-dependencies]
indoc.workspace = true
textwrap.workspace = true
futures-lite.workspace = true

[lints]
workspace = true
Expand Down
91 changes: 77 additions & 14 deletions core/wintertc/src/base64/mod.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,86 @@
//! TC55 `atob` / `btoa` implementation.
//! Base64 utility methods (`atob` and `btoa`).
//!
//! Spec: <https://html.spec.whatwg.org/multipage/webappapis.html#atob>
//! See <https://html.spec.whatwg.org/multipage/webappapis.html#atob>.
//!
//! # TC55 Status
//!
//! `atob` and `btoa` are required in the `WinterTC` TC55 Minimum Common Web API.
//!
//! # TODO
//!
//! - Migrate `atob` and `btoa` from `boa_runtime::base64`.
#![allow(clippy::needless_pass_by_value)]

use boa_engine::realm::Realm;
use boa_engine::{Context, JsResult, boa_module};

#[cfg(test)]
mod tests;

/// A forgiving Base64 engine that accepts input with or without padding,
/// matching the [forgiving-base64 decode](https://infra.spec.whatwg.org/#forgiving-base64-decode)
/// algorithm used by `atob`.
const FORGIVING: base64::engine::GeneralPurpose = base64::engine::GeneralPurpose::new(
&base64::alphabet::STANDARD,
base64::engine::general_purpose::GeneralPurposeConfig::new()
.with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent),
);

/// JavaScript module containing the `atob` and `btoa` functions.
#[boa_module]
pub mod js_module {
use super::FORGIVING;
use base64::Engine as _;
use boa_engine::{JsResult, js_error};

/// The [`btoa()`][mdn] method creates a Base64-encoded ASCII string from
/// a binary string (i.e., a string in which each character is treated as
/// a byte of binary data).
///
/// # Errors
/// Throws a `DOMException` (`InvalidCharacterError`) if the string
/// contains any character whose code point is greater than `0xFF`.
///
/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa
#[allow(clippy::cast_possible_truncation)]
pub fn btoa(data: String) -> JsResult<String> {
let bytes: Vec<u8> = data
.chars()
.map(|c| {
let cp = c as u32;
if cp > 0xFF {
Err(js_error!("InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range."))
} else {
Ok(cp as u8)
}
})
.collect::<JsResult<Vec<u8>>>()?;

/// Register `atob` and `btoa` into the given context.
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
}

/// The [`atob()`][mdn] method decodes a string of data which has been
/// encoded using Base64 encoding.
///
/// # Errors
/// Throws a `DOMException` (`InvalidCharacterError`) if the input is
/// not valid Base64.
///
/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Window/atob
pub fn atob(data: String) -> JsResult<String> {
let cleaned: String = data
.chars()
.filter(|c| !matches!(c, ' ' | '\t' | '\n' | '\x0C' | '\r'))
.collect();

let bytes = FORGIVING.decode(cleaned.as_bytes()).map_err(|_| {
js_error!("InvalidCharacterError: The string to be decoded is not correctly encoded.")
})?;

Ok(bytes.into_iter().map(char::from).collect())
}
}

/// Register the `atob` and `btoa` functions in the global context.
///
/// # Errors
///
/// Returns a [`boa_engine::JsError`] if registration fails.
pub fn register(
_realm: Option<boa_engine::realm::Realm>,
_ctx: &mut boa_engine::Context,
) -> boa_engine::JsResult<()> {
Ok(())
/// Returns an error if the functions cannot be registered.
pub fn register(realm: Option<Realm>, context: &mut Context) -> JsResult<()> {
js_module::boa_register(realm, context)
}
154 changes: 154 additions & 0 deletions core/wintertc/src/base64/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
use crate::test::{TestAction, run_test_actions_with};
use boa_engine::Context;
use indoc::indoc;

#[test]
fn btoa_basic() {
let context = &mut Context::default();
crate::base64::register(None, context).unwrap();

run_test_actions_with(
[TestAction::run(indoc! {r#"
if (btoa("hello") !== "aGVsbG8=") {
throw new Error("btoa('hello') failed: " + btoa("hello"));
}
if (btoa("") !== "") {
throw new Error("btoa('') failed");
}
if (btoa("a") !== "YQ==") {
throw new Error("btoa('a') failed: " + btoa("a"));
}
if (btoa("ab") !== "YWI=") {
throw new Error("btoa('ab') failed: " + btoa("ab"));
}
if (btoa("abc") !== "YWJj") {
throw new Error("btoa('abc') failed: " + btoa("abc"));
}
"#})],
context,
);
}

#[test]
fn atob_basic() {
let context = &mut Context::default();
crate::base64::register(None, context).unwrap();

run_test_actions_with(
[TestAction::run(indoc! {r#"
if (atob("aGVsbG8=") !== "hello") {
throw new Error("atob('aGVsbG8=') failed: " + atob("aGVsbG8="));
}
if (atob("") !== "") {
throw new Error("atob('') failed");
}
if (atob("YQ==") !== "a") {
throw new Error("atob('YQ==') failed");
}
if (atob("YWI=") !== "ab") {
throw new Error("atob('YWI=') failed");
}
if (atob("YWJj") !== "abc") {
throw new Error("atob('YWJj') failed");
}
"#})],
context,
);
}

#[test]
fn roundtrip() {
let context = &mut Context::default();
crate::base64::register(None, context).unwrap();

run_test_actions_with(
[TestAction::run(indoc! {r#"
var inputs = ["", "a", "ab", "abc", "hello", "Hello, World!", "\x80\xFF", "caf\xE9"];
for (var i = 0; i < inputs.length; i++) {
if (atob(btoa(inputs[i])) !== inputs[i]) {
throw new Error("roundtrip failed for input at index " + i);
}
}
"#})],
context,
);
}

#[test]
fn btoa_throws_on_non_latin1() {
let context = &mut Context::default();
crate::base64::register(None, context).unwrap();

run_test_actions_with(
[TestAction::run(indoc! {r#"
var threw = false;
try {
btoa("\u{2713}");
} catch (e) {
threw = true;
var msg = String(e);
if (msg.indexOf("InvalidCharacterError") === -1) {
throw new Error("Expected InvalidCharacterError, got: " + msg);
}
}
if (!threw) {
throw new Error("btoa should throw on non-Latin1 characters");
}
"#})],
context,
);
}

#[test]
fn atob_throws_on_invalid_input() {
let context = &mut Context::default();
crate::base64::register(None, context).unwrap();

run_test_actions_with(
[TestAction::run(indoc! {r#"
function expectThrow(input, label) {
var threw = false;
try {
atob(input);
} catch (e) {
threw = true;
}
if (!threw) {
throw new Error("atob should throw on " + label + ": " + input);
}
}

expectThrow("a", "length % 4 == 1");
expectThrow("!!!!", "invalid characters");
expectThrow("YQ=a", "misplaced padding");
"#})],
context,
);
}

#[test]
fn atob_ignores_whitespace() {
let context = &mut Context::default();
crate::base64::register(None, context).unwrap();

run_test_actions_with(
[TestAction::run(indoc! {r#"
if (atob(" aGVs bG8= ") !== "hello") {
throw new Error("atob should ignore spaces");
}
if (atob("\taGVs\tbG8=\t") !== "hello") {
throw new Error("atob should ignore tabs");
}
if (atob("\naGVs\nbG8=\n") !== "hello") {
throw new Error("atob should ignore newlines");
}
if (atob("\x0CaGVs\x0CbG8=\x0C") !== "hello") {
throw new Error("atob should ignore form feeds");
}
if (atob("\raGVs\rbG8=\r") !== "hello") {
throw new Error("atob should ignore carriage returns");
}
"#})],
context,
);
}
Loading
Loading