summaryrefslogtreecommitdiffstats
path: root/browser/components/attribution
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/attribution')
-rw-r--r--browser/components/attribution/AttributionCode.jsm453
-rw-r--r--browser/components/attribution/MacAttribution.jsm181
-rw-r--r--browser/components/attribution/docs/index.rst26
-rw-r--r--browser/components/attribution/moz.build39
-rw-r--r--browser/components/attribution/nsIMacAttribution.idl23
-rw-r--r--browser/components/attribution/nsMacAttribution.cpp51
-rw-r--r--browser/components/attribution/nsMacAttribution.h22
-rw-r--r--browser/components/attribution/test/browser/browser.ini8
-rw-r--r--browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js249
-rw-r--r--browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js101
-rw-r--r--browser/components/attribution/test/browser/head.js23
-rw-r--r--browser/components/attribution/test/xpcshell/head.js134
-rw-r--r--browser/components/attribution/test/xpcshell/test_AttributionCode.js72
-rw-r--r--browser/components/attribution/test/xpcshell/test_MacAttribution.js97
-rw-r--r--browser/components/attribution/test/xpcshell/test_attribution_parsing.js44
-rw-r--r--browser/components/attribution/test/xpcshell/xpcshell.ini10
16 files changed, 1533 insertions, 0 deletions
diff --git a/browser/components/attribution/AttributionCode.jsm b/browser/components/attribution/AttributionCode.jsm
new file mode 100644
index 0000000000..ce7072c83f
--- /dev/null
+++ b/browser/components/attribution/AttributionCode.jsm
@@ -0,0 +1,453 @@
+/* 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/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = ["AttributionCode", "AttributionIOUtils"];
+
+/**
+ * This is a policy object used to override behavior for testing.
+ */
+const AttributionIOUtils = {
+ write: async (path, bytes) => IOUtils.write(path, bytes),
+ read: async path => IOUtils.read(path),
+ exists: async path => IOUtils.exists(path),
+};
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "MacAttribution",
+ "resource:///modules/MacAttribution.jsm"
+);
+XPCOMUtils.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",
+];
+
+let gCachedAttrData = null;
+
+var AttributionCode = {
+ /**
+ * 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;
+ } else if (AppConstants.platform == "macosx") {
+ // There's no `UpdRootD` in xpcshell tests. Some existing tests override
+ // it, which is onerous and difficult to share across tests. When testing,
+ // if it's not defined, fallback to a nested subdirectory of the xpcshell
+ // temp directory. Nesting more closely replicates the situation where the
+ // update directory does not (yet) exist, testing a scenario witnessed in
+ // development.
+ let file;
+ try {
+ file = Services.dirsvc.get("UpdRootD", Ci.nsIFile);
+ } catch (ex) {
+ // It's most common to test for the profile dir, even though we actually
+ // are using the temp dir.
+ if (
+ ex instanceof Ci.nsIException &&
+ ex.result == Cr.NS_ERROR_FAILURE &&
+ Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")
+ ) {
+ let path = Services.env.get("XPCSHELL_TEST_TEMP_DIR");
+ file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(path);
+ file.append("nested_UpdRootD_1");
+ file.append("nested_UpdRootD_2");
+ } else {
+ throw ex;
+ }
+ }
+ file.append("macAttributionData");
+ 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, and Mac
+ // attribution, 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 an object containing a key-value pair for each piece of attribution
+ * data included in the passed-in URL containing a query string encoding an
+ * attribution code.
+ *
+ * We have less control of the attribution codes on macOS so we accept more
+ * URLs than we accept attribution codes on Windows.
+ *
+ * If the URL is empty, returns an empty object.
+ *
+ * If the URL doesn't parse, throws.
+ */
+ parseAttributionCodeFromUrl(url) {
+ if (!url) {
+ return {};
+ }
+
+ let parsed = {};
+
+ let params = new URL(url).searchParams;
+ for (let key of ATTR_CODE_KEYS) {
+ // We support the key prefixed with utm_ or not, but intentionally
+ // choose non-utm params over utm params.
+ for (let paramKey of [`utm_${key}`, `funnel_${key}`, key]) {
+ if (params.has(paramKey)) {
+ // We expect URI-encoded components in our attribution codes.
+ let value = encodeURIComponent(params.get(paramKey));
+ if (value && ATTR_CODE_VALUE_REGEX.test(value)) {
+ parsed[key] = value;
+ }
+ }
+ }
+ }
+
+ return parsed;
+ },
+
+ /**
+ * 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;
+ },
+
+ /**
+ * 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 (gCachedAttrData != null) {
+ lazy.log.debug(
+ `getAttrDataAsync: attribution is cached: ${JSON.stringify(
+ gCachedAttrData
+ )}`
+ );
+ return gCachedAttrData;
+ }
+
+ gCachedAttrData = {};
+ let attributionFile = this.attributionFile;
+ if (!attributionFile) {
+ // This platform doesn't support attribution.
+ lazy.log.debug(
+ `getAttrDataAsync: no attribution (attributionFile is null)`
+ );
+ return gCachedAttrData;
+ }
+
+ if (
+ AppConstants.platform == "macosx" &&
+ !(await AttributionIOUtils.exists(attributionFile.path))
+ ) {
+ lazy.log.debug(
+ `getAttrDataAsync: macOS && !exists("${attributionFile.path}")`
+ );
+
+ // On macOS, we fish the attribution data from the system quarantine DB.
+ try {
+ let referrer = await lazy.MacAttribution.getReferrerUrl();
+ lazy.log.debug(
+ `getAttrDataAsync: macOS attribution getReferrerUrl: "${referrer}"`
+ );
+
+ gCachedAttrData = this.parseAttributionCodeFromUrl(referrer);
+ } 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)}`
+ );
+
+ // We only want to try to fetch the referrer from the quarantine
+ // database once on macOS.
+ try {
+ let code = this.serializeAttributionData(gCachedAttrData);
+ lazy.log.debug(`macOS attribution data serializes as "${code}"`);
+ await this.writeAttributionFile(code);
+ } catch (ex) {
+ lazy.log.debug(
+ `Caught exception writing "${attributionFile.path}"`,
+ ex
+ );
+ Services.telemetry
+ .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
+ .add("write_error");
+ return gCachedAttrData;
+ }
+
+ lazy.log.debug(
+ `Returning after successfully writing "${attributionFile.path}"`
+ );
+ return gCachedAttrData;
+ }
+
+ lazy.log.debug(
+ `getAttrDataAsync: !macOS || !exists("${attributionFile.path}")`
+ );
+
+ let bytes;
+ try {
+ if (
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId")
+ ) {
+ // 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();
+ bytes = encoder.encode(
+ encodeURIComponent(
+ Cc["@mozilla.org/windows-package-manager;1"]
+ .createInstance(Ci.nsIWindowsPackageManager)
+ .getCampaignId()
+ )
+ );
+ } else {
+ bytes = await AttributionIOUtils.read(attributionFile.path);
+ }
+ } 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() {
+ 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.jsm b/browser/components/attribution/MacAttribution.jsm
new file mode 100644
index 0000000000..0170bf3246
--- /dev/null
+++ b/browser/components/attribution/MacAttribution.jsm
@@ -0,0 +1,181 @@
+/* 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/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = ["MacAttribution"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const lazy = {};
+XPCOMUtils.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.mac.loglevel",
+ prefix: "MacAttribution",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Subprocess: "resource://gre/modules/Subprocess.sys.mjs",
+});
+
+/**
+ * Get the location of the user's macOS quarantine database.
+ * @return {String} path.
+ */
+function getQuarantineDatabasePath() {
+ let file = Services.dirsvc.get("Home", Ci.nsIFile);
+ file.append("Library");
+ file.append("Preferences");
+ file.append("com.apple.LaunchServices.QuarantineEventsV2");
+ return file.path;
+}
+
+/**
+ * Query given path for quarantine extended attributes.
+ * @param {String} path of the file to query.
+ * @return {[String, String]} pair of the quarantine data GUID and remaining
+ * quarantine data (usually, Gatekeeper flags).
+ * @throws NS_ERROR_NOT_AVAILABLE if there is no quarantine GUID for the given path.
+ * @throws NS_ERROR_UNEXPECTED if there is a quarantine GUID, but it is malformed.
+ */
+async function getQuarantineAttributes(path) {
+ let bytes = await IOUtils.getMacXAttr(path, "com.apple.quarantine");
+ if (!bytes) {
+ throw new Components.Exception(
+ `No macOS quarantine xattrs found for ${path}`,
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+
+ let string = new TextDecoder("utf-8").decode(bytes);
+ let parts = string.split(";");
+ if (!parts.length) {
+ throw new Components.Exception(
+ `macOS quarantine data is not ; separated`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+ let guid = parts[parts.length - 1];
+ if (guid.length != 36) {
+ // Like "12345678-90AB-CDEF-1234-567890ABCDEF".
+ throw new Components.Exception(
+ `macOS quarantine data guid is not length 36: ${guid.length}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ return { guid, parts };
+}
+
+/**
+ * Invoke system SQLite binary to extract the referrer URL corresponding to
+ * the given GUID from the given macOS quarantine database.
+ * @param {String} path of the user's macOS quarantine database.
+ * @param {String} guid to query.
+ * @return {String} referrer URL.
+ */
+async function queryQuarantineDatabase(
+ guid,
+ path = getQuarantineDatabasePath()
+) {
+ let query = `SELECT COUNT(*), LSQuarantineOriginURLString
+ FROM LSQuarantineEvent
+ WHERE LSQuarantineEventIdentifier = '${guid}'
+ ORDER BY LSQuarantineTimeStamp DESC LIMIT 1`;
+
+ let proc = await lazy.Subprocess.call({
+ command: "/usr/bin/sqlite3",
+ arguments: [path, query],
+ environment: {},
+ stderr: "stdout",
+ });
+
+ let stdout = await proc.stdout.readString();
+
+ let { exitCode } = await proc.wait();
+ if (exitCode != 0) {
+ throw new Components.Exception(
+ "Failed to run sqlite3",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ // Output is like "integer|url".
+ let parts = stdout.split("|", 2);
+ if (parts.length != 2) {
+ throw new Components.Exception(
+ "Failed to parse sqlite3 output",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ if (parts[0].trim() == "0") {
+ throw new Components.Exception(
+ `Quarantine database does not contain URL for guid ${guid}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ return parts[1].trim();
+}
+
+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;
+ },
+
+ /**
+ * Used by the Attributions system to get the download referrer.
+ *
+ * @param {String} path to get the quarantine data from.
+ * Usually this is a `.app` directory but can be any
+ * (existing) file or directory. Default: `this.applicationPath`.
+ * @return {String} referrer URL.
+ * @throws NS_ERROR_NOT_AVAILABLE if there is no quarantine GUID for the given path.
+ * @throws NS_ERROR_UNEXPECTED if there is a quarantine GUID, but no corresponding referrer URL is known.
+ */
+ async getReferrerUrl(path = this.applicationPath) {
+ lazy.log.debug(`getReferrerUrl(${JSON.stringify(path)})`);
+
+ // First, determine the quarantine GUID assigned by macOS to the given path.
+ let guid;
+ try {
+ guid = (await getQuarantineAttributes(path)).guid;
+ } catch (ex) {
+ throw new Components.Exception(
+ `No macOS quarantine GUID found for ${path}`,
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+ lazy.log.debug(`getReferrerUrl: guid: ${guid}`);
+
+ // Second, fish the relevant record from the quarantine database.
+ let url = "";
+ try {
+ url = await queryQuarantineDatabase(guid);
+ lazy.log.debug(`getReferrerUrl: url: ${url}`);
+ } catch (ex) {
+ // This path is known to macOS but we failed to extract a referrer -- be noisy.
+ throw new Components.Exception(
+ `No macOS quarantine referrer URL found for ${path} with GUID ${guid}`,
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+
+ return url;
+ },
+};
diff --git a/browser/components/attribution/docs/index.rst b/browser/components/attribution/docs/index.rst
new file mode 100644
index 0000000000..8e1ee7ae20
--- /dev/null
+++ b/browser/components/attribution/docs/index.rst
@@ -0,0 +1,26 @@
+========================
+Installation Attribution
+========================
+
+---------------
+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..9513942e86
--- /dev/null
+++ b/browser/components/attribution/moz.build
@@ -0,0 +1,39 @@
+# -*- 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.ini"]
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
+
+EXTRA_JS_MODULES += [
+ "AttributionCode.jsm",
+]
+
+SPHINX_TREES["docs"] = "docs"
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ XPIDL_SOURCES += [
+ "nsIMacAttribution.idl",
+ ]
+
+ XPIDL_MODULE = "attribution"
+
+ EXPORTS += [
+ "nsMacAttribution.h",
+ ]
+
+ SOURCES += [
+ "nsMacAttribution.cpp",
+ ]
+
+ FINAL_LIBRARY = "browsercomps"
+
+ EXTRA_JS_MODULES += [
+ "MacAttribution.jsm",
+ ]
diff --git a/browser/components/attribution/nsIMacAttribution.idl b/browser/components/attribution/nsIMacAttribution.idl
new file mode 100644
index 0000000000..5bd1818817
--- /dev/null
+++ b/browser/components/attribution/nsIMacAttribution.idl
@@ -0,0 +1,23 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsISupports.idl"
+
+[scriptable, uuid(6FC66A78-6CBC-4B3F-B7BA-379289B29276)]
+interface nsIMacAttributionService : nsISupports
+{
+ /**
+ * Set the referrer URL on a given path.
+ *
+ * @param aFilePath A path to the file to set the quarantine data on.
+ * @param aReferrer A url to set as the referrer for the download.
+ * @param aCreate If true, creates new quarantine properties, overwriting
+ * any existing properties. If false, the referrer is only
+ * set if quarantine properties already exist on the file.
+ */
+ void setReferrerUrl(in AUTF8String aFilePath,
+ in AUTF8String aReferrer,
+ in boolean aCreate);
+};
diff --git a/browser/components/attribution/nsMacAttribution.cpp b/browser/components/attribution/nsMacAttribution.cpp
new file mode 100644
index 0000000000..7397285794
--- /dev/null
+++ b/browser/components/attribution/nsMacAttribution.cpp
@@ -0,0 +1,51 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsMacAttribution.h"
+
+#include <CoreFoundation/CoreFoundation.h>
+#include <ApplicationServices/ApplicationServices.h>
+
+#include "../../../xpcom/io/CocoaFileUtils.h"
+#include "nsCocoaFeatures.h"
+#include "nsString.h"
+
+using namespace mozilla;
+
+NS_IMPL_ISUPPORTS(nsMacAttributionService, nsIMacAttributionService)
+
+NS_IMETHODIMP
+nsMacAttributionService::SetReferrerUrl(const nsACString& aFilePath,
+ const nsACString& aReferrerUrl,
+ const bool aCreate) {
+ const nsCString& flat = PromiseFlatCString(aFilePath);
+ CFStringRef filePath = ::CFStringCreateWithCString(
+ kCFAllocatorDefault, flat.get(), kCFStringEncodingUTF8);
+
+ if (!filePath) {
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ const nsCString& flatReferrer = PromiseFlatCString(aReferrerUrl);
+ CFStringRef referrer = ::CFStringCreateWithCString(
+ kCFAllocatorDefault, flatReferrer.get(), kCFStringEncodingUTF8);
+ if (!referrer) {
+ ::CFRelease(filePath);
+ return NS_ERROR_UNEXPECTED;
+ }
+ CFURLRef referrerURL =
+ ::CFURLCreateWithString(kCFAllocatorDefault, referrer, nullptr);
+
+ CocoaFileUtils::AddQuarantineMetadataToFile(filePath, NULL, referrerURL, true,
+ aCreate);
+
+ ::CFRelease(filePath);
+ ::CFRelease(referrer);
+ if (referrerURL) {
+ ::CFRelease(referrerURL);
+ }
+
+ return NS_OK;
+}
diff --git a/browser/components/attribution/nsMacAttribution.h b/browser/components/attribution/nsMacAttribution.h
new file mode 100644
index 0000000000..9ccbee4ea4
--- /dev/null
+++ b/browser/components/attribution/nsMacAttribution.h
@@ -0,0 +1,22 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef nsmacattribution_h____
+#define nsmacattribution_h____
+
+#include "nsIMacAttribution.h"
+
+class nsMacAttributionService : public nsIMacAttributionService {
+ public:
+ nsMacAttributionService(){};
+
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIMACATTRIBUTIONSERVICE
+
+ protected:
+ virtual ~nsMacAttributionService(){};
+};
+
+#endif // nsmacattribution_h____
diff --git a/browser/components/attribution/test/browser/browser.ini b/browser/components/attribution/test/browser/browser.ini
new file mode 100644
index 0000000000..55050ac799
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+support-files =
+ head.js
+
+[browser_AttributionCode_telemetry.js]
+skip-if = (os != "win" && toolkit != "cocoa") # Windows and macOS only telemetry.
+[browser_AttributionCode_Mac_telemetry.js]
+skip-if = toolkit != "cocoa" # macOS only telemetry.
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..126d0d880d
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js
@@ -0,0 +1,249 @@
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+const { MacAttribution } = ChromeUtils.import(
+ "resource:///modules/MacAttribution.jsm"
+);
+const { AttributionIOUtils } = ChromeUtils.import(
+ "resource:///modules/AttributionCode.jsm"
+);
+const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+
+async function assertCacheExistsAndIsEmpty() {
+ // We should have written to the cache, and be able to read back
+ // with no errors.
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+ histogram.clear();
+
+ ok(await AttributionIOUtils.exists(AttributionCode.attributionFile.path));
+ Assert.deepEqual(
+ "",
+ new TextDecoder().decode(
+ await AttributionIOUtils.read(AttributionCode.attributionFile.path)
+ )
+ );
+
+ AttributionCode._clearCache();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Should be able to get cached result");
+
+ Assert.deepEqual({}, histogram.snapshot().values || {});
+}
+
+add_task(async function test_write_error() {
+ const sandbox = sinon.createSandbox();
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ attributionSvc.setReferrerUrl(
+ MacAttribution.applicationPath,
+ "https://example.com?content=content",
+ true
+ );
+
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+
+ let oldExists = AttributionIOUtils.exists;
+ let oldWrite = AttributionIOUtils.write;
+ try {
+ // Clear any existing telemetry
+ histogram.clear();
+
+ // Force the file to not exist and then cause a write error. This is delicate
+ // because various background tasks may invoke `IOUtils.writeAtomic` while
+ // this test is running. Be careful to only stub the one call.
+ AttributionIOUtils.exists = () => false;
+ AttributionIOUtils.write = () => {
+ throw new Error("write_error");
+ };
+
+ // Try to read the attribution code.
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ { content: "content" },
+ "Should be able to get a result even if the file doesn't write"
+ );
+
+ TelemetryTestUtils.assertHistogram(histogram, INDEX_WRITE_ERROR, 1);
+ } finally {
+ AttributionIOUtils.exists = oldExists;
+ AttributionIOUtils.write = oldWrite;
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ histogram.clear();
+ sandbox.restore();
+ }
+});
+
+add_task(async function test_unusual_referrer() {
+ // This referrer URL looks malformed, but the malformed bits are dropped, so
+ // it's actually ok. This is what allows extraneous bits like `fbclid` tags
+ // to be ignored.
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ attributionSvc.setReferrerUrl(
+ MacAttribution.applicationPath,
+ "https://example.com?content=&=campaign",
+ true
+ );
+
+ await AttributionCode.deleteFileAsync();
+ 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 || {});
+
+ await assertCacheExistsAndIsEmpty();
+ } finally {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ histogram.clear();
+ }
+});
+
+add_task(async function test_blank_referrer() {
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ attributionSvc.setReferrerUrl(MacAttribution.applicationPath, "", true);
+
+ await AttributionCode.deleteFileAsync();
+ 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 || {});
+
+ await assertCacheExistsAndIsEmpty();
+ } finally {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ histogram.clear();
+ }
+});
+
+add_task(async function test_no_referrer() {
+ const sandbox = sinon.createSandbox();
+ let newApplicationPath = MacAttribution.applicationPath + ".test";
+ sandbox.stub(MacAttribution, "applicationPath").get(() => newApplicationPath);
+
+ await AttributionCode.deleteFileAsync();
+ 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 || {});
+
+ await assertCacheExistsAndIsEmpty();
+ } finally {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ histogram.clear();
+ sandbox.restore();
+ }
+});
+
+add_task(async function test_broken_referrer() {
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ attributionSvc.setReferrerUrl(
+ MacAttribution.applicationPath,
+ "https://example.com?content=content",
+ true
+ );
+
+ // This uses macOS internals to change the GUID so that it will look like the
+ // application has quarantine data but nothing will be pressent in the
+ // quarantine database. This shouldn't happen in the wild.
+ function generateQuarantineGUID() {
+ let str = Services.uuid
+ .generateUUID()
+ .toString()
+ .toUpperCase();
+ // Strip {}.
+ return str.substring(1, str.length - 1);
+ }
+
+ // These magic constants are macOS GateKeeper flags.
+ let string = [
+ "01c1",
+ "5991b778",
+ "Safari.app",
+ generateQuarantineGUID(),
+ ].join(";");
+ let bytes = new TextEncoder().encode(string);
+ await IOUtils.setMacXAttr(
+ MacAttribution.applicationPath,
+ "com.apple.quarantine",
+ bytes
+ );
+
+ await AttributionCode.deleteFileAsync();
+ 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");
+
+ TelemetryTestUtils.assertHistogram(histogram, INDEX_QUARANTINE_ERROR, 1);
+ histogram.clear();
+
+ await assertCacheExistsAndIsEmpty();
+ } finally {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ histogram.clear();
+ }
+});
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..f51c7fbd15
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js
@@ -0,0 +1,101 @@
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+const { AttributionIOUtils } = ChromeUtils.import(
+ "resource:///modules/AttributionCode.jsm"
+);
+
+add_task(async function test_parse_error() {
+ if (AppConstants.platform == "macosx") {
+ // On macOS, the underlying data is the OS-level quarantine
+ // database. We need to start from nothing to isolate the cache.
+ const { MacAttribution } = ChromeUtils.import(
+ "resource:///modules/MacAttribution.jsm"
+ );
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ attributionSvc.setReferrerUrl(MacAttribution.applicationPath, "", true);
+ }
+
+ 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();
+ // Empty string is valid on macOS.
+ await AttributionCode.writeAttributionFile(
+ AppConstants.platform == "macosx" ? "invalid" : ""
+ );
+ 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");
+ };
+
+ registerCleanupFunction(() => {
+ AttributionIOUtils.exists = oldExists;
+ AttributionIOUtils.read = oldRead;
+ });
+
+ // 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..78e233e9f8
--- /dev/null
+++ b/browser/components/attribution/test/browser/head.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AttributionCode } = ChromeUtils.import(
+ "resource:///modules/AttributionCode.jsm"
+);
+
+// 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;
+
+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..389d27ba43
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/head.js
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AttributionCode } = ChromeUtils.import(
+ "resource:///modules/AttributionCode.jsm"
+);
+
+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" },
+ },
+];
+
+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.import("resource://testing-common/Sinon.jsm");
+
+ // 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.import(
+ "resource:///modules/MacAttribution.jsm"
+ );
+ 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..07006f0039
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_AttributionCode.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+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() {
+ for (let entry of validAttrCodes) {
+ AttributionCode._clearCache();
+ await AttributionCode.writeAttributionFile(entry.code);
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code
+ );
+ }
+ AttributionCode._clearCache();
+});
+
+/**
+ * Make sure codes with various formatting errors are not seen as valid.
+ */
+add_task(async function testInvalidAttrCodes() {
+ for (let code of invalidAttrCodes) {
+ AttributionCode._clearCache();
+ await AttributionCode.writeAttributionFile(code);
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Code should have failed to parse: " + code);
+ }
+ AttributionCode._clearCache();
+});
+
+/**
+ * Test the cache by deleting the attribution data file
+ * and making sure we still get the expected code.
+ */
+add_task(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..5c3ceebf30
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_MacAttribution.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { MacAttribution } = ChromeUtils.import(
+ "resource:///modules/MacAttribution.jsm"
+);
+
+add_task(async () => {
+ await setupStubs();
+});
+
+add_task(async function testValidAttrCodes() {
+ let appPath = MacAttribution.applicationPath;
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+
+ for (let entry of validAttrCodes) {
+ if (entry.platforms && !entry.platforms.includes("mac")) {
+ continue;
+ }
+
+ // Set a url referrer. In the macOS quarantine database, the
+ // referrer URL has components that areURI-encoded. Our test data
+ // URI-encodes the components and also the separators (?, &, =).
+ // So we decode it and re-encode it to leave just the components
+ // URI-encoded.
+ let url = `http://example.com?${encodeURI(decodeURIComponent(entry.code))}`;
+ attributionSvc.setReferrerUrl(appPath, url, true);
+ let referrer = await MacAttribution.getReferrerUrl(appPath);
+ equal(referrer, url, "overwrite referrer url");
+
+ // Read attribution code from referrer.
+ await AttributionCode.deleteFileAsync();
+ 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 file.
+ AttributionCode._clearCache();
+ 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.
+ attributionSvc.setReferrerUrl(appPath, "http://test.com", false);
+ referrer = await MacAttribution.getReferrerUrl(appPath);
+ equal(referrer, url, "update referrer url");
+
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code
+ );
+ }
+});
+
+add_task(async function testInvalidAttrCodes() {
+ let appPath = MacAttribution.applicationPath;
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+
+ for (let code of invalidAttrCodes) {
+ // Set a url referrer. Not all of these invalid codes can be represented
+ // in the quarantine database; skip those ones.
+ let url = `http://example.com?${code}`;
+ let referrer;
+ try {
+ attributionSvc.setReferrerUrl(appPath, url, true);
+ referrer = await MacAttribution.getReferrerUrl(appPath);
+ } catch (ex) {
+ continue;
+ }
+ if (!referrer) {
+ continue;
+ }
+ equal(referrer, url, "overwrite referrer url");
+
+ // Read attribution code from referrer.
+ await AttributionCode.deleteFileAsync();
+ 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.ini b/browser/components/attribution/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..9ad49727b9
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/xpcshell.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+firefox-appdir = browser
+skip-if = (os != "win" && toolkit != "cocoa") # Only available on Windows and macOS
+head = head.js
+
+[test_AttributionCode.js]
+skip-if = os == "win" && msix # https://bugzilla.mozilla.org/show_bug.cgi?id=1807924
+[test_MacAttribution.js]
+skip-if = toolkit != "cocoa" # osx specific tests
+[test_attribution_parsing.js]