Merge mozilla-central to autoland. a=merge CLOSED TREE
[gecko.git] / browser / components / attribution / AttributionCode.sys.mjs
blob516ca2c680d9e144ffb2af489b90f3e5472c478d
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/. */
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),
14 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
16 const lazy = {};
17 ChromeUtils.defineESModuleGetters(lazy, {
18   MacAttribution: "resource:///modules/MacAttribution.sys.mjs",
19 });
20 ChromeUtils.defineLazyGetter(lazy, "log", () => {
21   let { ConsoleAPI } = ChromeUtils.importESModule(
22     "resource://gre/modules/Console.sys.mjs"
23   );
24   let consoleOptions = {
25     // tip: set maxLogLevel to "debug" and use lazy.log.debug() to create
26     // detailed messages during development. See LOG_LEVELS in Console.sys.mjs
27     // for details.
28     maxLogLevel: "error",
29     maxLogLevelPref: "browser.attribution.loglevel",
30     prefix: "AttributionCode",
31   };
32   return new ConsoleAPI(consoleOptions);
33 });
35 // This maximum length was originally based on how much space we have in the PE
36 // file header that we store attribution codes in for full and stub installers.
37 // Windows Store builds instead use a "Campaign ID" passed through URLs to send
38 // attribution information, which Microsoft's documentation claims must be no
39 // longer than 100 characters. In our own testing, we've been able to retrieve
40 // the first 208 characters of the Campaign ID. Either way, the "max" length
41 // for Microsoft Store builds is much lower than this limit implies.
42 const ATTR_CODE_MAX_LENGTH = 1010;
43 const ATTR_CODE_VALUE_REGEX = /[a-zA-Z0-9_%\\-\\.\\(\\)]*/;
44 const ATTR_CODE_FIELD_SEPARATOR = "%26"; // URL-encoded &
45 const ATTR_CODE_KEY_VALUE_SEPARATOR = "%3D"; // URL-encoded =
46 const ATTR_CODE_KEYS = [
47   "source",
48   "medium",
49   "campaign",
50   "content",
51   "experiment",
52   "variation",
53   "ua",
54   "dltoken",
55   "msstoresignedin",
56   "dlsource",
59 let gCachedAttrData = null;
61 export var AttributionCode = {
62   /**
63    * Wrapper to pull campaign IDs from MSIX builds.
64    * This function solely exists to make it easy to mock out for tests.
65    */
66   async msixCampaignId() {
67     const windowsPackageManager = Cc[
68       "@mozilla.org/windows-package-manager;1"
69     ].createInstance(Ci.nsIWindowsPackageManager);
71     return windowsPackageManager.campaignId();
72   },
74   /**
75    * Returns a platform-specific nsIFile for the file containing the attribution
76    * data, or null if the current platform does not support (caching)
77    * attribution data.
78    */
79   get attributionFile() {
80     if (AppConstants.platform == "win") {
81       let file = Services.dirsvc.get("GreD", Ci.nsIFile);
82       file.append("postSigningData");
83       return file;
84     }
86     return null;
87   },
89   /**
90    * Write the given attribution code to the attribution file.
91    * @param {String} code to write.
92    */
93   async writeAttributionFile(code) {
94     // Writing attribution files is only used as part of test code
95     // so bailing here for MSIX builds is no big deal.
96     if (
97       AppConstants.platform === "win" &&
98       Services.sysinfo.getProperty("hasWinPackageId")
99     ) {
100       Services.console.logStringMessage(
101         "Attribution code cannot be written for MSIX builds, aborting."
102       );
103       return;
104     }
105     let file = AttributionCode.attributionFile;
106     await IOUtils.makeDirectory(file.parent.path);
107     let bytes = new TextEncoder().encode(code);
108     await AttributionIOUtils.write(file.path, bytes);
109   },
111   /**
112    * Returns an array of allowed attribution code keys.
113    */
114   get allowedCodeKeys() {
115     return [...ATTR_CODE_KEYS];
116   },
118   /**
119    * Returns an object containing a key-value pair for each piece of attribution
120    * data included in the passed-in attribution code string.
121    * If the string isn't a valid attribution code, returns an empty object.
122    */
123   parseAttributionCode(code) {
124     if (code.length > ATTR_CODE_MAX_LENGTH) {
125       return {};
126     }
128     let isValid = true;
129     let parsed = {};
130     for (let param of code.split(ATTR_CODE_FIELD_SEPARATOR)) {
131       let [key, value] = param.split(ATTR_CODE_KEY_VALUE_SEPARATOR, 2);
132       if (key && ATTR_CODE_KEYS.includes(key)) {
133         if (value && ATTR_CODE_VALUE_REGEX.test(value)) {
134           if (key === "msstoresignedin") {
135             if (value === "true") {
136               parsed[key] = true;
137             } else if (value === "false") {
138               parsed[key] = false;
139             } else {
140               throw new Error("Couldn't parse msstoresignedin");
141             }
142           } else {
143             parsed[key] = value;
144           }
145         }
146       } else {
147         lazy.log.debug(
148           `parseAttributionCode: "${code}" => isValid = false: "${key}", "${value}"`
149         );
150         isValid = false;
151         break;
152       }
153     }
155     if (isValid) {
156       return parsed;
157     }
159     Services.telemetry
160       .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
161       .add("decode_error");
163     return {};
164   },
166   /**
167    * Returns a string serializing the given attribution data.
168    *
169    * It is expected that the given values are already URL-encoded.
170    */
171   serializeAttributionData(data) {
172     // Iterating in this way makes the order deterministic.
173     let s = "";
174     for (let key of ATTR_CODE_KEYS) {
175       if (key in data) {
176         let value = data[key];
177         if (s) {
178           s += ATTR_CODE_FIELD_SEPARATOR; // URL-encoded &
179         }
180         s += `${key}${ATTR_CODE_KEY_VALUE_SEPARATOR}${value}`; // URL-encoded =
181       }
182     }
183     return s;
184   },
186   async _getMacAttrDataAsync() {
187     // On macOS, we fish the attribution data from an extended attribute on
188     // the .app bundle directory.
189     try {
190       let attrStr = await lazy.MacAttribution.getAttributionString();
191       lazy.log.debug(
192         `_getMacAttrDataAsync: getAttributionString: "${attrStr}"`
193       );
195       if (attrStr === null) {
196         gCachedAttrData = {};
198         lazy.log.debug(`_getMacAttrDataAsync: null attribution string`);
199         Services.telemetry
200           .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
201           .add("null_error");
202       } else if (attrStr == "") {
203         gCachedAttrData = {};
205         lazy.log.debug(`_getMacAttrDataAsync: empty attribution string`);
206         Services.telemetry
207           .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
208           .add("empty_error");
209       } else {
210         gCachedAttrData = this.parseAttributionCode(attrStr);
211       }
212     } catch (ex) {
213       // Avoid partial attribution data.
214       gCachedAttrData = {};
216       // No attributions.  Just `warn` 'cuz this isn't necessarily an error.
217       lazy.log.warn("Caught exception fetching macOS attribution codes!", ex);
219       if (
220         ex instanceof Ci.nsIException &&
221         ex.result == Cr.NS_ERROR_UNEXPECTED
222       ) {
223         // Bad quarantine data.
224         Services.telemetry
225           .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
226           .add("quarantine_error");
227       }
228     }
230     lazy.log.debug(
231       `macOS attribution data is ${JSON.stringify(gCachedAttrData)}`
232     );
234     return gCachedAttrData;
235   },
237   async _getWindowsNSISAttrDataAsync() {
238     return AttributionIOUtils.read(this.attributionFile.path);
239   },
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   },
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     }
278     gCachedAttrData = {};
280     if (AppConstants.platform == "macosx") {
281       lazy.log.debug(`getAttrDataAsync: macOS`);
282       return this._getMacAttrDataAsync();
283     }
285     lazy.log.debug("getAttrDataAsync: !macOS");
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);
316       Services.telemetry
317         .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
318         .add("read_error");
319     }
320     if (bytes) {
321       try {
322         let decoder = new TextDecoder();
323         let code = decoder.decode(bytes);
324         lazy.log.debug(
325           `getAttrDataAsync: attribution bytes deserializes to ${code}`
326         );
327         if (AppConstants.platform == "macosx" && !code) {
328           // On macOS, an empty attribution code is fine.  (On Windows, that
329           // means the stub/full installer has been incorrectly attributed,
330           // which is an error.)
331           return gCachedAttrData;
332         }
334         gCachedAttrData = this.parseAttributionCode(code);
335         lazy.log.debug(
336           `getAttrDataAsync: ${code} parses to ${JSON.stringify(
337             gCachedAttrData
338           )}`
339         );
340       } catch (ex) {
341         // TextDecoder can throw an error
342         Services.telemetry
343           .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
344           .add("decode_error");
345       }
346     }
348     return gCachedAttrData;
349   },
351   /**
352    * Return the cached attribution data synchronously without hitting
353    * the disk.
354    * @returns A dictionary with the attribution data if it's available,
355    *          null otherwise.
356    */
357   getCachedAttributionData() {
358     return gCachedAttrData;
359   },
361   /**
362    * Deletes the attribution data file.
363    * Returns a promise that resolves when the file is deleted,
364    * or if the file couldn't be deleted (the promise is never rejected).
365    */
366   async deleteFileAsync() {
367     // There is no cache file on macOS
368     if (AppConstants.platform == "win") {
369       try {
370         await IOUtils.remove(this.attributionFile.path);
371       } catch (ex) {
372         // The attribution file may already have been deleted,
373         // or it may have never been installed at all;
374         // failure to delete it isn't an error.
375       }
376     }
377   },
379   /**
380    * Clears the cached attribution code value, if any.
381    * Does nothing if called from outside of an xpcshell test.
382    */
383   _clearCache() {
384     if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
385       gCachedAttrData = null;
386     }
387   },