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
5/**
6 * Provides a class to import login-related data CSV files.
7 */
8
9"use strict";
10
11const EXPORTED_SYMBOLS = [
12  "LoginCSVImport",
13  "ImportFailedException",
14  "ImportFailedErrorType",
15];
16
17const { XPCOMUtils } = ChromeUtils.import(
18  "resource://gre/modules/XPCOMUtils.jsm"
19);
20
21XPCOMUtils.defineLazyModuleGetters(this, {
22  LoginHelper: "resource://gre/modules/LoginHelper.jsm",
23  OS: "resource://gre/modules/osfile.jsm",
24  ResponsivenessMonitor: "resource://gre/modules/ResponsivenessMonitor.jsm",
25  Services: "resource://gre/modules/Services.jsm",
26});
27
28XPCOMUtils.defineLazyGetter(this, "d3", () => {
29  let d3Scope = Cu.Sandbox(null);
30  Services.scriptloader.loadSubScript(
31    "chrome://global/content/third_party/d3/d3.js",
32    d3Scope
33  );
34  return Cu.waiveXrays(d3Scope.d3);
35});
36
37/**
38 * All the CSV column names will be converted to lower case before lookup
39 * so they must be specified here in lower case.
40 */
41const FIELD_TO_CSV_COLUMNS = {
42  origin: ["url", "login_uri"],
43  username: ["username", "login_username"],
44  password: ["password", "login_password"],
45  httpRealm: ["httprealm"],
46  formActionOrigin: ["formactionorigin"],
47  guid: ["guid"],
48  timeCreated: ["timecreated"],
49  timeLastUsed: ["timelastused"],
50  timePasswordChanged: ["timepasswordchanged"],
51};
52
53const ImportFailedErrorType = Object.freeze({
54  CONFLICTING_VALUES_ERROR: "CONFLICTING_VALUES_ERROR",
55  FILE_FORMAT_ERROR: "FILE_FORMAT_ERROR",
56  FILE_PERMISSIONS_ERROR: "FILE_PERMISSIONS_ERROR",
57  UNABLE_TO_READ_ERROR: "UNABLE_TO_READ_ERROR",
58});
59
60class ImportFailedException extends Error {
61  constructor(errorType, message) {
62    super(message != null ? message : errorType);
63    this.errorType = errorType;
64  }
65}
66
67/**
68 * Provides an object that has a method to import login-related data CSV files
69 */
70class LoginCSVImport {
71  /**
72   * Returns a map that has the csv column name as key and the value the field name.
73   *
74   * @returns {Map} A map that has the csv column name as key and the value the field name.
75   */
76  static _getCSVColumnToFieldMap() {
77    let csvColumnToField = new Map();
78    for (let [field, columns] of Object.entries(FIELD_TO_CSV_COLUMNS)) {
79      for (let column of columns) {
80        csvColumnToField.set(column.toLowerCase(), field);
81      }
82    }
83    return csvColumnToField;
84  }
85
86  /**
87   * Builds a vanilla JS object containing all the login fields from a row of CSV cells.
88   *
89   * @param {object} csvObject
90   *        An object created from a csv row. The keys are the csv column names, the values are the cells.
91   * @param {Map} csvColumnToFieldMap
92   *        A map where the keys are the csv properties and the values are the object keys.
93   * @returns {object} Representing login object with only properties, not functions.
94   */
95  static _getVanillaLoginFromCSVObject(csvObject, csvColumnToFieldMap) {
96    let vanillaLogin = Object.create(null);
97    for (let columnName of Object.keys(csvObject)) {
98      let fieldName = csvColumnToFieldMap.get(columnName.toLowerCase());
99      if (!fieldName) {
100        continue;
101      }
102
103      if (
104        typeof vanillaLogin[fieldName] != "undefined" &&
105        vanillaLogin[fieldName] !== csvObject[columnName]
106      ) {
107        // Differing column values map to one property.
108        // e.g. if two headings map to `origin` we won't know which to use.
109        return {};
110      }
111
112      vanillaLogin[fieldName] = csvObject[columnName];
113    }
114
115    // Since `null` can't be represented in a CSV file and the httpRealm header
116    // cannot be an empty string, assume that an empty httpRealm means this is
117    // a form login and therefore null-out httpRealm.
118    if (vanillaLogin.httpRealm === "") {
119      vanillaLogin.httpRealm = null;
120    }
121
122    return vanillaLogin;
123  }
124  static _recordHistogramTelemetry(histogram, report) {
125    for (let reportRow of report) {
126      let { result } = reportRow;
127      if (result.includes("error")) {
128        histogram.add("error");
129      } else {
130        histogram.add(result);
131      }
132    }
133  }
134  /**
135   * Imports logins from a CSV file (comma-separated values file).
136   * Existing logins may be updated in the process.
137   *
138   * @param {string} filePath
139   * @returns {Object[]} An array of rows where each is mapped to a row in the CSV and it's import information.
140   */
141  static async importFromCSV(filePath) {
142    TelemetryStopwatch.start("PWMGR_IMPORT_LOGINS_FROM_FILE_MS");
143    let responsivenessMonitor = new ResponsivenessMonitor();
144    let csvColumnToFieldMap = LoginCSVImport._getCSVColumnToFieldMap();
145    let csvFieldToColumnMap = new Map();
146    let csvString;
147    try {
148      csvString = await OS.File.read(filePath, { encoding: "utf-8" });
149    } catch (ex) {
150      TelemetryStopwatch.cancel("PWMGR_IMPORT_LOGINS_FROM_FILE_MS");
151      Cu.reportError(ex);
152      throw new ImportFailedException(
153        ImportFailedErrorType.FILE_PERMISSIONS_ERROR
154      );
155    }
156    let parsedLines;
157    let headerLine;
158    if (filePath.endsWith(".csv")) {
159      headerLine = d3.csv.parseRows(csvString)[0];
160      parsedLines = d3.csv.parse(csvString);
161    } else if (filePath.endsWith(".tsv")) {
162      headerLine = d3.tsv.parseRows(csvString)[0];
163      parsedLines = d3.tsv.parse(csvString);
164    }
165
166    if (parsedLines && headerLine) {
167      for (const columnName of headerLine) {
168        const fieldName = csvColumnToFieldMap.get(
169          columnName.toLocaleLowerCase()
170        );
171        if (fieldName) {
172          if (!csvFieldToColumnMap.has(fieldName)) {
173            csvFieldToColumnMap.set(fieldName, columnName);
174          } else {
175            TelemetryStopwatch.cancel("PWMGR_IMPORT_LOGINS_FROM_FILE_MS");
176            throw new ImportFailedException(
177              ImportFailedErrorType.CONFLICTING_VALUES_ERROR
178            );
179          }
180        }
181      }
182    }
183    if (csvFieldToColumnMap.size === 0) {
184      TelemetryStopwatch.cancel("PWMGR_IMPORT_LOGINS_FROM_FILE_MS");
185      throw new ImportFailedException(ImportFailedErrorType.FILE_FORMAT_ERROR);
186    }
187    if (
188      parsedLines[0] &&
189      (!csvFieldToColumnMap.has("origin") ||
190        !csvFieldToColumnMap.has("username") ||
191        !csvFieldToColumnMap.has("password"))
192    ) {
193      // The username *value* can be empty but we require a username column to
194      // ensure that we don't import logins without their usernames due to the
195      // username column not being recognized.
196      TelemetryStopwatch.cancel("PWMGR_IMPORT_LOGINS_FROM_FILE_MS");
197      throw new ImportFailedException(ImportFailedErrorType.FILE_FORMAT_ERROR);
198    }
199
200    let loginsToImport = parsedLines.map(csvObject => {
201      return LoginCSVImport._getVanillaLoginFromCSVObject(
202        csvObject,
203        csvColumnToFieldMap
204      );
205    });
206
207    let report = await LoginHelper.maybeImportLogins(loginsToImport);
208
209    for (const reportRow of report) {
210      if (reportRow.result === "error_missing_field") {
211        reportRow.field_name = csvFieldToColumnMap.get(reportRow.field_name);
212      }
213    }
214
215    // Record quantity, jank, and duration telemetry.
216    try {
217      let histogram = Services.telemetry.getHistogramById(
218        "PWMGR_IMPORT_LOGINS_FROM_FILE_CATEGORICAL"
219      );
220      this._recordHistogramTelemetry(histogram, report);
221      let accumulatedDelay = responsivenessMonitor.finish();
222      Services.telemetry
223        .getHistogramById("PWMGR_IMPORT_LOGINS_FROM_FILE_JANK_MS")
224        .add(accumulatedDelay);
225      TelemetryStopwatch.finish("PWMGR_IMPORT_LOGINS_FROM_FILE_MS");
226    } catch (ex) {
227      Cu.reportError(ex);
228    }
229    LoginCSVImport.lastImportReport = report;
230    return report;
231  }
232}
233