tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

SessionWriter.sys.mjs (13445B)


      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 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs",
      9 });
     10 
     11 /**
     12 * We just started (we haven't written anything to disk yet) from
     13 * `Paths.clean`. The backup directory may not exist.
     14 */
     15 const STATE_CLEAN = "clean";
     16 /**
     17 * We know that `Paths.recovery` is good, either because we just read
     18 * it (we haven't written anything to disk yet) or because have
     19 * already written once to `Paths.recovery` during this session.
     20 * `Paths.clean` is absent or invalid. The backup directory exists.
     21 */
     22 const STATE_RECOVERY = "recovery";
     23 /**
     24 * We just started from `Paths.upgradeBackup` (we haven't written
     25 * anything to disk yet). Both `Paths.clean`, `Paths.recovery` and
     26 * `Paths.recoveryBackup` are absent or invalid. The backup directory
     27 * exists.
     28 */
     29 const STATE_UPGRADE_BACKUP = "upgradeBackup";
     30 /**
     31 * We just started without a valid session store file (we haven't
     32 * written anything to disk yet). The backup directory may not exist.
     33 */
     34 const STATE_EMPTY = "empty";
     35 
     36 var sessionFileIOMutex = Promise.resolve();
     37 // Ensure that we don't do concurrent I/O on the same file.
     38 // Example usage:
     39 // const unlock = await lockIOWithMutex();
     40 // try {
     41 //   ... (Do I/O work here.)
     42 // } finally { unlock(); }
     43 function lockIOWithMutex() {
     44  // Return a Promise that resolves when the mutex is free.
     45  return new Promise(unlock => {
     46    // Overwrite the mutex variable with a chained-on, new Promise. The Promise
     47    // we returned to the caller can be called to resolve that new Promise
     48    // and unlock the mutex.
     49    sessionFileIOMutex = sessionFileIOMutex.then(() => {
     50      return new Promise(unlock);
     51    });
     52  });
     53 }
     54 
     55 /**
     56 * Interface dedicated to handling I/O for Session Store.
     57 */
     58 export const SessionWriter = {
     59  init(origin, useOldExtension, paths, prefs = {}) {
     60    return SessionWriterInternal.init(origin, useOldExtension, paths, prefs);
     61  },
     62 
     63  /**
     64   * Write the contents of the session file.
     65   *
     66   * @param state - May get changed on shutdown.
     67   */
     68  async write(state, options = {}) {
     69    const unlock = await lockIOWithMutex();
     70    try {
     71      return await SessionWriterInternal.write(state, options);
     72    } finally {
     73      unlock();
     74    }
     75  },
     76 
     77  async wipe() {
     78    const unlock = await lockIOWithMutex();
     79    try {
     80      return await SessionWriterInternal.wipe();
     81    } finally {
     82      unlock();
     83    }
     84  },
     85 };
     86 
     87 const SessionWriterInternal = {
     88  // Path to the files used by the SessionWriter
     89  Paths: null,
     90 
     91  /**
     92   * The current state of the session file, as one of the following strings:
     93   * - "empty" if we have started without any sessionstore;
     94   * - one of "clean", "recovery", "recoveryBackup", "cleanBackup",
     95   *   "upgradeBackup" if we have started by loading the corresponding file.
     96   */
     97  state: null,
     98 
     99  /**
    100   * A flag that indicates we loaded a session file with the deprecated .js extension.
    101   */
    102  useOldExtension: false,
    103 
    104  /**
    105   * Number of old upgrade backups that are being kept
    106   */
    107  maxUpgradeBackups: null,
    108 
    109  /**
    110   * Initialize (or reinitialize) the writer.
    111   *
    112   * @param {string} origin Which of sessionstore.js or its backups
    113   *   was used. One of the `STATE_*` constants defined above.
    114   * @param {boolean} a flag indicate whether we loaded a session file with ext .js
    115   * @param {object} paths The paths at which to find the various files.
    116   * @param {object} prefs The preferences the writer needs to know.
    117   */
    118  init(origin, useOldExtension, paths, prefs) {
    119    if (!(origin in paths || origin == STATE_EMPTY)) {
    120      throw new TypeError("Invalid origin: " + origin);
    121    }
    122 
    123    // Check that all required preference values were passed.
    124    for (let pref of [
    125      "maxUpgradeBackups",
    126      "maxSerializeBack",
    127      "maxSerializeForward",
    128    ]) {
    129      if (!prefs.hasOwnProperty(pref)) {
    130        throw new TypeError(`Missing preference value for ${pref}`);
    131      }
    132    }
    133 
    134    this.useOldExtension = useOldExtension;
    135    this.state = origin;
    136    this.Paths = paths;
    137    this.maxUpgradeBackups = prefs.maxUpgradeBackups;
    138    this.maxSerializeBack = prefs.maxSerializeBack;
    139    this.maxSerializeForward = prefs.maxSerializeForward;
    140    this.upgradeBackupNeeded = paths.nextUpgradeBackup != paths.upgradeBackup;
    141    return { result: true };
    142  },
    143 
    144  /**
    145   * Write the session to disk.
    146   * Write the session to disk, performing any necessary backup
    147   * along the way.
    148   *
    149   * @param {object} state The state to write to disk.
    150   * @param {object} options
    151   *  - performShutdownCleanup If |true|, we should
    152   *    perform shutdown-time cleanup to ensure that private data
    153   *    is not left lying around;
    154   *  - isFinalWrite If |true|, write to Paths.clean instead of
    155   *    Paths.recovery
    156   */
    157  async write(state, options) {
    158    let exn;
    159    let telemetry = {};
    160 
    161    // Cap the number of backward and forward shistory entries on shutdown.
    162    if (options.isFinalWrite) {
    163      for (let window of state.windows) {
    164        for (let tab of window.tabs) {
    165          let lower = 0;
    166          let upper = tab.entries.length;
    167 
    168          if (this.maxSerializeBack > -1) {
    169            lower = Math.max(lower, tab.index - this.maxSerializeBack - 1);
    170          }
    171          if (this.maxSerializeForward > -1) {
    172            upper = Math.min(upper, tab.index + this.maxSerializeForward);
    173          }
    174 
    175          tab.entries = tab.entries.slice(lower, upper);
    176          tab.index -= lower;
    177        }
    178      }
    179    }
    180 
    181    try {
    182      if (this.state == STATE_CLEAN || this.state == STATE_EMPTY) {
    183        // The backups directory may not exist yet. In all other cases,
    184        // we have either already read from or already written to this
    185        // directory, so we are satisfied that it exists.
    186        await IOUtils.makeDirectory(this.Paths.backups);
    187      }
    188 
    189      if (this.state == STATE_CLEAN) {
    190        // Move $Path.clean out of the way, to avoid any ambiguity as
    191        // to which file is more recent.
    192        if (!this.useOldExtension) {
    193          await IOUtils.move(this.Paths.clean, this.Paths.cleanBackup);
    194        } else {
    195          // Since we are migrating from .js to .jsonlz4,
    196          // we need to compress the deprecated $Path.clean
    197          // and write it to $Path.cleanBackup.
    198          let oldCleanPath = this.Paths.clean.replace("jsonlz4", "js");
    199          let d = await IOUtils.read(oldCleanPath);
    200          await IOUtils.write(this.Paths.cleanBackup, d, { compress: true });
    201        }
    202      }
    203 
    204      let startWriteMs = Date.now();
    205      let fileStat;
    206 
    207      if (options.isFinalWrite) {
    208        // We are shutting down. At this stage, we know that
    209        // $Paths.clean is either absent or corrupted. If it was
    210        // originally present and valid, it has been moved to
    211        // $Paths.cleanBackup a long time ago. We can therefore write
    212        // with the guarantees that we erase no important data.
    213        await IOUtils.writeJSON(this.Paths.clean, state, {
    214          tmpPath: this.Paths.clean + ".tmp",
    215          compress: true,
    216        });
    217        fileStat = await IOUtils.stat(this.Paths.clean);
    218      } else if (this.state == STATE_RECOVERY) {
    219        // At this stage, either $Paths.recovery was written >= 15
    220        // seconds ago during this session or we have just started
    221        // from $Paths.recovery left from the previous session. Either
    222        // way, $Paths.recovery is good. We can move $Path.backup to
    223        // $Path.recoveryBackup without erasing a good file with a bad
    224        // file.
    225        await IOUtils.writeJSON(this.Paths.recovery, state, {
    226          tmpPath: this.Paths.recovery + ".tmp",
    227          backupFile: this.Paths.recoveryBackup,
    228          compress: true,
    229        });
    230        fileStat = await IOUtils.stat(this.Paths.recovery);
    231      } else {
    232        // In other cases, either $Path.recovery is not necessary, or
    233        // it doesn't exist or it has been corrupted. Regardless,
    234        // don't backup $Path.recovery.
    235        await IOUtils.writeJSON(this.Paths.recovery, state, {
    236          tmpPath: this.Paths.recovery + ".tmp",
    237          compress: true,
    238        });
    239        fileStat = await IOUtils.stat(this.Paths.recovery);
    240      }
    241 
    242      telemetry.writeFileMs = Date.now() - startWriteMs;
    243      telemetry.fileSizeBytes = fileStat.size;
    244      lazy.sessionStoreLogger.debug(
    245        `SessionWriter.write wrote ${telemetry.fileSizeBytes} bytes in ${telemetry.writeFileMs}ms`
    246      );
    247    } catch (ex) {
    248      // Don't throw immediately
    249      lazy.sessionStoreLogger.warn(
    250        "SessionWriter.write, Caught exception:",
    251        ex
    252      );
    253      exn = exn || ex;
    254    }
    255 
    256    // If necessary, perform an upgrade backup
    257    let upgradeBackupComplete = false;
    258    if (
    259      this.upgradeBackupNeeded &&
    260      (this.state == STATE_CLEAN || this.state == STATE_UPGRADE_BACKUP)
    261    ) {
    262      try {
    263        // If we loaded from `clean`, the file has since then been renamed to `cleanBackup`.
    264        let path =
    265          this.state == STATE_CLEAN
    266            ? this.Paths.cleanBackup
    267            : this.Paths.upgradeBackup;
    268        await IOUtils.copy(path, this.Paths.nextUpgradeBackup);
    269        this.upgradeBackupNeeded = false;
    270        upgradeBackupComplete = true;
    271      } catch (ex) {
    272        // Don't throw immediately
    273        lazy.sessionStoreLogger.warn(
    274          "SessionWriter.write, Caught exception doing upgrade backup:",
    275          ex
    276        );
    277        exn = exn || ex;
    278      }
    279 
    280      // Find all backups
    281      let backups = [];
    282 
    283      try {
    284        let children = await IOUtils.getChildren(this.Paths.backups);
    285        backups = children.filter(path =>
    286          path.startsWith(this.Paths.upgradeBackupPrefix)
    287        );
    288      } catch (ex) {
    289        // Don't throw immediately
    290        lazy.sessionStoreLogger.warn(
    291          "SessionWriter.write, Caught exception looking for backups:",
    292          ex
    293        );
    294        exn = exn || ex;
    295      }
    296 
    297      // If too many backups exist, delete them
    298      if (backups.length > this.maxUpgradeBackups) {
    299        lazy.sessionStoreLogger.debug(
    300          `SessionWriter.write, cleaning up ${backups.length - this.maxUpgradeBackups} backup files`
    301        );
    302        // Use alphanumerical sort since dates are in YYYYMMDDHHMMSS format
    303        backups.sort();
    304        // remove backup file if it is among the first (n-maxUpgradeBackups) files
    305        for (let i = 0; i < backups.length - this.maxUpgradeBackups; i++) {
    306          try {
    307            await IOUtils.remove(backups[i]);
    308          } catch (ex) {
    309            lazy.sessionStoreLogger.warn(
    310              "SessionWriter.write, exception on removing backup file",
    311              ex
    312            );
    313            exn = exn || ex;
    314          }
    315        }
    316      }
    317    }
    318 
    319    if (options.performShutdownCleanup && !exn) {
    320      // During shutdown, if auto-restore is disabled, we need to
    321      // remove possibly sensitive data that has been stored purely
    322      // for crash recovery. Note that this slightly decreases our
    323      // ability to recover from OS-level/hardware-level issue.
    324 
    325      // If an exception was raised, we assume that we still need
    326      // these files.
    327      await IOUtils.remove(this.Paths.recoveryBackup);
    328      await IOUtils.remove(this.Paths.recovery);
    329    }
    330 
    331    this.state = STATE_RECOVERY;
    332 
    333    if (exn) {
    334      throw exn;
    335    }
    336 
    337    return {
    338      result: {
    339        upgradeBackup: upgradeBackupComplete,
    340      },
    341      telemetry,
    342    };
    343  },
    344 
    345  /**
    346   * Wipes all files holding session data from disk.
    347   */
    348  async wipe() {
    349    // Don't stop immediately in case of error.
    350    let exn = null;
    351 
    352    // Erase main session state file
    353    try {
    354      await IOUtils.remove(this.Paths.clean);
    355      // Remove old extension ones.
    356      let oldCleanPath = this.Paths.clean.replace("jsonlz4", "js");
    357      await IOUtils.remove(oldCleanPath, {
    358        ignoreAbsent: true,
    359      });
    360    } catch (ex) {
    361      // Don't stop immediately.
    362      exn = exn || ex;
    363    }
    364 
    365    // Wipe the Session Restore directory
    366    try {
    367      await IOUtils.remove(this.Paths.backups, { recursive: true });
    368    } catch (ex) {
    369      exn = exn || ex;
    370    }
    371 
    372    // Wipe legacy Session Restore files from the profile directory
    373    try {
    374      await this._wipeFromDir(PathUtils.profileDir, "sessionstore.bak");
    375    } catch (ex) {
    376      exn = exn || ex;
    377    }
    378 
    379    this.state = STATE_EMPTY;
    380    if (exn) {
    381      throw exn;
    382    }
    383 
    384    return { result: true };
    385  },
    386 
    387  /**
    388   * Wipe a number of files from a directory.
    389   *
    390   * @param {string} path The directory.
    391   * @param {string} prefix Remove files whose
    392   * name starts with the prefix.
    393   */
    394  async _wipeFromDir(path, prefix) {
    395    // Sanity check
    396    if (!prefix) {
    397      throw new TypeError("Must supply prefix");
    398    }
    399 
    400    let exn = null;
    401 
    402    let children = await IOUtils.getChildren(path, {
    403      ignoreAbsent: true,
    404    });
    405    for (let entryPath of children) {
    406      if (!PathUtils.filename(entryPath).startsWith(prefix)) {
    407        continue;
    408      }
    409      try {
    410        let { type } = await IOUtils.stat(entryPath);
    411        if (type == "directory") {
    412          continue;
    413        }
    414        await IOUtils.remove(entryPath);
    415      } catch (ex) {
    416        // Don't stop immediately
    417        exn = exn || ex;
    418      }
    419    }
    420 
    421    if (exn) {
    422      throw exn;
    423    }
    424  },
    425 };