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