1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4"use strict";
5
6var EXPORTED_SYMBOLS = ["ChromeMigrationUtils"];
7
8const { XPCOMUtils } = ChromeUtils.import(
9  "resource://gre/modules/XPCOMUtils.jsm"
10);
11XPCOMUtils.defineLazyModuleGetters(this, {
12  AppConstants: "resource://gre/modules/AppConstants.jsm",
13  LoginHelper: "resource://gre/modules/LoginHelper.jsm",
14  MigrationUtils: "resource:///modules/MigrationUtils.jsm",
15  OS: "resource://gre/modules/osfile.jsm",
16  Services: "resource://gre/modules/Services.jsm",
17});
18
19const S100NS_FROM1601TO1970 = 0x19db1ded53e8000;
20const S100NS_PER_MS = 10;
21
22var ChromeMigrationUtils = {
23  // Supported browsers with importable logins.
24  CONTEXTUAL_LOGIN_IMPORT_BROWSERS: ["chrome", "chromium-edge", "chromium"],
25
26  _extensionVersionDirectoryNames: {},
27
28  // The cache for the locale strings.
29  // For example, the data could be:
30  // {
31  //   "profile-id-1": {
32  //     "extension-id-1": {
33  //       "name": {
34  //         "message": "Fake App 1"
35  //       }
36  //   },
37  // }
38  _extensionLocaleStrings: {},
39
40  get supportsLoginsForPlatform() {
41    return ["macosx", "win"].includes(AppConstants.platform);
42  },
43
44  /**
45   * Get all extensions installed in a specific profile.
46   * @param {String} profileId - A Chrome user profile ID. For example, "Profile 1".
47   * @returns {Array} All installed Chrome extensions information.
48   */
49  async getExtensionList(profileId) {
50    if (profileId === undefined) {
51      profileId = await this.getLastUsedProfileId();
52    }
53    let path = this.getExtensionPath(profileId);
54    let iterator = new OS.File.DirectoryIterator(path);
55    let extensionList = [];
56    await iterator
57      .forEach(async entry => {
58        if (entry.isDir) {
59          let extensionInformation = await this.getExtensionInformation(
60            entry.name,
61            profileId
62          );
63          if (extensionInformation) {
64            extensionList.push(extensionInformation);
65          }
66        }
67      })
68      .catch(ex => Cu.reportError(ex));
69    return extensionList;
70  },
71
72  /**
73   * Get information of a specific Chrome extension.
74   * @param {String} extensionId - The extension ID.
75   * @param {String} profileId - The user profile's ID.
76   * @retruns {Object} The Chrome extension information.
77   */
78  async getExtensionInformation(extensionId, profileId) {
79    if (profileId === undefined) {
80      profileId = await this.getLastUsedProfileId();
81    }
82    let extensionInformation = null;
83    try {
84      let manifestPath = this.getExtensionPath(profileId);
85      manifestPath = OS.Path.join(manifestPath, extensionId);
86      // If there are multiple sub-directories in the extension directory,
87      // read the files in the latest directory.
88      let directories = await this._getSortedByVersionSubDirectoryNames(
89        manifestPath
90      );
91      if (!directories[0]) {
92        return null;
93      }
94
95      manifestPath = OS.Path.join(
96        manifestPath,
97        directories[0],
98        "manifest.json"
99      );
100      let manifest = await OS.File.read(manifestPath, { encoding: "utf-8" });
101      manifest = JSON.parse(manifest);
102      // No app attribute means this is a Chrome extension not a Chrome app.
103      if (!manifest.app) {
104        const DEFAULT_LOCALE = manifest.default_locale;
105        let name = await this._getLocaleString(
106          manifest.name,
107          DEFAULT_LOCALE,
108          extensionId,
109          profileId
110        );
111        let description = await this._getLocaleString(
112          manifest.description,
113          DEFAULT_LOCALE,
114          extensionId,
115          profileId
116        );
117        if (name) {
118          extensionInformation = {
119            id: extensionId,
120            name,
121            description,
122          };
123        } else {
124          throw new Error("Cannot read the Chrome extension's name property.");
125        }
126      }
127    } catch (ex) {
128      Cu.reportError(ex);
129    }
130    return extensionInformation;
131  },
132
133  /**
134   * Get the manifest's locale string.
135   * @param {String} key - The key of a locale string, for example __MSG_name__.
136   * @param {String} locale - The specific language of locale string.
137   * @param {String} extensionId - The extension ID.
138   * @param {String} profileId - The user profile's ID.
139   * @retruns {String} The locale string.
140   */
141  async _getLocaleString(key, locale, extensionId, profileId) {
142    // Return the key string if it is not a locale key.
143    // The key string starts with "__MSG_" and ends with "__".
144    // For example, "__MSG_name__".
145    // https://developer.chrome.com/apps/i18n
146    if (!key.startsWith("__MSG_") || !key.endsWith("__")) {
147      return key;
148    }
149
150    let localeString = null;
151    try {
152      let localeFile;
153      if (
154        this._extensionLocaleStrings[profileId] &&
155        this._extensionLocaleStrings[profileId][extensionId]
156      ) {
157        localeFile = this._extensionLocaleStrings[profileId][extensionId];
158      } else {
159        if (!this._extensionLocaleStrings[profileId]) {
160          this._extensionLocaleStrings[profileId] = {};
161        }
162        let localeFilePath = this.getExtensionPath(profileId);
163        localeFilePath = OS.Path.join(localeFilePath, extensionId);
164        let directories = await this._getSortedByVersionSubDirectoryNames(
165          localeFilePath
166        );
167        // If there are multiple sub-directories in the extension directory,
168        // read the files in the latest directory.
169        localeFilePath = OS.Path.join(
170          localeFilePath,
171          directories[0],
172          "_locales",
173          locale,
174          "messages.json"
175        );
176        localeFile = await OS.File.read(localeFilePath, { encoding: "utf-8" });
177        localeFile = JSON.parse(localeFile);
178        this._extensionLocaleStrings[profileId][extensionId] = localeFile;
179      }
180      const PREFIX_LENGTH = 6;
181      const SUFFIX_LENGTH = 2;
182      // Get the locale key from the string with locale prefix and suffix.
183      // For example, it will get the "name" sub-string from the "__MSG_name__" string.
184      key = key.substring(PREFIX_LENGTH, key.length - SUFFIX_LENGTH);
185      if (localeFile[key] && localeFile[key].message) {
186        localeString = localeFile[key].message;
187      }
188    } catch (ex) {
189      Cu.reportError(ex);
190    }
191    return localeString;
192  },
193
194  /**
195   * Check that a specific extension is installed or not.
196   * @param {String} extensionId - The extension ID.
197   * @param {String} profileId - The user profile's ID.
198   * @returns {Boolean} Return true if the extension is installed otherwise return false.
199   */
200  async isExtensionInstalled(extensionId, profileId) {
201    if (profileId === undefined) {
202      profileId = await this.getLastUsedProfileId();
203    }
204    let extensionPath = this.getExtensionPath(profileId);
205    let isInstalled = await OS.File.exists(
206      OS.Path.join(extensionPath, extensionId)
207    );
208    return isInstalled;
209  },
210
211  /**
212   * Get the last used user profile's ID.
213   * @returns {String} The last used user profile's ID.
214   */
215  async getLastUsedProfileId() {
216    let localState = await this.getLocalState();
217    return localState ? localState.profile.last_used : "Default";
218  },
219
220  /**
221   * Get the local state file content.
222   * @param {String} dataPath the type of Chrome data we're looking for (Chromium, Canary, etc.)
223   * @returns {Object} The JSON-based content.
224   */
225  async getLocalState(dataPath = "Chrome") {
226    let localState = null;
227    try {
228      let localStatePath = PathUtils.join(
229        this.getDataPath(dataPath),
230        "Local State"
231      );
232      localState = JSON.parse(await IOUtils.readUTF8(localStatePath));
233    } catch (ex) {
234      // Don't report the error if it's just a file not existing.
235      if (ex.name != "NotFoundError") {
236        Cu.reportError(ex);
237      }
238      throw ex;
239    }
240    return localState;
241  },
242
243  /**
244   * Get the path of Chrome extension directory.
245   * @param {String} profileId - The user profile's ID.
246   * @returns {String} The path of Chrome extension directory.
247   */
248  getExtensionPath(profileId) {
249    return PathUtils.join(this.getDataPath(), profileId, "Extensions");
250  },
251
252  /**
253   * Get the path of an application data directory.
254   * @param {String} chromeProjectName - The Chrome project name, e.g. "Chrome", "Canary", etc.
255   *                                     Defaults to "Chrome".
256   * @returns {String} The path of application data directory.
257   */
258  getDataPath(chromeProjectName = "Chrome") {
259    const SUB_DIRECTORIES = {
260      win: {
261        Brave: ["BraveSoftware", "Brave-Browser"],
262        Chrome: ["Google", "Chrome"],
263        "Chrome Beta": ["Google", "Chrome Beta"],
264        Chromium: ["Chromium"],
265        Canary: ["Google", "Chrome SxS"],
266        Edge: ["Microsoft", "Edge"],
267        "Edge Beta": ["Microsoft", "Edge Beta"],
268        "360 SE": ["360se6"],
269      },
270      macosx: {
271        Brave: ["BraveSoftware", "Brave-Browser"],
272        Chrome: ["Google", "Chrome"],
273        Chromium: ["Chromium"],
274        Canary: ["Google", "Chrome Canary"],
275        Edge: ["Microsoft Edge"],
276        "Edge Beta": ["Microsoft Edge Beta"],
277      },
278      linux: {
279        Brave: ["BraveSoftware", "Brave-Browser"],
280        Chrome: ["google-chrome"],
281        "Chrome Beta": ["google-chrome-beta"],
282        "Chrome Dev": ["google-chrome-unstable"],
283        Chromium: ["chromium"],
284        // Canary is not available on Linux.
285        // Edge is not available on Linux.
286      },
287    };
288    let subfolders = SUB_DIRECTORIES[AppConstants.platform][chromeProjectName];
289    if (!subfolders) {
290      return null;
291    }
292
293    let rootDir;
294    if (AppConstants.platform == "win") {
295      rootDir = chromeProjectName === "360 SE" ? "AppData" : "LocalAppData";
296      subfolders = subfolders.concat(["User Data"]);
297    } else if (AppConstants.platform == "macosx") {
298      rootDir = "ULibDir";
299      subfolders = ["Application Support"].concat(subfolders);
300    } else {
301      rootDir = "Home";
302      subfolders = [".config"].concat(subfolders);
303    }
304    try {
305      let target = Services.dirsvc.get(rootDir, Ci.nsIFile);
306      for (let subfolder of subfolders) {
307        target.append(subfolder);
308      }
309      return target.path;
310    } catch (ex) {
311      // The path logic here shouldn't error, so log it:
312      Cu.reportError(ex);
313    }
314    return null;
315  },
316
317  /**
318   * Get the directory objects sorted by version number.
319   * @param {String} path - The path to the extension directory.
320   * otherwise return all file/directory object.
321   * @returns {Array} The file/directory object array.
322   */
323  async _getSortedByVersionSubDirectoryNames(path) {
324    if (this._extensionVersionDirectoryNames[path]) {
325      return this._extensionVersionDirectoryNames[path];
326    }
327
328    let iterator = new OS.File.DirectoryIterator(path);
329    let entries = [];
330    await iterator
331      .forEach(async entry => {
332        if (entry.isDir) {
333          entries.push(entry.name);
334        }
335      })
336      .catch(ex => {
337        Cu.reportError(ex);
338        entries = [];
339      });
340    // The directory name is the version number string of the extension.
341    // For example, it could be "1.0_0", "1.0_1", "1.0_2", 1.1_0, 1.1_1, or 1.1_2.
342    // The "1.0_1" strings mean that the "1.0_0" directory is existed and you install the version 1.0 again.
343    // https://chromium.googlesource.com/chromium/src/+/0b58a813992b539a6b555ad7959adfad744b095a/chrome/common/extensions/extension_file_util_unittest.cc
344    entries.sort((a, b) => Services.vc.compare(b, a));
345
346    this._extensionVersionDirectoryNames[path] = entries;
347    return entries;
348  },
349
350  /**
351   * Convert Chrome time format to Date object
352   *
353   * @param   aTime
354   *          Chrome time
355   * @param   aFallbackValue
356   *          a date or timestamp (valid argument for the Date constructor)
357   *          that will be used if the chrometime value passed is invalid.
358   * @return  converted Date object
359   * @note    Google Chrome uses FILETIME / 10 as time.
360   *          FILETIME is based on same structure of Windows.
361   */
362  chromeTimeToDate(aTime, aFallbackValue) {
363    // The date value may be 0 in some cases. Because of the subtraction below,
364    // that'd generate a date before the unix epoch, which can upset consumers
365    // due to the unix timestamp then being negative. Catch this case:
366    if (!aTime) {
367      return new Date(aFallbackValue);
368    }
369    return new Date((aTime * S100NS_PER_MS - S100NS_FROM1601TO1970) / 10000);
370  },
371
372  /**
373   * Convert Date object to Chrome time format
374   *
375   * @param   aDate
376   *          Date object or integer equivalent
377   * @return  Chrome time
378   * @note    For details on Chrome time, see chromeTimeToDate.
379   */
380  dateToChromeTime(aDate) {
381    return (aDate * 10000 + S100NS_FROM1601TO1970) / S100NS_PER_MS;
382  },
383
384  /**
385   * Returns an array of chromium browser ids that have importable logins.
386   */
387  _importableLoginsCache: null,
388  async getImportableLogins(formOrigin) {
389    // Only provide importable if we actually support importing.
390    if (!this.supportsLoginsForPlatform) {
391      return undefined;
392    }
393
394    // Lazily fill the cache with all importable login browsers.
395    if (!this._importableLoginsCache) {
396      this._importableLoginsCache = new Map();
397
398      // Just handle these chromium-based browsers for now.
399      for (const browserId of this.CONTEXTUAL_LOGIN_IMPORT_BROWSERS) {
400        // Skip if there's no profile data.
401        const migrator = await MigrationUtils.getMigrator(browserId);
402        if (!migrator) {
403          continue;
404        }
405
406        // Check each profile for logins.
407        const dataPath = await migrator.wrappedJSObject._getChromeUserDataPathIfExists();
408        for (const profile of await migrator.getSourceProfiles()) {
409          const path = OS.Path.join(dataPath, profile.id, "Login Data");
410          // Skip if login data is missing.
411          if (!(await OS.File.exists(path))) {
412            Cu.reportError(`Missing file at ${path}`);
413            continue;
414          }
415
416          try {
417            for (const row of await MigrationUtils.getRowsFromDBWithoutLocks(
418              path,
419              `Importable ${browserId} logins`,
420              `SELECT origin_url
421               FROM logins
422               WHERE blacklisted_by_user = 0`
423            )) {
424              const url = row.getString(0);
425              try {
426                // Initialize an array if it doesn't exist for the origin yet.
427                const origin = LoginHelper.getLoginOrigin(url);
428                const entries = this._importableLoginsCache.get(origin) || [];
429                if (!entries.length) {
430                  this._importableLoginsCache.set(origin, entries);
431                }
432
433                // Add the browser if it doesn't exist yet.
434                if (!entries.includes(browserId)) {
435                  entries.push(browserId);
436                }
437              } catch (ex) {
438                Cu.reportError(
439                  `Failed to process importable url ${url} from ${browserId} ${ex}`
440                );
441              }
442            }
443          } catch (ex) {
444            Cu.reportError(
445              `Failed to get importable logins from ${browserId} ${ex}`
446            );
447          }
448        }
449      }
450    }
451    return this._importableLoginsCache.get(formOrigin);
452  },
453};
454