diff options
Diffstat (limited to 'toolkit/components/extensions/NativeMessaging.sys.mjs')
-rw-r--r-- | toolkit/components/extensions/NativeMessaging.sys.mjs | 396 |
1 files changed, 396 insertions, 0 deletions
diff --git a/toolkit/components/extensions/NativeMessaging.sys.mjs b/toolkit/components/extensions/NativeMessaging.sys.mjs new file mode 100644 index 0000000000..20924f169d --- /dev/null +++ b/toolkit/components/extensions/NativeMessaging.sys.mjs @@ -0,0 +1,396 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + NativeManifests: "resource://gre/modules/NativeManifests.sys.mjs", + Subprocess: "resource://gre/modules/Subprocess.sys.mjs", +}); + +const { ExtensionError, promiseTimeout } = ExtensionUtils; + +// For a graceful shutdown (i.e., when the extension is unloaded or when it +// explicitly calls disconnect() on a native port), how long we give the native +// application to exit before we start trying to kill it. (in milliseconds) +const GRACEFUL_SHUTDOWN_TIME = 3000; + +// Hard limits on maximum message size that can be read/written +// These are defined in the native messaging documentation, note that +// the write limit is imposed by the "wire protocol" in which message +// boundaries are defined by preceding each message with its length as +// 4-byte unsigned integer so this is the largest value that can be +// represented. Good luck generating a serialized message that large, +// the practical write limit is likely to be dictated by available memory. +const MAX_READ = 1024 * 1024; +const MAX_WRITE = 0xffffffff; + +// Preferences that can lower the message size limits above, +// used for testing the limits. +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = + "webextensions.native-messaging.max-output-message-bytes"; + +export var NativeApp = class extends EventEmitter { + /** + * @param {BaseContext} context The context that initiated the native app. + * @param {string} application The identifier of the native app. + */ + constructor(context, application) { + super(); + + this.context = context; + this.name = application; + + // We want a close() notification when the window is destroyed. + this.context.callOnClose(this); + + this.proc = null; + this.readPromise = null; + this.sendQueue = []; + this.writePromise = null; + this.cleanupStarted = false; + + this.startupPromise = lazy.NativeManifests.lookupManifest( + "stdio", + application, + context + ) + .then(hostInfo => { + // Report a generic error to not leak information about whether a native + // application is installed to addons that do not have the right permission. + if (!hostInfo) { + throw new ExtensionError(`No such native application ${application}`); + } + + let command = hostInfo.manifest.path; + if (AppConstants.platform == "win") { + // Normalize in case the extension used / instead of \. + command = command.replaceAll("/", "\\"); + + if (!PathUtils.isAbsolute(command)) { + // Note: hostInfo.path is an absolute path to the manifest. + const parentPath = PathUtils.parent( + hostInfo.path.replaceAll("/", "\\") + ); + // PathUtils.joinRelative cannot be used because it throws for "..". + // but command is allowed to contain ".." to traverse the directory. + command = `${parentPath}\\${command}`; + } + } else if (!PathUtils.isAbsolute(command)) { + // Only windows supports relative paths. + throw new Error( + "NativeApp requires absolute path to command on this platform" + ); + } + + let subprocessOpts = { + command: command, + arguments: [hostInfo.path, context.extension.id], + workdir: PathUtils.parent(command), + stderr: "pipe", + disclaim: true, + }; + + return lazy.Subprocess.call(subprocessOpts); + }) + .then(proc => { + this.startupPromise = null; + this.proc = proc; + this._startRead(); + this._startWrite(); + this._startStderrRead(); + }) + .catch(err => { + this.startupPromise = null; + Cu.reportError(err instanceof Error ? err : err.message); + this._cleanup(err); + }); + } + + /** + * Open a connection to a native messaging host. + * + * @param {number} portId A unique internal ID that identifies the port. + * @param {NativeMessenger} port Parent NativeMessenger used to send messages. + * @returns {ParentPort} + */ + onConnect(portId, port) { + // eslint-disable-next-line + this.on("message", (_, message) => { + port.sendPortMessage( + portId, + new StructuredCloneHolder( + `NativeMessaging/onConnect/${this.name}`, + null, + message + ) + ); + }); + this.once("disconnect", (_, error) => { + port.sendPortDisconnect(portId, error && new ClonedErrorHolder(error)); + }); + return { + onPortMessage: holder => this.send(holder), + onPortDisconnect: () => this.close(), + }; + } + + /** + * @param {BaseContext} context The scope from where `message` originates. + * @param {*} message A message from the extension, meant for a native app. + * @returns {ArrayBuffer} An ArrayBuffer that can be sent to the native app. + */ + static encodeMessage(context, message) { + message = context.jsonStringify(message); + let buffer = new TextEncoder().encode(message).buffer; + if (buffer.byteLength > NativeApp.maxWrite) { + throw new context.Error("Write too big"); + } + return buffer; + } + + // A port is definitely "alive" if this.proc is non-null. But we have + // to provide a live port object immediately when connecting so we also + // need to consider a port alive if proc is null but the startupPromise + // is still pending. + get _isDisconnected() { + return !this.proc && !this.startupPromise; + } + + _startRead() { + if (this.readPromise) { + throw new Error("Entered _startRead() while readPromise is non-null"); + } + this.readPromise = this.proc.stdout + .readUint32() + .then(len => { + if (len > NativeApp.maxRead) { + throw new ExtensionError( + `Native application tried to send a message of ${len} bytes, which exceeds the limit of ${NativeApp.maxRead} bytes.` + ); + } + return this.proc.stdout.readJSON(len); + }) + .then(msg => { + this.emit("message", msg); + this.readPromise = null; + this._startRead(); + }) + .catch(err => { + if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) { + Cu.reportError(err instanceof Error ? err : err.message); + } + this._cleanup(err); + }); + } + + _startWrite() { + if (!this.sendQueue.length) { + return; + } + + if (this.writePromise) { + throw new Error("Entered _startWrite() while writePromise is non-null"); + } + + let buffer = this.sendQueue.shift(); + let uintArray = Uint32Array.of(buffer.byteLength); + + this.writePromise = Promise.all([ + this.proc.stdin.write(uintArray.buffer), + this.proc.stdin.write(buffer), + ]) + .then(() => { + this.writePromise = null; + this._startWrite(); + }) + .catch(err => { + Cu.reportError(err.message); + this._cleanup(err); + }); + } + + _startStderrRead() { + let proc = this.proc; + let app = this.name; + (async function () { + let partial = ""; + while (true) { + let data = await proc.stderr.readString(); + if (!data.length) { + // We have hit EOF, just stop reading + if (partial) { + Services.console.logStringMessage( + `stderr output from native app ${app}: ${partial}` + ); + } + break; + } + + let lines = data.split(/\r?\n/); + lines[0] = partial + lines[0]; + partial = lines.pop(); + + for (let line of lines) { + Services.console.logStringMessage( + `stderr output from native app ${app}: ${line}` + ); + } + } + })(); + } + + send(holder) { + if (this._isDisconnected) { + throw new ExtensionError("Attempt to postMessage on disconnected port"); + } + let msg = holder.deserialize(globalThis); + if (Cu.getClassName(msg, true) != "ArrayBuffer") { + // This error cannot be triggered by extensions; it indicates an error in + // our implementation. + throw new Error( + "The message to the native messaging host is not an ArrayBuffer" + ); + } + + let buffer = msg; + + if (buffer.byteLength > NativeApp.maxWrite) { + throw new ExtensionError("Write too big"); + } + + this.sendQueue.push(buffer); + if (!this.startupPromise && !this.writePromise) { + this._startWrite(); + } + } + + // Shut down the native application and (by default) signal to the extension + // that the connect has been disconnected. + async _cleanup(err, fromExtension = false) { + if (this.cleanupStarted) { + return; + } + this.cleanupStarted = true; + this.context.forgetOnClose(this); + + if (!fromExtension) { + if (err && err.errorCode == lazy.Subprocess.ERROR_END_OF_FILE) { + err = null; + } + this.emit("disconnect", err); + } + + await this.startupPromise; + + if (!this.proc) { + // Failed to initialize proc in the constructor. + return; + } + + // To prevent an uncooperative process from blocking shutdown, we take the + // following actions, and wait for GRACEFUL_SHUTDOWN_TIME in between. + // + // 1. Allow exit by closing the stdin pipe. + // 2. Allow exit by a kill signal. + // 3. Allow exit by forced kill signal. + // 4. Give up and unblock shutdown despite the process still being alive. + + // Close the stdin stream and allow the process to exit on its own. + // proc.wait() below will resolve once the process has exited gracefully. + this.proc.stdin.close().catch(err => { + if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) { + Cu.reportError(err); + } + }); + let exitPromise = Promise.race([ + // 1. Allow the process to exit on its own after closing stdin. + this.proc.wait().then(() => { + this.proc = null; + }), + promiseTimeout(GRACEFUL_SHUTDOWN_TIME).then(() => { + if (this.proc) { + // 2. Kill the process gracefully. 3. Force kill after a timeout. + this.proc.kill(GRACEFUL_SHUTDOWN_TIME); + + // 4. If the process is still alive after a kill + timeout followed + // by a forced kill + timeout, give up and just resolve exitPromise. + // + // Note that waiting for just one interval is not enough, because the + // `proc.kill()` is asynchronous, so we need to wait a bit after the + // kill signal has been sent. + return promiseTimeout(2 * GRACEFUL_SHUTDOWN_TIME); + } + }), + ]); + + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + `Native Messaging: Wait for application ${this.name} to exit`, + exitPromise + ); + } + + // Called when the Context or Port is closed. + close() { + this._cleanup(null, true); + } + + sendMessage(holder) { + let responsePromise = new Promise((resolve, reject) => { + this.once("message", (what, msg) => { + resolve(msg); + }); + this.once("disconnect", (what, err) => { + reject(err); + }); + }); + + let result = this.startupPromise.then(() => { + // Skip .send() if _cleanup() has been called already; + // otherwise the error passed to _cleanup/"disconnect" would be hidden by the + // "Attempt to postMessage on disconnected port" error from this.send(). + if (!this.cleanupStarted) { + this.send(holder); + } + return responsePromise; + }); + + result.then( + () => { + this._cleanup(); + }, + () => { + // Prevent the response promise from being reported as an + // unchecked rejection if the startup promise fails. + responsePromise.catch(() => {}); + + this._cleanup(); + } + ); + + return result; + } +}; + +XPCOMUtils.defineLazyPreferenceGetter( + NativeApp, + "maxRead", + PREF_MAX_READ, + MAX_READ +); +XPCOMUtils.defineLazyPreferenceGetter( + NativeApp, + "maxWrite", + PREF_MAX_WRITE, + MAX_WRITE +); |