forked from mirrors/gecko-dev
		
	
		
			
				
	
	
		
			495 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			495 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/* 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.
 | 
						|
 */
 | 
						|
 | 
						|
// Globals
 | 
						|
 | 
						|
const lazy = {};
 | 
						|
 | 
						|
ChromeUtils.defineESModuleGetters(lazy, {
 | 
						|
  DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
 | 
						|
  FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
 | 
						|
  NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
 | 
						|
});
 | 
						|
 | 
						|
ChromeUtils.defineLazyGetter(lazy, "gTextDecoder", function () {
 | 
						|
  return new TextDecoder();
 | 
						|
});
 | 
						|
 | 
						|
const FileInputStream = Components.Constructor(
 | 
						|
  "@mozilla.org/network/file-input-stream;1",
 | 
						|
  "nsIFileInputStream",
 | 
						|
  "init"
 | 
						|
);
 | 
						|
 | 
						|
/**
 | 
						|
 * 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.
 | 
						|
 *        - sanitizedBasename: Sanitized string identifier used for logging,
 | 
						|
 *                             shutdown debugging, and telemetry.  Defaults to
 | 
						|
 *                             basename of given `path`, sanitized.
 | 
						|
 *        - 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 `IOUtils` 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.
 | 
						|
 */
 | 
						|
export function JSONFile(config) {
 | 
						|
  this.path = config.path;
 | 
						|
  this.sanitizedBasename =
 | 
						|
    config.sanitizedBasename ??
 | 
						|
    PathUtils.filename(this.path)
 | 
						|
      .replace(/\.json(.lz4)?$/, "")
 | 
						|
      .replaceAll(/[^a-zA-Z0-9_.]/g, "");
 | 
						|
 | 
						|
  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 lazy.DeferredTask(() => this._save(), config.saveDelayMs);
 | 
						|
 | 
						|
  this._options = {};
 | 
						|
  if (config.compression) {
 | 
						|
    this._options.decompress = this._options.compress = true;
 | 
						|
  }
 | 
						|
 | 
						|
  if (config.backupTo) {
 | 
						|
    this._options.backupFile = this._options.backupTo = config.backupTo;
 | 
						|
  }
 | 
						|
 | 
						|
  this._finalizeAt = config.finalizeAt || IOUtils.profileBeforeChange;
 | 
						|
  this._finalizeInternalBound = this._finalizeInternal.bind(this);
 | 
						|
  this._finalizeAt.addBlocker(
 | 
						|
    `JSON store: writing data for '${this.sanitizedBasename}'`,
 | 
						|
    this._finalizeInternalBound,
 | 
						|
    () => ({ sanitizedBasename: this.sanitizedBasename })
 | 
						|
  );
 | 
						|
 | 
						|
  Services.telemetry.setEventRecordingEnabled("jsonfile", true);
 | 
						|
}
 | 
						|
 | 
						|
JSONFile.prototype = {
 | 
						|
  /**
 | 
						|
   * String containing the file path where data should be saved.
 | 
						|
   */
 | 
						|
  path: "",
 | 
						|
 | 
						|
  /**
 | 
						|
   * Sanitized identifier used for logging, shutdown debugging, and telemetry.
 | 
						|
   */
 | 
						|
  sanitizedBasename: "",
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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 {
 | 
						|
      data = await IOUtils.readJSON(this.path, this._options);
 | 
						|
 | 
						|
      // If synchronous loading happened in the meantime, exit now.
 | 
						|
      if (this.dataReady) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
    } 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.sys.mjs 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 errorNo = ex.winLastError || ex.unixErrno;
 | 
						|
      this._recordTelemetry("load", errorNo ? errorNo.toString() : "");
 | 
						|
      if (!(DOMException.isInstance(ex) && ex.name == "NotFoundError")) {
 | 
						|
        console.error(ex);
 | 
						|
 | 
						|
        // Move the original file to a backup location, ignoring errors.
 | 
						|
        try {
 | 
						|
          let uniquePath = await IOUtils.createUniqueFile(
 | 
						|
            PathUtils.parent(this.path),
 | 
						|
            PathUtils.filename(this.path) + ".corrupt",
 | 
						|
            0o600
 | 
						|
          );
 | 
						|
          await IOUtils.move(this.path, uniquePath);
 | 
						|
          this._recordTelemetry("load", "invalid_json");
 | 
						|
        } catch (e2) {
 | 
						|
          console.error(e2);
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if (this._options.backupFile) {
 | 
						|
        // 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 IOUtils.copy(this._options.backupFile, this.path);
 | 
						|
        } catch (e) {
 | 
						|
          if (!(DOMException.isInstance(e) && e.name == "NotFoundError")) {
 | 
						|
            console.error(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.
 | 
						|
          data = await IOUtils.readJSON(
 | 
						|
            this._options.backupFile,
 | 
						|
            this._options
 | 
						|
          );
 | 
						|
          // If synchronous loading happened in the meantime, exit now.
 | 
						|
          if (this.dataReady) {
 | 
						|
            return;
 | 
						|
          }
 | 
						|
          this._recordTelemetry("load", "used_backup");
 | 
						|
        } catch (e3) {
 | 
						|
          if (!(DOMException.isInstance(e3) && e3.name == "NotFoundError")) {
 | 
						|
            console.error(e3);
 | 
						|
          }
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      // In some rare cases it's possible for data to have been added to
 | 
						|
      // our database between the call to IOUtils.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 lazy.FileUtils.File(this.path),
 | 
						|
        lazy.FileUtils.MODE_RDONLY,
 | 
						|
        lazy.FileUtils.PERMS_FILE,
 | 
						|
        0
 | 
						|
      );
 | 
						|
      try {
 | 
						|
        let bytes = lazy.NetUtil.readInputStream(
 | 
						|
          inputStream,
 | 
						|
          inputStream.available()
 | 
						|
        );
 | 
						|
        data = JSON.parse(lazy.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.sys.mjs 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
 | 
						|
        )
 | 
						|
      ) {
 | 
						|
        console.error(ex);
 | 
						|
        // Move the original file to a backup location, ignoring errors.
 | 
						|
        try {
 | 
						|
          let originalFile = new lazy.FileUtils.File(this.path);
 | 
						|
          let backupFile = originalFile.clone();
 | 
						|
          backupFile.leafName += ".corrupt";
 | 
						|
          backupFile.createUnique(
 | 
						|
            Ci.nsIFile.NORMAL_FILE_TYPE,
 | 
						|
            lazy.FileUtils.PERMS_FILE
 | 
						|
          );
 | 
						|
          backupFile.remove(false);
 | 
						|
          originalFile.moveTo(backupFile.parent, backupFile.leafName);
 | 
						|
        } catch (e2) {
 | 
						|
          console.error(e2);
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if (this._options.backupFile) {
 | 
						|
        // 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 = PathUtils.filename(this.path);
 | 
						|
          let backupFile = new lazy.FileUtils.File(this._options.backupFile);
 | 
						|
          backupFile.copyTo(null, basename);
 | 
						|
        } catch (e) {
 | 
						|
          if (e.result != Cr.NS_ERROR_FILE_NOT_FOUND) {
 | 
						|
            console.error(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 lazy.FileUtils.File(this._options.backupFile),
 | 
						|
            lazy.FileUtils.MODE_RDONLY,
 | 
						|
            lazy.FileUtils.PERMS_FILE,
 | 
						|
            0
 | 
						|
          );
 | 
						|
          try {
 | 
						|
            let bytes = lazy.NetUtil.readInputStream(
 | 
						|
              inputStream,
 | 
						|
              inputStream.available()
 | 
						|
            );
 | 
						|
            data = JSON.parse(lazy.gTextDecoder.decode(bytes));
 | 
						|
          } finally {
 | 
						|
            inputStream.close();
 | 
						|
          }
 | 
						|
        } catch (e3) {
 | 
						|
          if (e3.result != Cr.NS_ERROR_FILE_NOT_FOUND) {
 | 
						|
            console.error(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() {
 | 
						|
    // Create or overwrite the file.
 | 
						|
    if (this._beforeSave) {
 | 
						|
      await Promise.resolve(this._beforeSave());
 | 
						|
    }
 | 
						|
 | 
						|
    try {
 | 
						|
      await IOUtils.writeJSON(
 | 
						|
        this.path,
 | 
						|
        this._data,
 | 
						|
        Object.assign({ tmpPath: this.path + ".tmp" }, this._options)
 | 
						|
      );
 | 
						|
    } catch (ex) {
 | 
						|
      if (typeof this._data.toJSONSafe == "function") {
 | 
						|
        // If serialization fails, try fallback safe JSON converter.
 | 
						|
        await IOUtils.writeUTF8(
 | 
						|
          this.path,
 | 
						|
          this._data.toJSONSafe(),
 | 
						|
          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, value) {
 | 
						|
    if (!TELEMETRY_BASENAMES.has(this.sanitizedBasename)) {
 | 
						|
      // Avoid recording so we don't log an error in the console.
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    Services.telemetry.recordEvent(
 | 
						|
      "jsonfile",
 | 
						|
      method,
 | 
						|
      this.sanitizedBasename,
 | 
						|
      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);
 | 
						|
  },
 | 
						|
};
 |