summaryrefslogtreecommitdiffstats
path: root/services/settings/RemoteSettings.worker.mjs
blob: 66228f226ef814fe4e059229c9cfb9e00fc7cc99 (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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
/* 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/. */

/**
 * A worker dedicated to Remote Settings.
 */

// These files are imported into the worker scope, and are not shared singletons
// with the main thread.
/* eslint-disable mozilla/reject-import-system-module-from-non-system */
import { CanonicalJSON } from "resource://gre/modules/CanonicalJSON.sys.mjs";
import { IDBHelpers } from "resource://services-settings/IDBHelpers.sys.mjs";
import { SharedUtils } from "resource://services-settings/SharedUtils.sys.mjs";
import { jsesc } from "resource://gre/modules/third_party/jsesc/jsesc.mjs";
/* eslint-enable mozilla/reject-import-system-module-from-non-system */

const IDB_RECORDS_STORE = "records";
const IDB_TIMESTAMPS_STORE = "timestamps";

let gShutdown = false;

const Agent = {
  /**
   * Return the canonical JSON serialization of the specified records.
   * It has to match what is done on the server (See Kinto/kinto-signer).
   *
   * @param {Array<Object>} records
   * @param {String} timestamp
   * @returns {String}
   */
  async canonicalStringify(records, timestamp) {
    // Sort list by record id.
    let allRecords = records.sort((a, b) => {
      if (a.id < b.id) {
        return -1;
      }
      return a.id > b.id ? 1 : 0;
    });
    // All existing records are replaced by the version from the server
    // and deleted records are removed.
    for (let i = 0; i < allRecords.length /* no increment! */; ) {
      const rec = allRecords[i];
      const next = allRecords[i + 1];
      if ((next && rec.id == next.id) || rec.deleted) {
        allRecords.splice(i, 1); // remove local record
      } else {
        i++;
      }
    }
    const toSerialize = {
      last_modified: "" + timestamp,
      data: allRecords,
    };
    return CanonicalJSON.stringify(toSerialize, jsesc);
  },

  /**
   * If present, import the JSON file into the Remote Settings IndexedDB
   * for the specified bucket and collection.
   * (eg. blocklists/certificates, main/onboarding)
   * @param {String} bucket
   * @param {String} collection
   * @returns {int} Number of records loaded from dump or -1 if no dump found.
   */
  async importJSONDump(bucket, collection) {
    const { data: records, timestamp } = await SharedUtils.loadJSONDump(
      bucket,
      collection
    );
    if (records === null) {
      // Return -1 if file is missing.
      return -1;
    }
    if (gShutdown) {
      throw new Error("Can't import when we've started shutting down.");
    }
    await importDumpIDB(bucket, collection, records, timestamp);
    return records.length;
  },

  /**
   * Check that the specified file matches the expected size and SHA-256 hash.
   * @param {String} fileUrl file URL to read from
   * @param {Number} size expected file size
   * @param {String} size expected file SHA-256 as hex string
   * @returns {boolean}
   */
  async checkFileHash(fileUrl, size, hash) {
    let resp;
    try {
      resp = await fetch(fileUrl);
    } catch (e) {
      // File does not exist.
      return false;
    }
    const buffer = await resp.arrayBuffer();
    return SharedUtils.checkContentHash(buffer, size, hash);
  },

  async prepareShutdown() {
    gShutdown = true;
    // Ensure we can iterate and abort (which may delete items) by cloning
    // the list.
    let transactions = Array.from(gPendingTransactions);
    for (let transaction of transactions) {
      try {
        transaction.abort();
      } catch (ex) {
        // We can hit this case if the transaction has finished but
        // we haven't heard about it yet.
      }
    }
  },

  _test_only_import(bucket, collection, records, timestamp) {
    return importDumpIDB(bucket, collection, records, timestamp);
  },
};

/**
 * Wrap worker invocations in order to return the `callbackId` along
 * the result. This will allow to transform the worker invocations
 * into promises in `RemoteSettingsWorker.sys.mjs`.
 */
self.onmessage = event => {
  const { callbackId, method, args = [] } = event.data;
  Agent[method](...args)
    .then(result => {
      self.postMessage({ callbackId, result });
    })
    .catch(error => {
      console.log(`RemoteSettingsWorker error: ${error}`);
      self.postMessage({ callbackId, error: "" + error });
    });
};

let gPendingTransactions = new Set();

/**
 * Import the records into the Remote Settings Chrome IndexedDB.
 *
 * Note: This duplicates some logics from `kinto-offline-client.js`.
 *
 * @param {String} bucket
 * @param {String} collection
 * @param {Array<Object>} records
 * @param {Number} timestamp
 */
async function importDumpIDB(bucket, collection, records, timestamp) {
  // Open the DB. It will exist since if we are running this, it means
  // we already tried to read the timestamp in `remote-settings.sys.mjs`
  const db = await IDBHelpers.openIDB(false /* do not allow upgrades */);

  // try...finally to ensure we always close the db.
  try {
    if (gShutdown) {
      throw new Error("Can't import when we've started shutting down.");
    }

    // Each entry of the dump will be stored in the records store.
    // They are indexed by `_cid`.
    const cid = bucket + "/" + collection;
    // We can just modify the items in-place, as we got them from SharedUtils.loadJSONDump().
    records.forEach(item => {
      item._cid = cid;
    });
    // Store the collection timestamp.
    let { transaction, promise } = IDBHelpers.executeIDB(
      db,
      [IDB_RECORDS_STORE, IDB_TIMESTAMPS_STORE],
      "readwrite",
      ([recordsStore, timestampStore], rejectTransaction) => {
        // Wipe before loading
        recordsStore.delete(IDBKeyRange.bound([cid], [cid, []], false, true));
        IDBHelpers.bulkOperationHelper(
          recordsStore,
          {
            reject: rejectTransaction,
            completion() {
              timestampStore.put({ cid, value: timestamp });
            },
          },
          "put",
          records
        );
      }
    );
    gPendingTransactions.add(transaction);
    promise = promise.finally(() => gPendingTransactions.delete(transaction));
    await promise;
  } finally {
    // Close now that we're done.
    db.close();
  }
}