Bug 1805526 - Refactor extension.startup() permissions setup, r=robwu
[gecko.git] / toolkit / components / extensions / NativeMessaging.jsm
blob7248f20ff38b88976d43de10577e18911a2d5f3e
1 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
2 /* vim: set sts=2 sw=2 et tw=80: */
3 /* This Source Code Form is subject to the terms of the Mozilla Public
4  * License, v. 2.0. If a copy of the MPL was not distributed with this
5  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 "use strict";
8 var EXPORTED_SYMBOLS = ["NativeApp"];
10 const { XPCOMUtils } = ChromeUtils.importESModule(
11   "resource://gre/modules/XPCOMUtils.sys.mjs"
13 const { AppConstants } = ChromeUtils.importESModule(
14   "resource://gre/modules/AppConstants.sys.mjs"
17 const { EventEmitter } = ChromeUtils.importESModule(
18   "resource://gre/modules/EventEmitter.sys.mjs"
21 const {
22   ExtensionUtils: { ExtensionError, promiseTimeout },
23 } = ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
25 const lazy = {};
27 ChromeUtils.defineESModuleGetters(lazy, {
28   AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
29   Subprocess: "resource://gre/modules/Subprocess.sys.mjs",
30 });
32 XPCOMUtils.defineLazyModuleGetters(lazy, {
33   NativeManifests: "resource://gre/modules/NativeManifests.jsm",
34   OS: "resource://gre/modules/osfile.jsm",
35 });
37 // For a graceful shutdown (i.e., when the extension is unloaded or when it
38 // explicitly calls disconnect() on a native port), how long we give the native
39 // application to exit before we start trying to kill it.  (in milliseconds)
40 const GRACEFUL_SHUTDOWN_TIME = 3000;
42 // Hard limits on maximum message size that can be read/written
43 // These are defined in the native messaging documentation, note that
44 // the write limit is imposed by the "wire protocol" in which message
45 // boundaries are defined by preceding each message with its length as
46 // 4-byte unsigned integer so this is the largest value that can be
47 // represented.  Good luck generating a serialized message that large,
48 // the practical write limit is likely to be dictated by available memory.
49 const MAX_READ = 1024 * 1024;
50 const MAX_WRITE = 0xffffffff;
52 // Preferences that can lower the message size limits above,
53 // used for testing the limits.
54 const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes";
55 const PREF_MAX_WRITE =
56   "webextensions.native-messaging.max-output-message-bytes";
58 var NativeApp = class extends EventEmitter {
59   /**
60    * @param {BaseContext} context The context that initiated the native app.
61    * @param {string} application The identifier of the native app.
62    */
63   constructor(context, application) {
64     super();
66     this.context = context;
67     this.name = application;
69     // We want a close() notification when the window is destroyed.
70     this.context.callOnClose(this);
72     this.proc = null;
73     this.readPromise = null;
74     this.sendQueue = [];
75     this.writePromise = null;
76     this.cleanupStarted = false;
78     this.startupPromise = lazy.NativeManifests.lookupManifest(
79       "stdio",
80       application,
81       context
82     )
83       .then(hostInfo => {
84         // Report a generic error to not leak information about whether a native
85         // application is installed to addons that do not have the right permission.
86         if (!hostInfo) {
87           throw new ExtensionError(`No such native application ${application}`);
88         }
90         let command = hostInfo.manifest.path;
91         if (AppConstants.platform == "win") {
92           // OS.Path.join() ignores anything before the last absolute path
93           // it sees, so if command is already absolute, it remains unchanged
94           // here.  If it is relative, we get the proper absolute path here.
95           command = lazy.OS.Path.join(
96             lazy.OS.Path.dirname(hostInfo.path),
97             command
98           );
99           // Normalize in case the extension used / instead of \.
100           command = command.replaceAll("/", "\\");
101         }
103         let subprocessOpts = {
104           command: command,
105           arguments: [hostInfo.path, context.extension.id],
106           workdir: lazy.OS.Path.dirname(command),
107           stderr: "pipe",
108           disclaim: true,
109         };
111         return lazy.Subprocess.call(subprocessOpts);
112       })
113       .then(proc => {
114         this.startupPromise = null;
115         this.proc = proc;
116         this._startRead();
117         this._startWrite();
118         this._startStderrRead();
119       })
120       .catch(err => {
121         this.startupPromise = null;
122         Cu.reportError(err instanceof Error ? err : err.message);
123         this._cleanup(err);
124       });
125   }
127   /**
128    * Open a connection to a native messaging host.
129    *
130    * @param {number} portId A unique internal ID that identifies the port.
131    * @param {NativeMessenger} port Parent NativeMessenger used to send messages.
132    * @returns {ParentPort}
133    */
134   onConnect(portId, port) {
135     // eslint-disable-next-line
136     this.on("message", (_, message) => {
137       port.sendPortMessage(portId, new StructuredCloneHolder(message));
138     });
139     this.once("disconnect", (_, error) => {
140       port.sendPortDisconnect(portId, error && new ClonedErrorHolder(error));
141     });
142     return {
143       onPortMessage: holder => this.send(holder),
144       onPortDisconnect: () => this.close(),
145     };
146   }
148   /**
149    * @param {BaseContext} context The scope from where `message` originates.
150    * @param {*} message A message from the extension, meant for a native app.
151    * @returns {ArrayBuffer} An ArrayBuffer that can be sent to the native app.
152    */
153   static encodeMessage(context, message) {
154     message = context.jsonStringify(message);
155     let buffer = new TextEncoder().encode(message).buffer;
156     if (buffer.byteLength > NativeApp.maxWrite) {
157       throw new context.Error("Write too big");
158     }
159     return buffer;
160   }
162   // A port is definitely "alive" if this.proc is non-null.  But we have
163   // to provide a live port object immediately when connecting so we also
164   // need to consider a port alive if proc is null but the startupPromise
165   // is still pending.
166   get _isDisconnected() {
167     return !this.proc && !this.startupPromise;
168   }
170   _startRead() {
171     if (this.readPromise) {
172       throw new Error("Entered _startRead() while readPromise is non-null");
173     }
174     this.readPromise = this.proc.stdout
175       .readUint32()
176       .then(len => {
177         if (len > NativeApp.maxRead) {
178           throw new ExtensionError(
179             `Native application tried to send a message of ${len} bytes, which exceeds the limit of ${NativeApp.maxRead} bytes.`
180           );
181         }
182         return this.proc.stdout.readJSON(len);
183       })
184       .then(msg => {
185         this.emit("message", msg);
186         this.readPromise = null;
187         this._startRead();
188       })
189       .catch(err => {
190         if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) {
191           Cu.reportError(err instanceof Error ? err : err.message);
192         }
193         this._cleanup(err);
194       });
195   }
197   _startWrite() {
198     if (!this.sendQueue.length) {
199       return;
200     }
202     if (this.writePromise) {
203       throw new Error("Entered _startWrite() while writePromise is non-null");
204     }
206     let buffer = this.sendQueue.shift();
207     let uintArray = Uint32Array.of(buffer.byteLength);
209     this.writePromise = Promise.all([
210       this.proc.stdin.write(uintArray.buffer),
211       this.proc.stdin.write(buffer),
212     ])
213       .then(() => {
214         this.writePromise = null;
215         this._startWrite();
216       })
217       .catch(err => {
218         Cu.reportError(err.message);
219         this._cleanup(err);
220       });
221   }
223   _startStderrRead() {
224     let proc = this.proc;
225     let app = this.name;
226     (async function() {
227       let partial = "";
228       while (true) {
229         let data = await proc.stderr.readString();
230         if (!data.length) {
231           // We have hit EOF, just stop reading
232           if (partial) {
233             Services.console.logStringMessage(
234               `stderr output from native app ${app}: ${partial}`
235             );
236           }
237           break;
238         }
240         let lines = data.split(/\r?\n/);
241         lines[0] = partial + lines[0];
242         partial = lines.pop();
244         for (let line of lines) {
245           Services.console.logStringMessage(
246             `stderr output from native app ${app}: ${line}`
247           );
248         }
249       }
250     })();
251   }
253   send(holder) {
254     if (this._isDisconnected) {
255       throw new ExtensionError("Attempt to postMessage on disconnected port");
256     }
257     let msg = holder.deserialize(globalThis);
258     if (Cu.getClassName(msg, true) != "ArrayBuffer") {
259       // This error cannot be triggered by extensions; it indicates an error in
260       // our implementation.
261       throw new Error(
262         "The message to the native messaging host is not an ArrayBuffer"
263       );
264     }
266     let buffer = msg;
268     if (buffer.byteLength > NativeApp.maxWrite) {
269       throw new ExtensionError("Write too big");
270     }
272     this.sendQueue.push(buffer);
273     if (!this.startupPromise && !this.writePromise) {
274       this._startWrite();
275     }
276   }
278   // Shut down the native application and (by default) signal to the extension
279   // that the connect has been disconnected.
280   async _cleanup(err, fromExtension = false) {
281     if (this.cleanupStarted) {
282       return;
283     }
284     this.cleanupStarted = true;
285     this.context.forgetOnClose(this);
287     if (!fromExtension) {
288       if (err && err.errorCode == lazy.Subprocess.ERROR_END_OF_FILE) {
289         err = null;
290       }
291       this.emit("disconnect", err);
292     }
294     await this.startupPromise;
296     if (!this.proc) {
297       // Failed to initialize proc in the constructor.
298       return;
299     }
301     // To prevent an uncooperative process from blocking shutdown, we take the
302     // following actions, and wait for GRACEFUL_SHUTDOWN_TIME in between.
303     //
304     // 1. Allow exit by closing the stdin pipe.
305     // 2. Allow exit by a kill signal.
306     // 3. Allow exit by forced kill signal.
307     // 4. Give up and unblock shutdown despite the process still being alive.
309     // Close the stdin stream and allow the process to exit on its own.
310     // proc.wait() below will resolve once the process has exited gracefully.
311     this.proc.stdin.close().catch(err => {
312       if (err.errorCode != lazy.Subprocess.ERROR_END_OF_FILE) {
313         Cu.reportError(err);
314       }
315     });
316     let exitPromise = Promise.race([
317       // 1. Allow the process to exit on its own after closing stdin.
318       this.proc.wait().then(() => {
319         this.proc = null;
320       }),
321       promiseTimeout(GRACEFUL_SHUTDOWN_TIME).then(() => {
322         if (this.proc) {
323           // 2. Kill the process gracefully. 3. Force kill after a timeout.
324           this.proc.kill(GRACEFUL_SHUTDOWN_TIME);
326           // 4. If the process is still alive after a kill + timeout followed
327           // by a forced kill + timeout, give up and just resolve exitPromise.
328           //
329           // Note that waiting for just one interval is not enough, because the
330           // `proc.kill()` is asynchronous, so we need to wait a bit after the
331           // kill signal has been sent.
332           return promiseTimeout(2 * GRACEFUL_SHUTDOWN_TIME);
333         }
334       }),
335     ]);
337     lazy.AsyncShutdown.profileBeforeChange.addBlocker(
338       `Native Messaging: Wait for application ${this.name} to exit`,
339       exitPromise
340     );
341   }
343   // Called when the Context or Port is closed.
344   close() {
345     this._cleanup(null, true);
346   }
348   sendMessage(holder) {
349     let responsePromise = new Promise((resolve, reject) => {
350       this.once("message", (what, msg) => {
351         resolve(msg);
352       });
353       this.once("disconnect", (what, err) => {
354         reject(err);
355       });
356     });
358     let result = this.startupPromise.then(() => {
359       // Skip .send() if _cleanup() has been called already;
360       // otherwise the error passed to _cleanup/"disconnect" would be hidden by the
361       // "Attempt to postMessage on disconnected port" error from this.send().
362       if (!this.cleanupStarted) {
363         this.send(holder);
364       }
365       return responsePromise;
366     });
368     result.then(
369       () => {
370         this._cleanup();
371       },
372       () => {
373         // Prevent the response promise from being reported as an
374         // unchecked rejection if the startup promise fails.
375         responsePromise.catch(() => {});
377         this._cleanup();
378       }
379     );
381     return result;
382   }
385 XPCOMUtils.defineLazyPreferenceGetter(
386   NativeApp,
387   "maxRead",
388   PREF_MAX_READ,
389   MAX_READ
391 XPCOMUtils.defineLazyPreferenceGetter(
392   NativeApp,
393   "maxWrite",
394   PREF_MAX_WRITE,
395   MAX_WRITE