"use strict";

const { AsyncShutdown } = ChromeUtils.importESModule(
  "resource://gre/modules/AsyncShutdown.sys.mjs"
);
const { NativeManifests } = ChromeUtils.importESModule(
  "resource://gre/modules/NativeManifests.sys.mjs"
);
const { FileUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/FileUtils.sys.mjs"
);
const { Schemas } = ChromeUtils.importESModule(
  "resource://gre/modules/Schemas.sys.mjs"
);
const { Subprocess } = ChromeUtils.importESModule(
  "resource://gre/modules/Subprocess.sys.mjs"
);
const { NativeApp } = ChromeUtils.importESModule(
  "resource://gre/modules/NativeMessaging.sys.mjs"
);

let registry = null;
if (AppConstants.platform == "win") {
  var { MockRegistry } = ChromeUtils.importESModule(
    "resource://testing-common/MockRegistry.sys.mjs"
  );
  registry = new MockRegistry();
  registerCleanupFunction(() => {
    registry.shutdown();
  });
  ChromeUtils.defineESModuleGetters(this, {
    SubprocessImpl: "resource://gre/modules/subprocess/subprocess_win.sys.mjs",
  });
} else {
  ChromeUtils.defineESModuleGetters(this, {
    SubprocessImpl: "resource://gre/modules/subprocess/subprocess_unix.sys.mjs",
  });
}

const REGPATH = "Software\\Mozilla\\NativeMessagingHosts";

const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";

const TYPE_SLUG =
  AppConstants.platform === "linux"
    ? "native-messaging-hosts"
    : "NativeMessagingHosts";

let dir = FileUtils.getDir("TmpD", ["NativeManifests"]);
dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);

let userDir = dir.clone();
userDir.append("user");
userDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);

let globalDir = dir.clone();
globalDir.append("global");
globalDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);

add_setup(async function setup() {
  await IOUtils.makeDirectory(PathUtils.join(userDir.path, TYPE_SLUG));
  await IOUtils.makeDirectory(PathUtils.join(globalDir.path, TYPE_SLUG));
});

let dirProvider = {
  getFile(property) {
    if (property == "XREUserNativeManifests") {
      return userDir.clone();
    } else if (property == "XRESysNativeManifests") {
      return globalDir.clone();
    }
    return null;
  },
};

Services.dirsvc.registerProvider(dirProvider);

registerCleanupFunction(() => {
  Services.dirsvc.unregisterProvider(dirProvider);
  dir.remove(true);
});

function writeManifest(path, manifest) {
  if (typeof manifest != "string") {
    manifest = JSON.stringify(manifest);
  }
  return IOUtils.writeUTF8(path, manifest);
}

let PYTHON;
add_task(async function setup() {
  await Schemas.load(BASE_SCHEMA);

  try {
    PYTHON = await Subprocess.pathSearch(Services.env.get("PYTHON"));
  } catch (e) {
    notEqual(
      PYTHON,
      null,
      `Can't find a suitable python interpreter ${e.message}`
    );
  }
});

let global = this;

// Test of NativeManifests.lookupApplication() begin here...
let context = {
  extension: {
    id: "extension@tests.mozilla.org",
  },
  manifestVersion: 2,
  envType: "addon_parent",
  url: null,
  jsonStringify(...args) {
    return JSON.stringify(...args);
  },
  cloneScope: global,
  logError() {},
  preprocessors: {},
  callOnClose: () => {},
  forgetOnClose: () => {},
};

class MockContext extends ExtensionCommon.BaseContext {
  constructor(extensionId) {
    let fakeExtension = { id: extensionId, manifestVersion: 2 };
    super("addon_parent", fakeExtension);
    this.sandbox = Cu.Sandbox(global);
  }

  get cloneScope() {
    return global;
  }

  get principal() {
    return Cu.getObjectPrincipal(this.sandbox);
  }
}

let templateManifest = {
  name: "test",
  description: "this is only a test",
  path: "/bin/cat",
  type: "stdio",
  allowed_extensions: ["extension@tests.mozilla.org"],
};

function lookupApplication(app, ctx) {
  return NativeManifests.lookupManifest("stdio", app, ctx);
}

add_task(async function test_parse_good_manifest() {
  let manifest = await NativeManifests.parseManifest(
    "stdio",
    "/some/path",
    "test",
    context,
    templateManifest
  );
  deepEqual(
    manifest,
    templateManifest,
    "parseManifest returns the manifest contents"
  );
});

add_task(async function test_parse_invalid_manifest() {
  function matchLastConsoleMessage(regex) {
    ok(Services.console.getMessageArray().pop().message.match(regex));
  }

  equal(
    null,
    await NativeManifests.parseManifest(
      "pkcs11",
      "/some/path",
      "test",
      context,
      templateManifest
    )
  );
  matchLastConsoleMessage(
    /Native manifest \/some\/path has type property stdio \(expected pkcs11\)/
  );

  equal(
    null,
    await NativeManifests.parseManifest(
      "stdio",
      "/some/path",
      "foobar",
      context,
      templateManifest
    )
  );
  matchLastConsoleMessage(
    /Native manifest \/some\/path has name property test \(expected foobar\)/
  );

  const incompleteManifest = { ...templateManifest };
  delete incompleteManifest.description;
  equal(
    null,
    await NativeManifests.parseManifest(
      "stdio",
      "/some/path",
      "test",
      context,
      incompleteManifest
    )
  );
  matchLastConsoleMessage(/Value must either: match the pattern/);

  const unauthorizedManifest = { ...templateManifest };
  unauthorizedManifest.allowed_extensions = [];
  equal(
    null,
    await NativeManifests.parseManifest(
      "stdio",
      "/some/path",
      "test",
      context,
      unauthorizedManifest
    )
  );
  matchLastConsoleMessage(
    /Value must either: .allowed_extensions must have at least 1 items/
  );

  unauthorizedManifest.allowed_extensions = ["unauthorized@tests.mozilla.org"];
  equal(
    null,
    await NativeManifests.parseManifest(
      "stdio",
      "/some/path",
      "test",
      context,
      unauthorizedManifest
    )
  );
  matchLastConsoleMessage(
    /This extension does not have permission to use native manifest \/some\/path/
  );
});

add_task(async function test_nonexistent_manifest() {
  let result = await lookupApplication("test", context);
  equal(
    result,
    null,
    "lookupApplication returns null for non-existent application"
  );
});

const USER_TEST_JSON = PathUtils.join(userDir.path, TYPE_SLUG, "test.json");

add_task(async function test_nonexistent_manifest_with_registry_entry() {
  if (registry) {
    registry.setValue(
      Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
      `${REGPATH}\\test`,
      "",
      USER_TEST_JSON
    );
  }

  await IOUtils.remove(USER_TEST_JSON);
  let { messages, result } = await promiseConsoleOutput(() =>
    lookupApplication("test", context)
  );
  equal(
    result,
    null,
    "lookupApplication returns null for non-existent manifest"
  );

  let noSuchFileErrors = messages.filter(logMessage =>
    logMessage.message.includes(
      "file is referenced in the registry but does not exist"
    )
  );

  if (registry) {
    equal(
      noSuchFileErrors.length,
      1,
      "lookupApplication logs a non-existent manifest file pointed to by the registry"
    );
  } else {
    equal(
      noSuchFileErrors.length,
      0,
      "lookupApplication does not log about registry on non-windows platforms"
    );
  }
});

add_task(
  { skip_if: () => AppConstants.platform !== "win" },
  async function test_nonstring_registry_entry() {
    registry.setValue(
      Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
      `${REGPATH}\\test`,
      "",
      42,
      Ci.nsIWindowsRegKey.TYPE_INT
    );

    let { messages, result } = await promiseConsoleOutput(() =>
      lookupApplication("test", context)
    );
    equal(
      null,
      result,
      "lookupApplication returns null for non-string registry key"
    );

    let typeErrors = messages.filter(log =>
      log.message.includes(`registry entry ${REGPATH}\\test must be a string`)
    );

    equal(
      1,
      typeErrors.length,
      "lookupApplication logs an error for a non-string registry entry"
    );
  }
);

add_task(async function test_good_manifest() {
  await writeManifest(USER_TEST_JSON, templateManifest);
  if (registry) {
    registry.setValue(
      Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
      `${REGPATH}\\test`,
      "",
      USER_TEST_JSON
    );
  }

  let result = await lookupApplication("test", context);
  notEqual(result, null, "lookupApplication finds a good manifest");
  equal(
    result.path,
    USER_TEST_JSON,
    "lookupApplication returns the correct path"
  );
  deepEqual(
    result.manifest,
    templateManifest,
    "lookupApplication returns the manifest contents"
  );
});

add_task(
  { skip_if: () => AppConstants.platform != "win" },
  async function test_forward_slashes_instead_of_backslashes_in_registry() {
    Assert.ok(USER_TEST_JSON.includes("\\"), `Path has \\: ${USER_TEST_JSON}`);
    const manifest = { ...templateManifest, name: "testslash" };
    await writeManifest(USER_TEST_JSON, manifest);
    registry.setValue(
      Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
      `${REGPATH}\\testslash`,
      "",
      USER_TEST_JSON.replaceAll("\\", "/")
    );

    let result = await lookupApplication("testslash", context);
    notEqual(result, null, "lookupApplication finds the manifest despite /");
    equal(
      result.path,
      USER_TEST_JSON,
      "lookupApplication returns the correct path with platform-native slash"
    );
    // Side note: manifest.path does not contain a platform-native path,
    // but it is normalized when used in NativeMessaging.sys.mjs.
    deepEqual(
      result.manifest,
      manifest,
      "lookupApplication returns the manifest contents"
    );
  }
);

add_task(async function test_manifest_with_utf8_byte_order_mark() {
  const manifest = { ...templateManifest, description: "had BOM at start" };
  const manifestString = JSON.stringify(manifest);

  // "123" to have a placeholder where we'll fill in the 3 BOM bytes.
  const manifestBytes = new TextEncoder().encode("123" + manifestString);
  manifestBytes.set([0xef, 0xbb, 0xbf]);

  // Sanity check: verify that the bytes prepended above have the special
  // meaning of being a UTF-8 BOM. That is, when parsed as UTF-8, the bytes can
  // be removed without loss of meaning.
  equal(
    new TextDecoder().decode(manifestBytes),
    manifestString,
    "Sanity check: input bytes has UTF-8 BOM that is ordinarily stripped"
  );

  await IOUtils.write(USER_TEST_JSON, manifestBytes);
  if (registry) {
    registry.setValue(
      Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
      `${REGPATH}\\test`,
      "",
      USER_TEST_JSON
    );
  }
  let result = await lookupApplication("test", context);
  notEqual(result, null, "lookupApplication finds a good manifest despite BOM");
  deepEqual(
    result.manifest,
    manifest,
    "lookupApplication returns the manifest contents"
  );
});

add_task(async function test_manifest_with_invalid_utf_8() {
  const manifest = { ...templateManifest, description: "bad bytes" };
  const manifestString = JSON.stringify(manifest);
  const manifestBytes = Uint8Array.from(manifestString, c => c.charCodeAt(0));
  // manifestString ends with `bad bytes"}`. Replace the `s` with a bad byte:
  manifestBytes.set([0xff], manifestBytes.byteLength - 3);

  await IOUtils.write(USER_TEST_JSON, manifestBytes);
  if (registry) {
    registry.setValue(
      Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
      `${REGPATH}\\test`,
      "",
      USER_TEST_JSON
    );
  }
  let { messages, result } = await promiseConsoleOutput(() =>
    lookupApplication("test", context)
  );
  equal(result, null, "lookupApplication should reject file with invalid UTF8");
  let errorPattern =
    /NotReadableError: Could not read `.*': file is not UTF-8 encoded/;
  let utf8Errors = messages.filter(({ message }) => errorPattern.test(message));
  equal(utf8Errors.length, 1, "lookupApplication logs error about UTF-8");
});

add_task(async function test_invalid_json() {
  await writeManifest(USER_TEST_JSON, "this is not valid json");
  let { messages, result } = await promiseConsoleOutput(() =>
    lookupApplication("test", context)
  );
  equal(result, null, "lookupApplication ignores bad json");
  let errorPattern = /Error parsing native manifest .*test.json: JSON\.parse:/;
  let jsonErrors = messages.filter(({ message }) => errorPattern.test(message));
  equal(jsonErrors.length, 1, "lookupApplication logs JSON error");
});

add_task(async function test_invalid_name() {
  let manifest = Object.assign({}, templateManifest);
  manifest.name = "../test";
  await writeManifest(USER_TEST_JSON, manifest);
  let result = await lookupApplication("test", context);
  equal(result, null, "lookupApplication ignores an invalid name");
});

add_task(async function test_name_mismatch() {
  let manifest = Object.assign({}, templateManifest);
  manifest.name = "not test";
  await writeManifest(USER_TEST_JSON, manifest);
  let result = await lookupApplication("test", context);
  let what = AppConstants.platform == "win" ? "registry key" : "json filename";
  equal(
    result,
    null,
    `lookupApplication ignores mistmatch between ${what} and name property`
  );
});

add_task(async function test_missing_props() {
  const PROPS = ["name", "description", "path", "type", "allowed_extensions"];
  for (let prop of PROPS) {
    let manifest = Object.assign({}, templateManifest);
    delete manifest[prop];

    await writeManifest(USER_TEST_JSON, manifest);
    let result = await lookupApplication("test", context);
    equal(result, null, `lookupApplication ignores missing ${prop}`);
  }
});

add_task(async function test_invalid_type() {
  let manifest = Object.assign({}, templateManifest);
  manifest.type = "bogus";
  await writeManifest(USER_TEST_JSON, manifest);
  let result = await lookupApplication("test", context);
  equal(result, null, "lookupApplication ignores invalid type");
});

add_task(async function test_no_allowed_extensions() {
  let manifest = Object.assign({}, templateManifest);
  manifest.allowed_extensions = [];
  await writeManifest(USER_TEST_JSON, manifest);
  let result = await lookupApplication("test", context);
  equal(
    result,
    null,
    "lookupApplication ignores manifest with no allowed_extensions"
  );
});

const GLOBAL_TEST_JSON = PathUtils.join(globalDir.path, TYPE_SLUG, "test.json");
let globalManifest = Object.assign({}, templateManifest);
globalManifest.description = "This manifest is from the systemwide directory";

add_task(async function good_manifest_system_dir() {
  await IOUtils.remove(USER_TEST_JSON);
  await writeManifest(GLOBAL_TEST_JSON, globalManifest);
  if (registry) {
    registry.setValue(
      Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
      `${REGPATH}\\test`,
      "",
      null
    );
    registry.setValue(
      Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
      `${REGPATH}\\test`,
      "",
      GLOBAL_TEST_JSON
    );
  }

  let where =
    AppConstants.platform == "win" ? "registry location" : "directory";
  let result = await lookupApplication("test", context);
  notEqual(
    result,
    null,
    `lookupApplication finds a manifest in the system-wide ${where}`
  );
  equal(
    result.path,
    GLOBAL_TEST_JSON,
    `lookupApplication returns path in the system-wide ${where}`
  );
  deepEqual(
    result.manifest,
    globalManifest,
    `lookupApplication returns manifest contents from the system-wide ${where}`
  );
});

add_task(async function test_user_dir_precedence() {
  await writeManifest(USER_TEST_JSON, templateManifest);
  if (registry) {
    registry.setValue(
      Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
      `${REGPATH}\\test`,
      "",
      USER_TEST_JSON
    );
  }
  // global test.json and LOCAL_MACHINE registry key on windows are
  // still present from the previous test

  let result = await lookupApplication("test", context);
  notEqual(
    result,
    null,
    "lookupApplication finds a manifest when entries exist in both user-specific and system-wide locations"
  );
  equal(
    result.path,
    USER_TEST_JSON,
    "lookupApplication returns the user-specific path when user-specific and system-wide entries both exist"
  );
  deepEqual(
    result.manifest,
    templateManifest,
    "lookupApplication returns user-specific manifest contents with user-specific and system-wide entries both exist"
  );
});

// Test shutdown handling in NativeApp
add_task(async function test_native_app_shutdown() {
  const SCRIPT = String.raw`
import signal
import struct
import sys

signal.signal(signal.SIGTERM, signal.SIG_IGN)

stdin = getattr(sys.stdin, 'buffer', sys.stdin)
stdout = getattr(sys.stdout, 'buffer', sys.stdout)

while True:
    rawlen = stdin.read(4)
    if len(rawlen) == 0:
        signal.pause()
    msglen = struct.unpack('@I', rawlen)[0]
    msg = stdin.read(msglen)

    stdout.write(struct.pack('@I', msglen))
    stdout.write(msg)
`;

  let scriptPath = PathUtils.join(userDir.path, TYPE_SLUG, "wontdie.py");
  let manifestPath = PathUtils.join(userDir.path, TYPE_SLUG, "wontdie.json");

  const ID = "native@tests.mozilla.org";
  let manifest = {
    name: "wontdie",
    description: "test async shutdown of native apps",
    type: "stdio",
    allowed_extensions: [ID],
  };

  if (AppConstants.platform == "win") {
    await IOUtils.writeUTF8(scriptPath, SCRIPT);

    let batPath = PathUtils.join(userDir.path, TYPE_SLUG, "wontdie.bat");
    let batBody = `@ECHO OFF\n"${PYTHON}" -u "${scriptPath}" %*\n`;
    await IOUtils.writeUTF8(batPath, batBody);
    await IOUtils.setPermissions(batPath, 0o755);

    manifest.path = batPath;
    await writeManifest(manifestPath, manifest);

    registry.setValue(
      Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
      `${REGPATH}\\wontdie`,
      "",
      manifestPath
    );
  } else {
    await IOUtils.writeUTF8(scriptPath, `#!${PYTHON} -u\n${SCRIPT}`);
    await IOUtils.setPermissions(scriptPath, 0o755);
    manifest.path = scriptPath;
    await writeManifest(manifestPath, manifest);
  }

  let mockContext = new MockContext(ID);
  let app = new NativeApp(mockContext, "wontdie");

  // send a message and wait for the reply to make sure the app is running
  let MSG = "test";
  let recvPromise = new Promise(resolve => {
    let listener = (what, msg) => {
      equal(msg, MSG, "Received test message");
      app.off("message", listener);
      resolve();
    };
    app.on("message", listener);
  });

  let buffer = NativeApp.encodeMessage(mockContext, MSG);
  app.send(new StructuredCloneHolder("", null, buffer));
  await recvPromise;

  app._cleanup();

  info("waiting for async shutdown");
  Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true);
  AsyncShutdown.profileBeforeChange._trigger();
  Services.prefs.clearUserPref("toolkit.asyncshutdown.testing");

  let procs = await SubprocessImpl.Process.getWorker().call("getProcesses", []);
  equal(procs.size, 0, "native process exited");
});
