diff options
Diffstat (limited to 'toolkit/components/telemetry/app/ClientID.sys.mjs')
-rw-r--r-- | toolkit/components/telemetry/app/ClientID.sys.mjs | 373 |
1 files changed, 373 insertions, 0 deletions
diff --git a/toolkit/components/telemetry/app/ClientID.sys.mjs b/toolkit/components/telemetry/app/ClientID.sys.mjs new file mode 100644 index 0000000000..77649abeee --- /dev/null +++ b/toolkit/components/telemetry/app/ClientID.sys.mjs @@ -0,0 +1,373 @@ +/* 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"; +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const LOGGER_NAME = "Toolkit.Telemetry"; +const LOGGER_PREFIX = "ClientID::"; +// Must match ID in TelemetryUtils +const CANARY_CLIENT_ID = "c0ffeec0-ffee-c0ff-eec0-ffeec0ffeec0"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CommonUtils: "resource://services-common/utils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "CryptoHash", () => { + return Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" + ); +}); + +XPCOMUtils.defineLazyGetter(lazy, "gDatareportingPath", () => { + return PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "datareporting" + ); +}); + +XPCOMUtils.defineLazyGetter(lazy, "gStateFilePath", () => { + return PathUtils.join(lazy.gDatareportingPath, "state.json"); +}); + +const PREF_CACHED_CLIENTID = "toolkit.telemetry.cachedClientID"; + +/** + * Checks if client ID has a valid format. + * + * @param {String} id A string containing the client ID. + * @return {Boolean} True when the client ID has valid format, or False + * otherwise. + */ +function isValidClientID(id) { + const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return UUID_REGEX.test(id); +} + +export var ClientID = Object.freeze({ + /** + * This returns a promise resolving to the the stable client ID we use for + * data reporting (FHR & Telemetry). + * + * @return {Promise<string>} The stable client ID. + */ + getClientID() { + return ClientIDImpl.getClientID(); + }, + + /** + * Get the client id synchronously without hitting the disk. + * This returns: + * - the current on-disk client id if it was already loaded + * - the client id that we cached into preferences (if any) + * - null otherwise + */ + getCachedClientID() { + return ClientIDImpl.getCachedClientID(); + }, + + async getClientIdHash() { + return ClientIDImpl.getClientIdHash(); + }, + + /** + * Sets the client ID to the canary (known) client ID, + * writing it to disk and updating the cached version. + * + * Use `removeClientID` followed by `getClientID` to clear the + * existing ID and generate a new, random one if required. + * + * @return {Promise<void>} + */ + setCanaryClientID() { + return ClientIDImpl.setCanaryClientID(); + }, + + /** + * Clears the client ID asynchronously, removing it + * from disk. Use `getClientID()` to generate + * a fresh ID after calling this method. + * + * Should only be used if a reset is explicitly requested by the user. + * + * @return {Promise<void>} + */ + removeClientID() { + return ClientIDImpl.removeClientID(); + }, + + /** + * Only used for testing. Invalidates the cached client ID so that it is + * read again from file, but doesn't remove the existing ID from disk. + */ + _reset() { + return ClientIDImpl._reset(); + }, +}); + +var ClientIDImpl = { + _clientID: null, + _clientIDHash: null, + _loadClientIdTask: null, + _saveClientIdTask: null, + _removeClientIdTask: null, + _logger: null, + + _loadClientID() { + if (this._loadClientIdTask) { + return this._loadClientIdTask; + } + + this._loadClientIdTask = this._doLoadClientID(); + let clear = () => (this._loadClientIdTask = null); + this._loadClientIdTask.then(clear, clear); + return this._loadClientIdTask; + }, + + /** + * Load the client ID from the DataReporting Service state file. If it is + * missing, we generate a new one. + */ + async _doLoadClientID() { + this._log.trace(`_doLoadClientID`); + // If there's a removal in progress, let's wait for it + await this._removeClientIdTask; + + // Try to load the client id from the DRS state file. + let hasCurrentClientID = false; + try { + let state = await IOUtils.readJSON(lazy.gStateFilePath); + if (state) { + hasCurrentClientID = this.updateClientID(state.clientID); + if (hasCurrentClientID) { + this._log.trace(`_doLoadClientID: Client IDs loaded from state.`); + return { + clientID: this._clientID, + }; + } + } + } catch (e) { + // fall through to next option + } + + // Absent or broken state file? Check prefs as last resort. + if (!hasCurrentClientID) { + const cachedID = this.getCachedClientID(); + // Calling `updateClientID` with `null` logs an error, which breaks tests. + if (cachedID) { + hasCurrentClientID = this.updateClientID(cachedID); + } + } + + // We're missing the ID from the DRS state file and prefs. + // Generate a new one. + if (!hasCurrentClientID) { + this.updateClientID(lazy.CommonUtils.generateUUID()); + } + this._saveClientIdTask = this._saveClientID(); + + // Wait on persisting the id. Otherwise failure to save the ID would result in + // the client creating and subsequently sending multiple IDs to the server. + // This would appear as multiple clients submitting similar data, which would + // result in orphaning. + await this._saveClientIdTask; + + this._log.trace("_doLoadClientID: New client ID loaded and persisted."); + return { + clientID: this._clientID, + }; + }, + + /** + * Save the client ID to the client ID file. + * + * @return {Promise} A promise resolved when the client ID is saved to disk. + */ + async _saveClientID() { + try { + this._log.trace(`_saveClientID`); + let obj = { + clientID: this._clientID, + }; + await IOUtils.makeDirectory(lazy.gDatareportingPath); + await IOUtils.writeJSON(lazy.gStateFilePath, obj, { + tmpPath: `${lazy.gStateFilePath}.tmp`, + }); + this._saveClientIdTask = null; + } catch (ex) { + if (!DOMException.isInstance(ex) || ex.name !== "AbortError") { + throw ex; + } + } + }, + + /** + * This returns a promise resolving to the the stable client ID we use for + * data reporting (FHR & Telemetry). + * + * @return {Promise<string>} The stable client ID. + */ + async getClientID() { + if (!this._clientID) { + let { clientID } = await this._loadClientID(); + if (AppConstants.platform != "android") { + Glean.legacyTelemetry.clientId.set(clientID); + } + return clientID; + } + + return Promise.resolve(this._clientID); + }, + + /** + * Get the client id synchronously without hitting the disk. + * This returns: + * - the current on-disk client id if it was already loaded + * - the client id that we cached into preferences (if any) + * - null otherwise + */ + getCachedClientID() { + if (this._clientID) { + // Already loaded the client id from disk. + return this._clientID; + } + + // If the client id cache contains a value of the wrong type, + // reset the pref. We need to do this before |getStringPref| since + // it will just return |null| in that case and we won't be able + // to distinguish between the missing pref and wrong type cases. + if ( + Services.prefs.prefHasUserValue(PREF_CACHED_CLIENTID) && + Services.prefs.getPrefType(PREF_CACHED_CLIENTID) != + Ci.nsIPrefBranch.PREF_STRING + ) { + this._log.error( + "getCachedClientID - invalid client id type in preferences, resetting" + ); + Services.prefs.clearUserPref(PREF_CACHED_CLIENTID); + } + + // Not yet loaded, return the cached client id if we have one. + let id = Services.prefs.getStringPref(PREF_CACHED_CLIENTID, null); + if (id === null) { + return null; + } + if (!isValidClientID(id)) { + this._log.error( + "getCachedClientID - invalid client id in preferences, resetting", + id + ); + Services.prefs.clearUserPref(PREF_CACHED_CLIENTID); + return null; + } + return id; + }, + + async getClientIdHash() { + if (!this._clientIDHash) { + let byteArr = new TextEncoder().encode(await this.getClientID()); + let hash = new lazy.CryptoHash("sha256"); + hash.update(byteArr, byteArr.length); + this._clientIDHash = lazy.CommonUtils.bytesAsHex(hash.finish(false)); + } + return this._clientIDHash; + }, + + /* + * Resets the module. This is for testing only. + */ + async _reset() { + await this._loadClientIdTask; + await this._saveClientIdTask; + this._clientID = null; + this._clientIDHash = null; + }, + + async setCanaryClientID() { + this._log.trace("setCanaryClientID"); + this.updateClientID(CANARY_CLIENT_ID); + + this._saveClientIdTask = this._saveClientID(); + await this._saveClientIdTask; + return this._clientID; + }, + + async _doRemoveClientID() { + this._log.trace("_doRemoveClientID"); + + // Reset the cached client ID. + this._clientID = null; + this._clientIDHash = null; + + // Clear the client id from the preference cache. + Services.prefs.clearUserPref(PREF_CACHED_CLIENTID); + + // If there is a save in progress, wait for it to complete. + await this._saveClientIdTask; + + // Remove the client-id-containing state file from disk + await IOUtils.remove(lazy.gStateFilePath); + }, + + async removeClientID() { + this._log.trace("removeClientID"); + + if (AppConstants.platform != "android") { + // We can't clear the client_id in Glean, but we can make it the canary. + Glean.legacyTelemetry.clientId.set(CANARY_CLIENT_ID); + } + + // Wait for the removal. + // Asynchronous calls to getClientID will also be blocked on this. + this._removeClientIdTask = this._doRemoveClientID(); + let clear = () => (this._removeClientIdTask = null); + this._removeClientIdTask.then(clear, clear); + + await this._removeClientIdTask; + }, + + /** + * Sets the client id to the given value and updates the value cached in + * preferences only if the given id is a valid. + * + * @param {String} id A string containing the client ID. + * @return {Boolean} True when the client ID has valid format, or False + * otherwise. + */ + updateClientID(id) { + if (!isValidClientID(id)) { + this._log.error("updateClientID - invalid client ID", id); + return false; + } + + this._clientID = id; + if (AppConstants.platform != "android") { + Glean.legacyTelemetry.clientId.set(id); + } + + this._clientIDHash = null; + Services.prefs.setStringPref(PREF_CACHED_CLIENTID, this._clientID); + return true; + }, + + /** + * A helper for getting access to telemetry logger. + */ + get _log() { + if (!this._logger) { + this._logger = Log.repository.getLoggerWithMessagePrefix( + LOGGER_NAME, + LOGGER_PREFIX + ); + } + + return this._logger; + }, +}; |