diff --git a/package-lock.json b/package-lock.json index 7a4b66b0..2e6962f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,7 +137,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1990,7 +1989,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2037,7 +2035,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4542,7 +4539,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -5497,7 +5493,6 @@ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -6321,7 +6316,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7041,7 +7035,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -8848,7 +8841,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11754,7 +11746,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -13894,6 +13885,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -14455,7 +14447,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -16519,7 +16510,6 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -18190,7 +18180,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/packages/base64/index.js b/packages/base64/index.js index f2c85015..70f808cc 100644 --- a/packages/base64/index.js +++ b/packages/base64/index.js @@ -1,7 +1,26 @@ export function encode(string) { - return globalThis.btoa(string); + const encoder = new TextEncoder(); + const bytes = encoder.encode(string); + + if (typeof bytes.toBase64 === "function") { + return bytes.toBase64(); + } + + let binary = ""; + for (const byte of bytes) { + binary += String.fromCodePoint(byte); + } + return globalThis.btoa(binary); } -export function decode(string) { - return globalThis.atob(string); +export function decode(data) { + const decoder = new TextDecoder(); + + if (typeof Uint8Array.fromBase64 === "function") { + return decoder.decode(Uint8Array.fromBase64(data)); + } + + const binary = globalThis.atob(data); + const bytes = Uint8Array.from(binary, (c) => c.codePointAt(0)); + return decoder.decode(bytes); } diff --git a/packages/base64/test/test.js b/packages/base64/test/test.js new file mode 100644 index 00000000..c6399d6d --- /dev/null +++ b/packages/base64/test/test.js @@ -0,0 +1,115 @@ +import { encode, decode } from "../index.js"; + +test("encodes ASCII strings", () => { + expect(encode("hello")).toBe("aGVsbG8="); +}); + +test("decodes ASCII strings", () => { + expect(decode("aGVsbG8=")).toBe("hello"); +}); + +test("round-trips ASCII", () => { + const input = "foo\0bar\0baz"; + expect(decode(encode(input))).toBe(input); +}); + +test("round-trips null bytes in SASL PLAIN format", () => { + const input = "\0username\0password"; + expect(decode(encode(input))).toBe(input); +}); + +// Nordic characters (æ, ø, å) +test("round-trips Nordic characters æøå", () => { + expect(decode(encode("æ"))).toBe("æ"); + expect(decode(encode("ø"))).toBe("ø"); + expect(decode(encode("å"))).toBe("å"); + expect(decode(encode("Æ"))).toBe("Æ"); + expect(decode(encode("Ø"))).toBe("Ø"); + expect(decode(encode("Å"))).toBe("Å"); +}); + +// German umlauts +test("round-trips German umlauts äöüß", () => { + expect(decode(encode("ä"))).toBe("ä"); + expect(decode(encode("ö"))).toBe("ö"); + expect(decode(encode("ü"))).toBe("ü"); + expect(decode(encode("ß"))).toBe("ß"); +}); + +// French accented characters +test("round-trips French accented characters", () => { + expect(decode(encode("é"))).toBe("é"); + expect(decode(encode("è"))).toBe("è"); + expect(decode(encode("ê"))).toBe("ê"); + expect(decode(encode("ë"))).toBe("ë"); + expect(decode(encode("ç"))).toBe("ç"); + expect(decode(encode("ñ"))).toBe("ñ"); +}); + +// Cyrillic +test("round-trips Cyrillic characters", () => { + const input = "Привет"; + expect(decode(encode(input))).toBe(input); +}); + +// CJK characters +test("round-trips CJK characters", () => { + expect(decode(encode("日本語"))).toBe("日本語"); + expect(decode(encode("中文"))).toBe("中文"); + expect(decode(encode("한국어"))).toBe("한국어"); +}); + +// Arabic and Hebrew +test("round-trips Arabic and Hebrew characters", () => { + expect(decode(encode("مرحبا"))).toBe("مرحبا"); + expect(decode(encode("שלום"))).toBe("שלום"); +}); + +// Emoji (4-byte UTF-8 sequences) +test("round-trips emoji", () => { + expect(decode(encode("🎉"))).toBe("🎉"); + expect(decode(encode("👨‍💻"))).toBe("👨‍💻"); + expect(decode(encode("🇳🇴"))).toBe("🇳🇴"); +}); + +// Mixed scripts +test("round-trips mixed scripts in a single string", () => { + const input = "Hello æøå Привет 日本語 🎉"; + expect(decode(encode(input))).toBe(input); +}); + +// Verifies UTF-8 byte encoding rather than Latin-1 +test("encodes ø as UTF-8 bytes, not Latin-1", () => { + // ø (U+00F8) in UTF-8 is [0xC3, 0xB8] → base64 "w7g=" + // In Latin-1 (btoa) it would be [0xF8] → base64 "+A==" + expect(encode("ø")).toBe("w7g="); +}); + +test("encodes æ as UTF-8 bytes, not Latin-1", () => { + // æ (U+00E6) in UTF-8 is [0xC3, 0xA6] → base64 "w6Y=" + // In Latin-1 (btoa) it would be [0xE6] → base64 "5g==" + expect(encode("æ")).toBe("w6Y="); +}); + +test("encodes å as UTF-8 bytes, not Latin-1", () => { + // å (U+00E5) in UTF-8 is [0xC3, 0xA5] → base64 "w6U=" + // In Latin-1 (btoa) it would be [0xE5] → base64 "5Q==" + expect(encode("å")).toBe("w6U="); +}); + +// Full SASL PLAIN payload with non-ASCII username +test("SASL PLAIN payload with Nordic username", () => { + const payload = "\0øyvindranda@example.com\0session-token"; + const encoded = encode(payload); + expect(decode(encoded)).toBe(payload); +}); + +test("SASL PLAIN payload with German username", () => { + const payload = "\0müller@example.com\0password"; + expect(decode(encode(payload))).toBe(payload); +}); + +test("SASL PLAIN payload with Cyrillic username", () => { + const payload = "\0иван@example.com\0password"; + expect(decode(encode(payload))).toBe(payload); +});