Moderate firefox Logic Error 🔧 Commit mapped

Overview

Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionInformation disclosure in the PDF Viewer component
ComponentToolkit
Bug ClassLogic Error
Tracker2058800
Fix commit951468630aac (firefox) +342/-8
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-13

Changed Functions

FunctionChangeNotes
if
toolkit/components/pdfjs/content/PdfJsParent.sys.mjs
modified
add_task
toolkit/components/pdfjs/test/browser_pdfjs_mlguess_validation.js
modified
add_task
toolkit/components/pdfjs/test/unit/test_ml_guess_request.js
modified
for
toolkit/components/pdfjs/test/unit/test_ml_guess_request.js
modified

Files Changed

  • toolkit/components/pdfjs/content/PdfJsParent.sys.mjs
  • toolkit/components/pdfjs/test/browser.toml
  • toolkit/components/pdfjs/test/browser_pdfjs_mlguess_validation.js
  • toolkit/components/pdfjs/test/unit/test_ml_guess_request.js
  • toolkit/components/pdfjs/test/unit/xpcshell.toml
diff --git a/toolkit/components/pdfjs/content/PdfJsParent.sys.mjs b/toolkit/components/pdfjs/content/PdfJsParent.sys.mjs
index bf66210642e..940827c2e85 100644
--- a/toolkit/components/pdfjs/content/PdfJsParent.sys.mjs
+++ b/toolkit/components/pdfjs/content/PdfJsParent.sys.mjs
@@ -58,16 +58,18 @@ let gFindTypes = [
   "finddiacriticmatchingchange",
 ];
 
-// Defence-in-depth bounds for the signature-verification IPC handlers
-// below. Even though only our own viewer code is expected to call them,
-// the IPC boundary is privileged (chrome receives content-supplied
-// data) so we reject obviously malformed shapes before reaching NSS or
-// `about:certificate`. The validators below are exported so xpcshell
-// can exercise them without having to instantiate the JSWindowActor.
+// Defence-in-depth validation for content-supplied signature and alt-text IPC
+// payloads before they reach NSS, `about:certificate`, or the ML engine.
+// Exported for direct xpcshell coverage.
 const MAX_CERTS_PER_VIEW = 16;
 const MAX_DER_BASE64_LEN = 64 * 1024; // 64 KiB / cert is far above any real chain.
 const BASE64_RE = /^[A-Za-z0-9+/=]+$/;
 
+// The four properties sent by the viewer for an ImageData buffer.
+const ML_GUESS_KEYS = ["data", "width", "height", "channels"];
+// RawImage supports one to four channels.
+const MAX_ML_GUESS_CHANNELS = 4;
+
 // `instanceof Uint8Array` is unreliable across realms (xpcshell exercises
 // these validators from another global); brand-check a byte view instead.
 const isBytes = x => ArrayBuffer.isView(x) && x.BYTES_PER_ELEMENT === 1;
@@ -120,6 +122,44 @@ export function filterCertsForView(data) {
   return certs.length ? certs : null;
 }
 
+/**
+ * Validate an `mlGuess` IPC payload before forwarding it to the ML engine.
+ *
+ * The image-to-text pipeline calls `RawImage.fromURL()` when `url` is present,
+ * while the viewer sends only pixel data and geometry. Reject unknown
+ * properties and require the geometry to match `data`.
+ *
+ * @param {*} request Content-supplied object.
+ * @returns {boolean} `true` for a valid
+ *   `{data, width, height, channels}` request.
+ * Exported for unit tests.
+ * @internal
+ */
+export function validateMLGuessRequest(request) {
+  if (request === null || typeof request !== "object") {
+    return false;
+  }
+  const keys = Reflect.ownKeys(request);
+  if (
+    keys.length !== ML_GUESS_KEYS.length ||
+    !keys.every(key => ML_GUESS_KEYS.includes(key))
+  ) {
+    return false;
+  }
+  const { data, width, height, channels } = request;
+  return (
+    isBytes(data) &&
+    Number.isInteger(width) &&
+    width > 0 &&
+    Number.isInteger(height) &&
+    height > 0 &&
+    Number.isInteger(channels) &&
+    channels > 0 &&
+    channels <= MAX_ML_GUESS_CHANNELS &&
+    data.length === width * height * channels
+  );
+}
+
 export class PdfJsParent extends JSWindowActorParent {
   #signatureStorageChangedObserver = null;
 
@@ -541,8 +581,9 @@ export class PdfJsParent extends JSWindowActorParent {
     PdfJsTelemetry.report(data);
   }
 
-  async _mlGuess({ data: { service, request } }) {
-    if (service !== IMAGE_TO_TEXT_TASK) {
+  async _mlGuess({ data }) {
+    const { service, request } = data ?? {};
+    if (service !== IMAGE_TO_TEXT_TASK || !validateMLGuessRequest(request)) {
       return null;
     }
     try {
diff --git a/toolkit/components/pdfjs/test/browser.toml b/toolkit/components/pdfjs/test/browser.toml
index 8fcf50fd550..083777358cd 100644
--- a/toolkit/components/pdfjs/test/browser.toml
+++ b/toolkit/components/pdfjs/test/browser.toml
@@ -143,6 +143,9 @@ support-files = ["file_pdfjs_js.pdf"]
 
 ["browser_pdfjs_minimum_font_size.js"]
 
+["browser_pdfjs_mlguess_validation.js"]
+support-files = ["file_pdf_download_link.html"]
+
 ["browser_pdfjs_multipart_response.js"]
 support-files = ["pdf_multipart.sjs"]
 
diff --git a/toolkit/components/pdfjs/test/browser_pdfjs_mlguess_validation.js b/toolkit/components/pdfjs/test/browser_pdfjs_mlguess_validation.js
new file mode 100644
index 00000000000..7f021ce91d4
--- /dev/null
+++ b/toolkit/components/pdfjs/test/browser_pdfjs_mlguess_validation.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const RELATIVE_DIR = "toolkit/components/pdfjs/test/";
+const TESTROOT = "https://example.com/browser/" + RELATIVE_DIR;
+const IMAGE_TO_TEXT_TASK = "moz-image-to-text";
+
+// A valid 2x2 RGBA payload.
+function validRequest() {
+  return {
+    data: new Uint8ClampedArray(2 * 2 * 4),
+    width: 2,
+    height: 2,
+    channels: 4,
+  };
+}
+
+// Send directly through the all-frame PdfJs actor, as a compromised content
+// process could.
+function mlGuess(browser, data) {
+  return SpecialPowers.spawn(browser, [data], payload =>
+    content.windowGlobalChild
+      .getActor("PdfJs")
+      .sendQuery("PDFJS:Parent:mlGuess", payload)
+  );
+}
+
+// Only pixel data with matching geometry may reach the ML pipeline.
+add_task(async function test_mlGuess_only_accepts_raw_pixels() {
+  await BrowserTestUtils.withNewTab(
+    { gBrowser, url: TESTROOT + "file_pdf_download_link.html" },
+    async function (browser) {
+      // Accepted requests return the automation stub response; rejected
+      // requests return null.
+      Assert.deepEqual(
+        await mlGuess(browser, {
+          service: IMAGE_TO_TEXT_TASK,
+          request: validRequest(),
+        }),
+        { output: "In Automation" },
+        "the payload the viewer sends is forwarded to the engine"
+      );
+
+      const withUrl = validRequest();
+      withUrl.url = "file:///etc/passwd";
+      const insteadOfChannels = validRequest();
+      delete insteadOfChannels.channels;
+      insteadOfChannels.url = "file:///etc/passwd";
+
+      for (const [description, data] of [
+        ["no payload at all", undefined],
+        ["a null payload", null],
+        ["no request", { service: IMAGE_TO_TEXT_TASK }],
+        ["a null request", { service: IMAGE_TO_TEXT_TASK, request: null }],
+        [
+          "an unknown service",
+          { service: "moz-echo", request: validRequest() },
+        ],
+        [
+          "a url to fetch",
+          {
+            service: IMAGE_TO_TEXT_TASK,
+            request: { url: "file:///etc/passwd" },
+          },
+        ],
+        [
+          "a url next to valid pixels",
+          { service: IMAGE_TO_TEXT_TASK, request: withUrl },
+        ],
+        [
+          "a url in place of channels",
+          { service: IMAGE_TO_TEXT_TASK, request: insteadOfChannels },
+        ],
+        [
+          "a url as the whole payload",
+          { service: IMAGE_TO_TEXT_TASK, request: "file:///etc/passwd" },
+        ],
+        [
+          "dimensions that don't match the data",
+          {
+            service: IMAGE_TO_TEXT_TASK,
+            request: { ...validRequest(), width: 4096 },
+          },
+        ],
+        [
+          "data that isn't bytes",
+          {
+            service: IMAGE_TO_TEXT_TASK,
+            request: { ...validRequest(), data: "AAAAAAAAAAAAAAAA" },
+          },
+        ],
+      ]) {
+        Assert.equal(
+          await mlGuess(browser, data),
+          null,
+          `mlGuess with ${description} is rejected`
+        );
+      }
+    }
+  );
+});
diff --git a/toolkit/components/pdfjs/test/unit/test_ml_guess_request.js b/toolkit/components/pdfjs/test/unit/test_ml_guess_request.js
new file mode 100644
index 00000000000..31ea40a7bc5
--- /dev/null
+++ b/toolkit/components/pdfjs/test/unit/test_ml_guess_request.js
@@ -0,0 +1,182 @@
+/* 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
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { validateMLGuessRequest } = ChromeUtils.importESModule(
+  "resource://pdf.js/PdfJsParent.sys.mjs"
+);
+
+// Build a request with the viewer's four pixel fields.
+function pixels({ width = 2, height = 2, channels = 4, ...rest } = {}) {
+  return {
+    data: new Uint8ClampedArray(width * height * channels),
+    width,
+    height,
+    channels,
+    ...rest,
+  };
+}
+
+const label = v => (typeof v === "string" ? `"${v}"` : String(v));
+
+add_task(function test_rejects_non_objects() {
+  for (const request of [null, undefined, "", "data", 42, true, () => {}]) {
+    Assert.equal(
+      validateMLGuessRequest(request),
+      false,
+      `${label(request)} must be rejected`
+    );
+  }
+  Assert.equal(validateMLGuessRequest({}), false, "empty object");
+  Assert.equal(validateMLGuessRequest([1, 2, 3, 4]), false, "array");
+});
+
+// The image-to-text pipeline passes `request.url` to `RawImage.fromURL()`.
+add_task(function test_rejects_url_requests() {
+  Assert.equal(
+    validateMLGuessRequest({ url: "file:///etc/passwd" }),
+    false,
+    "bare url request"
+  );
+  Assert.equal(
+    validateMLGuessRequest(pixels({ url: "file:///etc/passwd" })),
+    false,
+    "url smuggled next to a well-formed payload"
+  );
+  const swapped = pixels({ url: "file:///etc/passwd" });
+  delete swapped.channels;
+  Assert.equal(
+    validateMLGuessRequest(swapped),
+    false,
+    "url in place of an expected property (right number of keys)"
+  );
+});
+
+add_task(function test_rejects_unknown_properties() {
+  Assert.equal(
+    validateMLGuessRequest(pixels({ options: { streamer: null } })),
+    false,
+    "extra property"
+  );
+  for (const key of ["data", "width", "height", "channels"]) {
+    const request = pixels();
+    delete request[key];
+    Assert.equal(
+      validateMLGuessRequest(request),
+      false,
+      `missing ${key} must be rejected`
+    );
+  }
+});
+
+add_task(function test_rejects_bad_data() {
+  for (const data of [
+    undefined,
+    null,
+    "AAAA",
+    [0, 0, 0, 0],
+    new ArrayBuffer(16),
+    new Uint16Array(16),
+    new Float32Array(16),
+  ]) {
+    Assert.equal(
+      validateMLGuessRequest(pixels({ data })),
+      false,
+      `data ${data?.constructor?.name ?? label(data)} must be rejected`
+    );
+  }
+});
+
+add_task(function test_rejects_bad_dimensions() {
+  for (const value of [
+    0,
+    -1,
+    -2048,
+    1.5,
+    NaN,
+    Infinity,
+    "2",
+    null,
+    undefined,
+  ]) {
+    Assert.equal(
+      validateMLGuessRequest({ ...pixels(), width: value }),
+      false,
+      `width ${label(value)} must be rejected`
+    );
+    Assert.equal(
+      validateMLGuessRequest({ ...pixels(), height: value }),
+      false,
+      `height ${label(value)} must be rejected`
+    );
+  }
+});
+
+add_task(function test_rejects_bad_channels() {
+  for (const channels of [0, -1, 5, 2.5, NaN, "4", null, undefined]) {
+    Assert.equal(
+      validateMLGuessRequest({ ...pixels(), channels }),
+      false,
+      `channels ${label(channels)} must be rejected`
+    );
+  }
+});
+
+// The data length must match the geometry passed to RawImage.
+add_task(function test_rejects_inconsistent_geometry() {
+  Assert.equal(
+    validateMLGuessRequest({ ...pixels(), data: new Uint8ClampedArray(15) }),
+    false,
+    "data shorter than the geometry"
+  );
+  Assert.equal(
+    validateMLGuessRequest({ ...pixels(), data: new Uint8ClampedArray(17) }),
+    false,
+    "data longer than the geometry"
+  );
+  Assert.equal(
+    validateMLGuessRequest({ ...pixels(), width: 4 }),
+    false,
+    "geometry wider than data"
+  );
+  Assert.equal(
+    validateMLGuessRequest({ ...pixels(), data: new Uint8ClampedArray(0) }),
+    false,
+    "empty data"
+  );
+  Assert.equal(
+    validateMLGuessRequest({ ...pixels(), width: 2 ** 32, height: 2 ** 32 }),
+    false,
+    "huge geometry"
+  );
+});
+
+add_task(function test_accepts_viewer_payload() {
+  Assert.equal(
+    validateMLGuessRequest(pixels()),
+    true,
+    "2x2 RGBA ImageData (Uint8ClampedArray, as canvas produces)"
+  );
+  Assert.equal(
+    validateMLGuessRequest({
+      data: new Uint8Array(4),
+      width: 2,
+      height: 2,
+      channels: 1,
+    }),
+    true,
+    "2x2 grayscale Uint8Array"
+  );
+  Assert.equal(
+    validateMLGuessRequest(pixels({ width: 1, height: 1 })),
+    true,
+    "single pixel"
+  );
+  Assert.equal(
+    validateMLGuessRequest(pixels({ width: 1024, height: 768 })),
+    true,
+    "larger RGBA payload"
+  );
+});
diff --git a/toolkit/components/pdfjs/test/unit/xpcshell.toml b/toolkit/components/pdfjs/test/unit/xpcshell.toml
index a5a6097a82d..a1c64455a90 100644
--- a/toolkit/components/pdfjs/test/unit/xpcshell.toml
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/toolkit/components/pdfjs/test/browser.toml b/toolkit/components/pdfjs/test/browser.toml
index 8fcf50fd550..083777358cd 100644
--- a/toolkit/components/pdfjs/test/browser.toml
+++ b/toolkit/components/pdfjs/test/browser.toml
@@ -143,6 +143,9 @@ support-files = ["file_pdfjs_js.pdf"]
 
 ["browser_pdfjs_minimum_font_size.js"]
 
+["browser_pdfjs_mlguess_validation.js"]
+support-files = ["file_pdf_download_link.html"]
+
 ["browser_pdfjs_multipart_response.js"]
 support-files = ["pdf_multipart.sjs"]
diff --git a/toolkit/components/pdfjs/test/browser_pdfjs_mlguess_validation.js b/toolkit/components/pdfjs/test/browser_pdfjs_mlguess_validation.js
new file mode 100644
index 00000000000..7f021ce91d4
--- /dev/null
+++ b/toolkit/components/pdfjs/test/browser_pdfjs_mlguess_validation.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const RELATIVE_DIR = "toolkit/components/pdfjs/test/";
+const TESTROOT = "https://example.com/browser/" + RELATIVE_DIR;
+const IMAGE_TO_TEXT_TASK = "moz-image-to-text";
+
+// A valid 2x2 RGBA payload.
+function validRequest() {
+  return {
+    data: new Uint8ClampedArray(2 * 2 * 4),
+    width: 2,
+    height: 2,
+    channels: 4,
+  };
+}
+
+// Send directly through the all-frame PdfJs actor, as a compromised content
+// process could.
+function mlGuess(browser, data) {
+  return SpecialPowers.spawn(browser, [data], payload =>
+    content.windowGlobalChild
+      .getActor("PdfJs")
+      .sendQuery("PDFJS:Parent:mlGuess", payload)
+  );
+}
+
+// Only pixel data with matching geometry may reach the ML pipeline.
+add_task(async function test_mlGuess_only_accepts_raw_pixels() {
+  await BrowserTestUtils.withNewTab(
+    { gBrowser, url: TESTROOT + "file_pdf_download_link.html" },
+    async function (browser) {
+      // Accepted requests return the automation stub response; rejected
+      // requests return null.
+      Assert.deepEqual(
+        await mlGuess(browser, {
+          service: IMAGE_TO_TEXT_TASK,
+          request: validRequest(),
+        }),
+        { output: "In Automation" },
+        "the payload the viewer sends is forwarded to the engine"
+      );
+
+      const withUrl = validRequest();
+      withUrl.url = "file:///etc/passwd";
+      const insteadOfChannels = validRequest();
+      delete insteadOfChannels.channels;
+      insteadOfChannels.url = "file:///etc/passwd";
+
+      for (const [description, data] of [
+        ["no payload at all", undefined],
+        ["a null payload", null],
+        ["no request", { service: IMAGE_TO_TEXT_TASK }],
+        ["a null request", { service: IMAGE_TO_TEXT_TASK, request: null }],
+        [
+          "an unknown service",
+          { service: "moz-echo", request: validRequest() },
+        ],
+        [
+          "a url to fetch",
+          {
+            service: IMAGE_TO_TEXT_TASK,
+            request: { url: "file:///etc/passwd" },
+          },
+        ],
+        [
+          "a url next to valid pixels",
+          { service: IMAGE_TO_TEXT_TASK, request: withUrl },
+        ],
+        [
+          "a url in place of channels",
+          { service: IMAGE_TO_TEXT_TASK, request: insteadOfChannels },
+        ],
+        [
+          "a url as the whole payload",
+          { service: IMAGE_TO_TEXT_TASK, request: "file:///etc/passwd" },
+        ],
+        [
+          "dimensions that don't match the data",
+          {
+            service: IMAGE_TO_TEXT_TASK,
+            request: { ...validRequest(), width: 4096 },
+          },
+        ],
+        [
+          "data that isn't bytes",
+          {
+            service: IMAGE_TO_TEXT_TASK,
+            request: { ...validRequest(), data: "AAAAAAAAAAAAAAAA" },
+          },
+        ],
+      ]) {
+        Assert.equal(
+          await mlGuess(browser, data),
+          null,
+          `mlGuess with ${description} is rejected`
+        );
+      }
+    }
+  );
+});
diff --git a/toolkit/components/pdfjs/test/unit/test_ml_guess_request.js b/toolkit/components/pdfjs/test/unit/test_ml_guess_request.js
new file mode 100644
index 00000000000..31ea40a7bc5
--- /dev/null
+++ b/toolkit/components/pdfjs/test/unit/test_ml_guess_request.js
@@ -0,0 +1,182 @@
+/* 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
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { validateMLGuessRequest } = ChromeUtils.importESModule(
+  "resource://pdf.js/PdfJsParent.sys.mjs"
+);
+
+// Build a request with the viewer's four pixel fields.
+function pixels({ width = 2, height = 2, channels = 4, ...rest } = {}) {
+  return {
+    data: new Uint8ClampedArray(width * height * channels),
+    width,
+    height,
+    channels,
+    ...rest,
+  };
+}
+
+const label = v => (typeof v === "string" ? `"${v}"` : String(v));
+
+add_task(function test_rejects_non_objects() {
+  for (const request of [null, undefined, "", "data", 42, true, () => {}]) {
+    Assert.equal(
+      validateMLGuessRequest(request),
+      false,
+      `${label(request)} must be rejected`
+    );
+  }
+  Assert.equal(validateMLGuessRequest({}), false, "empty object");
+  Assert.equal(validateMLGuessRequest([1, 2, 3, 4]), false, "array");
+});
+
+// The image-to-text pipeline passes `request.url` to `RawImage.fromURL()`.
+add_task(function test_rejects_url_requests() {
+  Assert.equal(
+    validateMLGuessRequest({ url: "file:///etc/passwd" }),
+    false,
+    "bare url request"
+  );
+  Assert.equal(
+    validateMLGuessRequest(pixels({ url: "file:///etc/passwd" })),
+    false,
+    "url smuggled next to a well-formed payload"
+  );
+  const swapped = pixels({ url: "file:///etc/passwd" });
+  delete swapped.channels;
+  Assert.equal(
+    validateMLGuessRequest(swapped),
+    false,
+    "url in place of an expected property (right number of keys)"
+  );
+});
+
+add_task(function test_rejects_unknown_properties() {
+  Assert.equal(
+    validateMLGuessRequest(pixels({ options: { streamer: null } })),
+    false,
+    "extra property"
+  );
+  for (const key of ["data", "width", "height", "channels"]) {
+    const request = pixels();
+    delete request[key];
+    Assert.equal(
+      validateMLGuessRequest(request),
+      false,
+      `missing ${key} must be rejected`
+    );
+  }
+});
+
+add_task(function test_rejects_bad_data() {
+  for (const data of [
+    undefined,
+    null,
+    "AAAA",
+    [0, 0, 0, 0],
+    new ArrayBuffer(16),
+    new Uint16Array(16),
+    new Float32Array(16),
+  ]) {
+    Assert.equal(
+      validateMLGuessRequest(pixels({ data })),
+      false,
+      `data ${data?.constructor?.name ?? label(data)} must be rejected`
+    );
+  }
+});
+
+add_task(function test_rejects_bad_dimensions() {
+  for (const value of [
+    0,
+    -1,
+    -2048,
+    1.5,
+    NaN,
+    Infinity,
+    "2",
+    null,
+    undefined,
+  ]) {
+    Assert.equal(
+      validateMLGuessRequest({ ...pixels(), width: value }),
+      false,
+      `width ${label(value)} must be rejected`
+    );
+    Assert.equal(
+      validateMLGuessRequest({ ...pixels(), height: value }),
+      false,
+      `height ${label(value)} must be rejected`
+    );
+  }
+});
+
+add_task(function test_rejects_bad_channels() {
+  for (const channels of [0, -1, 5, 2.5, NaN, "4", null, undefined]) {
+    Assert.equal(
+      validateMLGuessRequest({ ...pixels(), channels }),
+      false,
+      `channels ${label(channels)} must be rejected`
+    );
+  }
+});
+
+// The data length must match the geometry passed to RawImage.
+add_task(function test_rejects_inconsistent_geometry() {
+  Assert.equal(
+    validateMLGuessRequest({ ...pixels(), data: new Uint8ClampedArray(15) }),
+    false,
+    "data shorter than the geometry"
+  );
+  Assert.equal(
+    validateMLGuessRequest({ ...pixels(), data: new Uint8ClampedArray(17) }),
+    false,
+    "data longer than the geometry"
+  );
+  Assert.equal(
+    validateMLGuessRequest({ ...pixels(), width: 4 }),
+    false,
+    "geometry wider than data"
+  );
+  Assert.equal(
+    validateMLGuessRequest({ ...pixels(), data: new Uint8ClampedArray(0) }),
+    false,
+    "empty data"
+  );
+  Assert.equal(
+    validateMLGuessRequest({ ...pixels(), width: 2 ** 32, height: 2 ** 32 }),
+    false,
+    "huge geometry"
+  );
+});
+
+add_task(function test_accepts_viewer_payload() {
+  Assert.equal(
+    validateMLGuessRequest(pixels()),
+    true,
+    "2x2 RGBA ImageData (Uint8ClampedArray, as canvas produces)"
+  );
+  Assert.equal(
+    validateMLGuessRequest({
+      data: new Uint8Array(4),
+      width: 2,
+      height: 2,
+      channels: 1,
+    }),
+    true,
+    "2x2 grayscale Uint8Array"
+  );
+  Assert.equal(
... (truncated)
Loading diff…