summaryrefslogtreecommitdiffstats
path: root/browser/components/attribution
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/components/attribution
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/attribution')
-rw-r--r--browser/components/attribution/AttributionCode.jsm402
-rw-r--r--browser/components/attribution/MacAttribution.jsm186
-rw-r--r--browser/components/attribution/moz.build37
-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.js86
-rw-r--r--browser/components/attribution/test/browser/head.js27
-rw-r--r--browser/components/attribution/test/xpcshell/head.js121
-rw-r--r--browser/components/attribution/test/xpcshell/test_AttributionCode.js78
-rw-r--r--browser/components/attribution/test/xpcshell/test_MacAttribution.js93
-rw-r--r--browser/components/attribution/test/xpcshell/test_attribution_parsing.js44
-rw-r--r--browser/components/attribution/test/xpcshell/xpcshell.ini9
15 files changed, 1436 insertions, 0 deletions
diff --git a/browser/components/attribution/AttributionCode.jsm b/browser/components/attribution/AttributionCode.jsm
new file mode 100644
index 0000000000..59fe86920f
--- /dev/null
+++ b/browser/components/attribution/AttributionCode.jsm
@@ -0,0 +1,402 @@
+/* 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"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "AppConstants",
+ "resource://gre/modules/AppConstants.jsm"
+);
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MacAttribution",
+ "resource:///modules/MacAttribution.jsm"
+);
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+ let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {})
+ .ConsoleAPI;
+ let consoleOptions = {
+ // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
+ // messages during development. See LOG_LEVELS in Console.jsm for details.
+ maxLogLevel: "error",
+ maxLogLevelPref: "browser.attribution.loglevel",
+ prefix: "AttributionCode",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+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",
+];
+
+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("LocalAppData", Ci.nsIFile);
+ // appinfo does not exist in xpcshell, so we need defaults.
+ file.append(Services.appinfo.vendor || "mozilla");
+ file.append(AppConstants.MOZ_APP_NAME);
+ 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) {
+ let env = Cc["@mozilla.org/process/environment;1"].getService(
+ Ci.nsIEnvironment
+ );
+ // 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 &&
+ env.exists("XPCSHELL_TEST_PROFILE_DIR")
+ ) {
+ let path = 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) {
+ let file = AttributionCode.attributionFile;
+ let dir = file.parent;
+ try {
+ // This is a simple way to create the entire directory tree.
+ // `OS.File.makeDir` has an awkward API for our situation.
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
+ throw ex;
+ }
+ // Ignore the exception due to a directory that already exists.
+ }
+ let bytes = new TextEncoder().encode(code);
+ await OS.File.writeAtomic(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)) {
+ parsed[key] = value;
+ }
+ } else {
+ 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) {
+ log.debug(
+ `getAttrDataAsync: attribution is cached: ${JSON.stringify(
+ gCachedAttrData
+ )}`
+ );
+ return gCachedAttrData;
+ }
+
+ gCachedAttrData = {};
+ let attributionFile = this.attributionFile;
+ if (!attributionFile) {
+ // This platform doesn't support attribution.
+ log.debug(`getAttrDataAsync: no attribution (attributionFile is null)`);
+ return gCachedAttrData;
+ }
+
+ if (
+ AppConstants.platform == "macosx" &&
+ !(await OS.File.exists(attributionFile.path))
+ ) {
+ log.debug(
+ `getAttrDataAsync: macOS && !exists("${attributionFile.path}")`
+ );
+
+ // On macOS, we fish the attribution data from the system quarantine DB.
+ try {
+ let referrer = await MacAttribution.getReferrerUrl();
+ 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.
+ 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");
+ }
+ }
+
+ 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);
+ log.debug(`macOS attribution data serializes as "${code}"`);
+ await this.writeAttributionFile(code);
+ } catch (ex) {
+ log.debug(`Caught exception writing "${attributionFile.path}"`, ex);
+ Services.telemetry
+ .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
+ .add("write_error");
+ return gCachedAttrData;
+ }
+
+ log.debug(
+ `Returning after successfully writing "${attributionFile.path}"`
+ );
+ return gCachedAttrData;
+ }
+
+ log.debug(`getAttrDataAsync: !macOS || !exists("${attributionFile.path}")`);
+
+ let bytes;
+ try {
+ bytes = await OS.File.read(attributionFile.path);
+ } catch (ex) {
+ if (ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
+ log.debug(
+ `getAttrDataAsync: !exists("${
+ attributionFile.path
+ }"), returning ${JSON.stringify(gCachedAttrData)}`
+ );
+ return gCachedAttrData;
+ }
+ Services.telemetry
+ .getHistogramById("BROWSER_ATTRIBUTION_ERRORS")
+ .add("read_error");
+ }
+ if (bytes) {
+ try {
+ let decoder = new TextDecoder();
+ let code = decoder.decode(bytes);
+ log.debug(
+ `getAttrDataAsync: ${attributionFile.path} 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);
+ 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 OS.File.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() {
+ let env = Cc["@mozilla.org/process/environment;1"].getService(
+ Ci.nsIEnvironment
+ );
+ if (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..e42c5b0209
--- /dev/null
+++ b/browser/components/attribution/MacAttribution.jsm
@@ -0,0 +1,186 @@
+/* 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.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+ let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {})
+ .ConsoleAPI;
+ let consoleOptions = {
+ // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
+ // messages during development. See LOG_LEVELS in Console.jsm for details.
+ maxLogLevel: "error",
+ maxLogLevelPref: "browser.attribution.mac.loglevel",
+ prefix: "MacAttribution",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Subprocess",
+ "resource://gre/modules/Subprocess.jsm"
+);
+
+/**
+ * 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 OS.File.macGetXAttr(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 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) {
+ 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
+ );
+ }
+ log.debug(`getReferrerUrl: guid: ${guid}`);
+
+ // Second, fish the relevant record from the quarantine database.
+ let url = "";
+ try {
+ url = await queryQuarantineDatabase(guid);
+ 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/moz.build b/browser/components/attribution/moz.build
new file mode 100644
index 0000000000..9d8a560eda
--- /dev/null
+++ b/browser/components/attribution/moz.build
@@ -0,0 +1,37 @@
+# -*- 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",
+]
+
+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..bc5a87e585
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js
@@ -0,0 +1,249 @@
+ChromeUtils.defineModuleGetter(
+ this,
+ "TelemetryTestUtils",
+ "resource://testing-common/TelemetryTestUtils.jsm"
+);
+const { MacAttribution } = ChromeUtils.import(
+ "resource:///modules/MacAttribution.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 OS.File.exists(AttributionCode.attributionFile.path));
+ Assert.deepEqual(
+ "",
+ new TextDecoder().decode(
+ await OS.File.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"
+ );
+
+ 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 `OS.File.writeAtomic` while
+ // this test is running. Be careful to only stub the one call.
+ const writeAtomic = sandbox.stub(OS.File, "writeAtomic");
+ writeAtomic
+ .withArgs(
+ sinon.match(AttributionCode.attributionFile.path),
+ sinon.match.any
+ )
+ .throws(() => new Error("write_error"));
+ OS.File.writeAtomic.callThrough();
+
+ // 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 {
+ 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 = Cc["@mozilla.org/uuid-generator;1"]
+ .getService(Ci.nsIUUIDGenerator)
+ .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 OS.File.macSetXAttr(
+ 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..1bd997d4fd
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js
@@ -0,0 +1,86 @@
+ChromeUtils.defineModuleGetter(
+ this,
+ "TelemetryTestUtils",
+ "resource://testing-common/TelemetryTestUtils.jsm"
+);
+const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.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
+ 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"
+ );
+ const sandbox = sinon.createSandbox();
+ // 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
+ const exists = sandbox.stub(OS.File, "exists");
+ exists.resolves(true);
+ const read = sandbox.stub(OS.File, "read");
+ read.throws(() => new Error("read_error"));
+ // 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();
+ sandbox.restore();
+});
diff --git a/browser/components/attribution/test/browser/head.js b/browser/components/attribution/test/browser/head.js
new file mode 100644
index 0000000000..f7f54a1aeb
--- /dev/null
+++ b/browser/components/attribution/test/browser/head.js
@@ -0,0 +1,27 @@
+/* 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"
+);
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.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_task(function setup() {
+ // AttributionCode._clearCache is only possible in a testing environment
+ let env = Cc["@mozilla.org/process/environment;1"].getService(
+ Ci.nsIEnvironment
+ );
+ env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
+
+ registerCleanupFunction(() => {
+ 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..2e6c46e722
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/head.js
@@ -0,0 +1,121 @@
+/* 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%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.import(
+ "resource://gre/modules/AppConstants.jsm"
+ );
+ const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+ 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 OS.File.makeDir(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..f222c70a98
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_AttributionCode.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+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..4fb773264c
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_MacAttribution.js
@@ -0,0 +1,93 @@
+/* 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) {
+ // 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..f88924e216
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/xpcshell.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+firefox-appdir = browser
+skip-if = (os != "win" && toolkit != "cocoa") # Only available on Windows and macOS
+head = head.js
+
+[test_AttributionCode.js]
+[test_MacAttribution.js]
+skip-if = toolkit != "cocoa" # osx specific tests
+[test_attribution_parsing.js]