AttributionCode.sys.mjs (12055B)
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 * This is a policy object used to override behavior for testing. 7 */ 8 export const AttributionIOUtils = { 9 write: async (path, bytes) => IOUtils.write(path, bytes), 10 read: async path => IOUtils.read(path), 11 exists: async path => IOUtils.exists(path), 12 }; 13 14 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 15 16 const lazy = {}; 17 ChromeUtils.defineESModuleGetters(lazy, { 18 MacAttribution: 19 "moz-src:///browser/components/attribution/MacAttribution.sys.mjs", 20 }); 21 ChromeUtils.defineLazyGetter(lazy, "log", () => { 22 let { ConsoleAPI } = ChromeUtils.importESModule( 23 "resource://gre/modules/Console.sys.mjs" 24 ); 25 let consoleOptions = { 26 // tip: set maxLogLevel to "debug" and use lazy.log.debug() to create 27 // detailed messages during development. See LOG_LEVELS in Console.sys.mjs 28 // for details. 29 maxLogLevel: "error", 30 maxLogLevelPref: "browser.attribution.loglevel", 31 prefix: "AttributionCode", 32 }; 33 return new ConsoleAPI(consoleOptions); 34 }); 35 36 // This maximum length was originally based on how much space we have in the PE 37 // file header that we store attribution codes in for full and stub installers. 38 // Windows Store builds instead use a "Campaign ID" passed through URLs to send 39 // attribution information, which Microsoft's documentation claims must be no 40 // longer than 100 characters. In our own testing, we've been able to retrieve 41 // the first 208 characters of the Campaign ID. Either way, the "max" length 42 // for Microsoft Store builds is much lower than this limit implies. 43 const ATTR_CODE_MAX_LENGTH = 1010; 44 const ATTR_CODE_VALUE_REGEX = /[a-zA-Z0-9_%\\-\\.\\(\\)]*/; 45 const ATTR_CODE_FIELD_SEPARATOR = "%26"; // URL-encoded & 46 const ATTR_CODE_KEY_VALUE_SEPARATOR = "%3D"; // URL-encoded = 47 const MSCLKID_KEY_PREFIX = "storeBingAd_"; 48 const ATTR_CODE_KEYS = [ 49 "source", 50 "medium", 51 "campaign", 52 "content", 53 "experiment", 54 "variation", 55 "ua", 56 "dltoken", 57 "msstoresignedin", 58 "msclkid", 59 "dlsource", 60 ]; 61 62 let gCachedAttrData = null; 63 64 export var AttributionCode = { 65 /** 66 * Wrapper to pull campaign IDs from MSIX builds. 67 * This function solely exists to make it easy to mock out for tests. 68 */ 69 async msixCampaignId() { 70 const windowsPackageManager = Cc[ 71 "@mozilla.org/windows-package-manager;1" 72 ].createInstance(Ci.nsIWindowsPackageManager); 73 74 return windowsPackageManager.campaignId(); 75 }, 76 77 /** 78 * Returns a platform-specific nsIFile for the file containing the attribution 79 * data, or null if the current platform does not support (caching) 80 * attribution data. 81 */ 82 get attributionFile() { 83 if (AppConstants.platform == "win") { 84 let file = Services.dirsvc.get("GreD", Ci.nsIFile); 85 file.append("postSigningData"); 86 return file; 87 } 88 89 return null; 90 }, 91 92 /** 93 * Write the given attribution code to the attribution file. 94 * 95 * @param {string} code to write. 96 */ 97 async writeAttributionFile(code) { 98 // Writing attribution files is only used as part of test code 99 // so bailing here for MSIX builds is no big deal. 100 if ( 101 AppConstants.platform === "win" && 102 Services.sysinfo.getProperty("hasWinPackageId") 103 ) { 104 Services.console.logStringMessage( 105 "Attribution code cannot be written for MSIX builds, aborting." 106 ); 107 return; 108 } 109 let file = AttributionCode.attributionFile; 110 await IOUtils.makeDirectory(file.parent.path); 111 let bytes = new TextEncoder().encode(code); 112 await AttributionIOUtils.write(file.path, bytes); 113 }, 114 115 /** 116 * Returns an array of allowed attribution code keys. 117 */ 118 get allowedCodeKeys() { 119 return [...ATTR_CODE_KEYS]; 120 }, 121 122 /** 123 * Returns an object containing a key-value pair for each piece of attribution 124 * data included in the passed-in attribution code string. 125 * If the string isn't a valid attribution code, returns an empty object. 126 */ 127 parseAttributionCode(code) { 128 if (code.length > ATTR_CODE_MAX_LENGTH) { 129 return {}; 130 } 131 132 let isValid = true; 133 let parsed = {}; 134 for (let param of code.split(ATTR_CODE_FIELD_SEPARATOR)) { 135 let [key, value] = param.split(ATTR_CODE_KEY_VALUE_SEPARATOR, 2); 136 if (key && ATTR_CODE_KEYS.includes(key)) { 137 if (value && ATTR_CODE_VALUE_REGEX.test(value)) { 138 if (key === "msstoresignedin") { 139 if (value === "true") { 140 parsed[key] = true; 141 } else if (value === "false") { 142 parsed[key] = false; 143 } else { 144 throw new Error("Couldn't parse msstoresignedin"); 145 } 146 } else { 147 parsed[key] = value; 148 } 149 } 150 } else if (param.startsWith(MSCLKID_KEY_PREFIX)) { 151 // Microsoft Store Ads uses `_` to separate the key and value, therefore 152 // requires special handling. 153 parsed.msclkid = param.substring(MSCLKID_KEY_PREFIX.length); 154 } else { 155 lazy.log.debug( 156 `parseAttributionCode: "${code}" => isValid = false: "${key}", "${value}"` 157 ); 158 isValid = false; 159 break; 160 } 161 } 162 163 if (isValid) { 164 return parsed; 165 } 166 167 Glean.browser.attributionErrors.decode_error.add(1); 168 169 return {}; 170 }, 171 172 /** 173 * Returns a string serializing the given attribution data. 174 * 175 * It is expected that the given values are already URL-encoded. 176 */ 177 serializeAttributionData(data) { 178 // Iterating in this way makes the order deterministic. 179 let s = ""; 180 for (let key of ATTR_CODE_KEYS) { 181 if (key in data) { 182 let value = data[key]; 183 if (s) { 184 s += ATTR_CODE_FIELD_SEPARATOR; // URL-encoded & 185 } 186 s += `${key}${ATTR_CODE_KEY_VALUE_SEPARATOR}${value}`; // URL-encoded = 187 } 188 } 189 return s; 190 }, 191 192 async _getMacAttrDataAsync() { 193 // On macOS, we fish the attribution data from an extended attribute on 194 // the .app bundle directory. 195 try { 196 let attrStr = await lazy.MacAttribution.getAttributionString(); 197 lazy.log.debug( 198 `_getMacAttrDataAsync: getAttributionString: "${attrStr}"` 199 ); 200 201 if (attrStr === null) { 202 gCachedAttrData = {}; 203 204 lazy.log.debug(`_getMacAttrDataAsync: null attribution string`); 205 Glean.browser.attributionErrors.null_error.add(1); 206 } else if (attrStr == "") { 207 gCachedAttrData = {}; 208 209 lazy.log.debug(`_getMacAttrDataAsync: empty attribution string`); 210 Glean.browser.attributionErrors.empty_error.add(1); 211 } else { 212 gCachedAttrData = this.parseAttributionCode(attrStr); 213 } 214 } catch (ex) { 215 // Avoid partial attribution data. 216 gCachedAttrData = {}; 217 218 // No attributions. Just `warn` 'cuz this isn't necessarily an error. 219 lazy.log.warn("Caught exception fetching macOS attribution codes!", ex); 220 221 if ( 222 ex instanceof Ci.nsIException && 223 ex.result == Cr.NS_ERROR_UNEXPECTED 224 ) { 225 // Bad quarantine data. 226 Glean.browser.attributionErrors.quarantine_error.add(1); 227 } 228 } 229 230 lazy.log.debug( 231 `macOS attribution data is ${JSON.stringify(gCachedAttrData)}` 232 ); 233 234 return gCachedAttrData; 235 }, 236 237 async _getWindowsNSISAttrDataAsync() { 238 return AttributionIOUtils.read(this.attributionFile.path); 239 }, 240 241 async _getWindowsMSIXAttrDataAsync() { 242 // This comes out of windows-package-manager _not_ URL encoded or in an ArrayBuffer, 243 // but the parsing code wants it that way. It's easier to just provide that 244 // than have the parsing code support both. 245 lazy.log.debug( 246 `winPackageFamilyName is: ${Services.sysinfo.getProperty( 247 "winPackageFamilyName" 248 )}` 249 ); 250 let encoder = new TextEncoder(); 251 return encoder.encode(encodeURIComponent(await this.msixCampaignId())); 252 }, 253 254 /** 255 * Reads the attribution code, either from disk or a cached version. 256 * Returns a promise that fulfills with an object containing the parsed 257 * attribution data if the code could be read and is valid, 258 * or an empty object otherwise. 259 * 260 * On windows the attribution service converts utm_* keys, removing "utm_". 261 * On OSX the attributions are set directly on download and retain "utm_". We 262 * strip "utm_" while retrieving the params. 263 */ 264 async getAttrDataAsync() { 265 if (AppConstants.platform != "win" && AppConstants.platform != "macosx") { 266 // This platform doesn't support attribution. 267 return gCachedAttrData; 268 } 269 if (gCachedAttrData != null) { 270 lazy.log.debug( 271 `getAttrDataAsync: attribution is cached: ${JSON.stringify( 272 gCachedAttrData 273 )}` 274 ); 275 return gCachedAttrData; 276 } 277 278 gCachedAttrData = {}; 279 280 if (AppConstants.platform == "macosx") { 281 lazy.log.debug(`getAttrDataAsync: macOS`); 282 return this._getMacAttrDataAsync(); 283 } 284 285 lazy.log.debug("getAttrDataAsync: !macOS"); 286 287 let attributionFile = this.attributionFile; 288 let bytes; 289 try { 290 if ( 291 AppConstants.platform === "win" && 292 Services.sysinfo.getProperty("hasWinPackageId") 293 ) { 294 lazy.log.debug("getAttrDataAsync: MSIX"); 295 bytes = await this._getWindowsMSIXAttrDataAsync(); 296 } else { 297 lazy.log.debug("getAttrDataAsync: NSIS"); 298 bytes = await this._getWindowsNSISAttrDataAsync(); 299 } 300 } catch (ex) { 301 if (DOMException.isInstance(ex) && ex.name == "NotFoundError") { 302 lazy.log.debug( 303 `getAttrDataAsync: !exists("${ 304 attributionFile.path 305 }"), returning ${JSON.stringify(gCachedAttrData)}` 306 ); 307 return gCachedAttrData; 308 } 309 lazy.log.debug( 310 `other error trying to read attribution data: 311 attributionFile.path is: ${attributionFile.path}` 312 ); 313 lazy.log.debug("Full exception is:"); 314 lazy.log.debug(ex); 315 316 Glean.browser.attributionErrors.read_error.add(1); 317 } 318 if (bytes) { 319 try { 320 let decoder = new TextDecoder(); 321 let code = decoder.decode(bytes); 322 lazy.log.debug( 323 `getAttrDataAsync: attribution bytes deserializes to ${code}` 324 ); 325 if (AppConstants.platform == "macosx" && !code) { 326 // On macOS, an empty attribution code is fine. (On Windows, that 327 // means the stub/full installer has been incorrectly attributed, 328 // which is an error.) 329 return gCachedAttrData; 330 } 331 332 gCachedAttrData = this.parseAttributionCode(code); 333 lazy.log.debug( 334 `getAttrDataAsync: ${code} parses to ${JSON.stringify( 335 gCachedAttrData 336 )}` 337 ); 338 } catch (ex) { 339 // TextDecoder can throw an error 340 Glean.browser.attributionErrors.decode_error.add(1); 341 } 342 } 343 344 return gCachedAttrData; 345 }, 346 347 /** 348 * Return the cached attribution data synchronously without hitting 349 * the disk. 350 * 351 * @returns A dictionary with the attribution data if it's available, 352 * null otherwise. 353 */ 354 getCachedAttributionData() { 355 return gCachedAttrData; 356 }, 357 358 /** 359 * Deletes the attribution data file. 360 * Returns a promise that resolves when the file is deleted, 361 * or if the file couldn't be deleted (the promise is never rejected). 362 */ 363 async deleteFileAsync() { 364 // There is no cache file on macOS 365 if (AppConstants.platform == "win") { 366 try { 367 await IOUtils.remove(this.attributionFile.path); 368 } catch (ex) { 369 // The attribution file may already have been deleted, 370 // or it may have never been installed at all; 371 // failure to delete it isn't an error. 372 } 373 } 374 }, 375 376 /** 377 * Clears the cached attribution code value, if any. 378 * Does nothing if called from outside of an xpcshell test. 379 */ 380 _clearCache() { 381 if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { 382 gCachedAttrData = null; 383 } 384 }, 385 };