/* 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 https://mozilla.org/MPL/2.0/. */

/**
 * Bug 1816189 - Testing canvas randomization on canvas data extraction.
 *
 * In the test, we create canvas elements and offscreen canvas and test if
 * the extracted canvas data is altered because of the canvas randomization.
 */

const emptyPage =
  getRootDirectory(gTestPath).replace(
    "chrome://mochitests/content",
    "https://example.com"
  ) + "empty.html";

var TEST_CASES = [
  {
    name: "CanvasRenderingContext2D.getImageData() but constant color",
    shouldBeRandomized: false,
    extractCanvasData() {
      const canvas = document.createElement("canvas");
      canvas.width = 100;
      canvas.height = 100;

      const context = canvas.getContext("2d");

      context.fillStyle = "#EE2222";
      context.fillRect(0, 0, 100, 100);

      // Add the canvas element to the document
      document.body.appendChild(canvas);

      const imageData = context.getImageData(0, 0, 100, 100);

      // Access the data again.
      const imageDataSecond = context.getImageData(0, 0, 100, 100);

      return [imageData.data, imageDataSecond.data];
    },
    isDataRandomized: isDataRandomizedFuzzy,
  },
  {
    name: "CanvasRenderingContext2D.getImageData().",
    shouldBeRandomized: true,
    extractCanvasData() {
      const canvas = document.createElement("canvas");
      canvas.width = 100;
      canvas.height = 100;

      const context = canvas.getContext("2d");

      context.fillStyle = "#EE2222";
      context.fillRect(0, 0, 100, 100);
      context.fillStyle = "#2222EE";
      context.fillRect(20, 20, 100, 100);

      // Add the canvas element to the document
      document.body.appendChild(canvas);

      const imageData = context.getImageData(0, 0, 100, 100);

      // Access the data again.
      const imageDataSecond = context.getImageData(0, 0, 100, 100);

      return [imageData.data, imageDataSecond.data];
    },
    isDataRandomized: isDataRandomizedFuzzy,
  },
  {
    name: "HTMLCanvasElement.toDataURL() with a 2d context",
    shouldBeRandomized: true,
    extractCanvasData() {
      const canvas = document.createElement("canvas");
      canvas.width = 100;
      canvas.height = 100;

      const context = canvas.getContext("2d");

      context.fillStyle = "#EE2222";
      context.fillRect(0, 0, 100, 100);
      context.fillStyle = "#2222EE";
      context.fillRect(20, 20, 100, 100);

      // Add the canvas element to the document
      document.body.appendChild(canvas);

      const dataURL = canvas.toDataURL();

      // Access the data again.
      const dataURLSecond = canvas.toDataURL();

      return [dataURL, dataURLSecond];
    },
    isDataRandomized: isDataRandomizedNotEqual,
  },
  {
    name: "HTMLCanvasElement.toDataURL() with a webgl context",
    shouldBeRandomized: true,
    extractCanvasData() {
      const canvas = document.createElement("canvas");

      const context = canvas.getContext("webgl");

      context.enable(context.SCISSOR_TEST);
      context.scissor(0, 0, 100, 100);
      context.clearColor(1, 0.2, 0.2, 1);
      context.clear(context.COLOR_BUFFER_BIT);
      context.scissor(15, 15, 30, 15);
      context.clearColor(0.2, 1, 0.2, 1);
      context.clear(context.COLOR_BUFFER_BIT);
      context.scissor(50, 50, 15, 15);
      context.clearColor(0.2, 0.2, 1, 1);
      context.clear(context.COLOR_BUFFER_BIT);

      // Add the canvas element to the document
      document.body.appendChild(canvas);

      const dataURL = canvas.toDataURL();

      // Access the data again.
      const dataURLSecond = canvas.toDataURL();

      return [dataURL, dataURLSecond];
    },
    isDataRandomized: isDataRandomizedNotEqual,
  },
  {
    name: "HTMLCanvasElement.toDataURL() with a bitmaprenderer context",
    shouldBeRandomized: true,
    async extractCanvasData() {
      const canvas = document.createElement("canvas");
      canvas.width = 100;
      canvas.height = 100;

      const context = canvas.getContext("2d");

      context.fillStyle = "#EE2222";
      context.fillRect(0, 0, 100, 100);
      context.fillStyle = "#2222EE";
      context.fillRect(20, 20, 100, 100);

      // Add the canvas element to the document
      document.body.appendChild(canvas);

      const bitmapCanvas = document.createElement("canvas");
      bitmapCanvas.width = 100;
      bitmapCanvas.heigh = 100;
      document.body.appendChild(bitmapCanvas);

      let bitmap = await createImageBitmap(canvas);
      const bitmapContext = bitmapCanvas.getContext("bitmaprenderer");
      bitmapContext.transferFromImageBitmap(bitmap);

      const dataURL = bitmapCanvas.toDataURL();

      // Access the data again.
      const dataURLSecond = bitmapCanvas.toDataURL();

      return [dataURL, dataURLSecond];
    },
    isDataRandomized: isDataRandomizedNotEqual,
  },
  {
    name: "HTMLCanvasElement.toBlob() with a 2d context",
    shouldBeRandomized: true,
    async extractCanvasData() {
      const canvas = document.createElement("canvas");
      canvas.width = 100;
      canvas.height = 100;

      const context = canvas.getContext("2d");

      context.fillStyle = "#EE2222";
      context.fillRect(0, 0, 100, 100);
      context.fillStyle = "#2222EE";
      context.fillRect(20, 20, 100, 100);

      // Add the canvas element to the document
      document.body.appendChild(canvas);

      let data = await new Promise(resolve => {
        canvas.toBlob(blob => {
          let fileReader = new FileReader();
          fileReader.onload = () => {
            resolve(fileReader.result);
          };
          fileReader.readAsArrayBuffer(blob);
        });
      });

      // Access the data again.
      let dataSecond = await new Promise(resolve => {
        canvas.toBlob(blob => {
          let fileReader = new FileReader();
          fileReader.onload = () => {
            resolve(fileReader.result);
          };
          fileReader.readAsArrayBuffer(blob);
        });
      });

      return [data, dataSecond];
    },
    isDataRandomized: isDataRandomizedGreaterThanZero,
  },
  {
    name: "HTMLCanvasElement.toBlob() with a webgl context",
    shouldBeRandomized: true,
    async extractCanvasData() {
      const canvas = document.createElement("canvas");

      const context = canvas.getContext("webgl");

      context.enable(context.SCISSOR_TEST);
      context.scissor(0, 0, 100, 100);
      context.clearColor(1, 0.2, 0.2, 1);
      context.clear(context.COLOR_BUFFER_BIT);
      context.scissor(15, 15, 30, 15);
      context.clearColor(0.2, 1, 0.2, 1);
      context.clear(context.COLOR_BUFFER_BIT);
      context.scissor(50, 50, 15, 15);
      context.clearColor(0.2, 0.2, 1, 1);
      context.clear(context.COLOR_BUFFER_BIT);

      // Add the canvas element to the document
      document.body.appendChild(canvas);

      let data = await new Promise(resolve => {
        canvas.toBlob(blob => {
          let fileReader = new FileReader();
          fileReader.onload = () => {
            resolve(fileReader.result);
          };
          fileReader.readAsArrayBuffer(blob);
        });
      });

      // We don't get the consistent blob data on second access with webgl
      // context regardless of the canvas randomization. So, we report the
      // same data here to not fail the test. Ideally, we should look into
      // why this happens, but it's not caused by canvas randomization.

      return [data, data];
    },
    isDataRandomized: isDataRandomizedGreaterThanZero,
  },
  {
    name: "HTMLCanvasElement.toBlob() with a bitmaprenderer context",
    shouldBeRandomized: true,
    async extractCanvasData() {
      const canvas = document.createElement("canvas");
      canvas.width = 100;
      canvas.height = 100;

      const context = canvas.getContext("2d");

      context.fillStyle = "#EE2222";
      context.fillRect(0, 0, 100, 100);
      context.fillStyle = "#2222EE";
      context.fillRect(20, 20, 100, 100);

      // Add the canvas element to the document
      document.body.appendChild(canvas);

      const bitmapCanvas = document.createElement("canvas");
      bitmapCanvas.width = 100;
      bitmapCanvas.heigh = 100;
      document.body.appendChild(bitmapCanvas);

      let bitmap = await createImageBitmap(canvas);
      const bitmapContext = bitmapCanvas.getContext("bitmaprenderer");
      bitmapContext.transferFromImageBitmap(bitmap);

      let data = await new Promise(resolve => {
        bitmapCanvas.toBlob(blob => {
          let fileReader = new FileReader();
          fileReader.onload = () => {
            resolve(fileReader.result);
          };
          fileReader.readAsArrayBuffer(blob);
        });
      });

      // Access the data again.
      let dataSecond = await new Promise(resolve => {
        bitmapCanvas.toBlob(blob => {
          let fileReader = new FileReader();
          fileReader.onload = () => {
            resolve(fileReader.result);
          };
          fileReader.readAsArrayBuffer(blob);
        });
      });

      return [data, dataSecond];
    },
    isDataRandomized: isDataRandomizedGreaterThanZero,
  },
  {
    name: "OffscreenCanvas.convertToBlob() with a 2d context",
    shouldBeRandomized: true,
    async extractCanvasData() {
      let offscreenCanvas = new OffscreenCanvas(100, 100);

      const context = offscreenCanvas.getContext("2d");

      context.fillStyle = "#EE2222";
      context.fillRect(0, 0, 100, 100);
      context.fillStyle = "#2222EE";
      context.fillRect(20, 20, 100, 100);

      let blob = await offscreenCanvas.convertToBlob();
      let data = await blob.arrayBuffer();

      // Access the data again.
      let blobSecond = await offscreenCanvas.convertToBlob();
      let dataSecond = await blobSecond.arrayBuffer();

      return [data, dataSecond];
    },
    isDataRandomized: isDataRandomizedGreaterThanZero,
  },
  {
    name: "OffscreenCanvas.convertToBlob() with a webgl context",
    shouldBeRandomized: true,
    async extractCanvasData() {
      let offscreenCanvas = new OffscreenCanvas(100, 100);

      const context = offscreenCanvas.getContext("webgl");

      context.enable(context.SCISSOR_TEST);
      context.scissor(0, 0, 100, 100);
      context.clearColor(1, 0.2, 0.2, 1);
      context.clear(context.COLOR_BUFFER_BIT);
      context.scissor(15, 15, 30, 15);
      context.clearColor(0.2, 1, 0.2, 1);
      context.clear(context.COLOR_BUFFER_BIT);
      context.scissor(50, 50, 15, 15);
      context.clearColor(0.2, 0.2, 1, 1);
      context.clear(context.COLOR_BUFFER_BIT);

      let blob = await offscreenCanvas.convertToBlob();
      let data = await blob.arrayBuffer();

      // Access the data again.
      let blobSecond = await offscreenCanvas.convertToBlob();
      let dataSecond = await blobSecond.arrayBuffer();

      return [data, dataSecond];
    },
    isDataRandomized: isDataRandomizedGreaterThanZero,
  },
  {
    name: "OffscreenCanvas.convertToBlob() with a bitmaprenderer context",
    shouldBeRandomized: true,
    async extractCanvasData() {
      let offscreenCanvas = new OffscreenCanvas(100, 100);

      const context = offscreenCanvas.getContext("2d");

      context.fillStyle = "#EE2222";
      context.fillRect(0, 0, 100, 100);
      context.fillStyle = "#2222EE";
      context.fillRect(20, 20, 100, 100);

      const bitmapCanvas = new OffscreenCanvas(100, 100);

      let bitmap = await createImageBitmap(offscreenCanvas);
      const bitmapContext = bitmapCanvas.getContext("bitmaprenderer");
      bitmapContext.transferFromImageBitmap(bitmap);

      let blob = await bitmapCanvas.convertToBlob();
      let data = await blob.arrayBuffer();

      // Access the data again.
      let blobSecond = await bitmapCanvas.convertToBlob();
      let dataSecond = await blobSecond.arrayBuffer();

      return [data, dataSecond];
    },
    isDataRandomized: isDataRandomizedGreaterThanZero,
  },
  {
    name: "CanvasRenderingContext2D.getImageData() with a offscreen canvas",
    shouldBeRandomized: true,
    extractCanvasData() {
      let offscreenCanvas = new OffscreenCanvas(100, 100);

      const context = offscreenCanvas.getContext("2d");

      // Draw a red rectangle
      context.fillStyle = "#EE2222";
      context.fillRect(0, 0, 100, 100);
      context.fillStyle = "#2222EE";
      context.fillRect(20, 20, 100, 100);

      const imageData = context.getImageData(0, 0, 100, 100);

      // Access the data again.
      const imageDataSecond = context.getImageData(0, 0, 100, 100);

      return [imageData.data, imageDataSecond.data];
    },
    isDataRandomized: isDataRandomizedFuzzy,
  },
];

function runExtractCanvasData(test, tab) {
  let code = test.extractCanvasData.toString();
  return SpecialPowers.spawn(tab.linkedBrowser, [code], async code => {
    let result = await content.eval(`({${code}}).extractCanvasData()`);
    return result;
  });
}

async function runTest(enabled) {
  // Enable/Disable CanvasRandomization by the RFP target overrides.
  let RFPOverrides = enabled ? "+CanvasRandomization" : "-CanvasRandomization";
  await SpecialPowers.pushPrefEnv({
    set: [
      ["privacy.fingerprintingProtection", true],
      ["privacy.fingerprintingProtection.pbmode", true],
      ["privacy.fingerprintingProtection.overrides", RFPOverrides],
      ["privacy.resistFingerprinting", false],
    ],
  });

  // Open a private window.
  let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
    private: true,
  });

  // Open tabs in the normal and private window.
  const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage);

  const privateTab = await BrowserTestUtils.openNewForegroundTab(
    privateWindow.gBrowser,
    emptyPage
  );

  for (let test of TEST_CASES) {
    info(`Testing ${test.name}`);

    // Clear telemetry before starting test.
    Services.fog.testResetFOG();

    let data = await runExtractCanvasData(test, tab);
    let result = test.isDataRandomized(test.name, data[0], test.originalData);

    if (test.shouldBeRandomized) {
      is(
        result,
        enabled,
        `The image data is ${enabled ? "randomized" : "the same"} for ${
          test.name
        }.`
      );
    } else {
      is(
        result,
        false,
        `The image data for ${test.name} should never be randomized.`
      );
    }

    ok(
      !test.isDataRandomized(test.name, data[0], data[1]),
      `The data of first and second access should be the same for ${test.name}.`
    );

    let privateData = await runExtractCanvasData(test, privateTab);

    // Check if we add noise to canvas data in private windows.
    result = test.isDataRandomized(
      test.name,
      privateData[0],
      test.originalData,
      true
    );
    if (test.shouldBeRandomized) {
      is(
        result,
        enabled,
        `The private image data is ${enabled ? "randomized" : "the same"} for ${
          test.name
        }.`
      );
    } else {
      is(
        result,
        false,
        `The image data for ${test.name} should never be randomized.`
      );
    }

    ok(
      !test.isDataRandomized(test.name, privateData[0], privateData[1]),
      "The data of first and second access should be the same for private windows."
    );

    if (test.shouldBeRandomized) {
      // Make sure the noises are different between normal window and private
      // windows.
      result = test.isDataRandomized(test.name, privateData[0], data[0]);
      is(
        result,
        enabled,
        `The image data between the normal window and the private window are ${
          enabled ? "different" : "the same"
        } for ${test.name}.`
      );

      // Verify the telemetry is recorded if canvas randomization is enabled.
      if (enabled) {
        await Services.fog.testFlushAllChildren();

        Assert.greater(
          Glean.fingerprintingProtection.canvasNoiseCalculateTime2.testGetValue()
            .sum,
          0,
          "The telemetry of canvas randomization is recorded."
        );
      }
    }
  }

  BrowserTestUtils.removeTab(tab);
  BrowserTestUtils.removeTab(privateTab);
  await BrowserTestUtils.closeWindow(privateWindow);
}

add_setup(async function () {
  // Disable the fingerprinting randomization.
  await SpecialPowers.pushPrefEnv({
    set: [
      ["privacy.fingerprintingProtection", false],
      ["privacy.fingerprintingProtection.pbmode", false],
      ["privacy.resistFingerprinting", false],
    ],
  });

  // Open a tab for extracting the canvas data.
  const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, emptyPage);

  // Extract the original canvas data without random noise.
  for (let test of TEST_CASES) {
    let data = await runExtractCanvasData(test, tab);
    test.originalData = data[0];
  }

  BrowserTestUtils.removeTab(tab);
});

add_task(async function run_tests_with_randomization_enabled() {
  await runTest(true);
});

add_task(async function run_tests_with_randomization_enabled_with_siphash() {
  await SpecialPowers.pushPrefEnv({
    set: [
      ["privacy.resistFingerprinting.randomization.canvas.use_siphash", true],
    ],
  });
  await runTest(true);

  await SpecialPowers.popPrefEnv();
});

add_task(async function run_tests_with_randomization_disabled() {
  await runTest(false);
});
