RemoteSettings.worker.mjs (6333B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * A worker dedicated to Remote Settings. 7 */ 8 9 // These files are imported into the worker scope, and are not shared singletons 10 // with the main thread. 11 /* eslint-disable mozilla/reject-import-system-module-from-non-system */ 12 import { CanonicalJSON } from "resource://gre/modules/CanonicalJSON.sys.mjs"; 13 import { IDBHelpers } from "resource://services-settings/IDBHelpers.sys.mjs"; 14 import { SharedUtils } from "resource://services-settings/SharedUtils.sys.mjs"; 15 import { jsesc } from "resource://gre/modules/third_party/jsesc/jsesc.mjs"; 16 /* eslint-enable mozilla/reject-import-system-module-from-non-system */ 17 18 const IDB_RECORDS_STORE = "records"; 19 const IDB_TIMESTAMPS_STORE = "timestamps"; 20 21 let gShutdown = false; 22 23 const Agent = { 24 /** 25 * Return the canonical JSON serialization of the specified records. 26 * It has to match what is done on the server (See Kinto/kinto-signer). 27 * 28 * @param {Array<object>} records 29 * @param {string} timestamp 30 * @returns {string} 31 */ 32 async canonicalStringify(records, timestamp) { 33 // Sort list by record id. 34 let allRecords = records.sort((a, b) => { 35 if (a.id < b.id) { 36 return -1; 37 } 38 return a.id > b.id ? 1 : 0; 39 }); 40 // All existing records are replaced by the version from the server 41 // and deleted records are removed. 42 for (let i = 0; i < allRecords.length /* no increment! */; ) { 43 const rec = allRecords[i]; 44 const next = allRecords[i + 1]; 45 if ((next && rec.id == next.id) || rec.deleted) { 46 allRecords.splice(i, 1); // remove local record 47 } else { 48 i++; 49 } 50 } 51 const toSerialize = { 52 last_modified: "" + timestamp, 53 data: allRecords, 54 }; 55 return CanonicalJSON.stringify(toSerialize, jsesc); 56 }, 57 58 /** 59 * If present, import the JSON file into the Remote Settings IndexedDB 60 * for the specified bucket and collection. 61 * (eg. blocklists/certificates, main/onboarding) 62 * 63 * @param {string} bucket 64 * @param {string} collection 65 * @returns {int} Number of records loaded from dump or -1 if no dump found. 66 */ 67 async importJSONDump(bucket, collection) { 68 const { data: records, timestamp } = await SharedUtils.loadJSONDump( 69 bucket, 70 collection 71 ); 72 if (records === null) { 73 // Return -1 if file is missing. 74 return -1; 75 } 76 if (gShutdown) { 77 throw new Error("Can't import when we've started shutting down."); 78 } 79 await importDumpIDB(bucket, collection, records, timestamp); 80 return records.length; 81 }, 82 83 /** 84 * Check that the specified file matches the expected size and SHA-256 hash. 85 * 86 * @param {string} fileUrl file URL to read from 87 * @param {number} size expected file size 88 * @param {string} size expected file SHA-256 as hex string 89 * @returns {boolean} 90 */ 91 async checkFileHash(fileUrl, size, hash) { 92 let resp; 93 try { 94 resp = await fetch(fileUrl); 95 } catch (e) { 96 // File does not exist. 97 return false; 98 } 99 const buffer = await resp.arrayBuffer(); 100 return SharedUtils.checkContentHash(buffer, size, hash); 101 }, 102 103 async prepareShutdown() { 104 gShutdown = true; 105 // Ensure we can iterate and abort (which may delete items) by cloning 106 // the list. 107 let transactions = Array.from(gPendingTransactions); 108 for (let transaction of transactions) { 109 try { 110 transaction.abort(); 111 } catch (ex) { 112 // We can hit this case if the transaction has finished but 113 // we haven't heard about it yet. 114 } 115 } 116 }, 117 118 _test_only_import(bucket, collection, records, timestamp) { 119 return importDumpIDB(bucket, collection, records, timestamp); 120 }, 121 }; 122 123 /** 124 * Wrap worker invocations in order to return the `callbackId` along 125 * the result. This will allow to transform the worker invocations 126 * into promises in `RemoteSettingsWorker.sys.mjs`. 127 */ 128 self.onmessage = event => { 129 const { callbackId, method, args = [] } = event.data; 130 Agent[method](...args) 131 .then(result => { 132 self.postMessage({ callbackId, result }); 133 }) 134 .catch(error => { 135 console.log(`RemoteSettingsWorker error: ${error}`); 136 self.postMessage({ callbackId, error: "" + error }); 137 }); 138 }; 139 140 let gPendingTransactions = new Set(); 141 142 /** 143 * Import the records into the Remote Settings Chrome IndexedDB. 144 * 145 * Note: This duplicates some logics from `kinto-offline-client.sys.mjs`. 146 * 147 * @param {string} bucket 148 * @param {string} collection 149 * @param {Array<object>} records 150 * @param {number} timestamp 151 */ 152 async function importDumpIDB(bucket, collection, records, timestamp) { 153 // Open the DB. It will exist since if we are running this, it means 154 // we already tried to read the timestamp in `remote-settings.sys.mjs` 155 const db = await IDBHelpers.openIDB(false /* do not allow upgrades */); 156 157 // try...finally to ensure we always close the db. 158 try { 159 if (gShutdown) { 160 throw new Error("Can't import when we've started shutting down."); 161 } 162 163 // Each entry of the dump will be stored in the records store. 164 // They are indexed by `_cid`. 165 const cid = bucket + "/" + collection; 166 // We can just modify the items in-place, as we got them from SharedUtils.loadJSONDump(). 167 records.forEach(item => { 168 item._cid = cid; 169 }); 170 // Store the collection timestamp. 171 let { transaction, promise } = IDBHelpers.executeIDB( 172 db, 173 [IDB_RECORDS_STORE, IDB_TIMESTAMPS_STORE], 174 "readwrite", 175 ([recordsStore, timestampStore], rejectTransaction) => { 176 // Wipe before loading 177 recordsStore.delete(IDBKeyRange.bound([cid], [cid, []], false, true)); 178 IDBHelpers.bulkOperationHelper( 179 recordsStore, 180 { 181 reject: rejectTransaction, 182 completion() { 183 timestampStore.put({ cid, value: timestamp }); 184 }, 185 }, 186 "put", 187 records 188 ); 189 } 190 ); 191 gPendingTransactions.add(transaction); 192 promise = promise.finally(() => gPendingTransactions.delete(transaction)); 193 await promise; 194 } finally { 195 // Close now that we're done. 196 db.close(); 197 } 198 }