summaryrefslogtreecommitdiffstats
path: root/browser/components/attribution
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /browser/components/attribution
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/attribution')
-rw-r--r--browser/components/attribution/AttributionCode.sys.mjs388
-rw-r--r--browser/components/attribution/MacAttribution.sys.mjs49
-rw-r--r--browser/components/attribution/docs/index.rst143
-rw-r--r--browser/components/attribution/moz.build25
-rw-r--r--browser/components/attribution/test/browser/browser.toml10
-rw-r--r--browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js121
-rw-r--r--browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js94
-rw-r--r--browser/components/attribution/test/browser/head.js25
-rw-r--r--browser/components/attribution/test/xpcshell/head.js136
-rw-r--r--browser/components/attribution/test/xpcshell/test_AttributionCode.js153
-rw-r--r--browser/components/attribution/test/xpcshell/test_MacAttribution.js137
-rw-r--r--browser/components/attribution/test/xpcshell/test_attribution_parsing.js44
-rw-r--r--browser/components/attribution/test/xpcshell/xpcshell.toml14
13 files changed, 1339 insertions, 0 deletions
diff --git a/browser/components/attribution/AttributionCode.sys.mjs b/browser/components/attribution/AttributionCode.sys.mjs
new file mode 100644
index 0000000000..516ca2c680
--- /dev/null
+++ b/browser/components/attribution/AttributionCode.sys.mjs
@@ -0,0 +1,388 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This is a policy object used to override behavior for testing.
+ */
+export const AttributionIOUtils = {
+ write: async (path, bytes) => IOUtils.write(path, bytes),
+ read: async path => IOUtils.read(path),
+ exists: async path => IOUtils.exists(path),
+};
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ MacAttribution: "resource:///modules/MacAttribution.sys.mjs",
+});
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ let consoleOptions = {
+ // tip: set maxLogLevel to "debug" and use lazy.log.debug() to create
+ // detailed messages during development. See LOG_LEVELS in Console.sys.mjs
+ // for details.
+ maxLogLevel: "error",
+ maxLogLevelPref: "browser.attribution.loglevel",
+ prefix: "AttributionCode",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+
+// This maximum length was originally based on how much space we have in the PE
+// file header that we store attribution codes in for full and stub installers.
+// Windows Store builds instead use a "Campaign ID" passed through URLs to send
+// attribution information, which Microsoft's documentation claims must be no
+// longer than 100 characters. In our own testing, we've been able to retrieve
+// the first 208 characters of the Campaign ID. Either way, the "max" length
+// for Microsoft Store builds is much lower than this limit implies.
+const ATTR_CODE_MAX_LENGTH = 1010;
+const ATTR_CODE_VALUE_REGEX = /[a-zA-Z0-9_%\\-\\.\\(\\)]*/;
+const ATTR_CODE_FIELD_SEPARATOR = "%26"; // URL-encoded &
+const ATTR_CODE_KEY_VALUE_SEPARATOR = "%3D"; // URL-encoded =
+const ATTR_CODE_KEYS = [
+ "source",
+ "medium",
+ "campaign",
+ "content",
+ "experiment",
+ "variation",
+ "ua",
+ "dltoken",
+ "msstoresignedin",
+ "dlsource",
+];
+
+let gCachedAttrData = null;
+
+export var AttributionCode = {
+ /**
+ * Wrapper to pull campaign IDs from MSIX builds.
+ * This function solely exists to make it easy to mock out for tests.
+ */
+ async msixCampaignId() {
+ const windowsPackageManager = Cc[
+ "@mozilla.org/windows-package-manager;1"
+ ].createInstance(Ci.nsIWindowsPackageManager);
+
+ return windowsPackageManager.campaignId();
+ },
+
+ /**
+ * Returns a platform-specific nsIFile for the file containing the attribution
+ * data, or null if the current platform does not support (caching)
+ * attribution data.
+ */
+ get attributionFile() {
+ if (AppConstants.platform == "win") {
+ let file = Services.dirsvc.get("GreD", Ci.nsIFile);
+ file.append("postSigningData");
+ return file;
+ }
+
+ return null;
+ },
+
+ /**
+ * Write the given attribution code to the attribution file.
+ * @param {String} code to write.
+ */
+ async writeAttributionFile(code) {
+ // Writing attribution files is only used as part of test code
+ // so bailing here for MSIX builds is no big deal.
+ if (
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId")
+ ) {
+ Services.console.logStringMessage(
+ "Attribution code cannot be written for MSIX builds, aborting."
+ );
+ return;
+ }
+ let file = AttributionCode.attributionFile;
+ await IOUtils.makeDirectory(file.parent.path);
+ let bytes = new TextEncoder().encode(code);
+ await AttributionIOUtils.write(file.path, bytes);
+ },
+
+ /**
+ * Returns an array of allowed attribution code keys.
+ */
+ get allowedCodeKeys() {
+ return [...ATTR_CODE_KEYS];
+ },
+
+ /**
+ * Returns an object containing a key-value pair for each piece of attribution
+ * data included in the passed-in attribution code string.
+ * If the string isn't a valid attribution code, returns an empty object.
+ */
+ parseAttributionCode(code) {
+ if (code.length > ATTR_CODE_MAX_LENGTH) {
+ return {};
+ }
+
+ let isValid = true;
+ let parsed = {};
+ for (let param of code.split(ATTR_CODE_FIELD_SEPARATOR)) {
+ let [key, value] = param.split(ATTR_CODE_KEY_VALUE_SEPARATOR, 2);
+ if (key && ATTR_CODE_KEYS.includes(key)) {
+ if (value && ATTR_CODE_VALUE_REGEX.test(value)) {
+ if (key === "msstoresignedin") {
+ if (value === "true") {
+ parsed[key] = true;
+ } else if (value === "false") {
+ parsed[key] = false;
+ } else {
+ throw new Error("Couldn't parse msstoresignedin");
+ }
+ } else {
+ parsed[key] = value;
+ }
+ }
+ } else {
+ lazy.log.debug(
+ `parseAttributionCode: "${code}" => isValid = false: "${key}", "${value}"`
+ );
+ isValid = false;
+ break;
+ }
+ }
+
+ if (isValid) {
+ return parsed;
+ }
+
+ Services.telemetry
+ .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
+ .add("decode_error");
+
+ return {};
+ },
+
+ /**
+ * Returns a string serializing the given attribution data.
+ *
+ * It is expected that the given values are already URL-encoded.
+ */
+ serializeAttributionData(data) {
+ // Iterating in this way makes the order deterministic.
+ let s = "";
+ for (let key of ATTR_CODE_KEYS) {
+ if (key in data) {
+ let value = data[key];
+ if (s) {
+ s += ATTR_CODE_FIELD_SEPARATOR; // URL-encoded &
+ }
+ s += `${key}${ATTR_CODE_KEY_VALUE_SEPARATOR}${value}`; // URL-encoded =
+ }
+ }
+ return s;
+ },
+
+ async _getMacAttrDataAsync() {
+ // On macOS, we fish the attribution data from an extended attribute on
+ // the .app bundle directory.
+ try {
+ let attrStr = await lazy.MacAttribution.getAttributionString();
+ lazy.log.debug(
+ `_getMacAttrDataAsync: getAttributionString: "${attrStr}"`
+ );
+
+ if (attrStr === null) {
+ gCachedAttrData = {};
+
+ lazy.log.debug(`_getMacAttrDataAsync: null attribution string`);
+ Services.telemetry
+ .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
+ .add("null_error");
+ } else if (attrStr == "") {
+ gCachedAttrData = {};
+
+ lazy.log.debug(`_getMacAttrDataAsync: empty attribution string`);
+ Services.telemetry
+ .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
+ .add("empty_error");
+ } else {
+ gCachedAttrData = this.parseAttributionCode(attrStr);
+ }
+ } catch (ex) {
+ // Avoid partial attribution data.
+ gCachedAttrData = {};
+
+ // No attributions. Just `warn` 'cuz this isn't necessarily an error.
+ lazy.log.warn("Caught exception fetching macOS attribution codes!", ex);
+
+ if (
+ ex instanceof Ci.nsIException &&
+ ex.result == Cr.NS_ERROR_UNEXPECTED
+ ) {
+ // Bad quarantine data.
+ Services.telemetry
+ .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
+ .add("quarantine_error");
+ }
+ }
+
+ lazy.log.debug(
+ `macOS attribution data is ${JSON.stringify(gCachedAttrData)}`
+ );
+
+ return gCachedAttrData;
+ },
+
+ async _getWindowsNSISAttrDataAsync() {
+ return AttributionIOUtils.read(this.attributionFile.path);
+ },
+
+ async _getWindowsMSIXAttrDataAsync() {
+ // This comes out of windows-package-manager _not_ URL encoded or in an ArrayBuffer,
+ // but the parsing code wants it that way. It's easier to just provide that
+ // than have the parsing code support both.
+ lazy.log.debug(
+ `winPackageFamilyName is: ${Services.sysinfo.getProperty(
+ "winPackageFamilyName"
+ )}`
+ );
+ let encoder = new TextEncoder();
+ return encoder.encode(encodeURIComponent(await this.msixCampaignId()));
+ },
+
+ /**
+ * Reads the attribution code, either from disk or a cached version.
+ * Returns a promise that fulfills with an object containing the parsed
+ * attribution data if the code could be read and is valid,
+ * or an empty object otherwise.
+ *
+ * On windows the attribution service converts utm_* keys, removing "utm_".
+ * On OSX the attributions are set directly on download and retain "utm_". We
+ * strip "utm_" while retrieving the params.
+ */
+ async getAttrDataAsync() {
+ if (AppConstants.platform != "win" && AppConstants.platform != "macosx") {
+ // This platform doesn't support attribution.
+ return gCachedAttrData;
+ }
+ if (gCachedAttrData != null) {
+ lazy.log.debug(
+ `getAttrDataAsync: attribution is cached: ${JSON.stringify(
+ gCachedAttrData
+ )}`
+ );
+ return gCachedAttrData;
+ }
+
+ gCachedAttrData = {};
+
+ if (AppConstants.platform == "macosx") {
+ lazy.log.debug(`getAttrDataAsync: macOS`);
+ return this._getMacAttrDataAsync();
+ }
+
+ lazy.log.debug("getAttrDataAsync: !macOS");
+
+ let attributionFile = this.attributionFile;
+ let bytes;
+ try {
+ if (
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId")
+ ) {
+ lazy.log.debug("getAttrDataAsync: MSIX");
+ bytes = await this._getWindowsMSIXAttrDataAsync();
+ } else {
+ lazy.log.debug("getAttrDataAsync: NSIS");
+ bytes = await this._getWindowsNSISAttrDataAsync();
+ }
+ } catch (ex) {
+ if (DOMException.isInstance(ex) && ex.name == "NotFoundError") {
+ lazy.log.debug(
+ `getAttrDataAsync: !exists("${
+ attributionFile.path
+ }"), returning ${JSON.stringify(gCachedAttrData)}`
+ );
+ return gCachedAttrData;
+ }
+ lazy.log.debug(
+ `other error trying to read attribution data:
+ attributionFile.path is: ${attributionFile.path}`
+ );
+ lazy.log.debug("Full exception is:");
+ lazy.log.debug(ex);
+
+ Services.telemetry
+ .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
+ .add("read_error");
+ }
+ if (bytes) {
+ try {
+ let decoder = new TextDecoder();
+ let code = decoder.decode(bytes);
+ lazy.log.debug(
+ `getAttrDataAsync: attribution bytes deserializes to ${code}`
+ );
+ if (AppConstants.platform == "macosx" && !code) {
+ // On macOS, an empty attribution code is fine. (On Windows, that
+ // means the stub/full installer has been incorrectly attributed,
+ // which is an error.)
+ return gCachedAttrData;
+ }
+
+ gCachedAttrData = this.parseAttributionCode(code);
+ lazy.log.debug(
+ `getAttrDataAsync: ${code} parses to ${JSON.stringify(
+ gCachedAttrData
+ )}`
+ );
+ } catch (ex) {
+ // TextDecoder can throw an error
+ Services.telemetry
+ .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
+ .add("decode_error");
+ }
+ }
+
+ return gCachedAttrData;
+ },
+
+ /**
+ * Return the cached attribution data synchronously without hitting
+ * the disk.
+ * @returns A dictionary with the attribution data if it's available,
+ * null otherwise.
+ */
+ getCachedAttributionData() {
+ return gCachedAttrData;
+ },
+
+ /**
+ * Deletes the attribution data file.
+ * Returns a promise that resolves when the file is deleted,
+ * or if the file couldn't be deleted (the promise is never rejected).
+ */
+ async deleteFileAsync() {
+ // There is no cache file on macOS
+ if (AppConstants.platform == "win") {
+ try {
+ await IOUtils.remove(this.attributionFile.path);
+ } catch (ex) {
+ // The attribution file may already have been deleted,
+ // or it may have never been installed at all;
+ // failure to delete it isn't an error.
+ }
+ }
+ },
+
+ /**
+ * Clears the cached attribution code value, if any.
+ * Does nothing if called from outside of an xpcshell test.
+ */
+ _clearCache() {
+ if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ gCachedAttrData = null;
+ }
+ },
+};
diff --git a/browser/components/attribution/MacAttribution.sys.mjs b/browser/components/attribution/MacAttribution.sys.mjs
new file mode 100644
index 0000000000..09ec083a44
--- /dev/null
+++ b/browser/components/attribution/MacAttribution.sys.mjs
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const NUL = 0x0;
+const TAB = 0x9;
+
+export var MacAttribution = {
+ /**
+ * The file path to the `.app` directory.
+ */
+ get applicationPath() {
+ // On macOS, `GreD` is like "App.app/Contents/macOS". Return "App.app".
+ return Services.dirsvc.get("GreD", Ci.nsIFile).parent.parent.path;
+ },
+
+ async setAttributionString(aAttrStr, path = this.applicationPath) {
+ return IOUtils.setMacXAttr(
+ path,
+ "com.apple.application-instance",
+ new TextEncoder().encode(`__MOZCUSTOM__${aAttrStr}`)
+ );
+ },
+
+ async getAttributionString(path = this.applicationPath) {
+ let promise = IOUtils.getMacXAttr(path, "com.apple.application-instance");
+ return promise.then(bytes => {
+ // We need to process the extended attribute a little bit to isolate
+ // the attribution string:
+ // - nul bytes and tabs may be present in raw attribution strings, but are
+ // never part of the attribution data
+ // - attribution data is expected to be preceeded by the string `__MOZCUSTOM__`
+ let attrStr = new TextDecoder().decode(
+ bytes.filter(b => b != NUL && b != TAB)
+ );
+
+ if (attrStr.startsWith("__MOZCUSTOM__")) {
+ // Return everything after __MOZCUSTOM__
+ return attrStr.slice(13);
+ }
+
+ throw new Error(`No attribution data found in ${path}`);
+ });
+ },
+
+ async delAttributionString(path = this.applicationPath) {
+ return IOUtils.delMacXAttr(path, "com.apple.application-instance");
+ },
+};
diff --git a/browser/components/attribution/docs/index.rst b/browser/components/attribution/docs/index.rst
new file mode 100644
index 0000000000..05ffd78b28
--- /dev/null
+++ b/browser/components/attribution/docs/index.rst
@@ -0,0 +1,143 @@
+========================
+Installation Attribution
+========================
+
+*Installation Attribution* is a system that allows us to do a few things:
+
+- Gauge the success of marketing campaigns
+- Determine (roughly) the origin of an installer that a user ran
+- Support the Return to AMO workflow.
+
+We accomplish these things by adding an *attribution code* to Firefox installers which generally contains information supplied by www.mozilla.org (Bedrock and related services). This information is read by Firefox during and after installation, and sent back to Mozilla through Firefox Telemetry.
+
+The following information is supported by this system:
+
+* Traditional `UTM parameters <https://en.wikipedia.org/wiki/UTM_parameters>`_ (*source*, *medium*, *campaign*, and *content*)
+* *experiment*
+* *variation*
+* *ua*
+* *dltoken*
+* *dlsource*
+* *msstoresignedin*
+
+Descriptions of each of these can be found in :ref:`the Telemetry Environment documentation <environment>`.
+
+---------------------------------------
+Firefox Windows Installers & macOS DMGs
+---------------------------------------
+
+Installs done through Windows stub or full NSIS installers or macOS DMGs are capable of being attributed. When these packages are created, they are given initial attribution data of *dlsource=mozillaci*. Users who download their package via www.mozilla.org will typically have this attribution data overwritten (unless they have Do-not-track (DNT) enabled), with *dlsource=mozorg*, a *dltoken*, and whatever UTM parameters Bedrock deems appropriate.
+
+An additional complication here is that the attribution system is used (or abused, depending on your view) to support the Return to AMO workflow -- forcing the *campaign* and *content* UTM parameters to specific values.
+
+The below diagram illustrates the flow of the cases above:
+
+.. mermaid::
+
+ flowchart TD
+ subgraph Legend
+ direction LR
+ start1[ ] --->|"(1) Bedrock, Do-Not-Track enabled"| stop1[ ]
+ start2[ ] --->|"(2) Bedrock, Do-Not-Track disabled"| stop2[ ]
+ start3[ ] --->|"(3) Bedrock via Search Engine"| stop3[ ]
+ start4[ ] --->|"(4) Bedrock via Return to AMO flow"| stop4[ ]
+ start5[ ] --->|"(5) Direct download from CDN Origin"| stop5[ ]
+ start6[ ] --->|"Common Paths"| stop6[ ]
+
+ %% Legend colours
+ linkStyle 0 stroke-width:2px,fill:none,stroke:blue;
+ linkStyle 1 stroke-width:2px,fill:none,stroke:green;
+ linkStyle 2 stroke-width:2px,fill:none,stroke:red;
+ linkStyle 3 stroke-width:2px,fill:none,stroke:purple;
+ linkStyle 4 stroke-width:2px,fill:none,stroke:pink;
+ end
+
+ subgraph Download Flow
+ User([User])
+ Search[Search Engine]
+ AMO[AMO<br>addons.mozilla.org]
+ Bedrock[Bedrock<br>mozilla.org]
+ Bouncer[Bouncer<br>download.mozilla.org]
+ CDNOrigin[CDN Origin<br>archive.mozilla.org]
+ CDN[CDN<br>download-installer.cdn.mozilla.net]
+ Attr[Attribution Service<br>stubdownloader.services.mozilla.net]
+ AttrCDN[Attribution CDN<br>cdn.stubdownloader.services.mozilla.net]
+ Inst([Installer Downloaded])
+
+ %% Case 1: Bedrock, DNT enabled
+ User ----> Bedrock
+ Bedrock ---->|"No attribution data set"| Bouncer
+ Bouncer ----> CDN
+ CDN ---->|"Contains static attribution data:<br><i>dlsource</i>: mozillaci"| Inst
+
+ linkStyle 6,7,8,9 stroke-width:2px,fill:none,stroke:blue;
+
+ %% Case 2: Bedrock, DNT disabled
+ User ---> Bedrock
+
+ linkStyle 10 stroke-width:2px,fill:none,stroke:green;
+
+ %% Case 3: Bedrock via Search Engine
+ User ----> Search
+ Search ---->|"Sets UTM parameters"| Bedrock
+
+ linkStyle 11,12 stroke-width:2px,fill:none,stroke:red;
+
+ %% Case 4: Bedrock via Return to AMO flow
+ User ---->|"Initiates Return-to-AMO request"| AMO
+ AMO ---->|"Sets <i>campaign</i> and<br><i>content</i> UTM parameters"| Bedrock
+
+ linkStyle 13,14 stroke-width:2px,fill:none,stroke:purple;
+
+ %% Case 5: Direct download from CDN
+ User --> CDNOrigin
+ CDNOrigin -->|"Contains static attribution data:<br><i>dlsource</i>: mozillaci"| Inst
+
+ linkStyle 15,16 stroke-width:2px,fill:none,stroke:pink;
+
+ %% Common links for cases 2, 3, and 4
+ Bedrock ---->|"Attribution data forwarded:<br><i>dlsource</i>: mozorg<br><i>dltoken</i>: present<br>any UTM parameters set"| Bouncer
+ Bouncer ---->|"Forwards attribution data"| Attr
+ Attr <---->|"Fetches installer"| CDN
+ Attr ---->|"Places modified installer<br>on Attribution CDN"| AttrCDN
+ AttrCDN ---->|"Contains dynamic attribution data:<br><i>dlsource</i>: mozorg<br><i>dltoken</i>: present<br>any UTM parameters set"| Inst
+
+ %% Common links for everything
+ CDN <---->|"Fetches installer"| CDNOrigin
+ end
+
+~~~~~~~
+Windows
+~~~~~~~
+
+Windows attribution is implementing by injecting data into the signature block of NSIS installers at download time. This technique is described in the "Cheating Authenticode" section of `this Microsoft blog post <https://learn.microsoft.com/en-ca/archive/blogs/ieinternals/caveats-for-authenticode-code-signing#cheating-authenticode>`_.
+
+~~~~~
+macOS
+~~~~~
+
+macOS attribution is implemented by adding a ``com.apple.application-instance`` extended attribute to the ``.app`` bundle at download time. This special extended attribute is explicitly *not* part of the digital signature of the ``.app`` bundle as per `this Apple technical note <https://developer.apple.com/library/archive/technotes/tn2206/_index.html#//apple_ref/doc/uid/DTS40007919-CH1-TNTAG401>`_.
+
+
+---------------
+Microsoft Store
+---------------
+
+Firefox installs done through the Microsoft Store support extracting campaign IDs that may be embedded into them. This allows us to attribute installs through different channels by providing particular links to the Microsoft Store with attribution data included. For example:
+
+`ms-windows-store://pdp/?productid=9NZVDKPMR9RD&cid=source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set) <ms-windows-store://pdp/?productid=9NZVDKPMR9RD&cid=source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set)>`_
+
+
+`https://www.microsoft.com/store/apps/9NZVDKPMR9RD?cid=source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set) <https://www.microsoft.com/store/apps/9NZVDKPMR9RD?cid=source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set)>`_
+
+
+For more on how custom campaign IDs work in general in the Microsoft Store environment, `see Microsoft's documentation <https://docs.microsoft.com/en-us/windows/uwp/publish/create-a-custom-app-promotion-campaign>`_.
+
+The Microsoft Store provides a single `cid` (Campaign ID). Their documentation claims it is limited to 100 characters, although in our own testing we've been able to retrieve the first 208 characters of Campaign IDs. Firefox expects this Campaign ID to follow the same format as stub and full installer attribution codes, which have a maximum of length of 1010 characters. Since Campaign IDs are more limited than what Firefox allows, we need to be a bit more thoughtful about what we include in them vs. stub and full installer attribution. At the time of writing, we've yet to be able to test whether we can reliably pull more than the advertised 100 characters of a Campaign ID in the real world -- something that we should do before we send any crucial information past the first 100 characters.
+
+In addition to the attribution data retrieved through the campaign ID, we also add an extra key to it to indicate whether or not the user was signed into the Microsoft Store when they installed. This `msstoresignedin` key can have a value of `true` or `false`.
+
+There are a couple of other caveats to keep in mind:
+
+* A campaign ID is only set the *first* time a user installs Firefox through the Store. Subsequent installs will inherit the original campaign ID (even if it was an empty string). This means that only brand new installs will be attributed -- not reinstalls.
+* At the time of writing, it is not clear whether or not installs done without being signed into the Microsoft Store will be able to find their campaign ID. Microsoft's documentation claims they can, but our own testing has not been able to verify this.
diff --git a/browser/components/attribution/moz.build b/browser/components/attribution/moz.build
new file mode 100644
index 0000000000..7bb14300f8
--- /dev/null
+++ b/browser/components/attribution/moz.build
@@ -0,0 +1,25 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Telemetry")
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"]
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"]
+
+EXTRA_JS_MODULES += [
+ "AttributionCode.sys.mjs",
+]
+
+SPHINX_TREES["docs"] = "docs"
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ FINAL_LIBRARY = "browsercomps"
+
+ EXTRA_JS_MODULES += [
+ "MacAttribution.sys.mjs",
+ ]
diff --git a/browser/components/attribution/test/browser/browser.toml b/browser/components/attribution/test/browser/browser.toml
new file mode 100644
index 0000000000..7689f13c48
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser.toml
@@ -0,0 +1,10 @@
+[DEFAULT]
+support-files = ["head.js"]
+
+["browser_AttributionCode_Mac_telemetry.js"]
+run-if = ["os == 'mac'"] # macOS only telemetry.
+
+["browser_AttributionCode_telemetry.js"]
+# These tests only cover the attribution cache file - which only exists on
+# Windows.
+run-if = ["os == 'win'"]
diff --git a/browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js b/browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js
new file mode 100644
index 0000000000..f2995277ba
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js
@@ -0,0 +1,121 @@
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+);
+const { AttributionIOUtils } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+add_task(async function test_blank_attribution() {
+ // Ensure no attribution information is present
+ try {
+ await MacAttribution.delAttributionString();
+ } catch (ex) {
+ // NS_ERROR_DOM_NOT_FOUND_ERR means there was not an attribution
+ // string to delete - which we can safely ignore.
+ if (
+ !(ex instanceof Ci.nsIException) ||
+ ex.result != Cr.NS_ERROR_DOM_NOT_FOUND_ERR
+ ) {
+ throw ex;
+ }
+ }
+ AttributionCode._clearCache();
+
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+
+ try {
+ // Clear any existing telemetry
+ histogram.clear();
+
+ // Try to read the attribution code
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Should be able to get empty result");
+
+ Assert.deepEqual({}, histogram.snapshot().values || {});
+ } finally {
+ AttributionCode._clearCache();
+ histogram.clear();
+ }
+});
+
+add_task(async function test_no_attribution() {
+ const sandbox = sinon.createSandbox();
+ let newApplicationPath = MacAttribution.applicationPath + ".test";
+ sandbox.stub(MacAttribution, "applicationPath").get(() => newApplicationPath);
+
+ AttributionCode._clearCache();
+
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+ try {
+ // Clear any existing telemetry
+ histogram.clear();
+
+ // Try to read the attribution code
+ await AttributionCode.getAttrDataAsync();
+
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Should be able to get empty result");
+
+ Assert.deepEqual({}, histogram.snapshot().values || {});
+ } finally {
+ AttributionCode._clearCache();
+ histogram.clear();
+ sandbox.restore();
+ }
+});
+
+add_task(async function test_empty_attribution() {
+ const sandbox = sinon.createSandbox();
+ await MacAttribution.setAttributionString("");
+
+ AttributionCode._clearCache();
+
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+ try {
+ // Clear any existing telemetry
+ histogram.clear();
+
+ await AttributionCode.getAttrDataAsync();
+
+ TelemetryTestUtils.assertHistogram(histogram, INDEX_EMPTY_ERROR, 1);
+ } finally {
+ AttributionCode._clearCache();
+ histogram.clear();
+ sandbox.restore();
+ }
+});
+
+add_task(async function test_null_attribution() {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(MacAttribution, "getAttributionString").resolves(null);
+
+ AttributionCode._clearCache();
+
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+ try {
+ // Clear any existing telemetry
+ histogram.clear();
+
+ await AttributionCode.getAttrDataAsync();
+
+ TelemetryTestUtils.assertHistogram(histogram, INDEX_NULL_ERROR, 1);
+ } finally {
+ AttributionCode._clearCache();
+ histogram.clear();
+ sandbox.restore();
+ }
+});
diff --git a/browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js b/browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js
new file mode 100644
index 0000000000..4cbef07313
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js
@@ -0,0 +1,94 @@
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+const { AttributionIOUtils } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+
+add_task(async function test_parse_error() {
+ registerCleanupFunction(async () => {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ });
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+ // Delete the file to trigger a read error
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ // Clear any existing telemetry
+ histogram.clear();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ {},
+ "Shouldn't be able to get a result if the file doesn't exist"
+ );
+
+ // Write an invalid file to trigger a decode error
+ // Skip this for MSIX packages though - we can't write or delete
+ // the attribution file there, everything happens in memory instead.
+ if (
+ AppConstants.platform === "win" &&
+ !Services.sysinfo.getProperty("hasWinPackageId")
+ ) {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ await AttributionCode.writeAttributionFile("");
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Should have failed to parse");
+
+ // `assertHistogram` also ensures that `read_error` index 0 is 0
+ // as we should not have recorded telemetry from the previous `getAttrDataAsync` call
+ TelemetryTestUtils.assertHistogram(histogram, INDEX_DECODE_ERROR, 1);
+ // Reset
+ histogram.clear();
+ }
+});
+
+add_task(async function test_read_error() {
+ registerCleanupFunction(async () => {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ });
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+
+ // Delete the file to trigger a read error
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ // Clear any existing telemetry
+ histogram.clear();
+
+ // Force the file to exist but then cause a read error
+ let oldExists = AttributionIOUtils.exists;
+ AttributionIOUtils.exists = () => true;
+
+ let oldRead = AttributionIOUtils.read;
+ AttributionIOUtils.read = () => {
+ throw new Error("read_error");
+ };
+
+ // On MSIX builds, AttributionIOUtils.read is not used; AttributionCode.msixCampaignId is.
+ // Ensure we override that as well.
+ let oldMsixCampaignId = AttributionCode.msixCampaignId;
+ AttributionCode.msixCampaignId = async () => {
+ throw new Error("read_error");
+ };
+
+ registerCleanupFunction(() => {
+ AttributionIOUtils.exists = oldExists;
+ AttributionIOUtils.read = oldRead;
+ AttributionCode.msixCampaignId = oldMsixCampaignId;
+ });
+
+ // Try to read the file
+ await AttributionCode.getAttrDataAsync();
+
+ // It should record the read error
+ TelemetryTestUtils.assertHistogram(histogram, INDEX_READ_ERROR, 1);
+
+ // Clear any existing telemetry
+ histogram.clear();
+});
diff --git a/browser/components/attribution/test/browser/head.js b/browser/components/attribution/test/browser/head.js
new file mode 100644
index 0000000000..575a5e3ae9
--- /dev/null
+++ b/browser/components/attribution/test/browser/head.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AttributionCode } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+
+// Keep in sync with `BROWSER_ATTRIBUTION_ERRORS` in Histograms.json.
+const INDEX_READ_ERROR = 0;
+const INDEX_DECODE_ERROR = 1;
+const INDEX_WRITE_ERROR = 2;
+const INDEX_QUARANTINE_ERROR = 3;
+const INDEX_EMPTY_ERROR = 4;
+const INDEX_NULL_ERROR = 5;
+
+add_setup(function () {
+ // AttributionCode._clearCache is only possible in a testing environment
+ Services.env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
+
+ registerCleanupFunction(() => {
+ Services.env.set("XPCSHELL_TEST_PROFILE_DIR", null);
+ });
+});
diff --git a/browser/components/attribution/test/xpcshell/head.js b/browser/components/attribution/test/xpcshell/head.js
new file mode 100644
index 0000000000..67a93563d4
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/head.js
@@ -0,0 +1,136 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AttributionCode } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+
+let validAttrCodes = [
+ {
+ code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set)",
+ parsed: {
+ source: "google.com",
+ medium: "organic",
+ campaign: "(not%20set)",
+ content: "(not%20set)",
+ },
+ },
+ {
+ code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set)%26msstoresignedin%3Dtrue",
+ parsed: {
+ source: "google.com",
+ medium: "organic",
+ campaign: "(not%20set)",
+ content: "(not%20set)",
+ msstoresignedin: true,
+ },
+ platforms: ["win"],
+ },
+ {
+ code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D%26content%3D",
+ parsed: { source: "google.com", medium: "organic" },
+ doesNotRoundtrip: true, // `campaign=` and `=content` are dropped.
+ },
+ {
+ code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)",
+ parsed: {
+ source: "google.com",
+ medium: "organic",
+ campaign: "(not%20set)",
+ },
+ },
+ {
+ code: "source%3Dgoogle.com%26medium%3Dorganic",
+ parsed: { source: "google.com", medium: "organic" },
+ },
+ { code: "source%3Dgoogle.com", parsed: { source: "google.com" } },
+ { code: "medium%3Dgoogle.com", parsed: { medium: "google.com" } },
+ { code: "campaign%3Dgoogle.com", parsed: { campaign: "google.com" } },
+ { code: "content%3Dgoogle.com", parsed: { content: "google.com" } },
+ {
+ code: "experiment%3Dexperimental",
+ parsed: { experiment: "experimental" },
+ },
+ { code: "variation%3Dvaried", parsed: { variation: "varied" } },
+ {
+ code: "ua%3DGoogle%20Chrome%20123",
+ parsed: { ua: "Google%20Chrome%20123" },
+ },
+ {
+ code: "dltoken%3Dc18f86a3-f228-4d98-91bb-f90135c0aa9c",
+ parsed: { dltoken: "c18f86a3-f228-4d98-91bb-f90135c0aa9c" },
+ },
+ {
+ code: "dlsource%3Dsome-dl-source",
+ parsed: {
+ dlsource: "some-dl-source",
+ },
+ },
+];
+
+let invalidAttrCodes = [
+ // Empty string
+ "",
+ // Not escaped
+ "source=google.com&medium=organic&campaign=(not set)&content=(not set)",
+ // Too long
+ "campaign%3D" + "a".repeat(1000),
+ // Unknown key name
+ "source%3Dgoogle.com%26medium%3Dorganic%26large%3Dgeneticallymodified",
+ // Empty key name
+ "source%3Dgoogle.com%26medium%3Dorganic%26%3Dgeneticallymodified",
+];
+
+/**
+ * Arrange for each test to have a unique application path for storing
+ * quarantine data.
+ *
+ * The quarantine data is necessarily a shared system resource, managed by the
+ * OS, so we need to avoid polluting it during tests.
+ *
+ * There are at least two ways to achieve this. Here we use Sinon to stub the
+ * relevant accessors: this has the advantage of being local and relatively easy
+ * to follow. In the App Update Service tests, an `nsIDirectoryServiceProvider`
+ * is installed, which is global and much harder to extract for re-use.
+ */
+async function setupStubs() {
+ // Local imports to avoid polluting the global namespace.
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+ );
+
+ // This depends on the caller to invoke it by name. We do try to
+ // prevent the most obvious incorrect invocation, namely
+ // `add_task(setupStubs)`.
+ let caller = Components.stack.caller;
+ const testID = caller.filename.toString().split("/").pop().split(".")[0];
+ notEqual(testID, "head");
+
+ let applicationFile = do_get_tempdir();
+ applicationFile.append(testID);
+ applicationFile.append("App.app");
+
+ if (AppConstants.platform == "macosx") {
+ // We're implicitly using the fact that modules are shared between importers here.
+ const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+ );
+ sinon
+ .stub(MacAttribution, "applicationPath")
+ .get(() => applicationFile.path);
+ }
+
+ // The macOS quarantine database applies to existing paths only, so make
+ // sure our mock application path exists. This also creates the parent
+ // directory for the attribution file, needed on both macOS and Windows. We
+ // don't ignore existing paths because we're inside a temporary directory:
+ // this should never be invoked twice for the same test.
+ await IOUtils.makeDirectory(applicationFile.path, {
+ from: do_get_tempdir().path,
+ });
+}
diff --git a/browser/components/attribution/test/xpcshell/test_AttributionCode.js b/browser/components/attribution/test/xpcshell/test_AttributionCode.js
new file mode 100644
index 0000000000..69aaa5fdaf
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_AttributionCode.js
@@ -0,0 +1,153 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+add_task(async () => {
+ await setupStubs();
+});
+
+/**
+ * Test validation of attribution codes,
+ * to make sure we reject bad ones and accept good ones.
+ */
+add_task(async function testValidAttrCodes() {
+ let msixCampaignIdStub = sinon.stub(AttributionCode, "msixCampaignId");
+
+ let currentCode = null;
+ for (let entry of validAttrCodes) {
+ currentCode = entry.code;
+ // Attribution for MSIX builds works quite differently than regular Windows
+ // builds: the attribution codes come from a Windows API that we've wrapped
+ // with an XPCOM class. We don't have a way to inject codes into the build,
+ // so instead we mock out our XPCOM class and return the desired values from
+ // there. (A potential alternative is to have MSIX tests rely on attribution
+ // files, but that more or less invalidates the tests.)
+ if (
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId")
+ ) {
+ // In real life, the attribution codes returned from Microsoft APIs
+ // are not URI encoded, and the AttributionCode code that deals with
+ // them expects that - so we have to simulate that as well.
+ msixCampaignIdStub.callsFake(async () => decodeURIComponent(currentCode));
+ } else if (AppConstants.platform === "macosx") {
+ const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+ );
+
+ await MacAttribution.setAttributionString(currentCode);
+ } else {
+ // non-msix windows
+ await AttributionCode.writeAttributionFile(currentCode);
+ }
+ AttributionCode._clearCache();
+ let result = await AttributionCode.getAttrDataAsync();
+
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + currentCode
+ );
+ }
+ AttributionCode._clearCache();
+
+ // Restore the msixCampaignId stub so that other tests don't fail stubbing it
+ msixCampaignIdStub.restore();
+});
+
+/**
+ * Make sure codes with various formatting errors are not seen as valid.
+ */
+add_task(async function testInvalidAttrCodes() {
+ let msixCampaignIdStub = sinon.stub(AttributionCode, "msixCampaignId");
+ let currentCode = null;
+
+ for (let code of invalidAttrCodes) {
+ currentCode = code;
+
+ if (
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId")
+ ) {
+ if (code.includes("not set")) {
+ // One of the "invalid" codes that we test is an unescaped one.
+ // This is valid for most platforms, but we actually _expect_
+ // unescaped codes for MSIX builds, so that particular test is not
+ // valid for this case.
+ continue;
+ }
+
+ msixCampaignIdStub.callsFake(async () => decodeURIComponent(currentCode));
+ } else if (AppConstants.platform === "macosx") {
+ const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+ );
+
+ await MacAttribution.setAttributionString(currentCode);
+ } else {
+ // non-msix windows
+ await AttributionCode.writeAttributionFile(currentCode);
+ }
+ AttributionCode._clearCache();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ {},
+ "Code should have failed to parse: " + currentCode
+ );
+ }
+ AttributionCode._clearCache();
+
+ // Restore the msixCampaignId stub so that other tests don't fail stubbing it
+ msixCampaignIdStub.restore();
+});
+
+/**
+ * Test the cache by deleting the attribution data file
+ * and making sure we still get the expected code.
+ */
+let condition = {
+ // macOS and MSIX attribution codes are not cached by us, thus this test is
+ // unnecessary for those builds.
+ skip_if: () =>
+ (AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId")) ||
+ AppConstants.platform === "macosx",
+};
+add_task(condition, async function testDeletedFile() {
+ // Set up the test by clearing the cache and writing a valid file.
+ await AttributionCode.writeAttributionFile(validAttrCodes[0].code);
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ validAttrCodes[0].parsed,
+ "The code should be readable directly from the file"
+ );
+
+ // Delete the file and make sure we can still read the value back from cache.
+ await AttributionCode.deleteFileAsync();
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ validAttrCodes[0].parsed,
+ "The code should be readable from the cache"
+ );
+
+ // Clear the cache and check we can't read anything.
+ AttributionCode._clearCache();
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ {},
+ "Shouldn't be able to get a code after file is deleted and cache is cleared"
+ );
+});
diff --git a/browser/components/attribution/test/xpcshell/test_MacAttribution.js b/browser/components/attribution/test/xpcshell/test_MacAttribution.js
new file mode 100644
index 0000000000..10bbe6c965
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_MacAttribution.js
@@ -0,0 +1,137 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+);
+
+let extendedAttributeTestCases = [
+ {
+ raw: "__MOZCUSTOM__dlsource%3D=mozci",
+ expected: "dlsource%3D=mozci",
+ error: false,
+ },
+ {
+ raw: "__MOZCUSTOM__dlsource%3D=mozci\0\0\0\0\0",
+ expected: "dlsource%3D=mozci",
+ error: false,
+ },
+ {
+ raw: "__MOZCUSTOM__dlsource%3D=mozci\t\t\t\t\t",
+ expected: "dlsource%3D=mozci",
+ error: false,
+ },
+ {
+ raw: "__MOZCUSTOM__dlsource%3D=mozci\0\0\0\t\t",
+ expected: "dlsource%3D=mozci",
+ error: false,
+ },
+ {
+ raw: "__MOZCUSTOM__dlsource%3D=mozci\t\t\0\0",
+ expected: "dlsource%3D=mozci",
+ error: false,
+ },
+ {
+ raw: "__MOZCUSTOM__dlsource%3D=mozci\0\t\0\t\0\t",
+ expected: "dlsource%3D=mozci",
+ error: false,
+ },
+ {
+ raw: "",
+ expected: "",
+ error: true,
+ },
+ {
+ raw: "dlsource%3D=mozci\0\t\0\t\0\t",
+ expected: "",
+ error: true,
+ },
+];
+
+add_task(async () => {
+ await setupStubs();
+});
+
+// Tests to ensure that MacAttribution.getAttributionString
+// strips away the parts of the extended attribute that it should,
+// and that invalid extended attribute values result in no attribution
+// data.
+add_task(async function testExtendedAttributeProcessing() {
+ for (let entry of extendedAttributeTestCases) {
+ // We use setMacXAttr directly here rather than setAttributionString because
+ // we need the ability to set invalid attribution strings.
+ await IOUtils.setMacXAttr(
+ MacAttribution.applicationPath,
+ "com.apple.application-instance",
+ new TextEncoder().encode(entry.raw)
+ );
+ try {
+ let got = await MacAttribution.getAttributionString();
+ if (entry.error === true) {
+ Assert.ok(false, "Expected error, raw code was: " + entry.raw);
+ }
+ Assert.equal(
+ got,
+ entry.expected,
+ "Returned code should match expected value, raw code was: " + entry.raw
+ );
+ } catch (err) {
+ if (entry.error === false) {
+ Assert.ok(
+ false,
+ "Unexpected error, raw code was: " + entry.raw + " error is: " + err
+ );
+ }
+ }
+ }
+});
+
+add_task(async function testValidAttrCodes() {
+ for (let entry of validAttrCodes) {
+ if (entry.platforms && !entry.platforms.includes("mac")) {
+ continue;
+ }
+
+ await MacAttribution.setAttributionString(entry.code);
+
+ // Read attribution code from xattr.
+ AttributionCode._clearCache();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code
+ );
+
+ // Read attribution code from cache.
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code
+ );
+
+ // Does not overwrite cached existing attribution code.
+ await MacAttribution.setAttributionString("__MOZCUSTOM__testcode");
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code
+ );
+ }
+});
+
+add_task(async function testInvalidAttrCodes() {
+ for (let code of invalidAttrCodes) {
+ await MacAttribution.setAttributionString("__MOZCUSTOM__" + code);
+
+ // Read attribution code from xattr
+ AttributionCode._clearCache();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Code should have failed to parse: " + code);
+ }
+});
diff --git a/browser/components/attribution/test/xpcshell/test_attribution_parsing.js b/browser/components/attribution/test/xpcshell/test_attribution_parsing.js
new file mode 100644
index 0000000000..abf2456ce4
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_attribution_parsing.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+/**
+ * This test file exists to be run on any platform during development,
+ * whereas the test_AttributionCode.js will test the attribution file
+ * in the app local data dir on Windows. It will only run under
+ * Windows on try.
+ */
+
+/**
+ * Test validation of attribution codes.
+ */
+add_task(async function testValidAttrCodes() {
+ for (let entry of validAttrCodes) {
+ let result = AttributionCode.parseAttributionCode(entry.code);
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code
+ );
+
+ result = AttributionCode.serializeAttributionData(entry.parsed);
+ if (!entry.doesNotRoundtrip) {
+ Assert.deepEqual(
+ result,
+ entry.code,
+ "Serialized data should match expected value, code was: " + entry.code
+ );
+ }
+ }
+});
+
+/**
+ * Make sure codes with various formatting errors are not seen as valid.
+ */
+add_task(async function testInvalidAttrCodes() {
+ for (let code of invalidAttrCodes) {
+ let result = AttributionCode.parseAttributionCode(code);
+ Assert.deepEqual(result, {}, "Code should have failed to parse: " + code);
+ }
+});
diff --git a/browser/components/attribution/test/xpcshell/xpcshell.toml b/browser/components/attribution/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..5013d43519
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/xpcshell.toml
@@ -0,0 +1,14 @@
+[DEFAULT]
+firefox-appdir = "browser"
+run-if = [
+ "os == 'win'",
+ "os == 'mac'",
+]
+head = "head.js"
+
+["test_AttributionCode.js"]
+
+["test_MacAttribution.js"]
+run-if = ["os == 'mac'"] # osx specific tests
+
+["test_attribution_parsing.js"]