summaryrefslogtreecommitdiffstats
path: root/browser/components/attribution/MacAttribution.sys.mjs
blob: cb4a8373c84933744cf64fb5e448ae52863bbd6e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
/* 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/. */

import { XPCOMUtils } from "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();
}

export var MacAttribution = {
  /**
   * The file path to the `.app` directory.
   */
  get applicationPath() {
    // On macOS, `GreD` is like "App.app/Contents/macOS".  Return "App.app".
    return Services.dirsvc.get("GreD", Ci.nsIFile).parent.parent.path;
  },

  /**
   * 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;
  },
};