tor-browser

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

SessionFile.sys.mjs (17316B)


      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 * Implementation of all the disk I/O required by the session store.
      7 * This is a private API, meant to be used only by the session store.
      8 * It will change. Do not use it for any other purpose.
      9 *
     10 * Note that this module depends on SessionWriter and that it enqueues its I/O
     11 * requests and never attempts to simultaneously execute two I/O requests on
     12 * the files used by this module from two distinct threads.
     13 * Otherwise, we could encounter bugs, especially under Windows,
     14 * e.g. if a request attempts to write sessionstore.js while
     15 * another attempts to copy that file.
     16 */
     17 
     18 const lazy = {};
     19 
     20 ChromeUtils.defineESModuleGetters(lazy, {
     21  sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs",
     22  RunState: "resource:///modules/sessionstore/RunState.sys.mjs",
     23  SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
     24  SessionWriter: "resource:///modules/sessionstore/SessionWriter.sys.mjs",
     25 });
     26 
     27 const PREF_UPGRADE_BACKUP = "browser.sessionstore.upgradeBackup.latestBuildID";
     28 const PREF_MAX_UPGRADE_BACKUPS =
     29  "browser.sessionstore.upgradeBackup.maxUpgradeBackups";
     30 
     31 const PREF_MAX_SERIALIZE_BACK = "browser.sessionstore.max_serialize_back";
     32 const PREF_MAX_SERIALIZE_FWD = "browser.sessionstore.max_serialize_forward";
     33 
     34 export var SessionFile = {
     35  /**
     36   * Read the contents of the session file, asynchronously.
     37   */
     38  read() {
     39    return SessionFileInternal.read();
     40  },
     41  /**
     42   * Write the contents of the session file, asynchronously.
     43   *
     44   * @param aData - May get changed on shutdown.
     45   */
     46  write(aData) {
     47    return SessionFileInternal.write(aData);
     48  },
     49  /**
     50   * Wipe the contents of the session file, asynchronously.
     51   */
     52  wipe() {
     53    return SessionFileInternal.wipe();
     54  },
     55 
     56  /**
     57   * Return the paths to the files used to store, backup, etc.
     58   * the state of the file.
     59   */
     60  get Paths() {
     61    return SessionFileInternal.Paths;
     62  },
     63 };
     64 
     65 Object.freeze(SessionFile);
     66 
     67 const profileDir = PathUtils.profileDir;
     68 
     69 var SessionFileInternal = {
     70  Paths: Object.freeze({
     71    // The path to the latest version of sessionstore written during a clean
     72    // shutdown. After startup, it is renamed `cleanBackup`.
     73    clean: PathUtils.join(profileDir, "sessionstore.jsonlz4"),
     74 
     75    // The path at which we store the previous version of `clean`. Updated
     76    // whenever we successfully load from `clean`.
     77    cleanBackup: PathUtils.join(
     78      profileDir,
     79      "sessionstore-backups",
     80      "previous.jsonlz4"
     81    ),
     82 
     83    // The directory containing all sessionstore backups.
     84    backups: PathUtils.join(profileDir, "sessionstore-backups"),
     85 
     86    // The path to the latest version of the sessionstore written
     87    // during runtime. Generally, this file contains more
     88    // privacy-sensitive information than |clean|, and this file is
     89    // therefore removed during clean shutdown. This file is designed to protect
     90    // against crashes / sudden shutdown.
     91    recovery: PathUtils.join(
     92      profileDir,
     93      "sessionstore-backups",
     94      "recovery.jsonlz4"
     95    ),
     96 
     97    // The path to the previous version of the sessionstore written
     98    // during runtime (e.g. 15 seconds before recovery). In case of a
     99    // clean shutdown, this file is removed.  Generally, this file
    100    // contains more privacy-sensitive information than |clean|, and
    101    // this file is therefore removed during clean shutdown.  This
    102    // file is designed to protect against crashes that are nasty
    103    // enough to corrupt |recovery|.
    104    recoveryBackup: PathUtils.join(
    105      profileDir,
    106      "sessionstore-backups",
    107      "recovery.baklz4"
    108    ),
    109 
    110    // The path to a backup created during an upgrade of Firefox.
    111    // Having this backup protects the user essentially from bugs in
    112    // Firefox or add-ons, especially for users of Nightly. This file
    113    // does not contain any information more sensitive than |clean|.
    114    upgradeBackupPrefix: PathUtils.join(
    115      profileDir,
    116      "sessionstore-backups",
    117      "upgrade.jsonlz4-"
    118    ),
    119 
    120    // The path to the backup of the version of the session store used
    121    // during the latest upgrade of Firefox. During load/recovery,
    122    // this file should be used if both |path|, |backupPath| and
    123    // |latestStartPath| are absent/incorrect.  May be "" if no
    124    // upgrade backup has ever been performed. This file does not
    125    // contain any information more sensitive than |clean|.
    126    get upgradeBackup() {
    127      let latestBackupID = SessionFileInternal.latestUpgradeBackupID;
    128      if (!latestBackupID) {
    129        return "";
    130      }
    131      return this.upgradeBackupPrefix + latestBackupID;
    132    },
    133 
    134    // The path to a backup created during an upgrade of Firefox.
    135    // Having this backup protects the user essentially from bugs in
    136    // Firefox, especially for users of Nightly.
    137    get nextUpgradeBackup() {
    138      return this.upgradeBackupPrefix + Services.appinfo.platformBuildID;
    139    },
    140 
    141    /**
    142     * The order in which to search for a valid sessionstore file.
    143     */
    144    get loadOrder() {
    145      // If `clean` exists and has been written without corruption during
    146      // the latest shutdown, we need to use it.
    147      //
    148      // Otherwise, `recovery` and `recoveryBackup` represent the most
    149      // recent state of the session store.
    150      //
    151      // Finally, if nothing works, fall back to the last known state
    152      // that can be loaded (`cleanBackup`) or, if available, to the
    153      // backup performed during the latest upgrade.
    154      let order = ["clean", "recovery", "recoveryBackup", "cleanBackup"];
    155      if (SessionFileInternal.latestUpgradeBackupID) {
    156        // We have an upgradeBackup
    157        order.push("upgradeBackup");
    158      }
    159      return order;
    160    },
    161  }),
    162 
    163  // Number of attempted calls to `write`.
    164  // Note that we may have _attempts > _successes + _failures,
    165  // if attempts never complete.
    166  // Used for error reporting.
    167  _attempts: 0,
    168 
    169  // Number of successful calls to `write`.
    170  // Used for error reporting.
    171  _successes: 0,
    172 
    173  // Number of failed calls to `write`.
    174  // Used for error reporting.
    175  _failures: 0,
    176 
    177  // `true` once we have initialized SessionWriter.
    178  _initialized: false,
    179 
    180  // A string that will be set to the session file name part that was read from
    181  // disk. It will be available _after_ a session file read() is done.
    182  _readOrigin: null,
    183 
    184  // `true` if the old, uncompressed, file format was used to read from disk, as
    185  // a fallback mechanism.
    186  _usingOldExtension: false,
    187 
    188  // The ID of the latest version of Gecko for which we have an upgrade backup
    189  // or |undefined| if no upgrade backup was ever written.
    190  get latestUpgradeBackupID() {
    191    try {
    192      return Services.prefs.getCharPref(PREF_UPGRADE_BACKUP);
    193    } catch (ex) {
    194      return undefined;
    195    }
    196  },
    197 
    198  async _readInternal(useOldExtension) {
    199    let result;
    200    let noFilesFound = true;
    201    this._usingOldExtension = useOldExtension;
    202 
    203    // Attempt to load by order of priority from the various backups
    204    for (let key of this.Paths.loadOrder) {
    205      let corrupted = false;
    206      let exists = true;
    207      try {
    208        let path;
    209        let startMs = Date.now();
    210 
    211        let options = {};
    212        if (useOldExtension) {
    213          path = this.Paths[key]
    214            .replace("jsonlz4", "js")
    215            .replace("baklz4", "bak");
    216        } else {
    217          path = this.Paths[key];
    218          options.decompress = true;
    219        }
    220        let source = await IOUtils.readUTF8(path, options);
    221        let parsed = JSON.parse(source);
    222 
    223        if (parsed._cachedObjs) {
    224          try {
    225            let cacheMap = new Map(parsed._cachedObjs);
    226            for (let win of parsed.windows.concat(
    227              parsed._closedWindows || []
    228            )) {
    229              for (let tab of win.tabs.concat(win._closedTabs || [])) {
    230                tab.image = cacheMap.get(tab.image) || tab.image;
    231              }
    232            }
    233          } catch (e) {
    234            // This is temporary code to clean up after the backout of bug
    235            // 1546847. Just in case there are problems in the format of
    236            // the parsed data, continue on. Favicons might be broken, but
    237            // the session will at least be recovered
    238            lazy.sessionStoreLogger.error(e);
    239          }
    240        }
    241 
    242        if (
    243          !lazy.SessionStore.isFormatVersionCompatible(
    244            parsed.version || [
    245              "sessionrestore",
    246              0,
    247            ] /* fallback for old versions*/
    248          )
    249        ) {
    250          // Skip sessionstore files that we don't understand.
    251          lazy.sessionStoreLogger.warn(
    252            "Cannot extract data from Session Restore file ",
    253            path,
    254            ". Wrong format/version: " + JSON.stringify(parsed.version) + "."
    255          );
    256          Glean.sessionRestore.backupCanBeLoadedSessionFile.record({
    257            can_load: "false",
    258            path_key: key,
    259            loadfail_reason:
    260              "Wrong format/version: " + JSON.stringify(parsed.version) + ".",
    261          });
    262          continue;
    263        }
    264        result = {
    265          origin: key,
    266          source,
    267          parsed,
    268          useOldExtension,
    269        };
    270        Glean.sessionRestore.backupCanBeLoadedSessionFile.record({
    271          can_load: "true",
    272          path_key: key,
    273          loadfail_reason: "N/A",
    274        });
    275        Glean.sessionRestore.corruptFile.false.add();
    276        Glean.sessionRestore.readFile.accumulateSingleSample(
    277          Date.now() - startMs
    278        );
    279        lazy.sessionStoreLogger.debug(`Successful file read of ${key} file`);
    280        break;
    281      } catch (ex) {
    282        if (DOMException.isInstance(ex) && ex.name == "NotFoundError") {
    283          exists = false;
    284          Glean.sessionRestore.backupCanBeLoadedSessionFile.record({
    285            can_load: "false",
    286            path_key: key,
    287            loadfail_reason: "File doesn't exist.",
    288          });
    289          // A file not existing can be normal and expected.
    290          lazy.sessionStoreLogger.debug(
    291            `Can't read session file which doesn't exist: ${key}`
    292          );
    293        } else if (
    294          DOMException.isInstance(ex) &&
    295          ex.name == "NotReadableError"
    296        ) {
    297          // The file might incorrectly jsonlz4 encoded
    298          // We'll count it as "corrupted".
    299          lazy.sessionStoreLogger.error(
    300            `NotReadableError when reading session file: ${key}`,
    301            ex
    302          );
    303          corrupted = true;
    304          Glean.sessionRestore.backupCanBeLoadedSessionFile.record({
    305            can_load: "false",
    306            path_key: key,
    307            loadfail_reason: ` ${ex.name}: Could not read session file`,
    308          });
    309        } else if (
    310          DOMException.isInstance(ex) &&
    311          ex.name == "NotAllowedError"
    312        ) {
    313          // The file might be inaccessible due to wrong permissions
    314          // or similar failures. We'll just count it as "corrupted".
    315          lazy.sessionStoreLogger.error(
    316            `NotAllowedError when reading session file: ${key}`,
    317            ex
    318          );
    319          corrupted = true;
    320          Glean.sessionRestore.backupCanBeLoadedSessionFile.record({
    321            can_load: "false",
    322            path_key: key,
    323            loadfail_reason: ` ${ex.name}: Could not read session file`,
    324          });
    325        } else if (ex instanceof SyntaxError) {
    326          lazy.sessionStoreLogger.error(
    327            "Corrupt session file (invalid JSON found) ",
    328            ex,
    329            ex.stack
    330          );
    331          // File is corrupted, try next file
    332          corrupted = true;
    333          Glean.sessionRestore.backupCanBeLoadedSessionFile.record({
    334            can_load: "false",
    335            path_key: key,
    336            loadfail_reason: ` ${ex.name}: Corrupt session file (invalid JSON found)`,
    337          });
    338        }
    339      } finally {
    340        if (exists) {
    341          noFilesFound = false;
    342          Glean.sessionRestore.corruptFile[corrupted ? "true" : "false"].add();
    343          Glean.sessionRestore.backupCanBeLoadedSessionFile.record({
    344            can_load: (!corrupted).toString(),
    345            path_key: key,
    346            loadfail_reason: "N/A",
    347          });
    348        }
    349      }
    350    }
    351    return { result, noFilesFound };
    352  },
    353 
    354  // Find the correct session file and read it.
    355  async read() {
    356    // Load session files with lz4 compression.
    357    let { result, noFilesFound } = await this._readInternal(false);
    358    if (!result) {
    359      // No result? Probably because of migration, let's
    360      // load uncompressed session files.
    361      let r = await this._readInternal(true);
    362      result = r.result;
    363    }
    364 
    365    // All files are corrupted if files found but none could deliver a result.
    366    let allCorrupt = !noFilesFound && !result;
    367    Glean.sessionRestore.allFilesCorrupt[allCorrupt ? "true" : "false"].add();
    368 
    369    if (!result) {
    370      // If everything fails, start with an empty session.
    371      lazy.sessionStoreLogger.warn(
    372        "No readable session files found to restore, starting with empty session"
    373      );
    374      result = {
    375        origin: "empty",
    376        source: "",
    377        parsed: null,
    378        useOldExtension: false,
    379      };
    380    }
    381    this._readOrigin = result.origin;
    382 
    383    result.noFilesFound = noFilesFound;
    384 
    385    return result;
    386  },
    387 
    388  // Initialize SessionWriter and return it as a resolved promise.
    389  getWriter() {
    390    if (!this._initialized) {
    391      if (!this._readOrigin) {
    392        return Promise.reject(
    393          "SessionFileInternal.getWriter() called too early! Please read the session file from disk first."
    394        );
    395      }
    396 
    397      this._initialized = true;
    398      lazy.SessionWriter.init(
    399        this._readOrigin,
    400        this._usingOldExtension,
    401        this.Paths,
    402        {
    403          maxUpgradeBackups: Services.prefs.getIntPref(
    404            PREF_MAX_UPGRADE_BACKUPS,
    405            3
    406          ),
    407          maxSerializeBack: Services.prefs.getIntPref(
    408            PREF_MAX_SERIALIZE_BACK,
    409            10
    410          ),
    411          maxSerializeForward: Services.prefs.getIntPref(
    412            PREF_MAX_SERIALIZE_FWD,
    413            -1
    414          ),
    415        }
    416      );
    417    }
    418 
    419    return Promise.resolve(lazy.SessionWriter);
    420  },
    421 
    422  write(aData) {
    423    if (lazy.RunState.isClosed) {
    424      return Promise.reject(new Error("SessionFile is closed"));
    425    }
    426 
    427    let isFinalWrite = false;
    428    if (lazy.RunState.isClosing) {
    429      // If shutdown has started, we will want to stop receiving
    430      // write instructions.
    431      isFinalWrite = true;
    432      lazy.RunState.setClosed();
    433    }
    434 
    435    let performShutdownCleanup =
    436      isFinalWrite && !lazy.SessionStore.willAutoRestore;
    437 
    438    this._attempts++;
    439    let options = { isFinalWrite, performShutdownCleanup };
    440    let promise = this.getWriter().then(writer => writer.write(aData, options));
    441 
    442    // Wait until the write is done.
    443    promise = promise.then(
    444      msg => {
    445        // Record how long the write took.
    446        if (msg.telemetry.writeFileMs) {
    447          Glean.sessionRestore.writeFile.accumulateSingleSample(
    448            msg.telemetry.writeFileMs
    449          );
    450        }
    451        if (msg.telemetry.fileSizeBytes) {
    452          Glean.sessionRestore.fileSizeBytes.accumulate(
    453            msg.telemetry.fileSizeBytes
    454          );
    455        }
    456 
    457        this._successes++;
    458        if (msg.result.upgradeBackup) {
    459          // We have just completed a backup-on-upgrade, store the information
    460          // in preferences.
    461          Services.prefs.setCharPref(
    462            PREF_UPGRADE_BACKUP,
    463            Services.appinfo.platformBuildID
    464          );
    465        }
    466      },
    467      err => {
    468        // Catch and report any errors.
    469        lazy.sessionStoreLogger.error(
    470          "Could not write session state file ",
    471          err,
    472          err.stack
    473        );
    474        this._failures++;
    475        // By not doing anything special here we ensure that |promise| cannot
    476        // be rejected anymore. The shutdown/cleanup code at the end of the
    477        // function will thus always be executed.
    478      }
    479    );
    480 
    481    // Ensure that we can write sessionstore.js cleanly before the profile
    482    // becomes unaccessible.
    483    IOUtils.profileBeforeChange.addBlocker(
    484      "SessionFile: Finish writing Session Restore data",
    485      promise,
    486      {
    487        fetchState: () => ({
    488          options,
    489          attempts: this._attempts,
    490          successes: this._successes,
    491          failures: this._failures,
    492        }),
    493      }
    494    );
    495 
    496    // This code will always be executed because |promise| can't fail anymore.
    497    // We ensured that by having a reject handler that reports the failure but
    498    // doesn't forward the rejection.
    499    return promise.then(() => {
    500      // Remove the blocker, no matter if writing failed or not.
    501      IOUtils.profileBeforeChange.removeBlocker(promise);
    502 
    503      if (isFinalWrite) {
    504        Services.obs.notifyObservers(
    505          null,
    506          "sessionstore-final-state-write-complete"
    507        );
    508      }
    509    });
    510  },
    511 
    512  async wipe() {
    513    const writer = await this.getWriter();
    514    await writer.wipe();
    515    // After a wipe, we need to make sure to re-initialize upon the next read(),
    516    // because the state variables as sent to the writer have changed.
    517    this._initialized = false;
    518  },
    519 };