summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/JSONFile.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/modules/JSONFile.jsm')
-rw-r--r--toolkit/modules/JSONFile.jsm522
1 files changed, 522 insertions, 0 deletions
diff --git a/toolkit/modules/JSONFile.jsm b/toolkit/modules/JSONFile.jsm
new file mode 100644
index 0000000000..d83992c1ec
--- /dev/null
+++ b/toolkit/modules/JSONFile.jsm
@@ -0,0 +1,522 @@
+/* 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/. */
+
+/**
+ * Handles serialization of the data and persistence into a file.
+ *
+ * This modules handles the raw data stored in JavaScript serializable objects,
+ * and contains no special validation or query logic, that is handled entirely
+ * by "storage.js" instead.
+ *
+ * The data can be manipulated only after it has been loaded from disk. The
+ * load process can happen asynchronously, through the "load" method, or
+ * synchronously, through "ensureDataReady". After any modification, the
+ * "saveSoon" method must be called to flush the data to disk asynchronously.
+ *
+ * The raw data should be manipulated synchronously, without waiting for the
+ * event loop or for promise resolution, so that the saved file is always
+ * consistent. This synchronous approach also simplifies the query and update
+ * logic. For example, it is possible to find an object and modify it
+ * immediately without caring whether other code modifies it in the meantime.
+ *
+ * An asynchronous shutdown observer makes sure that data is always saved before
+ * the browser is closed. The data cannot be modified during shutdown.
+ *
+ * The file is stored in JSON format, without indentation, using UTF-8 encoding.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["JSONFile"];
+
+// Globals
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AsyncShutdown",
+ "resource://gre/modules/AsyncShutdown.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "DeferredTask",
+ "resource://gre/modules/DeferredTask.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "FileUtils",
+ "resource://gre/modules/FileUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+ChromeUtils.defineModuleGetter(
+ this,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+
+XPCOMUtils.defineLazyGetter(this, "gTextDecoder", function() {
+ return new TextDecoder();
+});
+
+XPCOMUtils.defineLazyGetter(this, "gTextEncoder", function() {
+ return new TextEncoder();
+});
+
+const FileInputStream = Components.Constructor(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+/**
+ * Delay between a change to the data and the related save operation.
+ */
+const kSaveDelayMs = 1500;
+
+/**
+ * Cleansed basenames of the filenames that telemetry can be recorded for.
+ * Keep synchronized with 'objects' from Events.yaml.
+ */
+const TELEMETRY_BASENAMES = new Set(["logins", "autofillprofiles"]);
+
+// JSONFile
+
+/**
+ * Handles serialization of the data and persistence into a file.
+ *
+ * @param config An object containing following members:
+ * - path: String containing the file path where data should be saved.
+ * - dataPostProcessor: Function triggered when data is just loaded. The
+ * data object will be passed as the first argument
+ * and should be returned no matter it's modified or
+ * not. Its failure leads to the failure of load()
+ * and ensureDataReady().
+ * - saveDelayMs: Number indicating the delay (in milliseconds) between a
+ * change to the data and the related save operation. The
+ * default value will be applied if omitted.
+ * - beforeSave: Promise-returning function triggered just before the
+ * data is written to disk. This can be used to create any
+ * intermediate directories before saving. The file will
+ * not be saved if the promise rejects or the function
+ * throws an exception.
+ * - finalizeAt: An `AsyncShutdown` phase or barrier client that should
+ * automatically finalize the file when triggered. Defaults
+ * to `profileBeforeChange`; exposed as an option for
+ * testing.
+ * - compression: A compression algorithm to use when reading and
+ * writing the data.
+ * - backupTo: A string value indicating where writeAtomic should create
+ * a backup before writing to json files. Note that using this
+ * option currently ensures that we automatically restore backed
+ * up json files in load() and ensureDataReady() when original
+ * files are missing or corrupt.
+ */
+function JSONFile(config) {
+ this.path = config.path;
+
+ if (typeof config.dataPostProcessor === "function") {
+ this._dataPostProcessor = config.dataPostProcessor;
+ }
+ if (typeof config.beforeSave === "function") {
+ this._beforeSave = config.beforeSave;
+ }
+
+ if (config.saveDelayMs === undefined) {
+ config.saveDelayMs = kSaveDelayMs;
+ }
+ this._saver = new DeferredTask(() => this._save(), config.saveDelayMs);
+
+ this._options = {};
+ if (config.compression) {
+ this._options.compression = config.compression;
+ }
+
+ if (config.backupTo) {
+ this._options.backupTo = config.backupTo;
+ }
+
+ this._finalizeAt = config.finalizeAt || AsyncShutdown.profileBeforeChange;
+ this._finalizeInternalBound = this._finalizeInternal.bind(this);
+ this._finalizeAt.addBlocker(
+ "JSON store: writing data",
+ this._finalizeInternalBound
+ );
+
+ Services.telemetry.setEventRecordingEnabled("jsonfile", true);
+}
+
+JSONFile.prototype = {
+ /**
+ * String containing the file path where data should be saved.
+ */
+ path: "",
+
+ /**
+ * True when data has been loaded.
+ */
+ dataReady: false,
+
+ /**
+ * DeferredTask that handles the save operation.
+ */
+ _saver: null,
+
+ /**
+ * Internal data object.
+ */
+ _data: null,
+
+ /**
+ * Internal fields used during finalization.
+ */
+ _finalizeAt: null,
+ _finalizePromise: null,
+ _finalizeInternalBound: null,
+
+ /**
+ * Serializable object containing the data. This is populated directly with
+ * the data loaded from the file, and is saved without modifications.
+ *
+ * The raw data should be manipulated synchronously, without waiting for the
+ * event loop or for promise resolution, so that the saved file is always
+ * consistent.
+ */
+ get data() {
+ if (!this.dataReady) {
+ throw new Error("Data is not ready.");
+ }
+ return this._data;
+ },
+
+ /**
+ * Sets the loaded data to a new object. This will overwrite any persisted
+ * data on the next save.
+ */
+ set data(data) {
+ this._data = data;
+ this.dataReady = true;
+ },
+
+ /**
+ * Loads persistent data from the file to memory.
+ *
+ * @return {Promise}
+ * @resolves When the operation finished successfully.
+ * @rejects JavaScript exception when dataPostProcessor fails. It never fails
+ * if there is no dataPostProcessor.
+ */
+ async load() {
+ if (this.dataReady) {
+ return;
+ }
+
+ let data = {};
+
+ try {
+ let bytes = await OS.File.read(this.path, this._options);
+
+ // If synchronous loading happened in the meantime, exit now.
+ if (this.dataReady) {
+ return;
+ }
+
+ data = JSON.parse(gTextDecoder.decode(bytes));
+ } catch (ex) {
+ // If an exception occurs because the file does not exist or it cannot be read,
+ // we do two things.
+ // 1. For consumers of JSONFile.jsm that have configured a `backupTo` path option,
+ // we try to look for and use backed up json files first. If the backup
+ // is also not found or if the backup is unreadable, we then start with an empty file.
+ // 2. If a consumer does not configure a `backupTo` path option, we just start
+ // with an empty file.
+
+ // In the event that the file exists, but an exception is thrown because it cannot be read,
+ // we store it as a .corrupt file for debugging purposes.
+
+ let cleansedBasename = OS.Path.basename(this.path)
+ .replace(/\.json$/, "")
+ .replaceAll(/[^a-zA-Z0-9_.]/g, "");
+ let errorNo = ex.winLastError || ex.unixErrno;
+ this._recordTelemetry(
+ "load",
+ cleansedBasename,
+ errorNo ? errorNo.toString() : ""
+ );
+ if (!(ex instanceof OS.File.Error && ex.becauseNoSuchFile)) {
+ Cu.reportError(ex);
+
+ // Move the original file to a backup location, ignoring errors.
+ try {
+ let openInfo = await OS.File.openUnique(this.path + ".corrupt", {
+ humanReadable: true,
+ });
+ await openInfo.file.close();
+ await OS.File.move(this.path, openInfo.path);
+ this._recordTelemetry("load", cleansedBasename, "invalid_json");
+ } catch (e2) {
+ Cu.reportError(e2);
+ }
+ }
+
+ if (this._options.backupTo) {
+ // Restore the original file from the backup here so fresh writes to empty
+ // json files don't happen at any time in the future compromising the backup
+ // in the process.
+ try {
+ await OS.File.copy(this._options.backupTo, this.path);
+ } catch (e) {
+ if (!(e instanceof OS.File.Error && ex.becauseNoSuchFile)) {
+ Cu.reportError(e);
+ }
+ }
+
+ try {
+ // We still read from the backup file here instead of the original file in case
+ // access to the original file is blocked, e.g. by anti-virus software on the
+ // user's computer.
+ let bytes = await OS.File.read(this._options.backupTo, this._options);
+
+ // If synchronous loading happened in the meantime, exit now.
+ if (this.dataReady) {
+ return;
+ }
+ data = JSON.parse(gTextDecoder.decode(bytes));
+ this._recordTelemetry("load", cleansedBasename, "used_backup");
+ } catch (e3) {
+ if (!(e3 instanceof OS.File.Error && ex.becauseNoSuchFile)) {
+ Cu.reportError(e3);
+ }
+ }
+ }
+
+ // In some rare cases it's possible for data to have been added to
+ // our database between the call to OS.File.read and when we've been
+ // notified that there was a problem with it. In that case, leave the
+ // synchronously-added data alone.
+ if (this.dataReady) {
+ return;
+ }
+ }
+
+ this._processLoadedData(data);
+ },
+
+ /**
+ * Loads persistent data from the file to memory, synchronously. An exception
+ * can be thrown only if dataPostProcessor exists and fails.
+ */
+ ensureDataReady() {
+ if (this.dataReady) {
+ return;
+ }
+
+ let data = {};
+
+ try {
+ // This reads the file and automatically detects the UTF-8 encoding.
+ let inputStream = new FileInputStream(
+ new FileUtils.File(this.path),
+ FileUtils.MODE_RDONLY,
+ FileUtils.PERMS_FILE,
+ 0
+ );
+ try {
+ let bytes = NetUtil.readInputStream(
+ inputStream,
+ inputStream.available()
+ );
+ data = JSON.parse(gTextDecoder.decode(bytes));
+ } finally {
+ inputStream.close();
+ }
+ } catch (ex) {
+ // If an exception occurs because the file does not exist or it cannot be read,
+ // we do two things.
+ // 1. For consumers of JSONFile.jsm that have configured a `backupTo` path option,
+ // we try to look for and use backed up json files first. If the backup
+ // is also not found or if the backup is unreadable, we then start with an empty file.
+ // 2. If a consumer does not configure a `backupTo` path option, we just start
+ // with an empty file.
+
+ // In the event that the file exists, but an exception is thrown because it cannot be read,
+ // we store it as a .corrupt file for debugging purposes.
+ if (
+ !(
+ ex instanceof Components.Exception &&
+ ex.result == Cr.NS_ERROR_FILE_NOT_FOUND
+ )
+ ) {
+ Cu.reportError(ex);
+ // Move the original file to a backup location, ignoring errors.
+ try {
+ let originalFile = new FileUtils.File(this.path);
+ let backupFile = originalFile.clone();
+ backupFile.leafName += ".corrupt";
+ backupFile.createUnique(
+ Ci.nsIFile.NORMAL_FILE_TYPE,
+ FileUtils.PERMS_FILE
+ );
+ backupFile.remove(false);
+ originalFile.moveTo(backupFile.parent, backupFile.leafName);
+ } catch (e2) {
+ Cu.reportError(e2);
+ }
+ }
+
+ if (this._options.backupTo) {
+ // Restore the original file from the backup here so fresh writes to empty
+ // json files don't happen at any time in the future compromising the backup
+ // in the process.
+ try {
+ let basename = OS.Path.basename(this.path);
+ let backupFile = new FileUtils.File(this._options.backupTo);
+ backupFile.copyTo(null, basename);
+ } catch (e) {
+ if (
+ e.result != Cr.NS_ERROR_FILE_TARGET_DOES_NOT_EXIST &&
+ e.result != Cr.NS_ERROR_FILE_NOT_FOUND
+ ) {
+ Cu.reportError(e);
+ }
+ }
+
+ try {
+ // We still read from the backup file here instead of the original file in case
+ // access to the original file is blocked, e.g. by anti-virus software on the
+ // user's computer.
+ // This reads the file and automatically detects the UTF-8 encoding.
+ let inputStream = new FileInputStream(
+ new FileUtils.File(this._options.backupTo),
+ FileUtils.MODE_RDONLY,
+ FileUtils.PERMS_FILE,
+ 0
+ );
+ try {
+ let bytes = NetUtil.readInputStream(
+ inputStream,
+ inputStream.available()
+ );
+ data = JSON.parse(gTextDecoder.decode(bytes));
+ } finally {
+ inputStream.close();
+ }
+ } catch (e3) {
+ if (
+ e3.result != Cr.NS_ERROR_FILE_TARGET_DOES_NOT_EXIST &&
+ e3.result != Cr.NS_ERROR_FILE_NOT_FOUND
+ ) {
+ Cu.reportError(e3);
+ }
+ }
+ }
+ }
+
+ this._processLoadedData(data);
+ },
+
+ /**
+ * Called when the data changed, this triggers asynchronous serialization.
+ */
+ saveSoon() {
+ return this._saver.arm();
+ },
+
+ /**
+ * Saves persistent data from memory to the file.
+ *
+ * If an error occurs, the previous file is not deleted.
+ *
+ * @return {Promise}
+ * @resolves When the operation finished successfully.
+ * @rejects JavaScript exception.
+ */
+ async _save() {
+ let json;
+ try {
+ json = JSON.stringify(this._data);
+ } catch (e) {
+ // If serialization fails, try fallback safe JSON converter.
+ if (typeof this._data.toJSONSafe == "function") {
+ json = JSON.stringify(this._data.toJSONSafe());
+ } else {
+ throw e;
+ }
+ }
+
+ // Create or overwrite the file.
+ let bytes = gTextEncoder.encode(json);
+ if (this._beforeSave) {
+ await Promise.resolve(this._beforeSave());
+ }
+ await OS.File.writeAtomic(
+ this.path,
+ bytes,
+ Object.assign({ tmpPath: this.path + ".tmp" }, this._options)
+ );
+ },
+
+ /**
+ * Synchronously work on the data just loaded into memory.
+ */
+ _processLoadedData(data) {
+ if (this._finalizePromise) {
+ // It's possible for `load` to race with `finalize`. In that case, don't
+ // process or set the loaded data.
+ return;
+ }
+ this.data = this._dataPostProcessor ? this._dataPostProcessor(data) : data;
+ },
+
+ _recordTelemetry(method, cleansedBasename, value) {
+ if (!TELEMETRY_BASENAMES.has(cleansedBasename)) {
+ // Avoid recording so we don't log an error in the console.
+ return;
+ }
+
+ Services.telemetry.recordEvent("jsonfile", method, cleansedBasename, value);
+ },
+
+ /**
+ * Finishes persisting data to disk and resets all state for this file.
+ *
+ * @return {Promise}
+ * @resolves When the object is finalized.
+ */
+ _finalizeInternal() {
+ if (this._finalizePromise) {
+ // Finalization already in progress; return the pending promise. This is
+ // possible if `finalize` is called concurrently with shutdown.
+ return this._finalizePromise;
+ }
+ this._finalizePromise = (async () => {
+ await this._saver.finalize();
+ this._data = null;
+ this.dataReady = false;
+ })();
+ return this._finalizePromise;
+ },
+
+ /**
+ * Ensures that all data is persisted to disk, and prevents future calls to
+ * `saveSoon`. This is called automatically on shutdown, but can also be
+ * called explicitly when the file is no longer needed.
+ */
+ async finalize() {
+ if (this._finalizePromise) {
+ throw new Error(`The file ${this.path} has already been finalized`);
+ }
+ // Wait for finalization before removing the shutdown blocker.
+ await this._finalizeInternal();
+ this._finalizeAt.removeBlocker(this._finalizeInternalBound);
+ },
+};