]> Lady’s Gitweb - Pisces/commitdiff
Fix base64 implementation
authorLady <redacted>
Fri, 19 May 2023 07:20:39 +0000 (00:20 -0700)
committerLady <redacted>
Sat, 20 May 2023 01:03:34 +0000 (18:03 -0700)
I believe that the old implementation was just wrong, and it simply
wasn’t caught because Deno’s `assertEquals` doesn’t actually know what
to do with raw buffers. Wrapping them to build typed arrays creates
working tests, and this implementation matches observed behaviour with
`atob` and `btoa`.

binary.js
binary.test.js
deno.json
dev-deps.js

index 272775fd2939b05959610ba12917e5906ceae2f9..362161bb8e83bc4268d7cfcf654ccb0600dc15f0 100644 (file)
--- a/binary.js
+++ b/binary.js
@@ -1,7 +1,7 @@
 // ♓🌟 Piscēs ∷ binary.js
 // ====================================================================
 //
-// Copyright © 2020–2022 Lady [@ Lady’s Computer].
+// Copyright © 2020–2023 Lady [@ Lady’s Computer].
 //
 // This Source Code Form is subject to the terms of the Mozilla Public
 // License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -68,6 +68,10 @@ const {
   setUint16: viewSetUint16,
 } = viewPrototype;
 
+/**
+ * Returns an ArrayBuffer for encoding generated from the provided
+ * arguments.
+ */
 const bufferFromArgs = ($, $s) =>
   $ instanceof Buffer
     ? $
@@ -128,7 +132,7 @@ const decodeBase64 = (source, safe = false) => {
         : -1;
       if (result < 0) {
         throw new RangeError(
-          `Piscēs: Invalid character in Base64: ${character}.`,
+          `Piscēs: Invalid character in Base64: ${ucsCharacter}.`,
         );
       } else {
         return result;
@@ -136,28 +140,37 @@ const decodeBase64 = (source, safe = false) => {
     },
   );
   const { length } = u6s;
-  const dataView = new View(new Buffer(floor(length * 3 / 4)));
-  for (let index = 0; index < length - 1;) {
-    const dataIndex = ceil(index * 3 / 4);
-    const remainder = index % 3;
-    if (remainder == 0) {
-      call(viewSetUint8, dataView, [
-        dataIndex,
-        (u6s[index] << 2) + (u6s[++index] >> 4),
-      ]);
-    } else if (remainder == 1) {
-      call(viewSetUint8, dataView, [
-        dataIndex,
-        ((u6s[index] & 0xF) << 4) + (u6s[++index] >> 2),
-      ]);
-    } else {
-      call(viewSetUint8, dataView, [
-        dataIndex,
-        ((u6s[index] & 0x3) << 6) + u6s[++index],
-      ]);
+  if (length % 4 == 1) {
+    throw new RangeError(
+      `Piscēs: Base64 string has invalid length: ${source}.`,
+    );
+  } else {
+    const dataView = new View(new Buffer(floor(length * 3 / 4)));
+    for (let index = 0; index < length - 1;) {
+      // The final index is not handled; if the string is not divisible
+      // by 4, some bits might be dropped. This matches the “forgiving
+      // decode” behaviour specified by WhatW·G for base64.
+      const dataIndex = ceil(index * 3 / 4);
+      const remainder = index % 4;
+      if (remainder == 0) {
+        call(viewSetUint8, dataView, [
+          dataIndex,
+          u6s[index] << 2 | u6s[++index] >> 4,
+        ]);
+      } else if (remainder == 1) {
+        call(viewSetUint8, dataView, [
+          dataIndex,
+          u6s[index] << 4 | u6s[++index] >> 2,
+        ]);
+      } else { // remainder == 2
+        call(viewSetUint8, dataView, [
+          dataIndex,
+          u6s[index] << 6 | u6s[++index, index++],
+        ]);
+      }
     }
+    return call(getViewBuffer, dataView, []);
   }
-  return call(getViewBuffer, dataView, []);
 };
 
 /**
@@ -186,21 +199,20 @@ const encodeBase64 = (buffer, safe = false) => {
     const codeUnitIndex = ceil(index * 4 / 3);
     const currentIndex = codeUnitIndex + +(
       index % 3 == 0 && resultingCodeUnits[codeUnitIndex] != 0x3D
-    );
+    ); // every third byte handles two letters; this is for the second
     const remainder = currentIndex % 4;
+    const currentByte = call(viewGetUint8, dataView, [index]);
+    const nextByte = remainder % 3 && ++index < byteLength
+      // digits 1 & 2 span multiple bytes
+      ? call(viewGetUint8, dataView, [index])
+      : 0;
     const u6 = remainder == 0
-      ? call(viewGetUint8, dataView, [index]) >> 2
+      ? currentByte >> 2
       : remainder == 1
-      ? ((call(viewGetUint8, dataView, [index++]) & 0x3) << 4) +
-        (index < byteLength
-          ? call(viewGetUint8, dataView, [index]) >> 4
-          : 0)
+      ? (currentByte & 0b00000011) << 4 | nextByte >> 4
       : remainder == 2
-      ? ((call(viewGetUint8, dataView, [index++]) & 0xF) << 2) +
-        (index < byteLength
-          ? call(viewGetUint8, dataView, [index]) >> 6
-          : 0)
-      : call(viewGetUint8, dataView, [index++]) & 0x3F;
+      ? (currentByte & 0b00001111) << 2 | nextByte >> 6
+      : (++index, currentByte & 0b00111111); // remainder == 3
     const result = u6 < 26
       ? u6 + 65
       : u6 < 52
@@ -246,6 +258,9 @@ const sourceFromArgs = ($, $s) =>
  *
  * This function can also be used as a tag for a template literal. The
  * literal will be interpreted akin to `String.raw`.
+ *
+ * ☡ This function throws if the provided string is not a valid base64
+ * string.
  */
 export const base64Binary = ($, ...$s) =>
   decodeBase64(sourceFromArgs($, $s));
@@ -266,6 +281,9 @@ export const base64String = ($, ...$s) =>
  *
  * This function can also be used as a tag for a template literal. The
  * literal will be interpreted akin to `String.raw`.
+ *
+ * ☡ This function throws if the provided string is not a valid
+ * filename‐safe base64 string.
  */
 export const filenameSafeBase64Binary = ($, ...$s) =>
   decodeBase64(sourceFromArgs($, $s), true);
index 161f8db2736ced1ac332c0f5ee3d8d105fa42a70..be6c6a3682a04b11fbaaaca1611717c020e79cf7 100755 (executable)
@@ -1,7 +1,7 @@
 // ♓🌟 Piscēs ∷ binary.test.js
 // ====================================================================
 //
-// Copyright © 2020–2022 Lady [@ Lady’s Computer].
+// Copyright © 2020–2023 Lady [@ Lady’s Computer].
 //
 // This Source Code Form is subject to the terms of the Mozilla Public
 // License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -24,42 +24,147 @@ import {
   isFilenameSafeBase64,
 } from "./binary.js";
 
-const base64s = {
-  "AGIAYQBzAGUANgA0": "base64",
-  "R/Q=": new Uint16Array([62535]),
-  "S0lCSQ==": new Uint8ClampedArray([75, 73, 66, 73]).buffer,
-  YmFzZTY0: new DataView(
-    new Uint8Array([98, 97, 115, 101, 54, 52]).buffer,
-  ),
-};
+// These tests assume a LITTLE‐ENDIAN environment.
+const data = new Map([
+  ["", {
+    base64: "",
+  }],
+  ["base64", {
+    base64: "AGIAYQBzAGUANgA0",
+  }],
+  [new Uint16Array([62535]), {
+    base64: "R/Q=",
+  }],
+  [new Uint8ClampedArray([75, 73, 66, 73]).buffer, {
+    base64: "S0lCSQ==",
+  }],
+  [new DataView(new Uint8Array([98, 97, 115, 101, 54, 52]).buffer), {
+    base64: "YmFzZTY0",
+  }],
+
+  // The following three examples are from RFC 3548.
+  [new Uint8Array([0x14, 0xFB, 0x9C, 0x03, 0xD9, 0x7E]), {
+    base64: "FPucA9l+",
+  }],
+  [new Uint8Array([0x14, 0xFB, 0x9C, 0x03, 0xD9]), {
+    base64: "FPucA9k=",
+  }],
+  [new Uint8Array([0x14, 0xFB, 0x9C, 0x03]), {
+    base64: "FPucAw==",
+  }],
+
+  // The following examples are from the Ruby base32 gem.
+  [new Uint8Array([0x28]), {
+    base64: "KA==",
+  }],
+  [new Uint8Array([0xD6]), {
+    base64: "1g==",
+  }],
+  [new Uint16Array([0xF8D6]), {
+    base64: "1vg=",
+  }],
+  [new Uint8Array([0xD6, 0xF8, 0x00]), {
+    base64: "1vgA",
+  }],
+  [new Uint8Array([0xD6, 0xF8, 0x10]), {
+    base64: "1vgQ",
+  }],
+  [new Uint32Array([0x0C11F8D6]), {
+    base64: "1vgRDA==",
+  }],
+  [new Uint8Array([0xD6, 0xF8, 0x11, 0x0C, 0x80]), {
+    base64: "1vgRDIA=",
+  }],
+  [new Uint16Array([0xF8D6, 0x0C11, 0x3085]), {
+    base64: "1vgRDIUw",
+  }],
+]);
 
 describe("base64Binary", () => {
   it("[[Call]] returns the correct data", () => {
     assertEquals(
-      base64Binary("AGIAYQBzAGUANgA0"),
-      new Uint8Array(
-        Array.prototype.map.call(
-          "\u{0}b\u{0}a\u{0}s\u{0}e\u{0}6\u{0}4",
-          ($) => $.charCodeAt(0),
-        ),
-      ).buffer,
+      new Uint8Array(base64Binary("")),
+      new Uint8Array([]),
+      "<empty>",
+    );
+    assertEquals(
+      new Uint8Array(base64Binary("AGIAYQBzAGUANgA0")),
+      Uint8Array.from(
+        "\u{0}b\u{0}a\u{0}s\u{0}e\u{0}6\u{0}4",
+        ($) => $.charCodeAt(0),
+      ),
       "AGIAYQBzAGUANgA0",
     );
     assertEquals(
-      base64Binary("R/Q="),
-      new Uint16Array([62535]).buffer,
+      new Uint16Array(base64Binary("R/Q=")),
+      new Uint16Array([62535]),
       "R/Q=",
     );
     assertEquals(
-      base64Binary("S0lCSQ=="),
-      new Uint8ClampedArray([75, 73, 66, 73]).buffer,
+      new Uint8ClampedArray(base64Binary("S0lCSQ==")),
+      new Uint8ClampedArray([75, 73, 66, 73]),
       "S0lCSQ==",
     );
     assertEquals(
-      base64Binary("YmFzZTY0"),
-      new Uint8Array([98, 97, 115, 101, 54, 52]).buffer,
+      new Uint8Array(base64Binary("YmFzZTY0")),
+      new Uint8Array([98, 97, 115, 101, 54, 52]),
       "YmFzZTY0",
     );
+    assertEquals(
+      new Uint8Array(base64Binary("FPucA9l+")),
+      new Uint8Array([0x14, 0xFB, 0x9C, 0x03, 0xD9, 0x7E]),
+      "FPucA9l+",
+    );
+    assertEquals(
+      new Uint8Array(base64Binary("FPucA9k=")),
+      new Uint8Array([0x14, 0xFB, 0x9C, 0x03, 0xD9]),
+      "FPucA9k=",
+    );
+    assertEquals(
+      new Uint8Array(base64Binary("FPucAw==")),
+      new Uint8Array([0x14, 0xFB, 0x9C, 0x03]),
+      "FPucAw==",
+    );
+    assertEquals(
+      new Uint8Array(base64Binary("KA==")),
+      new Uint8Array([0x28]),
+      "KA==",
+    );
+    assertEquals(
+      new Uint8Array(base64Binary("1g==")),
+      new Uint8Array([0xD6]),
+      "1g==",
+    );
+    assertEquals(
+      new Uint16Array(base64Binary("1vg")),
+      new Uint16Array([0xF8D6]),
+      "1vg",
+    );
+    assertEquals(
+      new Uint8Array(base64Binary("1vgA")),
+      new Uint8Array([0xD6, 0xF8, 0x00]),
+      "1vgA",
+    );
+    assertEquals(
+      new Uint8Array(base64Binary("1vgQ")),
+      new Uint8Array([0xD6, 0xF8, 0x10]),
+      "1vgQ",
+    );
+    assertEquals(
+      new Uint32Array(base64Binary("1vgRDA==")),
+      new Uint32Array([0x0C11F8D6]),
+      "1vgRDA==",
+    );
+    assertEquals(
+      new Uint8Array(base64Binary("1vgRDIA=")),
+      new Uint8Array([0xD6, 0xF8, 0x11, 0x0C, 0x80]),
+      "1vgRDIA=",
+    );
+    assertEquals(
+      new Uint16Array(base64Binary("1vgRDIUw")),
+      new Uint16Array([0xF8D6, 0x0C11, 0x3085]),
+      "1vgRDIUw",
+    );
   });
 
   it("[[Call]] throws when provided with an invalid character", () => {
@@ -78,39 +183,82 @@ describe("base64Binary", () => {
 
 describe("base64String", () => {
   it("[[Call]] returns the correct string", () => {
-    Object.entries(base64s).forEach(([key, value]) =>
-      assertStrictEquals(base64String(value), key)
-    );
+    for (const [source, { base64 }] of data) {
+      assertStrictEquals(base64String(source), base64);
+    }
   });
 });
 
 describe("filenameSafeBase64Binary", () => {
   it("[[Call]] returns the correct data", () => {
     assertEquals(
-      filenameSafeBase64Binary("AGIAYQBzAGUANgA0"),
-      new Uint8Array(
-        Array.prototype.map.call(
-          "\u{0}b\u{0}a\u{0}s\u{0}e\u{0}6\u{0}4",
-          ($) => $.charCodeAt(0),
-        ),
-      ).buffer,
+      new Uint8Array(filenameSafeBase64Binary("")),
+      new Uint8Array([]),
+      "<empty>",
+    );
+    assertEquals(
+      new Uint8Array(filenameSafeBase64Binary("AGIAYQBzAGUANgA0")),
+      Uint8Array.from(
+        "\u{0}b\u{0}a\u{0}s\u{0}e\u{0}6\u{0}4",
+        ($) => $.charCodeAt(0),
+      ),
       "AGIAYQBzAGUANgA0",
     );
     assertEquals(
-      filenameSafeBase64Binary("R_Q="),
-      new Uint16Array([62535]).buffer,
+      new Uint16Array(filenameSafeBase64Binary("R_Q=")),
+      new Uint16Array([62535]),
       "R/Q=",
     );
     assertEquals(
-      filenameSafeBase64Binary("S0lCSQ=="),
-      new Uint8ClampedArray([75, 73, 66, 73]).buffer,
+      new Uint8ClampedArray(filenameSafeBase64Binary("S0lCSQ==")),
+      new Uint8ClampedArray([75, 73, 66, 73]),
       "S0lCSQ==",
     );
     assertEquals(
-      filenameSafeBase64Binary("YmFzZTY0"),
-      new Uint8Array([98, 97, 115, 101, 54, 52]).buffer,
+      new Uint8Array(filenameSafeBase64Binary("YmFzZTY0")),
+      new Uint8Array([98, 97, 115, 101, 54, 52]),
       "YmFzZTY0",
     );
+    assertEquals(
+      new Uint8Array(filenameSafeBase64Binary("KA==")),
+      new Uint8Array([0x28]),
+      "KA==",
+    );
+    assertEquals(
+      new Uint8Array(filenameSafeBase64Binary("1g==")),
+      new Uint8Array([0xD6]),
+      "1g==",
+    );
+    assertEquals(
+      new Uint16Array(filenameSafeBase64Binary("1vg")),
+      new Uint16Array([0xF8D6]),
+      "1vg",
+    );
+    assertEquals(
+      new Uint8Array(filenameSafeBase64Binary("1vgA")),
+      new Uint8Array([0xD6, 0xF8, 0x00]),
+      "1vgA",
+    );
+    assertEquals(
+      new Uint8Array(filenameSafeBase64Binary("1vgQ")),
+      new Uint8Array([0xD6, 0xF8, 0x10]),
+      "1vgQ",
+    );
+    assertEquals(
+      new Uint32Array(filenameSafeBase64Binary("1vgQDA==")),
+      new Uint32Array([0x0C10F8D6]),
+      "1vgQDA==",
+    );
+    assertEquals(
+      new Uint8Array(filenameSafeBase64Binary("1vgQDIA=")),
+      new Uint8Array([0xD6, 0xF8, 0x10, 0x0C, 0x80]),
+      "1vgQDIA=",
+    );
+    assertEquals(
+      new Uint16Array(filenameSafeBase64Binary("1vgQDIUw")),
+      new Uint16Array([0xF8D6, 0x0C10, 0x3085]),
+      "1vgQDIUw",
+    );
   });
 
   it("[[Call]] throws when provided with an invalid character", () => {
@@ -129,18 +277,20 @@ describe("filenameSafeBase64Binary", () => {
 
 describe("filenameSafeBase64String", () => {
   it("[[Call]] returns the correct string", () => {
-    Object.entries(base64s).forEach(([key, value]) =>
+    for (const [source, { base64 }] of data) {
       assertStrictEquals(
-        filenameSafeBase64String(value),
-        key.replace("+", "-").replace("/", "_"),
-      )
-    );
+        filenameSafeBase64String(source),
+        base64.replace("+", "-").replace("/", "_"),
+      );
+    }
   });
 });
 
 describe("isBase64", () => {
   it("[[Call]] returns true for base64 strings", () => {
-    Object.keys(base64s).forEach((key) => assert(isBase64(key)));
+    for (const { base64 } of data.values()) {
+      assert(isBase64(base64));
+    }
   });
 
   it("[[Call]] returns false for others", () => {
@@ -164,11 +314,13 @@ describe("isBase64", () => {
 
 describe("isFilenameSafeBase64", () => {
   it("[[Call]] returns true for filename‐safe base64 strings", () => {
-    Object.keys(base64s).forEach((key) =>
+    for (const { base64 } of data.values()) {
       assert(
-        isFilenameSafeBase64(key.replace("+", "-").replace("/", "_")),
-      )
-    );
+        isFilenameSafeBase64(
+          base64.replace("+", "-").replace("/", "_"),
+        ),
+      );
+    }
   });
 
   it("[[Call]] returns false for others", () => {
index 548f1fea373746fbf03798804ccb69d7b86acc7f..6bdb9a3f3757b4f9d3602f9e15d608706baf5f7c 100644 (file)
--- a/deno.json
+++ b/deno.json
@@ -1,4 +1,5 @@
 {
-  "fmt": { "options": { "lineWidth": 71 } },
-  "lint": { "rules": { "exclude": ["constructor-super"] } }
+  "fmt": { "lineWidth": 71 },
+  "lint": { "rules": { "exclude": ["constructor-super"] } },
+  "lock": false
 }
index 96858a643d9cde04561a55470cc6d618148c8122..f2ed7c9708eaa05b21411866fa98947c920e112f 100644 (file)
@@ -1,7 +1,7 @@
 // ♓🌟 Piscēs ∷ dev-deps.js
 // ====================================================================
 //
-// Copyright © 2022 Lady [@ Lady’s Computer].
+// Copyright © 2022–2023 Lady [@ Lady’s Computer].
 //
 // This Source Code Form is subject to the terms of the Mozilla Public
 // License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -12,13 +12,13 @@ export {
   assertEquals,
   assertStrictEquals,
   assertThrows,
-} from "https://deno.land/std@0.148.0/testing/asserts.ts";
+} from "https://deno.land/std@0.188.0/testing/asserts.ts";
 export {
   describe,
   it,
-} from "https://deno.land/std@0.148.0/testing/bdd.ts";
+} from "https://deno.land/std@0.188.0/testing/bdd.ts";
 export {
   assertSpyCall,
   assertSpyCalls,
   spy,
-} from "https://deno.land/std@0.148.0/testing/mock.ts";
+} from "https://deno.land/std@0.188.0/testing/mock.ts";
This page took 0.070659 seconds and 4 git commands to generate.