tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

AttributionCode.sys.mjs (12055B)


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