BackupService.sys.mjs (168658B)
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 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import * as DefaultBackupResources from "resource:///modules/backup/BackupResources.sys.mjs"; 6 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; 7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 8 import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; 9 import { 10 MeasurementUtils, 11 BYTES_IN_KILOBYTE, 12 BYTES_IN_MEGABYTE, 13 BYTES_IN_MEBIBYTE, 14 } from "resource:///modules/backup/MeasurementUtils.sys.mjs"; 15 16 import { 17 ERRORS, 18 STEPS, 19 errorString, 20 } from "chrome://browser/content/backup/backup-constants.mjs"; 21 import { BackupError } from "resource:///modules/backup/BackupError.mjs"; 22 23 const BACKUP_DIR_PREF_NAME = "browser.backup.location"; 24 const BACKUP_ERROR_CODE_PREF_NAME = "browser.backup.errorCode"; 25 const SCHEDULED_BACKUPS_ENABLED_PREF_NAME = "browser.backup.scheduled.enabled"; 26 const BACKUP_ARCHIVE_ENABLED_PREF_NAME = "browser.backup.archive.enabled"; 27 const BACKUP_ARCHIVE_ENABLED_OVERRIDE_PREF_NAME = 28 "browser.backup.archive.overridePlatformCheck"; 29 const BACKUP_RESTORE_ENABLED_PREF_NAME = "browser.backup.restore.enabled"; 30 const BACKUP_RESTORE_ENABLED_OVERRIDE_PREF_NAME = 31 "browser.backup.restore.overridePlatformCheck"; 32 const IDLE_THRESHOLD_SECONDS_PREF_NAME = 33 "browser.backup.scheduled.idle-threshold-seconds"; 34 const MINIMUM_TIME_BETWEEN_BACKUPS_SECONDS_PREF_NAME = 35 "browser.backup.scheduled.minimum-time-between-backups-seconds"; 36 const LAST_BACKUP_TIMESTAMP_PREF_NAME = 37 "browser.backup.scheduled.last-backup-timestamp"; 38 const LAST_BACKUP_FILE_NAME_PREF_NAME = 39 "browser.backup.scheduled.last-backup-file"; 40 const BACKUP_RETRY_LIMIT_PREF_NAME = "browser.backup.backup-retry-limit"; 41 const DISABLED_ON_IDLE_RETRY_PREF_NAME = 42 "browser.backup.disabled-on-idle-backup-retry"; 43 const BACKUP_DEBUG_INFO_PREF_NAME = "browser.backup.backup-debug-info"; 44 const MAXIMUM_NUMBER_OF_UNREMOVABLE_STAGING_ITEMS_PREF_NAME = 45 "browser.backup.max-num-unremovable-staging-items"; 46 const CREATED_MANAGED_PROFILES_PREF_NAME = "browser.profiles.created"; 47 const RESTORED_BACKUP_METADATA_PREF_NAME = 48 "browser.backup.restored-backup-metadata"; 49 const SANITIZE_ON_SHUTDOWN_PREF_NAME = "privacy.sanitize.sanitizeOnShutdown"; 50 51 const SCHEMAS = Object.freeze({ 52 BACKUP_MANIFEST: 1, 53 ARCHIVE_JSON_BLOCK: 2, 54 }); 55 56 const lazy = {}; 57 58 ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { 59 return console.createInstance({ 60 prefix: "BackupService", 61 maxLogLevel: Services.prefs.getBoolPref("browser.backup.log", false) 62 ? "Debug" 63 : "Warn", 64 }); 65 }); 66 67 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { 68 return ChromeUtils.importESModule( 69 "resource://gre/modules/FxAccounts.sys.mjs" 70 ).getFxAccountsSingleton(); 71 }); 72 73 ChromeUtils.defineESModuleGetters(lazy, { 74 AddonManager: "resource://gre/modules/AddonManager.sys.mjs", 75 ArchiveDecryptor: "resource:///modules/backup/ArchiveEncryption.sys.mjs", 76 ArchiveEncryptionState: 77 "resource:///modules/backup/ArchiveEncryptionState.sys.mjs", 78 ArchiveUtils: "resource:///modules/backup/ArchiveUtils.sys.mjs", 79 BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs", 80 ClientID: "resource://gre/modules/ClientID.sys.mjs", 81 DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", 82 DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", 83 FileUtils: "resource://gre/modules/FileUtils.sys.mjs", 84 JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs", 85 NetUtil: "resource://gre/modules/NetUtil.sys.mjs", 86 NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", 87 SelectableProfileService: 88 "resource:///modules/profiles/SelectableProfileService.sys.mjs", 89 UIState: "resource://services-sync/UIState.sys.mjs", 90 }); 91 92 ChromeUtils.defineLazyGetter(lazy, "ZipWriter", () => 93 Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter", "open") 94 ); 95 ChromeUtils.defineLazyGetter(lazy, "ZipReader", () => 96 Components.Constructor( 97 "@mozilla.org/libjar/zip-reader;1", 98 "nsIZipReader", 99 "open" 100 ) 101 ); 102 ChromeUtils.defineLazyGetter(lazy, "nsLocalFile", () => 103 Components.Constructor("@mozilla.org/file/local;1", "nsIFile", "initWithPath") 104 ); 105 106 ChromeUtils.defineLazyGetter(lazy, "BinaryInputStream", () => 107 Components.Constructor( 108 "@mozilla.org/binaryinputstream;1", 109 "nsIBinaryInputStream", 110 "setInputStream" 111 ) 112 ); 113 114 ChromeUtils.defineLazyGetter(lazy, "gFluentStrings", function () { 115 return new Localization( 116 ["branding/brand.ftl", "browser/backupSettings.ftl"], 117 true 118 ); 119 }); 120 121 ChromeUtils.defineLazyGetter(lazy, "gDOMLocalization", function () { 122 return new DOMLocalization([ 123 "branding/brand.ftl", 124 "browser/backupSettings.ftl", 125 ]); 126 }); 127 128 XPCOMUtils.defineLazyPreferenceGetter( 129 lazy, 130 "scheduledBackupsPref", 131 SCHEDULED_BACKUPS_ENABLED_PREF_NAME, 132 false, 133 function onUpdateScheduledBackups(_pref, _prevVal, newVal) { 134 let bs = BackupService.get(); 135 if (bs) { 136 bs.onUpdateScheduledBackups(newVal); 137 } 138 } 139 ); 140 141 XPCOMUtils.defineLazyPreferenceGetter( 142 lazy, 143 "backupDirPref", 144 BACKUP_DIR_PREF_NAME, 145 /** 146 * To avoid disk access upon startup, do not set DEFAULT_PARENT_DIR_PATH 147 * as a fallback value here. Let registered widgets prompt BackupService 148 * to update the parentDirPath. 149 * 150 * @see BackupService.state 151 * @see DEFAULT_PARENT_DIR_PATH 152 * @see setParentDirPath 153 */ 154 null, 155 async function onUpdateLocationDirPath(_pref, _prevVal, newVal) { 156 let bs; 157 try { 158 bs = BackupService.get(); 159 } catch (e) { 160 // This can throw if the BackupService hasn't initialized yet, which 161 // is a case we're okay to ignore. 162 } 163 if (bs) { 164 await bs.onUpdateLocationDirPath(newVal); 165 } 166 } 167 ); 168 169 XPCOMUtils.defineLazyPreferenceGetter( 170 lazy, 171 "minimumTimeBetweenBackupsSeconds", 172 MINIMUM_TIME_BETWEEN_BACKUPS_SECONDS_PREF_NAME, 173 86400 /* 1 day */ 174 ); 175 176 XPCOMUtils.defineLazyPreferenceGetter( 177 lazy, 178 "backupRetryLimit", 179 BACKUP_RETRY_LIMIT_PREF_NAME, 180 100 181 ); 182 183 XPCOMUtils.defineLazyPreferenceGetter( 184 lazy, 185 "isRetryDisabledOnIdle", 186 DISABLED_ON_IDLE_RETRY_PREF_NAME, 187 false 188 ); 189 190 XPCOMUtils.defineLazyPreferenceGetter( 191 lazy, 192 "maximumNumberOfUnremovableStagingItems", 193 MAXIMUM_NUMBER_OF_UNREMOVABLE_STAGING_ITEMS_PREF_NAME, 194 5 195 ); 196 197 XPCOMUtils.defineLazyPreferenceGetter( 198 lazy, 199 "backupErrorCode", 200 BACKUP_ERROR_CODE_PREF_NAME, 201 0, 202 function onUpdateBackupErrorCode(_pref, _prevVal, newVal) { 203 let bs = BackupService.get(); 204 if (bs) { 205 bs.onUpdateBackupErrorCode(newVal); 206 } 207 } 208 ); 209 210 XPCOMUtils.defineLazyPreferenceGetter( 211 lazy, 212 "lastBackupFileName", 213 LAST_BACKUP_FILE_NAME_PREF_NAME, 214 "", 215 function onUpdateLastBackupFileName(_pref, _prevVal, newVal) { 216 let bs; 217 try { 218 bs = BackupService.get(); 219 } catch (e) { 220 // This can throw if the BackupService hasn't initialized yet, which 221 // is a case we're okay to ignore. 222 } 223 if (bs) { 224 bs.onUpdateLastBackupFileName(newVal); 225 } 226 } 227 ); 228 229 XPCOMUtils.defineLazyServiceGetter( 230 lazy, 231 "idleService", 232 "@mozilla.org/widget/useridleservice;1", 233 Ci.nsIUserIdleService 234 ); 235 236 XPCOMUtils.defineLazyServiceGetter( 237 lazy, 238 "nativeOSKeyStore", 239 "@mozilla.org/security/oskeystore;1", 240 Ci.nsIOSKeyStore 241 ); 242 243 /** 244 * A class that wraps a multipart/mixed stream converter instance, and streams 245 * in the binary part of a single-file archive (which should be at the second 246 * index of the attachments) as a ReadableStream. 247 * 248 * The bytes that are read in are text decoded, but are not guaranteed to 249 * represent a "full chunk" of base64 data. Consumers should ensure to buffer 250 * the strings emitted by this stream, and to search for `\n` characters, which 251 * indicate the end of a (potentially encrypted and) base64 encoded block. 252 */ 253 class BinaryReadableStream { 254 #channel = null; 255 256 /** 257 * Constructs a BinaryReadableStream. 258 * 259 * @param {nsIChannel} channel 260 * The channel through which to begin the flow of bytes from the 261 * inputStream 262 */ 263 constructor(channel) { 264 this.#channel = channel; 265 } 266 267 /** 268 * Implements `start` from the `underlyingSource` of a ReadableStream 269 * 270 * @param {ReadableStreamDefaultController} controller 271 * The controller for the ReadableStream to feed strings into. 272 */ 273 start(controller) { 274 let streamConv = Cc["@mozilla.org/streamConverters;1"].getService( 275 Ci.nsIStreamConverterService 276 ); 277 278 let textDecoder = new TextDecoder(); 279 280 // The attachment index that should contain the binary data. 281 const EXPECTED_CONTENT_TYPE = "application/octet-stream"; 282 283 // This is fairly clumsy, but by using an object nsIStreamListener like 284 // this, I can keep from stashing the `controller` somewhere, as it's 285 // available in the closure. 286 let multipartListenerForBinary = { 287 /** 288 * True once we've found an attachment matching our EXPECTED_CONTENT_TYPE. 289 * Once this is true, bytes flowing into onDataAvailable will be 290 * enqueued through the controller. 291 * 292 * @type {boolean} 293 */ 294 _enabled: false, 295 296 /** 297 * True once onStopRequest has been called once the listener is enabled. 298 * After this, the listener will not attempt to read any data passed 299 * to it through onDataAvailable. 300 * 301 * @type {boolean} 302 */ 303 _done: false, 304 305 QueryInterface: ChromeUtils.generateQI([ 306 "nsIStreamListener", 307 "nsIRequestObserver", 308 "nsIMultiPartChannelListener", 309 ]), 310 311 /** 312 * Called when we begin to load an attachment from the MIME message. 313 * 314 * @param {nsIRequest} request 315 * The request corresponding to the source of the data. 316 */ 317 onStartRequest(request) { 318 if (!(request instanceof Ci.nsIChannel)) { 319 throw Components.Exception( 320 "onStartRequest expected an nsIChannel request", 321 Cr.NS_ERROR_UNEXPECTED 322 ); 323 } 324 this._enabled = request.contentType == EXPECTED_CONTENT_TYPE; 325 }, 326 327 /** 328 * Called when data is flowing in for an attachment. 329 * 330 * @param {nsIRequest} request 331 * The request corresponding to the source of the data. 332 * @param {nsIInputStream} stream 333 * The input stream containing the data chunk. 334 * @param {number} offset 335 * The number of bytes that were sent in previous onDataAvailable calls 336 * for this request. In other words, the sum of all previous count 337 * parameters. 338 * @param {number} count 339 * The number of bytes available in the stream 340 */ 341 onDataAvailable(request, stream, offset, count) { 342 if (this._done) { 343 // No need to load anything else - abort reading in more 344 // attachments. 345 throw Components.Exception( 346 "Got binary block - cancelling loading the multipart stream.", 347 Cr.NS_BINDING_ABORTED 348 ); 349 } 350 if (!this._enabled) { 351 // We don't care about this data, just move on. 352 return; 353 } 354 355 let binStream = new lazy.BinaryInputStream(stream); 356 let bytes = new Uint8Array(count); 357 binStream.readArrayBuffer(count, bytes.buffer); 358 let string = textDecoder.decode(bytes); 359 controller.enqueue(string); 360 }, 361 362 /** 363 * Called when the load of an attachment finishes. 364 */ 365 onStopRequest() { 366 if (this._enabled && !this._done) { 367 this._enabled = false; 368 this._done = true; 369 370 controller.close(); 371 } 372 }, 373 374 onAfterLastPart() { 375 if (!this._done) { 376 // We finished reading the parts before we found the binary block, 377 // so the binary block is missing. 378 controller.error( 379 new BackupError( 380 "Could not find binary block.", 381 ERRORS.CORRUPTED_ARCHIVE 382 ) 383 ); 384 } 385 }, 386 }; 387 388 let conv = streamConv.asyncConvertData( 389 "multipart/mixed", 390 "*/*", 391 multipartListenerForBinary, 392 null 393 ); 394 395 this.#channel.asyncOpen(conv); 396 } 397 } 398 399 /** 400 * A TransformStream class that takes in chunks of base64 encoded data, 401 * decodes (and eventually, decrypts) them before passing the resulting 402 * bytes along to the next step in the pipe. 403 * 404 * The BinaryReadableStream feeds strings into this TransformStream, but the 405 * buffering of these streams means that we cannot be certain that the string 406 * that was passed is the entirety of a base64 encoded block. ArchiveWorker 407 * puts every block on its own line, meaning that we must simply look for 408 * newlines to indicate when a break between full blocks is, and buffer chunks 409 * until we see those breaks - only decoding once we have a full block. 410 */ 411 export class DecoderDecryptorTransformer { 412 #buffer = ""; 413 #decryptor = null; 414 415 /** 416 * Constructs the DecoderDecryptorTransformer. 417 * 418 * @param {ArchiveDecryptor|null} decryptor 419 * An initialized ArchiveDecryptor, if this stream of bytes is presumed to 420 * be encrypted. 421 */ 422 constructor(decryptor) { 423 this.#decryptor = decryptor; 424 } 425 426 /** 427 * Consumes a single chunk of a base64 encoded string sent by 428 * BinaryReadableStream. 429 * 430 * @param {string} chunkPart 431 * A part of a chunk of a base64 encoded string sent by 432 * BinaryReadableStream. 433 * @param {TransformStreamDefaultController} controller 434 * The controller to send decoded bytes to. 435 * @returns {Promise<undefined>} 436 */ 437 async transform(chunkPart, controller) { 438 // A small optimization, but considering the size of these strings, it's 439 // likely worth it. 440 if (this.#buffer) { 441 this.#buffer += chunkPart; 442 } else { 443 this.#buffer = chunkPart; 444 } 445 446 // If the compressed archive was large enough, then it got split up over 447 // several chunks. In that case, each chunk is separated by a newline. We 448 // also filter out any extraneous newlines that might have been included 449 // at the end. 450 let chunks = this.#buffer.split("\n").filter(chunk => chunk != ""); 451 452 this.#buffer = chunks.pop(); 453 // If there were any remaining parts that we split out from the buffer, 454 // they must constitute full blocks that we can decode. 455 for (let chunk of chunks) { 456 await this.#processChunk(controller, chunk); 457 } 458 } 459 460 /** 461 * Called once BinaryReadableStream signals that it has sent all of its 462 * strings, in which case we know that whatever is in the buffer should be 463 * a valid block. 464 * 465 * @param {TransformStreamDefaultController} controller 466 * The controller to send decoded bytes to. 467 * @returns {Promise<undefined>} 468 */ 469 async flush(controller) { 470 await this.#processChunk(controller, this.#buffer, true); 471 this.#buffer = ""; 472 } 473 474 /** 475 * Decodes (and potentially decrypts) a valid base64 encoded chunk into a 476 * Uint8Array and sends it to the next step in the pipe. 477 * 478 * @param {TransformStreamDefaultController} controller 479 * The controller to send decoded bytes to. 480 * @param {string} chunk 481 * The base64 encoded string to decode and potentially decrypt. 482 * @param {boolean} [isLastChunk=false] 483 * True if this is the last chunk to be processed. 484 * @returns {Promise<undefined>} 485 */ 486 async #processChunk(controller, chunk, isLastChunk = false) { 487 try { 488 let bytes = lazy.ArchiveUtils.stringToArray(chunk); 489 490 if (this.#decryptor) { 491 let plaintextBytes = await this.#decryptor.decrypt(bytes, isLastChunk); 492 controller.enqueue(plaintextBytes); 493 } else { 494 controller.enqueue(bytes); 495 } 496 } catch (e) { 497 // Something went wrong base64 decoding or decrypting. Tell the controller 498 // that we're done, so that it can destroy anything that was decoded / 499 // decrypted already. 500 controller.error("Corrupted archive."); 501 } 502 } 503 } 504 505 /** 506 * A class that lets us construct a WritableStream that writes bytes to a file 507 * on disk somewhere. 508 */ 509 export class FileWriterStream { 510 /** 511 * @type {string} 512 */ 513 #destPath = null; 514 515 /** 516 * @type {nsIOutputStream} 517 */ 518 #outStream = null; 519 520 /** 521 * @type {nsIBinaryOutputStream} 522 */ 523 #binStream = null; 524 525 /** 526 * @type {ArchiveDecryptor} 527 */ 528 #decryptor = null; 529 530 /** 531 * Constructor for FileWriterStream. 532 * 533 * @param {string} destPath 534 * The path to write the incoming bytes to. 535 * @param {ArchiveDecryptor|null} decryptor 536 * An initialized ArchiveDecryptor, if this stream of bytes is presumed to 537 * be encrypted. 538 */ 539 constructor(destPath, decryptor) { 540 this.#destPath = destPath; 541 this.#decryptor = decryptor; 542 } 543 544 /** 545 * Called once the first set of bytes comes in from the 546 * DecoderDecryptorTransformer. This creates the file, and sets up the 547 * underlying nsIOutputStream mechanisms to let us write bytes to the file. 548 */ 549 async start() { 550 let extractionDestFile = await IOUtils.getFile(this.#destPath); 551 this.#outStream = 552 lazy.FileUtils.openSafeFileOutputStream(extractionDestFile); 553 this.#binStream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance( 554 Ci.nsIBinaryOutputStream 555 ); 556 this.#binStream.setOutputStream(this.#outStream); 557 } 558 559 /** 560 * Writes bytes to the destination on the file system. 561 * 562 * @param {Uint8Array} chunk 563 * The bytes to stream to the destination file. 564 */ 565 write(chunk) { 566 this.#binStream.writeByteArray(chunk); 567 } 568 569 /** 570 * Called once the stream of bytes finishes flowing in and closes the stream. 571 * 572 * @param {WritableStreamDefaultController} controller 573 * The controller for the WritableStream. 574 */ 575 close(controller) { 576 lazy.FileUtils.closeSafeFileOutputStream(this.#outStream); 577 if (this.#decryptor && !this.#decryptor.isDone()) { 578 lazy.logConsole.error( 579 "Decryptor was not done when the stream was closed." 580 ); 581 controller.error("Corrupted archive."); 582 } 583 } 584 585 /** 586 * Called if something went wrong while decoding / decrypting the stream of 587 * bytes. This destroys any bytes that may have been decoded / decrypted 588 * prior to the error. 589 * 590 * @param {string} reason 591 * The reported reason for aborting the decoding / decrpytion. 592 */ 593 async abort(reason) { 594 lazy.logConsole.error(`Writing to ${this.#destPath} failed: `, reason); 595 lazy.FileUtils.closeSafeFileOutputStream(this.#outStream); 596 await IOUtils.remove(this.#destPath, { 597 ignoreAbsent: true, 598 retryReadonly: true, 599 }); 600 } 601 } 602 603 /** 604 * The BackupService class orchestrates the scheduling and creation of profile 605 * backups. It also does most of the heavy lifting for the restoration of a 606 * profile backup. 607 */ 608 export class BackupService extends EventTarget { 609 /** 610 * The BackupService singleton instance. 611 * 612 * @static 613 * @type {BackupService|null} 614 */ 615 static #instance = null; 616 617 /** 618 * Map of instantiated BackupResource classes. 619 * 620 * @type {Map<string, BackupResource>} 621 */ 622 #resources = new Map(); 623 624 /** 625 * The name of the backup folder. Should be localized. 626 * 627 * @see BACKUP_DIR_NAME 628 */ 629 static #backupFolderName = "Restore Firefox"; 630 631 /** 632 * The name of the backup archive file. Should be localized. 633 * 634 * @see BACKUP_FILE_NAME 635 */ 636 static #backupFileName = null; 637 638 /** 639 * Number of retries that have occured in this session on error 640 */ 641 static #errorRetries = 0; 642 643 /** 644 * Time to wait (in seconds) until the next backup attempt. 645 * 646 * This uses exponential backoff based on the number of consecutive 647 * failed backup attempts since the last successful backup. 648 * 649 * Backoff formula: 650 * 2^(retryCount) * 60 651 * 652 * Example: 653 * If 2 backup attempts have failed since the last successful backup, 654 * the next attempt will occur after: 655 * 656 * 2^2 * 60 = 240 seconds (4 minutes) 657 * 658 * This differs from minimumTimeBetweenBackupsSeconds, which is used to determine 659 * the time between successful backups. 660 */ 661 static backoffSeconds = () => Math.pow(2, BackupService.#errorRetries) * 60; 662 663 /** 664 * @typedef {object} EnabledStatus 665 * @property {boolean} enabled 666 * True if the feature is enabled. 667 * @property {string} [reason] 668 * Reason the feature is disabled if `enabled` is false. 669 */ 670 671 /** 672 * Context for whether creating a backup archive is enabled. 673 * 674 * @type {EnabledStatus} 675 */ 676 get archiveEnabledStatus() { 677 // Check if disabled by Nimbus killswitch. 678 const archiveKillswitchTriggered = 679 lazy.NimbusFeatures.backupService.getVariable("archiveKillswitch"); 680 const archiveOverrideEnabled = Services.prefs.getBoolPref( 681 BACKUP_ARCHIVE_ENABLED_OVERRIDE_PREF_NAME, 682 false 683 ); 684 // Only disable feature if archiveKillswitch is true. 685 if (archiveKillswitchTriggered && !archiveOverrideEnabled) { 686 return { 687 enabled: false, 688 reason: "Archiving a profile disabled remotely.", 689 internalReason: "nimbus", 690 }; 691 } 692 693 if (!Services.prefs.getBoolPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME)) { 694 if (Services.prefs.prefIsLocked(BACKUP_ARCHIVE_ENABLED_PREF_NAME)) { 695 // If it's locked, assume it was set by an enterprise policy. 696 return { 697 enabled: false, 698 reason: "Archiving a profile disabled by policy.", 699 internalReason: "policy", 700 }; 701 } 702 703 return { 704 enabled: false, 705 reason: "Archiving a profile disabled by user pref.", 706 internalReason: "pref", 707 }; 708 } 709 710 if (lazy.SelectableProfileService.hasCreatedSelectableProfiles()) { 711 return { 712 enabled: false, 713 reason: 714 "Archiving a profile is disabled because the user has created selectable profiles.", 715 internalReason: "selectable profiles", 716 }; 717 } 718 719 if ( 720 !this.#osSupportsBackup && 721 !Services.prefs.getBoolPref( 722 BACKUP_ARCHIVE_ENABLED_OVERRIDE_PREF_NAME, 723 false 724 ) 725 ) { 726 return { 727 enabled: false, 728 reason: "Backup creation not enabled on this os version yet", 729 internalReason: "os version", 730 }; 731 } 732 733 return { enabled: true }; 734 } 735 736 /** 737 * Context for whether restore from backup is enabled. 738 * 739 * @type {EnabledStatus} 740 */ 741 get restoreEnabledStatus() { 742 // Check if disabled by Nimbus killswitch. 743 const restoreKillswitchTriggered = 744 lazy.NimbusFeatures.backupService.getVariable("restoreKillswitch"); 745 const restoreOverrideEnabled = Services.prefs.getBoolPref( 746 BACKUP_RESTORE_ENABLED_OVERRIDE_PREF_NAME, 747 false 748 ); 749 750 if (restoreKillswitchTriggered && !restoreOverrideEnabled) { 751 return { 752 enabled: false, 753 reason: "Restore from backup disabled remotely.", 754 internalReason: "nimbus", 755 }; 756 } 757 758 if (!Services.prefs.getBoolPref(BACKUP_RESTORE_ENABLED_PREF_NAME)) { 759 if (Services.prefs.prefIsLocked(BACKUP_RESTORE_ENABLED_PREF_NAME)) { 760 // If it's locked, assume it was set by an enterprise policy. 761 return { 762 enabled: false, 763 reason: "Restoring a profile disabled by policy.", 764 internalReason: "policy", 765 }; 766 } 767 768 return { 769 enabled: false, 770 reason: "Restoring a profile disabled by user pref.", 771 internalReason: "pref", 772 }; 773 } 774 775 if (lazy.SelectableProfileService.hasCreatedSelectableProfiles()) { 776 return { 777 enabled: false, 778 reason: 779 "Restoring a profile is disabled because the user has created selectable profiles.", 780 internalReason: "selectable profiles", 781 }; 782 } 783 if ( 784 !this.#osSupportsRestore && 785 !Services.prefs.getBoolPref( 786 BACKUP_RESTORE_ENABLED_OVERRIDE_PREF_NAME, 787 false 788 ) 789 ) { 790 return { 791 enabled: false, 792 reason: "Backup restore not enabled on this os version yet", 793 internalReason: "os version", 794 }; 795 } 796 797 return { enabled: true }; 798 } 799 800 /** 801 * Set to true if a backup is currently in progress. Causes stateUpdate() 802 * to be called. 803 * 804 * @see BackupService.stateUpdate() 805 * @param {boolean} val 806 * True if a backup is in progress. 807 */ 808 set #backupInProgress(val) { 809 if (this.#_state.backupInProgress != val) { 810 this.#_state.backupInProgress = val; 811 this.stateUpdate(); 812 } 813 } 814 815 /** 816 * True if a backup is currently in progress. 817 * 818 * @type {boolean} 819 */ 820 get #backupInProgress() { 821 return this.#_state.backupInProgress; 822 } 823 824 /** 825 * Dispatches an event to let listeners know that the BackupService state 826 * object has been updated. 827 */ 828 stateUpdate() { 829 this.dispatchEvent(new CustomEvent("BackupService:StateUpdate")); 830 } 831 832 /** 833 * Sets the recovery error code and updates the state. 834 * 835 * @param {number} errorCode - The error code to set 836 */ 837 setRecoveryError(errorCode) { 838 this.#_state.recoveryErrorCode = errorCode; 839 this.stateUpdate(); 840 } 841 842 /** 843 * Sets the persisted options between screens for embedded components. 844 * This is specifically used in the Spotlight onboarding experience. 845 * 846 * This data is flushed upon creating a backup or exiting the backup flow. 847 * 848 * @param {object} data - data to persist between screens. 849 */ 850 setEmbeddedComponentPersistentData(data) { 851 this.#_state.embeddedComponentPersistentData = { ...data }; 852 this.stateUpdate(); 853 } 854 855 /** 856 * An object holding the current state of the BackupService instance, for 857 * the purposes of representing it in the user interface. Ideally, this would 858 * be named #state instead of #_state, but sphinx-js seems to be fairly 859 * unhappy with that coupled with the ``state`` getter. 860 * 861 * @type {object} 862 */ 863 #_state = { 864 backupDirPath: lazy.backupDirPref, 865 defaultParent: {}, 866 backupFileToRestore: null, 867 backupFileInfo: null, 868 backupInProgress: false, 869 scheduledBackupsEnabled: lazy.scheduledBackupsPref, 870 encryptionEnabled: false, 871 /** @type {number?} Number of seconds since UNIX epoch */ 872 lastBackupDate: null, 873 lastBackupFileName: lazy.lastBackupFileName, 874 supportBaseLink: Services.urlFormatter.formatURLPref("app.support.baseURL"), 875 recoveryInProgress: false, 876 /** 877 * Every file we load successfully is going to get a restore ID which is 878 * basically the identifier for that profile restore event. If we actually 879 * do restore it, this ID will end up being propagated into the restored 880 * file and used to correlate this restore event with the profile that was 881 * restored. 882 */ 883 restoreID: null, 884 /** Utilized by the spotlight to persist information between screens */ 885 embeddedComponentPersistentData: {}, 886 recoveryErrorCode: ERRORS.NONE, 887 backupErrorCode: lazy.backupErrorCode, 888 }; 889 890 /** 891 * A Promise that will resolve once the postRecovery steps are done. It will 892 * also resolve if postRecovery steps didn't need to run. 893 * 894 * @see BackupService.checkForPostRecovery() 895 * @type {Promise<undefined>} 896 */ 897 #postRecoveryPromise; 898 899 /** 900 * The resolving function for #postRecoveryPromise, which should be called 901 * by checkForPostRecovery() before exiting. 902 * 903 * @type {Function} 904 */ 905 #postRecoveryResolver; 906 907 /** 908 * The currently used ArchiveEncryptionState. Callers should use 909 * loadEncryptionState() instead, to ensure that any pre-serialized 910 * encryption state has been read in and deserialized. 911 * 912 * This member can be in 3 states: 913 * 914 * 1. undefined - no attempt has been made to load encryption state from 915 * disk yet. 916 * 2. null - encryption is not enabled. 917 * 3. ArchiveEncryptionState - encryption is enabled. 918 * 919 * @see BackupService.loadEncryptionState() 920 * @type {ArchiveEncryptionState|null|undefined} 921 */ 922 #encState = undefined; 923 924 /** 925 * The PlacesObserver instance used to monitor the Places database for 926 * history and bookmark removals to determine if backups should be 927 * regenerated. 928 * 929 * @type {PlacesObserver|null} 930 */ 931 #placesObserver = null; 932 933 /** 934 * The AbortController used to abort any queued requests to create or delete 935 * backups that might be waiting on the WRITE_BACKUP_LOCK_NAME lock. 936 * 937 * @type {AbortController} 938 */ 939 #backupWriteAbortController = null; 940 941 /** 942 * A DeferredTask that will cause the last known backup to be deleted, and 943 * a new backup to be created. 944 * 945 * See BackupService.#debounceRegeneration() 946 * 947 * @type {DeferredTask} 948 */ 949 #regenerationDebouncer = null; 950 951 /** 952 * True if takeMeasurements has been called and various measurements related 953 * to the BackupService have been taken. 954 * 955 * @type {boolean} 956 */ 957 #takenMeasurements = false; 958 959 /** 960 * Stores whether backing up has been disabled at some point during this 961 * session. If it has been, the archiveDisabledReason telemetry metric is set 962 * on each backup. (It cannot be unset due to Glean limitations.) 963 * 964 * @type {boolean} 965 */ 966 #wasArchivePreviouslyDisabled = false; 967 968 /** 969 * Stores whether restoring up has been disabled at some point during this 970 * session. If it has been, the restoreDisabledReason telemetry metric is set 971 * on each backup. (It cannot be unset due to Glean limitations.) 972 * 973 * @type {boolean} 974 */ 975 #wasRestorePreviouslyDisabled = false; 976 977 /** 978 * Called when prefs or other conditions relevant to the status of the backup 979 * service change. Unlike #observer, this does not wait for an idle tick. 980 * 981 * This callback doesn't take any parameters. It's here so it can be removed 982 * by uninitStatusObservers, and also so its 'this' value remains accurate. 983 * If null, the conditions are not currently being monitored. 984 * 985 * @type {Function?} 986 */ 987 #statusPrefObserver = null; 988 989 /** 990 * The path of the default parent directory for saving backups. 991 * The current default is the Documents directory. 992 * 993 * @returns {string} The path of the default parent directory 994 */ 995 static get DEFAULT_PARENT_DIR_PATH() { 996 return ( 997 BackupService.oneDriveFolderPath?.path || 998 BackupService.docsDirFolderPath?.path || 999 "" 1000 ); 1001 } 1002 1003 /** 1004 * The localized name for the user's backup folder. 1005 * 1006 * @returns {string} The localized backup folder name 1007 */ 1008 static get BACKUP_DIR_NAME() { 1009 if (!BackupService.#backupFolderName) { 1010 BackupService.#backupFolderName = lazy.DownloadPaths.sanitize( 1011 lazy.gFluentStrings.formatValueSync("backup-folder-name") 1012 ); 1013 } 1014 return BackupService.#backupFolderName; 1015 } 1016 1017 /** 1018 * The localized name for the user's backup archive file. This will have 1019 * `.html` appended to it before writing the archive file. 1020 * 1021 * @returns {string} The localized backup file name 1022 */ 1023 static get BACKUP_FILE_NAME() { 1024 if (!BackupService.#backupFileName) { 1025 BackupService.#backupFileName = lazy.DownloadPaths.sanitize( 1026 lazy.gFluentStrings.formatValueSync("backup-file-name") 1027 ); 1028 } 1029 return BackupService.#backupFileName; 1030 } 1031 1032 /** 1033 * The name of the folder within the profile folder where this service reads 1034 * and writes state to. 1035 * 1036 * @type {string} 1037 */ 1038 static get PROFILE_FOLDER_NAME() { 1039 return "backups"; 1040 } 1041 1042 /** 1043 * The name of the folder within the PROFILE_FOLDER_NAME where the staging 1044 * folder / prior backups will be stored. 1045 * 1046 * @type {string} 1047 */ 1048 static get SNAPSHOTS_FOLDER_NAME() { 1049 return "snapshots"; 1050 } 1051 1052 /** 1053 * The name of the backup manifest file. 1054 * 1055 * @type {string} 1056 */ 1057 static get MANIFEST_FILE_NAME() { 1058 return "backup-manifest.json"; 1059 } 1060 1061 /** 1062 * A promise that resolves to the schema for the backup manifest that this 1063 * BackupService uses when creating a backup. This should be accessed via 1064 * the `MANIFEST_SCHEMA` static getter. 1065 * 1066 * @type {Promise<object>} 1067 */ 1068 static #manifestSchemaPromise = null; 1069 1070 /** 1071 * The current schema version of the backup manifest that this BackupService 1072 * uses when creating a backup. 1073 * 1074 * @type {Promise<object>} 1075 */ 1076 static get MANIFEST_SCHEMA() { 1077 if (!BackupService.#manifestSchemaPromise) { 1078 BackupService.#manifestSchemaPromise = BackupService.getSchemaForVersion( 1079 SCHEMAS.BACKUP_MANIFEST, 1080 lazy.ArchiveUtils.SCHEMA_VERSION 1081 ); 1082 } 1083 1084 return BackupService.#manifestSchemaPromise; 1085 } 1086 1087 /** 1088 * The name of the post recovery file written into the newly created profile 1089 * directory just after a profile is recovered from a backup. 1090 * 1091 * @type {string} 1092 */ 1093 static get POST_RECOVERY_FILE_NAME() { 1094 return "post-recovery.json"; 1095 } 1096 1097 /** 1098 * The name of the serialized ArchiveEncryptionState that is written to disk 1099 * if encryption is enabled. 1100 * 1101 * @type {string} 1102 */ 1103 static get ARCHIVE_ENCRYPTION_STATE_FILE() { 1104 return "enc-state.json"; 1105 } 1106 1107 /** 1108 * Returns the SCHEMAS constants, which is a key/value store of constants. 1109 * 1110 * @type {object} 1111 */ 1112 static get SCHEMAS() { 1113 return SCHEMAS; 1114 } 1115 1116 /** 1117 * Returns the filename used for the intermediary compressed ZIP file that 1118 * is extracted from archives during recovery. 1119 * 1120 * @type {string} 1121 */ 1122 static get RECOVERY_ZIP_FILE_NAME() { 1123 return "recovery.zip"; 1124 } 1125 1126 /** 1127 * Prefs that should be monitored. When one of these prefs changes, the 1128 * 'backup-service-status-changed' observers are notified and telemetry 1129 * updates. 1130 * 1131 * @type {string[]} 1132 */ 1133 static get STATUS_OBSERVER_PREFS() { 1134 return [ 1135 BACKUP_ARCHIVE_ENABLED_PREF_NAME, 1136 BACKUP_RESTORE_ENABLED_PREF_NAME, 1137 SANITIZE_ON_SHUTDOWN_PREF_NAME, 1138 CREATED_MANAGED_PROFILES_PREF_NAME, 1139 ]; 1140 } 1141 1142 /** 1143 * Returns the schema for the schemaType for a given version. 1144 * 1145 * @param {number} schemaType 1146 * One of the constants from SCHEMAS. 1147 * @param {number} version 1148 * The version of the schema to return. 1149 * @returns {Promise<object>} 1150 */ 1151 static async getSchemaForVersion(schemaType, version) { 1152 let schemaURL; 1153 1154 if (schemaType == SCHEMAS.BACKUP_MANIFEST) { 1155 schemaURL = `chrome://browser/content/backup/BackupManifest.${version}.schema.json`; 1156 } else if (schemaType == SCHEMAS.ARCHIVE_JSON_BLOCK) { 1157 schemaURL = `chrome://browser/content/backup/ArchiveJSONBlock.${version}.schema.json`; 1158 } else { 1159 throw new BackupError( 1160 `Did not recognize SCHEMAS constant: ${schemaType}`, 1161 ERRORS.UNKNOWN 1162 ); 1163 } 1164 1165 let response = await fetch(schemaURL); 1166 return response.json(); 1167 } 1168 1169 /** 1170 * The level of Zip compression to use on the zipped staging folder. 1171 * 1172 * @type {number} 1173 */ 1174 static get COMPRESSION_LEVEL() { 1175 return Ci.nsIZipWriter.COMPRESSION_BEST; 1176 } 1177 1178 /** 1179 * Returns the chrome:// URI string for the template that should be used to 1180 * construct the single-file archive. 1181 * 1182 * @type {string} 1183 */ 1184 static get ARCHIVE_TEMPLATE() { 1185 return "chrome://browser/content/backup/archive.template.html"; 1186 } 1187 1188 /** 1189 * The native OSKeyStore label used for the temporary recovery store. The 1190 * temporary recovery store is initialized with the original OSKeyStore 1191 * secret that was included in an encrypted backup, and then used by any 1192 * BackupResource's that need to decrypt / re-encrypt OSKeyStore secrets for 1193 * the current device. 1194 * 1195 * @type {string} 1196 */ 1197 static get RECOVERY_OSKEYSTORE_LABEL() { 1198 return AppConstants.MOZ_APP_BASENAME + " Backup Recovery Storage"; 1199 } 1200 1201 /** 1202 * The name of the exclusive Web Lock that will be requested and held when 1203 * creating or deleting a backup. 1204 * 1205 * @type {string} 1206 */ 1207 static get WRITE_BACKUP_LOCK_NAME() { 1208 return "write-backup"; 1209 } 1210 1211 /** 1212 * The amount of time (in milliseconds) to wait for our backup regeneration 1213 * debouncer to kick off a regeneration. 1214 * 1215 * @type {number} 1216 */ 1217 static get REGENERATION_DEBOUNCE_RATE_MS() { 1218 return 10000; 1219 } 1220 1221 /** 1222 * The user's personal OneDrive folder, or null if none exists. 1223 * 1224 * @returns {nsIFile|null} The OneDrive folder or null 1225 */ 1226 static get oneDriveFolderPath() { 1227 try { 1228 let oneDriveDir = Services.dirsvc.get("OneDrPD", Ci.nsIFile); 1229 // This check should be redundant -- the OneDrive folder should exist. 1230 return oneDriveDir.exists() ? oneDriveDir : null; 1231 } catch { 1232 // Ignore exceptions. The OneDrive folder not existing is an exception. 1233 } 1234 return null; 1235 } 1236 1237 /** 1238 * Gets the user's Documents folder. 1239 * If it doesn't exist, return null. 1240 * 1241 * @returns {nsIFile|null} The Documents folder or null 1242 */ 1243 static get docsDirFolderPath() { 1244 try { 1245 return Services.dirsvc.get("Docs", Ci.nsIFile); 1246 } catch (e) { 1247 lazy.logConsole.warn( 1248 "There was an error while trying to get the Document's directory", 1249 e 1250 ); 1251 } 1252 return null; 1253 } 1254 1255 /** 1256 * Returns a reference to a BackupService singleton. If this is the first time 1257 * that this getter is accessed, this causes the BackupService singleton to be 1258 * instantiated. 1259 * 1260 * @static 1261 * @param {object} BackupResources 1262 * Optional object containing BackupResource classes to initialize the instance with. 1263 * @returns {BackupService} 1264 * The BackupService singleton instance. 1265 */ 1266 static init(BackupResources = DefaultBackupResources) { 1267 if (this.#instance) { 1268 return this.#instance; 1269 } 1270 1271 // If there is unsent restore telemetry, send it now. 1272 GleanPings.profileRestore.submit(); 1273 1274 this.#instance = new BackupService(BackupResources); 1275 1276 this.#instance.checkForPostRecovery(); 1277 this.#instance.initBackupScheduler(); 1278 this.#instance.initStatusObservers(); 1279 return this.#instance; 1280 } 1281 1282 /** 1283 * Clears the BackupService singleton instance. 1284 * This should only be used in tests. 1285 * 1286 * @static 1287 */ 1288 static uninit() { 1289 if (this.#instance) { 1290 lazy.logConsole.debug("Uninitting the BackupService"); 1291 1292 this.#instance.uninitBackupScheduler(); 1293 this.#instance.uninitStatusObservers(); 1294 this.#instance = null; 1295 } 1296 } 1297 1298 /** 1299 * Returns a reference to the BackupService singleton. If the singleton has 1300 * not been initialized, an error is thrown. 1301 * 1302 * @static 1303 * @returns {BackupService} 1304 */ 1305 static get() { 1306 if (!this.#instance) { 1307 throw new BackupError( 1308 "BackupService not initialized", 1309 ERRORS.UNINITIALIZED 1310 ); 1311 } 1312 return this.#instance; 1313 } 1314 1315 static checkOsSupportsBackup(osParams) { 1316 // Currently we only want to show Backup on Windows 10 devices. 1317 // The first build of Windows 11 is 22000 1318 return ( 1319 osParams.name == "Windows_NT" && 1320 osParams.version == "10.0" && 1321 osParams.build && 1322 Number(osParams.build) < 22000 1323 ); 1324 } 1325 1326 /** 1327 * Create a BackupService instance. 1328 * 1329 * @param {object} [backupResources=DefaultBackupResources] 1330 * Object containing BackupResource classes to associate with this service. 1331 */ 1332 constructor(backupResources = DefaultBackupResources) { 1333 super(); 1334 lazy.logConsole.debug("Instantiated"); 1335 1336 for (const resourceName in backupResources) { 1337 let resource = backupResources[resourceName]; 1338 this.#resources.set(resource.key, resource); 1339 } 1340 1341 let { promise, resolve } = Promise.withResolvers(); 1342 this.#postRecoveryPromise = promise; 1343 this.#postRecoveryResolver = resolve; 1344 this.#backupWriteAbortController = new AbortController(); 1345 this.#regenerationDebouncer = new lazy.DeferredTask(async () => { 1346 if ( 1347 !this.#backupWriteAbortController.signal.aborted && 1348 this.archiveEnabledStatus.enabled 1349 ) { 1350 await this.createBackupOnIdleDispatch({ 1351 reason: "user deleted some data", 1352 }); 1353 } 1354 }, BackupService.REGENERATION_DEBOUNCE_RATE_MS); 1355 this.#postRecoveryPromise.then(() => { 1356 const payload = { 1357 is_restored: 1358 !!Services.prefs.getIntPref( 1359 "browser.backup.profile-restoration-date", 1360 0 1361 ) && 1362 !Services.prefs.getBoolPref("browser.profiles.profile-copied", false), 1363 }; 1364 if (payload.is_restored) { 1365 let backupMetadata = {}; 1366 try { 1367 backupMetadata = JSON.parse( 1368 Services.prefs.getStringPref( 1369 RESTORED_BACKUP_METADATA_PREF_NAME, 1370 "{}" 1371 ) 1372 ); 1373 } catch {} 1374 payload.backup_timestamp = backupMetadata.date 1375 ? new Date(backupMetadata.date).getTime() 1376 : null; 1377 payload.backup_app_name = backupMetadata.appName || null; 1378 payload.backup_app_version = backupMetadata.appVersion || null; 1379 payload.backup_build_id = backupMetadata.buildID || null; 1380 payload.backup_os_name = backupMetadata.osName || null; 1381 payload.backup_os_version = backupMetadata.osVersion || null; 1382 payload.backup_legacy_client_id = backupMetadata.legacyClientID || null; 1383 } 1384 Glean.browserBackup.restoredProfileData.set(payload); 1385 }); 1386 const osParams = { 1387 name: Services.sysinfo.getProperty("name"), 1388 version: Services.sysinfo.getProperty("version"), 1389 build: Services.sysinfo.getProperty("build"), 1390 }; 1391 this.#osSupportsBackup = BackupService.checkOsSupportsBackup(osParams); 1392 this.#osSupportsRestore = true; 1393 this.#lastSeenArchiveStatus = this.archiveEnabledStatus; 1394 this.#lastSeenRestoreStatus = this.restoreEnabledStatus; 1395 } 1396 1397 // Backup is currently limited to Windows 10. Will be populated by constructor 1398 #osSupportsBackup = false; 1399 // Restore is not limited, but leaving this in place if restrictions are needed. 1400 #osSupportsRestore = true; 1401 // Remembering status allows us to notify observers when the status changes 1402 #lastSeenArchiveStatus = false; 1403 #lastSeenRestoreStatus = false; 1404 1405 /** 1406 * Returns a reference to a Promise that will resolve with undefined once 1407 * postRecovery steps have had a chance to run. This will also be resolved 1408 * with undefined if no postRecovery steps needed to be run. 1409 * 1410 * @see BackupService.checkForPostRecovery() 1411 * @returns {Promise<undefined>} 1412 */ 1413 get postRecoveryComplete() { 1414 return this.#postRecoveryPromise; 1415 } 1416 1417 /** 1418 * Returns a state object describing the state of the BackupService for the 1419 * purposes of representing it in the user interface. The returned state 1420 * object is immutable. 1421 * 1422 * @type {object} 1423 */ 1424 get state() { 1425 if ( 1426 !Object.keys(this.#_state.defaultParent).length || 1427 !this.#_state.defaultParent.path 1428 ) { 1429 let defaultPath = BackupService.DEFAULT_PARENT_DIR_PATH; 1430 this.#_state.defaultParent = { 1431 path: defaultPath, 1432 fileName: defaultPath ? PathUtils.filename(defaultPath) : "", 1433 iconURL: defaultPath ? this.getIconFromFilePath(defaultPath) : "", 1434 }; 1435 } 1436 1437 return Object.freeze(structuredClone(this.#_state)); 1438 } 1439 1440 /** 1441 * Attempts to find the right folder to write the single-file archive to, creating 1442 * it if it does not exist yet. 1443 * 1444 * @param {string} configuredDestFolderPath 1445 * The currently configured destination folder for the archive. 1446 * @returns {Promise<string, Error>} 1447 */ 1448 async resolveArchiveDestFolderPath(configuredDestFolderPath) { 1449 try { 1450 await IOUtils.makeDirectory(configuredDestFolderPath, { 1451 createAncestors: true, 1452 ignoreExisting: true, 1453 }); 1454 return configuredDestFolderPath; 1455 } catch (e) { 1456 lazy.logConsole.warn("Could not create configured destination path: ", e); 1457 throw new BackupError( 1458 "Could not resolve to a writable destination folder path.", 1459 ERRORS.FILE_SYSTEM_ERROR 1460 ); 1461 } 1462 } 1463 1464 /** 1465 * Computes the appropriate link to place in the single-file archive for 1466 * downloading a version of this application for the same update channel. 1467 * 1468 * When bug 1905909 lands, we'll first check to see if there are download 1469 * links available in Remote Settings. 1470 * 1471 * If there aren't any, we will fallback by looking for preference values at 1472 * browser.backup.template.fallback-download.${updateChannel}. 1473 * 1474 * If no such preference exists, a final "ultimate" fallback download link is 1475 * chosen for the release channel. 1476 * 1477 * @param {string} updateChannel 1478 * The current update channel for the application, as provided by 1479 * AppConstants.MOZ_UPDATE_CHANNEL. 1480 * @returns {Promise<string>} 1481 */ 1482 async resolveDownloadLink(updateChannel) { 1483 // If all else fails, this is the download link we'll put into the rendered 1484 // template. 1485 const ULTIMATE_FALLBACK_DOWNLOAD_URL = 1486 "https://www.firefox.com/?utm_medium=firefox-desktop&utm_source=html-backup"; 1487 const FALLBACK_DOWNLOAD_URL = Services.prefs.getStringPref( 1488 `browser.backup.template.fallback-download.${updateChannel}`, 1489 ULTIMATE_FALLBACK_DOWNLOAD_URL 1490 ); 1491 1492 // Bug 1905909: Once we set up the download links in RemoteSettings, we can 1493 // query for them here. 1494 1495 return FALLBACK_DOWNLOAD_URL; 1496 } 1497 1498 /** 1499 * Creates a backup for a given profile into a staging foler. 1500 * 1501 * @param {string} profilePath The path to the profile to backup. 1502 * @returns {Promsie<object>} An object containing the results of this function. 1503 * @property {STEPS} currentStep The current step of the backup process. 1504 * @property {string} backupDirPath The path to the folder containing backups. 1505 * Only included if this function completed successfully. 1506 * @property {string} stagingPath The path to the staging folder. 1507 * Only included if this function completed successfully. 1508 * @property {object} manifest An object containing meta data for the backup. 1509 * See the BackupManifest schema for the specific shape of the returned 1510 * manifest object. 1511 * @property {Error} error An error. Only included if an error was thrown. 1512 */ 1513 async createAndPopulateStagingFolder(profilePath) { 1514 let currentStep, backupDirPath, renamedStagingPath, manifest; 1515 try { 1516 currentStep = STEPS.CREATE_BACKUP_CREATE_MANIFEST; 1517 manifest = await this.#createBackupManifest(); 1518 1519 currentStep = STEPS.CREATE_BACKUP_CREATE_BACKUPS_FOLDER; 1520 // First, check to see if a `backups` directory already exists in the 1521 // profile. 1522 backupDirPath = PathUtils.join( 1523 profilePath, 1524 BackupService.PROFILE_FOLDER_NAME, 1525 BackupService.SNAPSHOTS_FOLDER_NAME 1526 ); 1527 lazy.logConsole.debug("Creating backups folder"); 1528 1529 // ignoreExisting: true is the default, but we're being explicit that it's 1530 // okay if this folder already exists. 1531 await IOUtils.makeDirectory(backupDirPath, { 1532 ignoreExisting: true, 1533 createAncestors: true, 1534 }); 1535 1536 currentStep = STEPS.CREATE_BACKUP_CREATE_STAGING_FOLDER; 1537 let stagingPath = await this.#prepareStagingFolder(backupDirPath); 1538 1539 // Sort resources be priority. 1540 let sortedResources = Array.from(this.#resources.values()).sort( 1541 (a, b) => { 1542 return b.priority - a.priority; 1543 } 1544 ); 1545 1546 currentStep = STEPS.CREATE_BACKUP_LOAD_ENCSTATE; 1547 let encState = await this.loadEncryptionState(profilePath); 1548 let encryptionEnabled = !!encState; 1549 lazy.logConsole.debug("Encryption enabled: ", encryptionEnabled); 1550 1551 currentStep = STEPS.CREATE_BACKUP_RUN_BACKUP; 1552 // Perform the backup for each resource. 1553 for (let resourceClass of sortedResources) { 1554 try { 1555 lazy.logConsole.debug( 1556 `Backing up resource with key ${resourceClass.key}. ` + 1557 `Requires encryption: ${resourceClass.requiresEncryption}` 1558 ); 1559 1560 if (resourceClass.requiresEncryption && !encryptionEnabled) { 1561 lazy.logConsole.debug( 1562 "Encryption is not currently enabled. Skipping." 1563 ); 1564 continue; 1565 } 1566 1567 if (!resourceClass.canBackupResource) { 1568 lazy.logConsole.debug( 1569 `We cannot backup ${resourceClass.key}. Skipping.` 1570 ); 1571 continue; 1572 } 1573 1574 let resourcePath = PathUtils.join(stagingPath, resourceClass.key); 1575 await IOUtils.makeDirectory(resourcePath); 1576 1577 // `backup` on each BackupResource should return us a ManifestEntry 1578 // that we eventually write to a JSON manifest file, but for now, 1579 // we're just going to log it. 1580 let manifestEntry = await new resourceClass().backup( 1581 resourcePath, 1582 profilePath, 1583 encryptionEnabled 1584 ); 1585 1586 if (manifestEntry === undefined) { 1587 lazy.logConsole.error( 1588 `Backup of resource with key ${resourceClass.key} returned undefined 1589 as its ManifestEntry instead of null or an object` 1590 ); 1591 } else { 1592 lazy.logConsole.debug( 1593 `Backup of resource with key ${resourceClass.key} completed`, 1594 manifestEntry 1595 ); 1596 manifest.resources[resourceClass.key] = manifestEntry; 1597 } 1598 } catch (e) { 1599 lazy.logConsole.error( 1600 `Failed to backup resource: ${resourceClass.key}`, 1601 e 1602 ); 1603 } 1604 } 1605 1606 currentStep = STEPS.CREATE_BACKUP_VERIFY_MANIFEST; 1607 // Ensure that the manifest abides by the current schema, and log 1608 // an error if somehow it doesn't. We'll want to collect telemetry for 1609 // this case to make sure it's not happening in the wild. We debated 1610 // throwing an exception here too, but that's not meaningfully better 1611 // than creating a backup that's not schema-compliant. At least in this 1612 // case, a user so-inclined could theoretically repair the manifest 1613 // to make it valid. 1614 let manifestSchema = await BackupService.MANIFEST_SCHEMA; 1615 let schemaValidationResult = lazy.JsonSchema.validate( 1616 manifest, 1617 manifestSchema 1618 ); 1619 if (!schemaValidationResult.valid) { 1620 lazy.logConsole.error( 1621 "Backup manifest does not conform to schema:", 1622 manifest, 1623 manifestSchema, 1624 schemaValidationResult 1625 ); 1626 // TODO: Collect telemetry for this case. (bug 1891817) 1627 } 1628 1629 currentStep = STEPS.CREATE_BACKUP_WRITE_MANIFEST; 1630 // Write the manifest to the staging folder. 1631 let manifestPath = PathUtils.join( 1632 stagingPath, 1633 BackupService.MANIFEST_FILE_NAME 1634 ); 1635 await IOUtils.writeJSON(manifestPath, manifest); 1636 1637 currentStep = STEPS.CREATE_BACKUP_FINALIZE_STAGING; 1638 renamedStagingPath = await this.#finalizeStagingFolder(stagingPath); 1639 lazy.logConsole.log( 1640 "Wrote backup to staging directory at ", 1641 renamedStagingPath 1642 ); 1643 1644 // Record the total size of the backup staging directory 1645 let totalSizeKilobytes = 1646 await BackupResource.getDirectorySize(renamedStagingPath); 1647 let totalSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize( 1648 totalSizeKilobytes * BYTES_IN_KILOBYTE, 1649 1 * BYTES_IN_MEBIBYTE 1650 ); 1651 lazy.logConsole.debug( 1652 "total staging directory size in bytes: " + 1653 totalSizeBytesNearestMebibyte 1654 ); 1655 1656 Glean.browserBackup.totalBackupSize.accumulate( 1657 totalSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE 1658 ); 1659 } catch (e) { 1660 return { currentStep, error: e }; 1661 } 1662 1663 return { 1664 currentStep, 1665 backupDirPath, 1666 stagingPath: renamedStagingPath, 1667 manifest, 1668 }; 1669 } 1670 1671 /** 1672 * @typedef {object} CreateBackupResult 1673 * @property {object} manifest 1674 * The backup manifest data of the created backup. See BackupManifest 1675 * schema for specific details. 1676 * @property {string} archivePath 1677 * The path to the single file archive that was created. 1678 */ 1679 1680 /** 1681 * Create a backup of the user's profile. 1682 * 1683 * @param {object} [options] 1684 * Options for the backup. 1685 * @param {string} [options.profilePath=PathUtils.profileDir] 1686 * The path to the profile to backup. By default, this is the current 1687 * profile. 1688 * @param {string} [options.reason=unknown] 1689 * The reason for starting the backup. This is sent along with the 1690 * backup.backup_start event. 1691 * @returns {Promise<CreateBackupResult|null>} 1692 * A promise that resolves to information about the backup that was 1693 * created, or null if the backup failed. 1694 */ 1695 async createBackup({ 1696 profilePath = PathUtils.profileDir, 1697 reason = "unknown", 1698 } = {}) { 1699 let status = this.archiveEnabledStatus; 1700 if (!status.enabled) { 1701 lazy.logConsole.debug(status.reason); 1702 return null; 1703 } 1704 1705 // createBackup does not allow re-entry or concurrent backups. 1706 if (this.#backupInProgress) { 1707 lazy.logConsole.warn("Backup attempt already in progress"); 1708 return null; 1709 } 1710 1711 Glean.browserBackup.backupStart.record({ reason }); 1712 1713 return locks.request( 1714 BackupService.WRITE_BACKUP_LOCK_NAME, 1715 { signal: this.#backupWriteAbortController.signal }, 1716 async () => { 1717 let currentStep = STEPS.CREATE_BACKUP_ENTRYPOINT; 1718 this.#backupInProgress = true; 1719 const backupTimer = Glean.browserBackup.totalBackupTime.start(); 1720 1721 // reset the error state prefs 1722 Services.prefs.clearUserPref(BACKUP_DEBUG_INFO_PREF_NAME); 1723 Services.prefs.setIntPref(BACKUP_ERROR_CODE_PREF_NAME, ERRORS.NONE); 1724 // reset profile copied pref so the backup welcome messaging will show 1725 Services.prefs.clearUserPref("browser.profiles.profile-copied"); 1726 1727 try { 1728 lazy.logConsole.debug( 1729 `Creating backup for profile at ${profilePath}` 1730 ); 1731 1732 currentStep = STEPS.CREATE_BACKUP_RESOLVE_DESTINATION; 1733 let archiveDestFolderPath = await this.resolveArchiveDestFolderPath( 1734 lazy.backupDirPref 1735 ); 1736 lazy.logConsole.debug( 1737 `Destination for archive: ${archiveDestFolderPath}` 1738 ); 1739 1740 let result = await this.createAndPopulateStagingFolder(profilePath); 1741 currentStep = result.currentStep; 1742 if (result.error) { 1743 // Re-throw the error so we can catch it below for telemetry 1744 throw result.error; 1745 } 1746 1747 let { backupDirPath, stagingPath, manifest } = result; 1748 1749 currentStep = STEPS.CREATE_BACKUP_COMPRESS_STAGING; 1750 let compressedStagingPath = await this.#compressStagingFolder( 1751 stagingPath, 1752 backupDirPath 1753 ).finally(async () => { 1754 // retryReadonly is needed in case there were read only files in 1755 // the profile. 1756 await IOUtils.remove(stagingPath, { 1757 recursive: true, 1758 retryReadonly: true, 1759 }); 1760 }); 1761 1762 currentStep = STEPS.CREATE_BACKUP_CREATE_ARCHIVE; 1763 // Now create the single-file archive. For now, we'll stash this in the 1764 // backups folder while it gets written. Once that's done, we'll attempt 1765 // to move it to the user's configured backup path. 1766 let archiveTmpPath = PathUtils.join(backupDirPath, "archive.html"); 1767 lazy.logConsole.log( 1768 "Exporting single-file archive to ", 1769 archiveTmpPath 1770 ); 1771 await this.createArchive( 1772 archiveTmpPath, 1773 BackupService.ARCHIVE_TEMPLATE, 1774 compressedStagingPath, 1775 this.#encState, 1776 manifest.meta 1777 ).finally(async () => { 1778 await IOUtils.remove(compressedStagingPath, { 1779 retryReadonly: true, 1780 }); 1781 }); 1782 1783 // Record the size of the complete single-file archive 1784 let archiveSizeKilobytes = 1785 await BackupResource.getFileSize(archiveTmpPath); 1786 let archiveSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize( 1787 archiveSizeKilobytes * BYTES_IN_KILOBYTE, 1788 1 * BYTES_IN_MEBIBYTE 1789 ); 1790 lazy.logConsole.debug( 1791 "backup archive size in bytes: " + archiveSizeBytesNearestMebibyte 1792 ); 1793 1794 Glean.browserBackup.compressedArchiveSize.accumulate( 1795 archiveSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE 1796 ); 1797 1798 currentStep = STEPS.CREATE_BACKUP_FINALIZE_ARCHIVE; 1799 let archivePath = await this.finalizeSingleFileArchive( 1800 archiveTmpPath, 1801 archiveDestFolderPath, 1802 manifest.meta 1803 ); 1804 1805 let nowSeconds = Math.floor(Date.now() / 1000); 1806 Services.prefs.setIntPref( 1807 LAST_BACKUP_TIMESTAMP_PREF_NAME, 1808 nowSeconds 1809 ); 1810 this.#_state.lastBackupDate = nowSeconds; 1811 Glean.browserBackup.totalBackupTime.stopAndAccumulate(backupTimer); 1812 1813 Glean.browserBackup.created.record({ 1814 encrypted: this.#_state.encryptionEnabled, 1815 location: this.classifyLocationForTelemetry(archiveDestFolderPath), 1816 size: archiveSizeBytesNearestMebibyte, 1817 }); 1818 1819 // we should reset any values that were set for retry error handling 1820 Services.prefs.clearUserPref(DISABLED_ON_IDLE_RETRY_PREF_NAME); 1821 BackupService.#errorRetries = 0; 1822 1823 return { manifest, archivePath }; 1824 } catch (e) { 1825 Glean.browserBackup.totalBackupTime.cancel(backupTimer); 1826 Glean.browserBackup.error.record({ 1827 error_code: String(e.cause || ERRORS.UNKNOWN), 1828 backup_step: String(currentStep), 1829 }); 1830 1831 // TODO: show more specific error messages to the user 1832 Services.prefs.setIntPref( 1833 BACKUP_ERROR_CODE_PREF_NAME, 1834 ERRORS.UNKNOWN 1835 ); 1836 1837 Services.prefs.setStringPref( 1838 BACKUP_DEBUG_INFO_PREF_NAME, 1839 JSON.stringify({ 1840 lastBackupAttempt: Math.floor(Date.now() / 1000), 1841 errorCode: e instanceof BackupError ? e : ERRORS.UNKNOWN, 1842 lastRunStep: currentStep, 1843 }) 1844 ); 1845 1846 this.stateUpdate(); 1847 throw e; 1848 } finally { 1849 this.#backupInProgress = false; 1850 } 1851 } 1852 ); 1853 } 1854 1855 /** 1856 * Creates a coarse name corresponding to the location where the backup will 1857 * be stored. This is sent by telemetry, and aims to anonymize the data. 1858 * 1859 * Normally, the path should end in 'Restore Firefox'; if it doesn't, you 1860 * might be passing the wrong path and will get the wrong result. 1861 * 1862 * This isn't private so it can be used by the tests; avoid relying on this 1863 * code from elsewhere. 1864 * 1865 * @param {string} path The absolute path that contains the backup file. 1866 * @returns {string} A coarse location to send with the telemetry. 1867 */ 1868 classifyLocationForTelemetry(path) { 1869 let knownLocations = { 1870 onedrive: "OneDrPD", 1871 documents: "Docs", 1872 }; 1873 1874 let location; 1875 try { 1876 // By default, the backup will go into a folder called 'Restore Firefox', 1877 // so we actually want the parent directory. 1878 location = lazy.nsLocalFile(path).parent; 1879 } catch (e) { 1880 // initWithPath (at least on Windows) is _really_ picky; e.g. 1881 // "C:/Windows/system32" will fail. Bail out if something went wrong so 1882 // this doesn't affect the backup. 1883 return `Error: ${e.name ?? "Unknown error"}`; 1884 } 1885 1886 for (let label of Object.keys(knownLocations)) { 1887 try { 1888 let candidate = Services.dirsvc.get(knownLocations[label], Ci.nsIFile); 1889 if (candidate.equals(location)) { 1890 return label; 1891 } 1892 } catch (e) { 1893 // ignore (maybe it wasn't found?) 1894 } 1895 } 1896 1897 return "other"; 1898 } 1899 1900 /** 1901 * Generates a string from a Date in the form of: 1902 * 1903 * YYYYMMDD-HHMM 1904 * 1905 * @param {Date} date 1906 * The date to convert into the archive date suffix. 1907 * @returns {string} 1908 */ 1909 generateArchiveDateSuffix(date) { 1910 let year = date.getFullYear().toString(); 1911 1912 // In all cases, months or days with single digits are expected to start 1913 // with a 0. 1914 1915 // Note that getMonth() is 0-indexed for some reason, so we increment by 1. 1916 let month = `${date.getMonth() + 1}`.padStart(2, "0"); 1917 1918 let day = `${date.getDate()}`.padStart(2, "0"); 1919 let hours = `${date.getHours()}`.padStart(2, "0"); 1920 let minutes = `${date.getMinutes()}`.padStart(2, "0"); 1921 1922 return `${year}${month}${day}-${hours}${minutes}`; 1923 } 1924 1925 /** 1926 * Moves the single-file archive into its configured location with a filename 1927 * that is sanitized and contains a timecode. This also removes any existing 1928 * single-file archives in that same folder after the move completes. 1929 * 1930 * @param {string} sourcePath 1931 * The file system location of the single-file archive prior to the move. 1932 * @param {string} destFolder 1933 * The folder that the single-file archive is configured to be eventually 1934 * written to. 1935 * @param {object} metadata 1936 * The metadata for the backup. See the BackupManifest schema for details. 1937 * @returns {Promise<string>} 1938 * Resolves with the path that the single-file archive was moved to. 1939 */ 1940 async finalizeSingleFileArchive(sourcePath, destFolder, metadata) { 1941 let archiveDateSuffix = this.generateArchiveDateSuffix( 1942 new Date(metadata.date) 1943 ); 1944 1945 let existingChildren = await IOUtils.getChildren(destFolder); 1946 1947 const FILENAME_PREFIX = `${BackupService.BACKUP_FILE_NAME}_${metadata.profileName}`; 1948 const FILENAME = `${FILENAME_PREFIX}_${archiveDateSuffix}.html`; 1949 let destPath = PathUtils.join(destFolder, FILENAME); 1950 lazy.logConsole.log("Moving single-file archive to ", destPath); 1951 await IOUtils.move(sourcePath, destPath); 1952 1953 Services.prefs.setStringPref(LAST_BACKUP_FILE_NAME_PREF_NAME, FILENAME); 1954 1955 for (let childFilePath of existingChildren) { 1956 let childFileName = PathUtils.filename(childFilePath); 1957 // We check both the prefix and the suffix, because the prefix encodes 1958 // the profile name in it. If there are other profiles from the same 1959 // application performing backup, we don't want to accidentally remove 1960 // those. 1961 if ( 1962 childFileName.startsWith(FILENAME_PREFIX) && 1963 childFileName.endsWith(".html") 1964 ) { 1965 if (childFileName == FILENAME) { 1966 // Since filenames don't include seconds, this might occur if a 1967 // backup was created seconds after the last one during the same 1968 // minute. That tends not to happen in practice, but might occur 1969 // during testing, in which case, we'll skip clearing this file. 1970 lazy.logConsole.warn( 1971 "Collided with a pre-existing archive name, so not clearing: ", 1972 FILENAME 1973 ); 1974 continue; 1975 } 1976 lazy.logConsole.debug("Getting rid of ", childFilePath); 1977 await IOUtils.remove(childFilePath); 1978 } 1979 } 1980 1981 return destPath; 1982 } 1983 1984 /** 1985 * Constructs the staging folder for the backup in the passed in backup 1986 * folder. If the backup (snapshots) folder isn't empty, it will be cleared 1987 * out. If that process fails to remove more than 1988 * lazy.maximumNumberOfUnremovableStagingItems then the backup is aborted. 1989 * 1990 * @param {string} backupDirPath 1991 * The path to the backup folder. 1992 * @returns {Promise<string>} 1993 * The path to the empty staging folder. 1994 */ 1995 async #prepareStagingFolder(backupDirPath) { 1996 lazy.logConsole.debug(`Clearing snapshot folder ${backupDirPath}`); 1997 let numUnremovableStagingItems = 0; 1998 let folder = await IOUtils.getFile(backupDirPath); 1999 let folderEntries = folder.directoryEntries; 2000 if (folderEntries) { 2001 let unremovableContents = []; 2002 for (let folderItem of folderEntries) { 2003 try { 2004 lazy.logConsole.debug(`Removing ${folderItem.path}`); 2005 await IOUtils.remove(folderItem, { 2006 recursive: true, 2007 retryReadonly: true, 2008 }); 2009 } catch (e) { 2010 lazy.logConsole.warn( 2011 `Failed to remove stale snapshot item ${folderItem.path}. Exception: ${e}` 2012 ); 2013 // Whatever the problem was with removing the snapshot dir contents 2014 // (presumably a staging dir or archive), keep going until 2015 // maximumNumberOfUnremovableStagingItems + 1 have failed to be 2016 // removed, at which point we abandon the backup, in order to avoid 2017 // filling drive space. 2018 numUnremovableStagingItems++; 2019 unremovableContents.push(folderItem.path); 2020 if ( 2021 numUnremovableStagingItems > 2022 lazy.maximumNumberOfUnremovableStagingItems 2023 ) { 2024 let error = new BackupError( 2025 `Failed to remove ${numUnremovableStagingItems} items from ${backupDirPath}`, 2026 ERRORS.FILE_SYSTEM_ERROR 2027 ); 2028 error.stack = e.stack; 2029 error.unremovableContents = unremovableContents; 2030 throw error; 2031 } 2032 } 2033 } 2034 } 2035 2036 lazy.logConsole.debug( 2037 `${numUnremovableStagingItems} unremovable staging items found. Proceeding with backup. Determining staging folder.` 2038 ); 2039 let stagingPath; 2040 for (let i = 0; i < lazy.maximumNumberOfUnremovableStagingItems + 1; i++) { 2041 // Attempt to use "staging-i" as the name of the staging folder. 2042 let potentialStagingPath = PathUtils.join(backupDirPath, "staging-" + i); 2043 if (!(await IOUtils.exists(potentialStagingPath))) { 2044 stagingPath = potentialStagingPath; 2045 await IOUtils.makeDirectory(stagingPath); 2046 break; 2047 } 2048 } 2049 2050 if (!stagingPath) { 2051 // Should be impossible. We determined there were no more than 2052 // maximumNumberOfUnremovableStagingItems items but we then found 2053 // maximumNumberOfUnremovableStagingItems + 1 staging folders. 2054 throw new BackupError( 2055 `Internal error in attempt to create staging folder`, 2056 ERRORS.FILE_SYSTEM_ERROR 2057 ); 2058 } 2059 2060 lazy.logConsole.debug(`Staging folder ${stagingPath} is prepared`); 2061 return stagingPath; 2062 } 2063 2064 /** 2065 * Compresses a staging folder into a Zip file. If a pre-existing Zip file 2066 * for a staging folder resides in destFolderPath, it is overwritten. The 2067 * Zip file will have the same name as the stagingPath folder, with `.zip` 2068 * as the extension. 2069 * 2070 * @param {string} stagingPath 2071 * The path to the staging folder to be compressed. 2072 * @param {string} destFolderPath 2073 * The parent folder to write the Zip file to. 2074 * @returns {Promise<string>} 2075 * Resolves with the path to the created Zip file. 2076 */ 2077 async #compressStagingFolder(stagingPath, destFolderPath) { 2078 const PR_RDWR = 0x04; 2079 const PR_CREATE_FILE = 0x08; 2080 const PR_TRUNCATE = 0x20; 2081 2082 let archivePath = PathUtils.join( 2083 destFolderPath, 2084 `${PathUtils.filename(stagingPath)}.zip` 2085 ); 2086 let archiveFile = await IOUtils.getFile(archivePath); 2087 2088 let writer = new lazy.ZipWriter( 2089 archiveFile, 2090 PR_RDWR | PR_CREATE_FILE | PR_TRUNCATE 2091 ); 2092 2093 lazy.logConsole.log("Compressing staging folder to ", archivePath); 2094 let rootPathNSIFile = await IOUtils.getDirectory(stagingPath); 2095 await this.#compressChildren(rootPathNSIFile, stagingPath, writer); 2096 await new Promise(resolve => { 2097 let observer = { 2098 onStartRequest(_request) { 2099 lazy.logConsole.debug("Starting to write out archive file"); 2100 }, 2101 onStopRequest(_request, status) { 2102 lazy.logConsole.log("Done writing archive file"); 2103 resolve(status); 2104 }, 2105 }; 2106 writer.processQueue(observer, null); 2107 }); 2108 writer.close(); 2109 2110 return archivePath; 2111 } 2112 2113 /** 2114 * A helper function for #compressStagingFolder that iterates through a 2115 * directory, and adds each file to a nsIZipWriter. For each directory it 2116 * finds, it recurses. 2117 * 2118 * @param {nsIFile} rootPathNSIFile 2119 * An nsIFile pointing at the root of the folder being compressed. 2120 * @param {string} parentPath 2121 * The path to the folder whose children should be iterated. 2122 * @param {nsIZipWriter} writer 2123 * The writer to add all of the children to. 2124 * @returns {Promise<undefined>} 2125 */ 2126 async #compressChildren(rootPathNSIFile, parentPath, writer) { 2127 let children = await IOUtils.getChildren(parentPath); 2128 for (let childPath of children) { 2129 let childState = await IOUtils.stat(childPath); 2130 if (childState.type == "directory") { 2131 await this.#compressChildren(rootPathNSIFile, childPath, writer); 2132 } else { 2133 let childFile = await IOUtils.getFile(childPath); 2134 // nsIFile.getRelativePath returns paths using the "/" separator, 2135 // regardless of which platform we're on. That's handy, because this 2136 // is the same separator that nsIZipWriter expects for entries. 2137 let pathRelativeToRoot = childFile.getRelativePath(rootPathNSIFile); 2138 writer.addEntryFile( 2139 pathRelativeToRoot, 2140 BackupService.COMPRESSION_LEVEL, 2141 childFile, 2142 true 2143 ); 2144 } 2145 } 2146 } 2147 2148 /** 2149 * Decompressed a compressed recovery file into recoveryFolderDestPath. 2150 * 2151 * @param {string} recoveryFilePath 2152 * The path to the compressed recovery file to decompress. 2153 * @param {string} recoveryFolderDestPath 2154 * The path to the folder that the compressed recovery file should be 2155 * decompressed within. 2156 * @returns {Promise<undefined>} 2157 */ 2158 async decompressRecoveryFile(recoveryFilePath, recoveryFolderDestPath) { 2159 let recoveryFile = await IOUtils.getFile(recoveryFilePath); 2160 let recoveryArchive = new lazy.ZipReader(recoveryFile); 2161 lazy.logConsole.log( 2162 "Decompressing recovery folder to ", 2163 recoveryFolderDestPath 2164 ); 2165 try { 2166 // null is passed to test if we're meant to CRC test the entire 2167 // ZIP file. If an exception is thrown, this means we failed the CRC 2168 // check. See the nsIZipReader.idl documentation for details. 2169 recoveryArchive.test(null); 2170 } catch (e) { 2171 recoveryArchive.close(); 2172 lazy.logConsole.error("Compressed recovery file was corrupt."); 2173 await IOUtils.remove(recoveryFilePath, { 2174 retryReadonly: true, 2175 }); 2176 throw new BackupError("Corrupt archive.", ERRORS.CORRUPTED_ARCHIVE); 2177 } 2178 2179 await this.#decompressChildren(recoveryFolderDestPath, "", recoveryArchive); 2180 recoveryArchive.close(); 2181 } 2182 2183 /** 2184 * A helper method that recursively decompresses any children within a folder 2185 * within a compressed archive. 2186 * 2187 * @param {string} rootPath 2188 * The path to the root folder that is being decompressed into. 2189 * @param {string} parentEntryName 2190 * The name of the parent folder within the compressed archive that is 2191 * having its children decompressed. 2192 * @param {nsIZipReader} reader 2193 * The nsIZipReader for the compressed archive. 2194 * @returns {Promise<undefined>} 2195 */ 2196 async #decompressChildren(rootPath, parentEntryName, reader) { 2197 // nsIZipReader.findEntries has an interesting querying language that is 2198 // documented in the nsIZipReader IDL file, in case you're curious about 2199 // what these symbols mean. 2200 let childEntryNames = reader.findEntries( 2201 parentEntryName + "?*~" + parentEntryName + "?*/?*" 2202 ); 2203 2204 for (let childEntryName of childEntryNames) { 2205 let childEntry = reader.getEntry(childEntryName); 2206 if (childEntry.isDirectory) { 2207 await this.#decompressChildren(rootPath, childEntryName, reader); 2208 } else { 2209 let inputStream = reader.getInputStream(childEntryName); 2210 // ZIP files all use `/` as their path separators, regardless of 2211 // platform. 2212 let fileNameParts = childEntryName.split("/"); 2213 let outputFilePath = PathUtils.join(rootPath, ...fileNameParts); 2214 let outputFile = await IOUtils.getFile(outputFilePath); 2215 let outputStream = Cc[ 2216 "@mozilla.org/network/file-output-stream;1" 2217 ].createInstance(Ci.nsIFileOutputStream); 2218 2219 outputStream.init( 2220 outputFile, 2221 -1, 2222 -1, 2223 Ci.nsIFileOutputStream.DEFER_OPEN 2224 ); 2225 2226 await new Promise(resolve => { 2227 lazy.logConsole.debug("Writing ", outputFilePath); 2228 lazy.NetUtil.asyncCopy(inputStream, outputStream, () => { 2229 lazy.logConsole.debug("Done writing ", outputFilePath); 2230 outputStream.close(); 2231 resolve(); 2232 }); 2233 }); 2234 } 2235 } 2236 } 2237 2238 /** 2239 * Given a URI to an HTML template for the single-file backup archive, 2240 * produces the static markup that will then be used as the beginning of that 2241 * single-file backup archive. 2242 * 2243 * @param {string} templateURI 2244 * A URI pointing at a template for the HTML content for the page. This is 2245 * what is visible if the file is loaded in a web browser. 2246 * @param {boolean} isEncrypted 2247 * True if the template should indicate that the backup is encrypted. 2248 * @param {object} backupMetadata 2249 * The metadata for the backup, which is also stored in the backup manifest 2250 * of the compressed backup snapshot. 2251 * @returns {Promise<string>} 2252 */ 2253 async renderTemplate(templateURI, isEncrypted, backupMetadata) { 2254 const ARCHIVE_STYLES = "chrome://browser/content/backup/archive.css"; 2255 const ARCHIVE_SCRIPT = "chrome://browser/content/backup/archive.js"; 2256 const LOGO = "chrome://branding/content/icon128.png"; 2257 2258 let templateResponse = await fetch(templateURI); 2259 let templateString = await templateResponse.text(); 2260 let templateDOM = new DOMParser().parseFromString( 2261 templateString, 2262 "text/html" 2263 ); 2264 2265 // Set the lang attribute on the <html> element 2266 templateDOM.documentElement.setAttribute( 2267 "lang", 2268 Services.locale.appLocaleAsBCP47 2269 ); 2270 2271 let downloadLink = templateDOM.querySelector("#download-moz-browser"); 2272 downloadLink.href = await this.resolveDownloadLink( 2273 AppConstants.MOZ_UPDATE_CHANNEL 2274 ); 2275 2276 let supportURI = new URL( 2277 "firefox-backup", 2278 Services.urlFormatter.formatURLPref("app.support.baseURL") 2279 ); 2280 supportURI.searchParams.set("utm_medium", "firefox-desktop"); 2281 supportURI.searchParams.set("utm_source", "html-backup"); 2282 supportURI.searchParams.set("utm_campaign", "fx-backup-restore"); 2283 2284 let supportLink = templateDOM.querySelector("#support-link"); 2285 supportLink.href = supportURI.href; 2286 2287 // Now insert the logo as a dataURL, since we want the single-file backup 2288 // archive to be entirely self-contained. 2289 let logoResponse = await fetch(LOGO); 2290 let logoBlob = await logoResponse.blob(); 2291 let logoDataURL = await new Promise((resolve, reject) => { 2292 let reader = new FileReader(); 2293 reader.addEventListener("load", () => resolve(reader.result)); 2294 reader.addEventListener("error", reject); 2295 reader.readAsDataURL(logoBlob); 2296 }); 2297 2298 let logoNode = templateDOM.querySelector("#logo"); 2299 logoNode.src = logoDataURL; 2300 2301 let encStateNode = templateDOM.querySelector("#encryption-state-value"); 2302 lazy.gDOMLocalization.setAttributes( 2303 encStateNode, 2304 isEncrypted 2305 ? "backup-file-encryption-state-value-encrypted" 2306 : "backup-file-encryption-state-value-not-encrypted" 2307 ); 2308 2309 let createdDateNode = templateDOM.querySelector("#creation-date-value"); 2310 lazy.gDOMLocalization.setArgs(createdDateNode, { 2311 // It's very unlikely that backupMetadata.date isn't a valid Date string, 2312 // but if it _is_, then Fluent will cause us to crash in debug builds. 2313 // We fallback to the current date if all else fails. 2314 date: new Date(backupMetadata.date).getTime() || new Date().getTime(), 2315 }); 2316 2317 let creationDeviceNode = templateDOM.querySelector( 2318 "#creation-device-value" 2319 ); 2320 creationDeviceNode.textContent = backupMetadata.machineName; 2321 2322 try { 2323 await lazy.gDOMLocalization.translateFragment( 2324 templateDOM.documentElement 2325 ); 2326 } catch (_) { 2327 // This shouldn't happen, but we don't want a missing locale string to 2328 // cause backup creation to fail. 2329 } 2330 2331 // We have to insert styles and scripts after we serialize to XML, otherwise 2332 // the XMLSerializer will escape things like descendent selectors in CSS 2333 // with >. 2334 let stylesResponse = await fetch(ARCHIVE_STYLES); 2335 let scriptResponse = await fetch(ARCHIVE_SCRIPT); 2336 2337 // These days, we don't really support CSS preprocessor directives, so we 2338 // can't ifdef out the MPL license header in styles before writing it into 2339 // the archive file. Instead, we'll ensure that the license header is there, 2340 // and then manually remove it here at runtime. 2341 let stylesText = await stylesResponse.text(); 2342 const MPL_LICENSE = `/** 2343 * This Source Code Form is subject to the terms of the Mozilla Public 2344 * License, v. 2.0. If a copy of the MPL was not distributed with this 2345 * file, You can obtain one at https://mozilla.org/MPL/2.0/. 2346 */`; 2347 if (!stylesText.includes(MPL_LICENSE)) { 2348 throw new BackupError( 2349 "Expected the MPL license block within archive.css", 2350 ERRORS.UNKNOWN 2351 ); 2352 } 2353 2354 stylesText = stylesText.replace(MPL_LICENSE, ""); 2355 2356 let serializer = new XMLSerializer(); 2357 return serializer 2358 .serializeToString(templateDOM) 2359 .replace("{{styles}}", stylesText) 2360 .replace("{{script}}", await scriptResponse.text()); 2361 } 2362 2363 /** 2364 * Creates a portable, potentially encrypted single-file archive containing 2365 * a compressed backup snapshot. The single-file archive is a specially 2366 * crafted HTML file that embeds the compressed backup snapshot and 2367 * backup metadata. 2368 * 2369 * @param {string} archivePath 2370 * The path to write the single-file archive to. 2371 * @param {string} templateURI 2372 * A URI pointing at a template for the HTML content for the page. This is 2373 * what is visible if the file is loaded in a web browser. 2374 * @param {string} compressedBackupSnapshotPath 2375 * The path on the file system where the compressed backup snapshot exists. 2376 * @param {ArchiveEncryptionState|null} encState 2377 * The ArchiveEncryptionState to encrypt the backup with, if encryption is 2378 * enabled. If null is passed, the backup will not be encrypted. 2379 * @param {object} backupMetadata 2380 * The metadata for the backup, which is also stored in the backup manifest 2381 * of the compressed backup snapshot. 2382 * @param {object} options 2383 * Options to pass to the worker, mainly for testing. 2384 * @param {object} [options.chunkSize=ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE] 2385 * The chunk size to break the bytes into. 2386 */ 2387 async createArchive( 2388 archivePath, 2389 templateURI, 2390 compressedBackupSnapshotPath, 2391 encState, 2392 backupMetadata, 2393 options = {} 2394 ) { 2395 let markup = await this.renderTemplate( 2396 templateURI, 2397 !!encState, 2398 backupMetadata 2399 ); 2400 2401 let worker = new lazy.BasePromiseWorker( 2402 "resource:///modules/backup/Archive.worker.mjs", 2403 { type: "module" } 2404 ); 2405 worker.ExceptionHandlers[BackupError.name] = BackupError.fromMsg; 2406 2407 let chunkSize = 2408 options.chunkSize || lazy.ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE; 2409 2410 try { 2411 let encryptionArgs = encState 2412 ? { 2413 publicKey: encState.publicKey, 2414 salt: encState.salt, 2415 nonce: encState.nonce, 2416 backupAuthKey: encState.backupAuthKey, 2417 wrappedSecrets: encState.wrappedSecrets, 2418 } 2419 : null; 2420 2421 await worker 2422 .post("constructArchive", [ 2423 { 2424 archivePath, 2425 markup, 2426 backupMetadata, 2427 compressedBackupSnapshotPath, 2428 encryptionArgs, 2429 chunkSize, 2430 }, 2431 ]) 2432 .catch(e => { 2433 lazy.logConsole.error(e); 2434 if (!(e instanceof BackupError)) { 2435 throw new BackupError("Failed to create archive", ERRORS.UNKNOWN); 2436 } 2437 throw e; 2438 }); 2439 } finally { 2440 worker.terminate(); 2441 } 2442 } 2443 2444 /** 2445 * Constructs an nsIChannel that serves the bytes from an nsIInputStream - 2446 * specifically, a nsIInputStream of bytes being streamed from a file. 2447 * 2448 * @see BackupService.#extractMetadataFromArchive() 2449 * @param {nsIInputStream} inputStream 2450 * The nsIInputStream to create the nsIChannel for. 2451 * @param {string} contentType 2452 * The content type for the nsIChannel. This is provided by 2453 * BackupService.#extractMetadataFromArchive(). 2454 * @returns {nsIChannel} 2455 */ 2456 #createExtractionChannel(inputStream, contentType) { 2457 let uri = "http://localhost"; 2458 let httpChan = lazy.NetUtil.newChannel({ 2459 uri, 2460 loadUsingSystemPrincipal: true, 2461 }); 2462 2463 let channel = Cc["@mozilla.org/network/input-stream-channel;1"] 2464 .createInstance(Ci.nsIInputStreamChannel) 2465 .QueryInterface(Ci.nsIChannel); 2466 2467 channel.setURI(httpChan.URI); 2468 channel.loadInfo = httpChan.loadInfo; 2469 2470 channel.contentStream = inputStream; 2471 channel.contentType = contentType; 2472 return channel; 2473 } 2474 2475 /** 2476 * A helper for BackupService.extractCompressedSnapshotFromArchive() that 2477 * reads in the JSON block from the MIME message embedded within an 2478 * archiveFile. 2479 * 2480 * @see BackupService.extractCompressedSnapshotFromArchive() 2481 * @param {nsIFile} archiveFile 2482 * The file to read the MIME message out from. 2483 * @param {number} startByteOffset 2484 * The start byte offset of the MIME message. 2485 * @param {string} contentType 2486 * The Content-Type of the MIME message. 2487 * @returns {Promise<object>} 2488 */ 2489 async #extractJSONFromArchive(archiveFile, startByteOffset, contentType) { 2490 let fileInputStream = Cc[ 2491 "@mozilla.org/network/file-input-stream;1" 2492 ].createInstance(Ci.nsIFileInputStream); 2493 fileInputStream.init( 2494 archiveFile, 2495 -1, 2496 -1, 2497 Ci.nsIFileInputStream.CLOSE_ON_EOF 2498 ); 2499 fileInputStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, startByteOffset); 2500 2501 const EXPECTED_CONTENT_TYPE = "application/json"; 2502 2503 let extractionChannel = this.#createExtractionChannel( 2504 fileInputStream, 2505 contentType 2506 ); 2507 let textDecoder = new TextDecoder(); 2508 return new Promise((resolve, reject) => { 2509 let streamConv = Cc["@mozilla.org/streamConverters;1"].getService( 2510 Ci.nsIStreamConverterService 2511 ); 2512 let multipartListenerForJSON = { 2513 /** 2514 * True once we've found an attachment matching our 2515 * EXPECTED_CONTENT_TYPE. Once this is true, bytes flowing into 2516 * onDataAvailable will be enqueued through the controller. 2517 * 2518 * @type {boolean} 2519 */ 2520 _enabled: false, 2521 2522 /** 2523 * True once onStopRequest has been called once the listener is enabled. 2524 * After this, the listener will not attempt to read any data passed 2525 * to it through onDataAvailable. 2526 * 2527 * @type {boolean} 2528 */ 2529 _done: false, 2530 2531 /** 2532 * A buffer with which we will cobble together the JSON string that 2533 * will get parsed once the attachment finishes being read in. 2534 * 2535 * @type {string} 2536 */ 2537 _buffer: "", 2538 2539 QueryInterface: ChromeUtils.generateQI([ 2540 "nsIStreamListener", 2541 "nsIRequestObserver", 2542 "nsIMultiPartChannelListener", 2543 ]), 2544 2545 /** 2546 * Called when we begin to load an attachment from the MIME message. 2547 * 2548 * @param {nsIRequest} request 2549 * The request corresponding to the source of the data. 2550 */ 2551 onStartRequest(request) { 2552 if (!(request instanceof Ci.nsIChannel)) { 2553 throw Components.Exception( 2554 "onStartRequest expected an nsIChannel request", 2555 Cr.NS_ERROR_UNEXPECTED 2556 ); 2557 } 2558 this._enabled = request.contentType == EXPECTED_CONTENT_TYPE; 2559 }, 2560 2561 /** 2562 * Called when data is flowing in for an attachment. 2563 * 2564 * @param {nsIRequest} request 2565 * The request corresponding to the source of the data. 2566 * @param {nsIInputStream} stream 2567 * The input stream containing the data chunk. 2568 * @param {number} offset 2569 * The number of bytes that were sent in previous onDataAvailable 2570 * calls for this request. In other words, the sum of all previous 2571 * count parameters. 2572 * @param {number} count 2573 * The number of bytes available in the stream 2574 */ 2575 onDataAvailable(request, stream, offset, count) { 2576 if (this._done) { 2577 // No need to load anything else - abort reading in more 2578 // attachments. 2579 throw Components.Exception( 2580 "Got JSON block. Aborting further reads.", 2581 Cr.NS_BINDING_ABORTED 2582 ); 2583 } 2584 if (!this._enabled) { 2585 // We don't care about this data, just move on. 2586 return; 2587 } 2588 2589 let binStream = new lazy.BinaryInputStream(stream); 2590 let arrBuffer = new ArrayBuffer(count); 2591 binStream.readArrayBuffer(count, arrBuffer); 2592 let jsonBytes = new Uint8Array(arrBuffer); 2593 this._buffer += textDecoder.decode(jsonBytes); 2594 }, 2595 2596 /** 2597 * Called when the load of an attachment finishes. 2598 */ 2599 onStopRequest() { 2600 if (this._enabled && !this._done) { 2601 this._enabled = false; 2602 this._done = true; 2603 2604 try { 2605 let archiveMetadata = JSON.parse(this._buffer); 2606 resolve(archiveMetadata); 2607 } catch (e) { 2608 reject( 2609 new BackupError( 2610 "Could not parse archive metadata.", 2611 ERRORS.CORRUPTED_ARCHIVE 2612 ) 2613 ); 2614 } 2615 } 2616 }, 2617 2618 onAfterLastPart() { 2619 if (!this._done) { 2620 // We finished reading the parts before we found the JSON block, so 2621 // the JSON block is missing. 2622 reject( 2623 new BackupError( 2624 "Could not find JSON block.", 2625 ERRORS.CORRUPTED_ARCHIVE 2626 ) 2627 ); 2628 } 2629 }, 2630 }; 2631 let conv = streamConv.asyncConvertData( 2632 "multipart/mixed", 2633 "*/*", 2634 multipartListenerForJSON, 2635 null 2636 ); 2637 2638 extractionChannel.asyncOpen(conv); 2639 }); 2640 } 2641 2642 /** 2643 * A helper for BackupService.#extractCompressedSnapshotFromArchive that 2644 * constructs a BinaryReadableStream for a single-file archive on the 2645 * file system. The BinaryReadableStream will be used to read out the binary 2646 * attachment from the archive. 2647 * 2648 * @param {nsIFile} archiveFile 2649 * The single-file archive to create the BinaryReadableStream for. 2650 * @param {number} startByteOffset 2651 * The start byte offset of the MIME message. 2652 * @param {string} contentType 2653 * The Content-Type of the MIME message. 2654 * @returns {ReadableStream} 2655 */ 2656 async createBinaryReadableStream(archiveFile, startByteOffset, contentType) { 2657 let fileInputStream = Cc[ 2658 "@mozilla.org/network/file-input-stream;1" 2659 ].createInstance(Ci.nsIFileInputStream); 2660 fileInputStream.init( 2661 archiveFile, 2662 -1, 2663 -1, 2664 Ci.nsIFileInputStream.CLOSE_ON_EOF 2665 ); 2666 fileInputStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, startByteOffset); 2667 2668 let extractionChannel = this.#createExtractionChannel( 2669 fileInputStream, 2670 contentType 2671 ); 2672 2673 return new ReadableStream(new BinaryReadableStream(extractionChannel)); 2674 } 2675 2676 /** 2677 * @typedef {object} SampleArchiveResult 2678 * @property {boolean} isEncrypted 2679 * True if the archive claims to be encrypted, and has the necessary data 2680 * within the JSON block to attempt to initialize an ArchiveDecryptor. 2681 * @property {number} startByteOffset 2682 * The start byte offset of the MIME message. 2683 * @property {string} contentType 2684 * The Content-Type of the MIME message. 2685 * @property {object} archiveJSON 2686 * The deserialized JSON block from the archive. See the ArchiveJSONBlock 2687 * schema for details of its structure. 2688 */ 2689 2690 /** 2691 * Reads from a file to determine if it seems to be a backup archive, and if 2692 * so, resolves with some information about the archive without actually 2693 * unpacking it. The returned Promise may reject if the file does not appear 2694 * to be a backup archive, or the backup archive appears to have been 2695 * corrupted somehow. 2696 * 2697 * @param {string} archivePath 2698 * The path to the archive file to sample. 2699 * @returns {Promise<SampleArchiveResult, Error>} 2700 */ 2701 async sampleArchive(archivePath) { 2702 let worker = new lazy.BasePromiseWorker( 2703 "resource:///modules/backup/Archive.worker.mjs", 2704 { type: "module" } 2705 ); 2706 worker.ExceptionHandlers[BackupError.name] = BackupError.fromMsg; 2707 2708 if (!(await IOUtils.exists(archivePath))) { 2709 throw new BackupError( 2710 "Archive file does not exist at path " + archivePath, 2711 ERRORS.UNKNOWN 2712 ); 2713 } 2714 2715 try { 2716 let { startByteOffset, contentType } = await worker 2717 .post("parseArchiveHeader", [archivePath]) 2718 .catch(e => { 2719 lazy.logConsole.error(e); 2720 if (!(e instanceof BackupError)) { 2721 throw new BackupError( 2722 "Failed to parse archive header", 2723 ERRORS.CORRUPTED_ARCHIVE 2724 ); 2725 } 2726 throw e; 2727 }); 2728 let archiveFile = await IOUtils.getFile(archivePath); 2729 let archiveJSON; 2730 try { 2731 archiveJSON = await this.#extractJSONFromArchive( 2732 archiveFile, 2733 startByteOffset, 2734 contentType 2735 ); 2736 2737 if (!archiveJSON.version) { 2738 throw new BackupError( 2739 "Missing version in the archive JSON block.", 2740 ERRORS.CORRUPTED_ARCHIVE 2741 ); 2742 } 2743 if (archiveJSON.version > lazy.ArchiveUtils.SCHEMA_VERSION) { 2744 throw new BackupError( 2745 `Archive JSON block is a version newer than we can interpret: ${archiveJSON.version}`, 2746 ERRORS.UNSUPPORTED_BACKUP_VERSION 2747 ); 2748 } 2749 2750 let archiveJSONSchema = await BackupService.getSchemaForVersion( 2751 SCHEMAS.ARCHIVE_JSON_BLOCK, 2752 archiveJSON.version 2753 ); 2754 2755 let manifestSchema = await BackupService.getSchemaForVersion( 2756 SCHEMAS.BACKUP_MANIFEST, 2757 archiveJSON.version 2758 ); 2759 2760 let validator = new lazy.JsonSchema.Validator(archiveJSONSchema); 2761 validator.addSchema(manifestSchema); 2762 2763 let schemaValidationResult = validator.validate(archiveJSON); 2764 if (!schemaValidationResult.valid) { 2765 lazy.logConsole.error( 2766 "Archive JSON block does not conform to schema:", 2767 archiveJSON, 2768 archiveJSONSchema, 2769 schemaValidationResult 2770 ); 2771 2772 // TODO: Collect telemetry for this case. (bug 1891817) 2773 throw new BackupError( 2774 `Archive JSON block does not conform to schema version ${archiveJSON.version}`, 2775 ERRORS.CORRUPTED_ARCHIVE 2776 ); 2777 } 2778 } catch (e) { 2779 lazy.logConsole.error(e); 2780 throw e; 2781 } 2782 2783 lazy.logConsole.debug("Read out archive JSON: ", archiveJSON); 2784 2785 return { 2786 isEncrypted: !!archiveJSON.encConfig, 2787 startByteOffset, 2788 contentType, 2789 archiveJSON, 2790 }; 2791 } catch (e) { 2792 lazy.logConsole.error(e); 2793 throw e; 2794 } finally { 2795 worker.terminate(); 2796 } 2797 } 2798 2799 /** 2800 * Attempts to extract the compressed backup snapshot from a single-file 2801 * archive, and write the extracted file to extractionDestPath. This may 2802 * reject if the single-file archive appears malformed or cannot be 2803 * properly decrypted. If the backup was encrypted, a native nsIOSKeyStore 2804 * is also initialized with label BackupService.RECOVERY_OSKEYSTORE_LABEL 2805 * with the secret used on the original backup machine. Callers are 2806 * responsible for clearing this secret after any decryptions with it are 2807 * completed. 2808 * 2809 * NOTE: Currently, this base64 decoding currently occurs on the main thread. 2810 * We may end up moving all of this into the Archive Worker if we can modify 2811 * IOUtils to allow writing via a stream. 2812 * 2813 * @param {string} archivePath 2814 * The single-file archive that contains the backup. 2815 * @param {string} extractionDestPath 2816 * The path to write the extracted file to. 2817 * @param {string} [recoveryCode=null] 2818 * The recovery code to decrypt an encrypted backup with. 2819 * @returns {Promise<undefined, Error>} 2820 */ 2821 async extractCompressedSnapshotFromArchive( 2822 archivePath, 2823 extractionDestPath, 2824 recoveryCode = null 2825 ) { 2826 let { isEncrypted, startByteOffset, contentType, archiveJSON } = 2827 await this.sampleArchive(archivePath); 2828 2829 let decryptor = null; 2830 if (isEncrypted) { 2831 if (!recoveryCode) { 2832 throw new BackupError( 2833 "A recovery code is required to decrypt this archive.", 2834 ERRORS.UNAUTHORIZED 2835 ); 2836 } 2837 decryptor = await lazy.ArchiveDecryptor.initialize( 2838 recoveryCode, 2839 archiveJSON 2840 ); 2841 } 2842 2843 await IOUtils.remove(extractionDestPath, { 2844 ignoreAbsent: true, 2845 retryReadonly: true, 2846 }); 2847 2848 let archiveFile = await IOUtils.getFile(archivePath); 2849 let archiveStream = await this.createBinaryReadableStream( 2850 archiveFile, 2851 startByteOffset, 2852 contentType 2853 ); 2854 2855 let binaryDecoder = new TransformStream( 2856 new DecoderDecryptorTransformer(decryptor) 2857 ); 2858 let fileWriter = new WritableStream( 2859 new FileWriterStream(extractionDestPath, decryptor) 2860 ); 2861 await archiveStream.pipeThrough(binaryDecoder).pipeTo(fileWriter); 2862 2863 if (decryptor) { 2864 await lazy.nativeOSKeyStore.asyncRecoverSecret( 2865 BackupService.RECOVERY_OSKEYSTORE_LABEL, 2866 decryptor.OSKeyStoreSecret 2867 ); 2868 } 2869 } 2870 2871 /** 2872 * Renames the staging folder to an ISO 8601 date string with dashes replacing colons and fractional seconds stripped off. 2873 * The ISO date string should be formatted from YYYY-MM-DDTHH:mm:ss.sssZ to YYYY-MM-DDTHH-mm-ssZ 2874 * 2875 * @param {string} stagingPath 2876 * The path to the populated staging folder. 2877 * @returns {Promise<string|null>} 2878 * The path to the renamed staging folder, or null if the stagingPath was 2879 * not pointing to a valid folder. 2880 */ 2881 async #finalizeStagingFolder(stagingPath) { 2882 if (!(await IOUtils.exists(stagingPath))) { 2883 // If we somehow can't find the specified staging folder, cancel this step. 2884 lazy.logConsole.error( 2885 `Failed to finalize staging folder. Cannot find ${stagingPath}.` 2886 ); 2887 return null; 2888 } 2889 2890 try { 2891 lazy.logConsole.debug("Finalizing and renaming staging folder"); 2892 let currentDateISO = new Date().toISOString(); 2893 // First strip the fractional seconds 2894 let dateISOStripped = currentDateISO.replace(/\.\d+\Z$/, "Z"); 2895 // Now replace all colons with dashes 2896 let dateISOFormatted = dateISOStripped.replaceAll(":", "-"); 2897 2898 let stagingPathParent = PathUtils.parent(stagingPath); 2899 let renamedBackupPath = PathUtils.join( 2900 stagingPathParent, 2901 dateISOFormatted 2902 ); 2903 await IOUtils.move(stagingPath, renamedBackupPath); 2904 2905 let existingBackups = await IOUtils.getChildren(stagingPathParent); 2906 2907 /** 2908 * Bug 1892532: for now, we only support a single backup file. 2909 * If there are other pre-existing backup folders, delete them - but don't 2910 * delete anything that doesn't match the backup folder naming scheme. 2911 */ 2912 let expectedFormatRegex = /\d{4}(-\d{2}){2}T(\d{2}-){2}\d{2}Z/; 2913 for (let existingBackupPath of existingBackups) { 2914 if ( 2915 existingBackupPath !== renamedBackupPath && 2916 existingBackupPath.match(expectedFormatRegex) 2917 ) { 2918 try { 2919 // If any copied source files were read-only then we need to remove 2920 // read-only status from them to delete the staging folder. 2921 await IOUtils.remove(existingBackupPath, { 2922 recursive: true, 2923 retryReadonly: true, 2924 }); 2925 } catch (e) { 2926 // Ignore any failures in removing staging items. 2927 lazy.logConsole.debug( 2928 `Failed to remove staging item ${existingBackupPath}. Exception ${e}` 2929 ); 2930 } 2931 } 2932 } 2933 return renamedBackupPath; 2934 } catch (e) { 2935 lazy.logConsole.error( 2936 `Something went wrong while finalizing the staging folder. ${e}` 2937 ); 2938 throw new BackupError( 2939 "Failed to finalize staging folder", 2940 ERRORS.FILE_SYSTEM_ERROR 2941 ); 2942 } 2943 } 2944 2945 /** 2946 * Creates and resolves with a backup manifest object with an empty resources 2947 * property. See the BackupManifest schema for the specific shape of the 2948 * returned manifest object. 2949 * 2950 * @returns {Promise<object>} 2951 */ 2952 async #createBackupManifest() { 2953 let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService( 2954 Ci.nsIToolkitProfileService 2955 ); 2956 let profileName; 2957 if (!profileSvc.currentProfile) { 2958 // We're probably running on a local build or in some special configuration. 2959 // Let's pull in a profile name from the profile directory. 2960 let profileFolder = PathUtils.split(PathUtils.profileDir).at(-1); 2961 profileName = profileFolder.substring(profileFolder.indexOf(".") + 1); 2962 } else { 2963 profileName = profileSvc.currentProfile.name; 2964 } 2965 2966 let meta = { 2967 date: new Date().toISOString(), 2968 appName: AppConstants.MOZ_APP_NAME, 2969 appVersion: AppConstants.MOZ_APP_VERSION, 2970 buildID: AppConstants.MOZ_BUILDID, 2971 profileName, 2972 deviceName: Services.sysinfo.get("device") || Services.dns.myHostName, 2973 machineName: lazy.fxAccounts.device.getLocalName(), 2974 osName: Services.sysinfo.getProperty("name"), 2975 osVersion: Services.sysinfo.getProperty("version"), 2976 legacyClientID: await lazy.ClientID.getClientID(), 2977 profileGroupID: await lazy.ClientID.getProfileGroupID(), 2978 healthTelemetryEnabled: Services.prefs.getBoolPref( 2979 "datareporting.healthreport.uploadEnabled", 2980 false 2981 ), 2982 usageTelemetryEnabled: Services.prefs.getBoolPref( 2983 "datareporting.usage.uploadEnabled", 2984 false 2985 ), 2986 }; 2987 2988 let fxaState = lazy.UIState.get(); 2989 if (fxaState.status == lazy.UIState.STATUS_SIGNED_IN) { 2990 meta.accountID = fxaState.uid; 2991 meta.accountEmail = fxaState.email; 2992 } 2993 2994 return { 2995 version: lazy.ArchiveUtils.SCHEMA_VERSION, 2996 meta, 2997 resources: {}, 2998 }; 2999 } 3000 3001 /** 3002 * Given a backup archive at archivePath, this method does the 3003 * following: 3004 * 3005 * 1. Potentially decrypts, and then extracts the compressed backup snapshot 3006 * from the archive to a file named BackupService.RECOVERY_ZIP_FILE_NAME in 3007 * the PROFILE_FOLDER_NAME folder. 3008 * 2. Decompresses that file into a subdirectory of PROFILE_FOLDER_NAME named 3009 * "recovery". 3010 * 3. Deletes the BackupService.RECOVERY_ZIP_FILE_NAME file. 3011 * 4. Calls into recoverFromSnapshotFolder on the decompressed "recovery" 3012 * folder. 3013 * 5. Optionally launches the newly created profile. 3014 * 6. Returns the name of the newly created profile directory. 3015 * 3016 * @see BackupService.recoverFromSnapshotFolder 3017 * @param {string} archivePath 3018 * The path to the single-file backup archive on the file system. 3019 * @param {string|null} recoveryCode 3020 * The recovery code to use to attempt to decrypt the archive if it was 3021 * encrypted. 3022 * @param {boolean} [shouldLaunch=false] 3023 * An optional argument that specifies whether an instance of the app 3024 * should be launched with the newly recovered profile after recovery is 3025 * complete. 3026 * @param {boolean} [profilePath=PathUtils.profileDir] 3027 * The profile path where the recovery files will be written to within the 3028 * PROFILE_FOLDER_NAME. This is only used for testing. 3029 * @param {string} [profileRootPath=null] 3030 * An optional argument that specifies the root directory where the new 3031 * profile directory should be created. If not provided, the default 3032 * profile root directory will be used. This is primarily meant for 3033 * testing. 3034 * @returns {Promise<nsIToolkitProfile>} 3035 * The nsIToolkitProfile that was created for the recovered profile. 3036 * @throws {Exception} 3037 * In the event that unpacking the archive, decompressing the snapshot, or 3038 * recovery from the snapshot somehow failed. 3039 */ 3040 async recoverFromBackupArchive( 3041 archivePath, 3042 recoveryCode = null, 3043 shouldLaunch = false, 3044 profilePath = PathUtils.profileDir, 3045 profileRootPath = null 3046 ) { 3047 const status = this.restoreEnabledStatus; 3048 if (!status.enabled) { 3049 throw new Error(status.reason); 3050 } 3051 3052 // No concurrent recoveries. 3053 if (this.#_state.recoveryInProgress) { 3054 lazy.logConsole.warn("Recovery attempt already in progress"); 3055 return null; 3056 } 3057 3058 Glean.browserBackup.restoreStarted.record({ 3059 restore_id: this.#_state.restoreID, 3060 }); 3061 3062 try { 3063 this.#_state.recoveryInProgress = true; 3064 this.#_state.recoveryErrorCode = 0; 3065 this.stateUpdate(); 3066 const RECOVERY_FILE_DEST_PATH = PathUtils.join( 3067 profilePath, 3068 BackupService.PROFILE_FOLDER_NAME, 3069 BackupService.RECOVERY_ZIP_FILE_NAME 3070 ); 3071 await this.extractCompressedSnapshotFromArchive( 3072 archivePath, 3073 RECOVERY_FILE_DEST_PATH, 3074 recoveryCode 3075 ); 3076 3077 let encState = null; 3078 if (recoveryCode) { 3079 // We were passed a recovery code and made it to this line. That implies 3080 // that the backup was encrypted, and the recovery code was the correct 3081 // one to decrypt it. We now generate a new ArchiveEncryptionState with 3082 // that recovery code to write into the recovered profile. 3083 ({ instance: encState } = 3084 await lazy.ArchiveEncryptionState.initialize(recoveryCode)); 3085 } 3086 3087 const RECOVERY_FOLDER_DEST_PATH = PathUtils.join( 3088 profilePath, 3089 BackupService.PROFILE_FOLDER_NAME, 3090 "recovery" 3091 ); 3092 await this.decompressRecoveryFile( 3093 RECOVERY_FILE_DEST_PATH, 3094 RECOVERY_FOLDER_DEST_PATH 3095 ); 3096 3097 // Now that we've decompressed it, reclaim some disk space by getting rid of 3098 // the ZIP file. 3099 try { 3100 await IOUtils.remove(RECOVERY_FILE_DEST_PATH, { retryReadonly: true }); 3101 } catch (_) { 3102 lazy.logConsole.warn("Could not remove ", RECOVERY_FILE_DEST_PATH); 3103 } 3104 3105 try { 3106 // We're using a try/finally here to clean up the temporary OSKeyStore. 3107 // We need to make sure that cleanup occurs _after_ the recovery has 3108 // either fully succeeded, or fully failed. We await the return value 3109 // of recoverFromSnapshotFolder so that the finally will not execute 3110 // until after recoverFromSnapshotFolder has finished resolving or 3111 // rejecting. 3112 let newProfile = await this.recoverFromSnapshotFolder( 3113 RECOVERY_FOLDER_DEST_PATH, 3114 shouldLaunch, 3115 profileRootPath, 3116 encState 3117 ); 3118 3119 Glean.browserBackup.restoreComplete.record({ 3120 restore_id: this.#_state.restoreID, 3121 }); 3122 // We are probably about to shutdown, so we want to submit this ASAP. 3123 // But this will also clear out the data in this ping, which is a bit 3124 // of a problem for testing. So fire off an event first that tests can 3125 // listen for. 3126 Services.obs.notifyObservers(null, "browser-backup-restore-complete"); 3127 GleanPings.profileRestore.submit(); 3128 3129 return newProfile; 3130 } finally { 3131 // If we had decrypted a backup, we would have created the temporary 3132 // recovery OSKeyStore row with the label 3133 // BackupService.RECOVERY_OSKEYSTORE_LABEL, which we will now delete, 3134 // no matter if we succeeded or failed to recover. 3135 // 3136 // Note that according to nsIOSKeyStore, this is a no-op in the event that 3137 // no secret exists at BackupService.RECOVERY_OSKEYSTORE_LABEL, so we're 3138 // fine to do this even if we were recovering from an unencrypted 3139 // backup. 3140 if (recoveryCode) { 3141 await lazy.nativeOSKeyStore.asyncDeleteSecret( 3142 BackupService.RECOVERY_OSKEYSTORE_LABEL 3143 ); 3144 } 3145 } 3146 } catch (ex) { 3147 Glean.browserBackup.restoreFailed.record({ 3148 restore_id: this.#_state.restoreID, 3149 error_type: errorString(ex.cause), 3150 }); 3151 throw ex; 3152 } finally { 3153 this.#_state.recoveryInProgress = false; 3154 this.stateUpdate(); 3155 } 3156 } 3157 3158 /** 3159 * Given a recovery path, read in the backup manifest from the archive and 3160 * ensures that it is valid. Will throw an error for an invalid manifest. 3161 * 3162 * @param {string} recoveryPath The path to the decompressed backup archive 3163 * on the file system. 3164 * @returns {object} See the BackupManifest schema for the specific shape of the 3165 * returned manifest object. 3166 */ 3167 async #readAndValidateManifest(recoveryPath) { 3168 // Read in the backup manifest. 3169 let manifestPath = PathUtils.join( 3170 recoveryPath, 3171 BackupService.MANIFEST_FILE_NAME 3172 ); 3173 3174 let manifest = await IOUtils.readJSON(manifestPath); 3175 if (!manifest.version) { 3176 throw new BackupError( 3177 "Backup manifest version not found", 3178 ERRORS.CORRUPTED_ARCHIVE 3179 ); 3180 } 3181 3182 if (manifest.version > lazy.ArchiveUtils.SCHEMA_VERSION) { 3183 throw new BackupError( 3184 "Cannot recover from a manifest newer than the current schema version", 3185 ERRORS.UNSUPPORTED_BACKUP_VERSION 3186 ); 3187 } 3188 3189 // Make sure that it conforms to the schema. 3190 let manifestSchema = await BackupService.getSchemaForVersion( 3191 SCHEMAS.BACKUP_MANIFEST, 3192 manifest.version 3193 ); 3194 let schemaValidationResult = lazy.JsonSchema.validate( 3195 manifest, 3196 manifestSchema 3197 ); 3198 if (!schemaValidationResult.valid) { 3199 lazy.logConsole.error( 3200 "Backup manifest does not conform to schema:", 3201 manifest, 3202 manifestSchema, 3203 schemaValidationResult 3204 ); 3205 // TODO: Collect telemetry for this case. (bug 1891817) 3206 throw new BackupError( 3207 "Cannot recover from an invalid backup manifest", 3208 ERRORS.CORRUPTED_ARCHIVE 3209 ); 3210 } 3211 3212 // In the future, if we ever bump the ArchiveUtils.SCHEMA_VERSION and need 3213 // to do any special behaviours to interpret older schemas, this is where 3214 // we can do that, and we can remove this comment. 3215 3216 let meta = manifest.meta; 3217 3218 if (meta.appName != AppConstants.MOZ_APP_NAME) { 3219 throw new BackupError( 3220 `Cannot recover a backup from ${meta.appName} in ${AppConstants.MOZ_APP_NAME}`, 3221 ERRORS.UNSUPPORTED_APPLICATION 3222 ); 3223 } 3224 3225 if ( 3226 Services.vc.compare(AppConstants.MOZ_APP_VERSION, meta.appVersion) < 0 3227 ) { 3228 throw new BackupError( 3229 `Cannot recover a backup created on version ${meta.appVersion} in ${AppConstants.MOZ_APP_VERSION}`, 3230 ERRORS.UNSUPPORTED_BACKUP_VERSION 3231 ); 3232 } 3233 3234 return manifest; 3235 } 3236 3237 /** 3238 * Iterates over each resource in the manifest and calls the recover() method 3239 * on each found BackupResource, passing in the associated ManifestEntry from 3240 * the backup manifest, and collects any post-recovery data from those 3241 * resources. 3242 * 3243 * @param {object} manifest See the BackupManifest schema for the specific 3244 * shape of the returned manifest object. 3245 * @param {string} recoveryPath The path to the decompressed backup archive 3246 * on the file system. 3247 * @param {string} profilePath The path of the newly recovered profile 3248 * @returns {object} 3249 * An object containing post recovery data for each resource. 3250 */ 3251 async #recoverResources(manifest, recoveryPath, profilePath) { 3252 let postRecovery = {}; 3253 3254 // Iterate over each resource in the manifest and call recover() on each 3255 // associated BackupResource. 3256 for (let resourceKey in manifest.resources) { 3257 let manifestEntry = manifest.resources[resourceKey]; 3258 let resourceClass = this.#resources.get(resourceKey); 3259 if (!resourceClass) { 3260 lazy.logConsole.error(`No BackupResource found for key ${resourceKey}`); 3261 continue; 3262 } 3263 3264 try { 3265 lazy.logConsole.debug( 3266 `Restoring resource with key ${resourceKey}. ` + 3267 `Requires encryption: ${resourceClass.requiresEncryption}` 3268 ); 3269 let resourcePath = PathUtils.join(recoveryPath, resourceKey); 3270 let postRecoveryEntry = await new resourceClass().recover( 3271 manifestEntry, 3272 resourcePath, 3273 profilePath 3274 ); 3275 postRecovery[resourceKey] = postRecoveryEntry; 3276 } catch (e) { 3277 lazy.logConsole.error(`Failed to recover resource: ${resourceKey}`, e); 3278 throw e; 3279 } 3280 } 3281 3282 return postRecovery; 3283 } 3284 3285 /** 3286 * If the encState exists, write the encrypted state object to the 3287 * ARCHIVE_ENCRYPTION_STATE_FILE. 3288 * 3289 * @param {ArchiveEncryptionState|null} encState Set if the backup being 3290 * recovered was encrypted. This implies that the profile being recovered 3291 * was configured to create encrypted backups. This ArchiveEncryptionState 3292 * is therefore needed to generate the ARCHIVE_ENCRYPTION_STATE_FILE for 3293 * the recovered profile (since the original ARCHIVE_ENCRYPTION_STATE_FILE 3294 * was intentionally not backed up, as the recovery device might have a 3295 * different OSKeyStore secret). 3296 * @param {string} profilePath The path of the newly recovered profile 3297 */ 3298 async #maybeWriteEncryptedStateObject(encState, profilePath) { 3299 if (encState) { 3300 // The backup we're recovering was originally encrypted, meaning that 3301 // the recovered profile is configured to create encrypted backups. Our 3302 // caller passed us a _new_ ArchiveEncryptionState generated for this 3303 // device with the backup's recovery code so that we can serialize the 3304 // ArchiveEncryptionState for the recovered profile. 3305 let encStatePath = PathUtils.join( 3306 profilePath, 3307 BackupService.PROFILE_FOLDER_NAME, 3308 BackupService.ARCHIVE_ENCRYPTION_STATE_FILE 3309 ); 3310 let encStateObject = await encState.serialize(); 3311 await IOUtils.writeJSON(encStatePath, encStateObject); 3312 } 3313 } 3314 3315 /** 3316 * Write the post recovery data to the newly recovered profile. 3317 * 3318 * @param {object} postRecoveryData An object containing post recovery data 3319 * from each resource recovered. 3320 * @param {string} profilePath The path of the newly recovered profile 3321 */ 3322 async #writePostRecoveryData(postRecoveryData, profilePath) { 3323 let postRecoveryPath = PathUtils.join( 3324 profilePath, 3325 BackupService.POST_RECOVERY_FILE_NAME 3326 ); 3327 await IOUtils.writeJSON(postRecoveryPath, postRecoveryData); 3328 } 3329 3330 /** 3331 * Given a decompressed backup archive at recoveryPath, this method does the 3332 * following: 3333 * 3334 * 1. Reads in the backup manifest from the archive and ensures that it is 3335 * valid. 3336 * 2. Creates a new named profile directory using the same name as the one 3337 * found in the backup manifest, but with a different prefix. 3338 * 3. Iterates over each resource in the manifest and calls the recover() 3339 * method on each found BackupResource, passing in the associated 3340 * ManifestEntry from the backup manifest, and collects any post-recovery 3341 * data from those resources. 3342 * 4. Writes a `post-recovery.json` file into the newly created profile 3343 * directory. 3344 * 5. Returns the name of the newly created profile directory. 3345 * 6. Regardless of whether or not recovery succeeded, clears the native 3346 * OSKeyStore of any secret labeled with 3347 * BackupService.RECOVERY_OSKEYSTORE_LABEL. 3348 * 3349 * @param {string} recoveryPath 3350 * The path to the decompressed backup archive on the file system. 3351 * @param {boolean} [shouldLaunch=false] 3352 * An optional argument that specifies whether an instance of the app 3353 * should be launched with the newly recovered profile after recovery is 3354 * complete. 3355 * @param {string} [profileRootPath=null] 3356 * An optional argument that specifies the root directory where the new 3357 * profile directory should be created. If not provided, the default 3358 * profile root directory will be used. This is primarily meant for 3359 * testing. 3360 * @param {ArchiveEncryptionState} [encState=null] 3361 * Set if the backup being recovered was encrypted. This implies that the 3362 * profile being recovered was configured to create encrypted backups. This 3363 * ArchiveEncryptionState is therefore needed to generate the 3364 * ARCHIVE_ENCRYPTION_STATE_FILE for the recovered profile (since the 3365 * original ARCHIVE_ENCRYPTION_STATE_FILE was intentionally not backed up, 3366 * as the recovery device might have a different OSKeyStore secret). 3367 * @returns {Promise<nsIToolkitProfile>} 3368 * The nsIToolkitProfile that was created for the recovered profile. 3369 * @throws {Exception} 3370 * In the event that recovery somehow failed. 3371 */ 3372 async recoverFromSnapshotFolder( 3373 recoveryPath, 3374 shouldLaunch = false, 3375 profileRootPath = null, 3376 encState = null 3377 ) { 3378 lazy.logConsole.debug("Recovering from backup at ", recoveryPath); 3379 3380 try { 3381 let manifest = await this.#readAndValidateManifest(recoveryPath); 3382 3383 // Okay, we have a valid backup-manifest.json. Let's create a new profile 3384 // and start invoking the recover() method on each BackupResource. 3385 let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService( 3386 Ci.nsIToolkitProfileService 3387 ); 3388 let profile = profileSvc.createUniqueProfile( 3389 profileRootPath ? await IOUtils.getDirectory(profileRootPath) : null, 3390 manifest.meta.profileName 3391 ); 3392 3393 let postRecovery = await this.#recoverResources( 3394 manifest, 3395 recoveryPath, 3396 profile.rootDir.path 3397 ); 3398 3399 try { 3400 postRecovery.backupServiceInternal = { 3401 // Indicates that this is not a result of a profile copy (which uses the 3402 // same mechanism, but doesn't go through this function). 3403 isProfileRestore: true, 3404 restoreID: this.#_state.restoreID, 3405 backupMetadata: { 3406 date: this.#_state.backupFileInfo.date, 3407 appName: this.#_state.backupFileInfo.appName, 3408 appVersion: this.#_state.backupFileInfo.appVersion, 3409 buildID: this.#_state.backupFileInfo.buildID, 3410 osName: this.#_state.backupFileInfo.osName, 3411 osVersion: this.#_state.backupFileInfo.osVersion, 3412 legacyClientID: this.#_state.backupFileInfo.legacyClientID, 3413 }, 3414 }; 3415 } catch {} 3416 3417 await this.#maybeWriteEncryptedStateObject( 3418 encState, 3419 profile.rootDir.path 3420 ); 3421 3422 await this.#writePostRecoveryData(postRecovery, profile.rootDir.path); 3423 3424 // In a release scenario, this should always be true 3425 // this makes it easier to get around setting up profiles for testing other functionality 3426 if (profileSvc.currentProfile) { 3427 // if our current profile was default, let's make the new one default 3428 if (profileSvc.currentProfile === profileSvc.defaultProfile) { 3429 profileSvc.defaultProfile = profile; 3430 } 3431 3432 // If the profile already has an [old-] prefix, let's skip adding new prefixes 3433 if (!profileSvc.currentProfile.name.startsWith("old-")) { 3434 // Looks like this is a new restoration of this profile, 3435 // add the prefix old-[profile_name] 3436 profileSvc.currentProfile.name = `old-${profileSvc.currentProfile.name}`; 3437 } 3438 } 3439 3440 await profileSvc.asyncFlush(); 3441 3442 if (shouldLaunch) { 3443 // Launch with the user's default homepage instead of the last selected tab 3444 // to avoid problems with the messaging system (see Bug 2002732) 3445 Services.startup.createInstanceWithProfile(profile, [ 3446 "--url", 3447 "about:home", 3448 ]); 3449 } 3450 3451 return profile; 3452 } catch (e) { 3453 lazy.logConsole.error( 3454 "Failed to recover from backup at ", 3455 recoveryPath, 3456 e 3457 ); 3458 throw e; 3459 } 3460 } 3461 3462 /** 3463 * Given a decompressed backup archive at recoveryPath, this method does the 3464 * following: 3465 * 3466 * 1. Reads in the backup manifest from the archive and ensures that it is 3467 * valid. 3468 * 2. Creates a new SelectableProfile profile directory using the same name 3469 * as the one found in the backup manifest, but with a different prefix. 3470 * 3. Iterates over each resource in the manifest and calls the recover() 3471 * method on each found BackupResource, passing in the associated 3472 * ManifestEntry from the backup manifest, and collects any post-recovery 3473 * data from those resources. 3474 * 4. Writes a `post-recovery.json` file into the newly created profile 3475 * directory. 3476 * 5. Returns the name of the newly created profile directory. 3477 * 6. Regardless of whether or not recovery succeeded, clears the native 3478 * OSKeyStore of any secret labeled with 3479 * BackupService.RECOVERY_OSKEYSTORE_LABEL. 3480 * 3481 * @param {string} recoveryPath 3482 * The path to the decompressed backup archive on the file system. 3483 * @param {boolean} [shouldLaunch=false] 3484 * An optional argument that specifies whether an instance of the app 3485 * should be launched with the newly recovered profile after recovery is 3486 * complete. 3487 * @param {ArchiveEncryptionState} [encState=null] 3488 * Set if the backup being recovered was encrypted. This implies that the 3489 * profile being recovered was configured to create encrypted backups. This 3490 * ArchiveEncryptionState is therefore needed to generate the 3491 * ARCHIVE_ENCRYPTION_STATE_FILE for the recovered profile (since the 3492 * original ARCHIVE_ENCRYPTION_STATE_FILE was intentionally not backed up, 3493 * as the recovery device might have a different OSKeyStore secret). 3494 * @param {SelectableProfile} [copiedProfile=null] 3495 * If the profile we are recovering is a "copied" profile, we don't want to 3496 * inherit the client ID as this profile will be a new profile in the 3497 * profile group. If we are copying a profile, we will use 3498 * copiedProfile.name to show that the new profile is a copy of 3499 * copiedProfile on about:editprofile. 3500 * @returns {Promise<SelectableProfile>} 3501 * The SelectableProfile that was created for the recovered profile. 3502 * @throws {Exception} 3503 * In the event that recovery somehow failed. 3504 */ 3505 async recoverFromSnapshotFolderIntoSelectableProfile( 3506 recoveryPath, 3507 shouldLaunch = false, 3508 encState = null, 3509 copiedProfile = null 3510 ) { 3511 lazy.logConsole.debug( 3512 "Recovering SelectableProfile from backup at ", 3513 recoveryPath 3514 ); 3515 3516 try { 3517 let manifest = await this.#readAndValidateManifest(recoveryPath); 3518 3519 // Okay, we have a valid backup-manifest.json. Let's create a new profile 3520 // and start invoking the recover() method on each BackupResource. 3521 let profile = await lazy.SelectableProfileService.createNewProfile(false); 3522 3523 let postRecovery = await this.#recoverResources( 3524 manifest, 3525 recoveryPath, 3526 profile.path 3527 ); 3528 3529 await this.#maybeWriteEncryptedStateObject(encState, profile.path); 3530 3531 await this.#writePostRecoveryData(postRecovery, profile.path); 3532 3533 if (shouldLaunch) { 3534 lazy.SelectableProfileService.launchInstance( 3535 profile, 3536 // Using URL Search Params on this about: page didn't work because 3537 // the RPM communication so we use the hash and parse that instead. 3538 [ 3539 "about:editprofile" + 3540 (copiedProfile ? `#copiedProfileName=${copiedProfile.name}` : ""), 3541 ] 3542 ); 3543 } 3544 3545 return profile; 3546 } catch (e) { 3547 lazy.logConsole.error( 3548 "Failed to recover SelectableProfile from backup at ", 3549 recoveryPath, 3550 e 3551 ); 3552 throw e; 3553 } 3554 } 3555 3556 /** 3557 * Checks for the POST_RECOVERY_FILE_NAME in the current profile directory. 3558 * If one exists, instantiates any relevant BackupResource's, and calls 3559 * postRecovery() on them with the appropriate entry from the file. Once 3560 * this is done, deletes the file. 3561 * 3562 * The file is deleted even if one of the postRecovery() steps rejects or 3563 * fails. 3564 * 3565 * This function resolves silently if the POST_RECOVERY_FILE_NAME file does 3566 * not exist, which should be the majority of cases. 3567 * 3568 * @param {string} [profilePath=PathUtils.profileDir] 3569 * The profile path to look for the POST_RECOVERY_FILE_NAME file. Defaults 3570 * to the current profile. 3571 * @returns {Promise<undefined>} 3572 */ 3573 async checkForPostRecovery(profilePath = PathUtils.profileDir) { 3574 lazy.logConsole.debug(`Checking for post-recovery file in ${profilePath}`); 3575 let postRecoveryFile = PathUtils.join( 3576 profilePath, 3577 BackupService.POST_RECOVERY_FILE_NAME 3578 ); 3579 3580 if (!(await IOUtils.exists(postRecoveryFile))) { 3581 lazy.logConsole.debug("Did not find post-recovery file."); 3582 this.#postRecoveryResolver(); 3583 return; 3584 } 3585 3586 lazy.logConsole.debug("Found post-recovery file. Loading..."); 3587 3588 try { 3589 let postRecovery = await IOUtils.readJSON(postRecoveryFile); 3590 for (let resourceKey in postRecovery) { 3591 let postRecoveryEntry = postRecovery[resourceKey]; 3592 if ( 3593 resourceKey == "backupServiceInternal" && 3594 postRecoveryEntry.isProfileRestore 3595 ) { 3596 Services.prefs.setStringPref( 3597 RESTORED_BACKUP_METADATA_PREF_NAME, 3598 JSON.stringify(postRecoveryEntry.backupMetadata) 3599 ); 3600 Glean.browserBackup.restoredProfileLaunched.record({ 3601 restore_id: postRecoveryEntry.restoreID, 3602 }); 3603 // This will clear out the data in this ping, which is a bit of a problem 3604 // for testing. So fire off an event first that tests can listen for. 3605 Services.obs.notifyObservers( 3606 null, 3607 "browser-backup-restored-profile-telemetry-set" 3608 ); 3609 GleanPings.postProfileRestore.submit(); 3610 } else { 3611 let resourceClass = this.#resources.get(resourceKey); 3612 if (!resourceClass) { 3613 lazy.logConsole.error( 3614 `Invalid resource for post-recovery step: ${resourceKey}` 3615 ); 3616 continue; 3617 } 3618 3619 lazy.logConsole.debug( 3620 `Running post-recovery step for ${resourceKey}` 3621 ); 3622 await new resourceClass().postRecovery(postRecoveryEntry); 3623 lazy.logConsole.debug(`Done post-recovery step for ${resourceKey}`); 3624 } 3625 } 3626 } finally { 3627 await IOUtils.remove(postRecoveryFile, { 3628 ignoreAbsent: true, 3629 retryReadonly: true, 3630 }); 3631 this.#postRecoveryResolver(); 3632 } 3633 } 3634 3635 /** 3636 * Sets the parent directory of the backups folder. Calling this function will update 3637 * browser.backup.location. 3638 * 3639 * @param {string} parentDirPath directory path 3640 */ 3641 setParentDirPath(parentDirPath) { 3642 try { 3643 let filename = parentDirPath ? PathUtils.filename(parentDirPath) : null; 3644 if (!filename) { 3645 throw new BackupError( 3646 "Parent directory path is invalid.", 3647 ERRORS.FILE_SYSTEM_ERROR 3648 ); 3649 } 3650 3651 let fullPath = parentDirPath; 3652 if (filename != BackupService.BACKUP_DIR_NAME) { 3653 // Recreate the backups path with the new parent directory. 3654 fullPath = PathUtils.join(parentDirPath, BackupService.BACKUP_DIR_NAME); 3655 } 3656 3657 Services.prefs.setStringPref(BACKUP_DIR_PREF_NAME, fullPath); 3658 } catch (e) { 3659 lazy.logConsole.error( 3660 `Failed to set parent directory ${parentDirPath}. ${e}` 3661 ); 3662 throw e; 3663 } 3664 } 3665 3666 /** 3667 * Updates backupDirPath in the backup service state. Should be called every time the value 3668 * for browser.backup.location changes. 3669 * 3670 * @param {string} newDirPath the new directory path for storing backups 3671 */ 3672 async onUpdateLocationDirPath(newDirPath) { 3673 lazy.logConsole.debug(`Updating backup location to ${newDirPath}`); 3674 3675 Glean.browserBackup.changeLocation.record(); 3676 3677 this.#_state.backupDirPath = newDirPath; 3678 this.stateUpdate(); 3679 } 3680 3681 /** 3682 * Updates backupErrorCode in the backup service state. Should be called every time 3683 * the value for browser.backup.errorCode changes. 3684 * 3685 * @param {number} newErrorCode 3686 * Any of the ERROR code's from backup-constants.mjs 3687 */ 3688 onUpdateBackupErrorCode(newErrorCode) { 3689 lazy.logConsole.debug(`Updating backup error code to ${newErrorCode}`); 3690 3691 this.#_state.backupErrorCode = newErrorCode; 3692 this.stateUpdate(); 3693 } 3694 3695 /** 3696 * Updates lastBackupFileName in the backup service state. Should be called every time 3697 * the value for browser.backup.scheduled.last-backup-file changes. 3698 * 3699 * @param {string} newLastBackupFileName 3700 * Name of the last known backup file 3701 */ 3702 onUpdateLastBackupFileName(newLastBackupFileName) { 3703 lazy.logConsole.debug( 3704 `The last backup file name is being updated to ${newLastBackupFileName}` 3705 ); 3706 3707 this.#_state.lastBackupFileName = newLastBackupFileName; 3708 3709 if (!newLastBackupFileName) { 3710 lazy.logConsole.debug( 3711 `Looks like we've cleared the last backup file name, let's also clear the last backup date` 3712 ); 3713 3714 this.#_state.lastBackupDate = null; 3715 Services.prefs.clearUserPref(LAST_BACKUP_TIMESTAMP_PREF_NAME); 3716 } 3717 3718 this.stateUpdate(); 3719 } 3720 3721 /** 3722 * Returns the moz-icon URL of a file. To get the moz-icon URL, the 3723 * file path is convered to a fileURI. If there is a problem retreiving 3724 * the moz-icon due to an invalid file path, return null instead. 3725 * 3726 * @param {string} path Path of the file to read its icon from. 3727 * @returns {string|null} The moz-icon URL of the specified file, or 3728 * null if the icon cannot be retreived. 3729 */ 3730 getIconFromFilePath(path) { 3731 if (!path) { 3732 return null; 3733 } 3734 3735 try { 3736 let fileURI = PathUtils.toFileURI(path); 3737 return `moz-icon:${fileURI}?size=16`; 3738 } catch (e) { 3739 return null; 3740 } 3741 } 3742 3743 /** 3744 * Sets browser.backup.scheduled.enabled to true or false. 3745 * 3746 * @param { boolean } shouldEnableScheduledBackups true if scheduled backups should be enabled. Else, false. 3747 */ 3748 setScheduledBackups(shouldEnableScheduledBackups) { 3749 Services.prefs.setBoolPref( 3750 SCHEDULED_BACKUPS_ENABLED_PREF_NAME, 3751 shouldEnableScheduledBackups 3752 ); 3753 3754 if (shouldEnableScheduledBackups) { 3755 // reset the error states when reenabling backup 3756 Services.prefs.setIntPref(BACKUP_ERROR_CODE_PREF_NAME, ERRORS.NONE); 3757 3758 // flush the embedded component's persistent data 3759 this.setEmbeddedComponentPersistentData({}); 3760 } else { 3761 // set user-disabled pref if backup is being disabled 3762 Services.prefs.setBoolPref( 3763 "browser.backup.scheduled.user-disabled", 3764 true 3765 ); 3766 } 3767 } 3768 3769 /** 3770 * Updates scheduledBackupsEnabled in the backup service state. Should be called every time 3771 * the value for browser.backup.scheduled.enabled changes. 3772 * 3773 * @param {boolean} isScheduledBackupsEnabled True if scheduled backups are enabled. Else false. 3774 */ 3775 onUpdateScheduledBackups(isScheduledBackupsEnabled) { 3776 if (this.#_state.scheduledBackupsEnabled != isScheduledBackupsEnabled) { 3777 if (isScheduledBackupsEnabled) { 3778 Glean.browserBackup.toggleOn.record({ 3779 encrypted: this.#_state.encryptionEnabled, 3780 location: this.classifyLocationForTelemetry(lazy.backupDirPref), 3781 }); 3782 } else { 3783 Glean.browserBackup.toggleOff.record(); 3784 } 3785 3786 lazy.logConsole.debug( 3787 "Updating scheduled backups", 3788 isScheduledBackupsEnabled 3789 ); 3790 this.#_state.scheduledBackupsEnabled = isScheduledBackupsEnabled; 3791 this.stateUpdate(); 3792 } 3793 } 3794 3795 /** 3796 * Take measurements of the current profile state for Telemetry. 3797 * 3798 * @returns {Promise<undefined>} 3799 */ 3800 async takeMeasurements() { 3801 lazy.logConsole.debug("Taking Telemetry measurements"); 3802 3803 // We'll start by taking some basic BackupService state measurements. 3804 Glean.browserBackup.enabled.set(true); 3805 Glean.browserBackup.schedulerEnabled.set(lazy.scheduledBackupsPref); 3806 3807 await this.loadEncryptionState(); 3808 Glean.browserBackup.pswdEncrypted.set(this.#_state.encryptionEnabled); 3809 3810 const USING_DEFAULT_DIR_PATH = 3811 lazy.backupDirPref == 3812 PathUtils.join( 3813 BackupService.DEFAULT_PARENT_DIR_PATH, 3814 BackupService.BACKUP_DIR_NAME 3815 ); 3816 Glean.browserBackup.locationOnDevice.set(USING_DEFAULT_DIR_PATH ? 1 : 2); 3817 3818 // Next, we'll measure the available disk space on the storage 3819 // device that the profile directory is on. 3820 let profileDir = await IOUtils.getFile(PathUtils.profileDir); 3821 3822 let profDDiskSpaceBytes = profileDir.diskSpaceAvailable; 3823 3824 // Make the measurement fuzzier by rounding to the nearest 10MB. 3825 let profDDiskSpaceFuzzed = MeasurementUtils.fuzzByteSize( 3826 profDDiskSpaceBytes, 3827 10 * BYTES_IN_MEGABYTE 3828 ); 3829 3830 // And then record the value in kilobytes, since that's what everything 3831 // else is going to be measured in. 3832 Glean.browserBackup.profDDiskSpace.set( 3833 profDDiskSpaceFuzzed / BYTES_IN_KILOBYTE 3834 ); 3835 3836 // Measure the size of each file we are going to backup. 3837 for (let resourceClass of this.#resources.values()) { 3838 try { 3839 await new resourceClass().measure(PathUtils.profileDir); 3840 } catch (e) { 3841 lazy.logConsole.error( 3842 `Failed to measure for resource: ${resourceClass.key}`, 3843 e 3844 ); 3845 } 3846 } 3847 } 3848 3849 /** 3850 * The internal promise that is created on the first call to 3851 * loadEncryptionState. 3852 * 3853 * @type {Promise} 3854 */ 3855 #loadEncryptionStatePromise = null; 3856 3857 /** 3858 * Returns the current ArchiveEncryptionState. This method will only attempt 3859 * to read the state from the disk the first time it is called. 3860 * 3861 * @param {string} [profilePath=PathUtils.profileDir] 3862 * The profile path where the encryption state might exist. This is only 3863 * used for testing. 3864 * @returns {Promise<ArchiveEncryptionState>} 3865 */ 3866 loadEncryptionState(profilePath = PathUtils.profileDir) { 3867 if (this.#encState !== undefined) { 3868 return Promise.resolve(this.#encState); 3869 } 3870 3871 // This little dance makes it so that we only attempt to read the state off 3872 // of the disk the first time `loadEncryptionState` is called. Any 3873 // subsequent calls will await this same promise, OR, after the state has 3874 // been read in, they'll just get the #encState which is set after the 3875 // state has been read in. 3876 if (!this.#loadEncryptionStatePromise) { 3877 this.#loadEncryptionStatePromise = (async () => { 3878 // Default this to null here - that way, if we fail to read it in, 3879 // the null will indicate that we have at least _tried_ to load the 3880 // state. 3881 let encState = null; 3882 let encStateFile = PathUtils.join( 3883 profilePath, 3884 BackupService.PROFILE_FOLDER_NAME, 3885 BackupService.ARCHIVE_ENCRYPTION_STATE_FILE 3886 ); 3887 3888 // Try to read in any pre-existing encryption state. If that fails, 3889 // we fallback to not encrypting, and only backing up non-sensitive data. 3890 try { 3891 if (await IOUtils.exists(encStateFile)) { 3892 let stateObject = await IOUtils.readJSON(encStateFile); 3893 ({ instance: encState } = 3894 await lazy.ArchiveEncryptionState.initialize(stateObject)); 3895 } 3896 } catch (e) { 3897 lazy.logConsole.error( 3898 "Failed to read / deserialize archive encryption state file: ", 3899 e 3900 ); 3901 // TODO: This kind of error might be worth collecting telemetry on. 3902 } 3903 3904 this.#_state.encryptionEnabled = !!encState; 3905 this.stateUpdate(); 3906 3907 this.#encState = encState; 3908 return encState; 3909 })(); 3910 } 3911 3912 return this.#loadEncryptionStatePromise; 3913 } 3914 3915 /** 3916 * Enables encryption for backups, allowing sensitive data to be backed up. 3917 * After enabling encryption, the state is written to disk. 3918 * 3919 * @throws Exception 3920 * @param {string} password 3921 * A non-blank password ("recovery code") that can be used to derive keys 3922 * for encrypting the backup. 3923 * @param {string} [profilePath=PathUtils.profileDir] 3924 * The profile path where the encryption state will be written. This is only 3925 * used for testing. 3926 */ 3927 async enableEncryption(password, profilePath = PathUtils.profileDir) { 3928 lazy.logConsole.debug("Enabling encryption."); 3929 if (!password) { 3930 throw new BackupError( 3931 "Cannot supply a blank password.", 3932 ERRORS.INVALID_PASSWORD 3933 ); 3934 } 3935 3936 if (password.length < 8) { 3937 throw new BackupError( 3938 "Password must be at least 8 characters.", 3939 ERRORS.INVALID_PASSWORD 3940 ); 3941 } 3942 3943 let { instance: encState } = 3944 await lazy.ArchiveEncryptionState.initialize(password); 3945 if (!encState) { 3946 throw new BackupError( 3947 "Failed to construct ArchiveEncryptionState", 3948 ERRORS.UNKNOWN 3949 ); 3950 } 3951 3952 this.#encState = encState; 3953 3954 let encStateFile = PathUtils.join( 3955 profilePath, 3956 BackupService.PROFILE_FOLDER_NAME, 3957 BackupService.ARCHIVE_ENCRYPTION_STATE_FILE 3958 ); 3959 3960 let stateObj = await encState.serialize(); 3961 await IOUtils.writeJSON(encStateFile, stateObj); 3962 3963 this.#_state.encryptionEnabled = true; 3964 this.stateUpdate(); 3965 } 3966 3967 /** 3968 * Disables encryption of backups. 3969 * 3970 * @throws Exception 3971 * @param {string} [profilePath=PathUtils.profileDir] 3972 * The profile path where the encryption state exists. This is only used for 3973 * testing. 3974 * @returns {Promise<undefined>} 3975 */ 3976 async disableEncryption(profilePath = PathUtils.profileDir) { 3977 lazy.logConsole.debug("Disabling encryption."); 3978 let encStateFile = PathUtils.join( 3979 profilePath, 3980 BackupService.PROFILE_FOLDER_NAME, 3981 BackupService.ARCHIVE_ENCRYPTION_STATE_FILE 3982 ); 3983 await IOUtils.remove(encStateFile, { 3984 ignoreAbsent: true, 3985 retryReadonly: true, 3986 }); 3987 3988 this.#encState = null; 3989 this.#_state.encryptionEnabled = false; 3990 this.stateUpdate(); 3991 } 3992 3993 /** 3994 * The value of IDLE_THRESHOLD_SECONDS_PREF_NAME at the time that 3995 * initBackupScheduler was called. This is recorded so that if the preference 3996 * changes at runtime, that we properly remove the idle observer in 3997 * uninitBackupScheduler, since it's mapped to the idle time value. 3998 * 3999 * @see BackupService.initBackupScheduler() 4000 * @see BackupService.uninitBackupScheduler() 4001 * @type {number} 4002 */ 4003 #idleThresholdSeconds = null; 4004 4005 /** 4006 * An ES6 class that extends EventTarget cannot, apparently, be coerced into 4007 * a nsIObserver, even when we define QueryInterface. We work around this 4008 * limitation by having the observer be a function that we define at 4009 * registration time. We hold a reference to the observer so that we can 4010 * properly unregister. 4011 * 4012 * @see BackupService.initBackupScheduler() 4013 * @type {Function} 4014 */ 4015 #observer = null; 4016 4017 /** 4018 * True if the backup scheduler system has been initted via 4019 * initBackupScheduler(). 4020 * 4021 * @see BackupService.initBackupScheduler() 4022 * @type {boolean} 4023 */ 4024 #backupSchedulerInitted = false; 4025 4026 /** 4027 * Initializes the backup scheduling system. This should be done shortly 4028 * after startup. It is exposed as a public method mainly for ease in testing. 4029 * 4030 * The scheduler will automatically uninitialize itself on the 4031 * quit-application-granted observer notification. 4032 */ 4033 initBackupScheduler() { 4034 if (this.#backupSchedulerInitted) { 4035 lazy.logConsole.warn( 4036 "BackupService scheduler already initting or initted." 4037 ); 4038 return; 4039 } 4040 4041 this.#backupSchedulerInitted = true; 4042 4043 let lastBackupPrefValue = Services.prefs.getIntPref( 4044 LAST_BACKUP_TIMESTAMP_PREF_NAME, 4045 0 4046 ); 4047 4048 this.#_state.lastBackupDate = lastBackupPrefValue || null; 4049 4050 this.stateUpdate(); 4051 4052 // We'll default to 5 minutes of idle time unless otherwise configured. 4053 const FIVE_MINUTES_IN_SECONDS = 5 * 60; 4054 4055 this.#idleThresholdSeconds = Services.prefs.getIntPref( 4056 IDLE_THRESHOLD_SECONDS_PREF_NAME, 4057 FIVE_MINUTES_IN_SECONDS 4058 ); 4059 this.#observer = (subject, topic, data) => { 4060 this.onObserve(subject, topic, data); 4061 }; 4062 lazy.logConsole.debug( 4063 `Registering idle observer for ${ 4064 this.#idleThresholdSeconds 4065 } seconds of idle time` 4066 ); 4067 lazy.idleService.addIdleObserver( 4068 this.#observer, 4069 this.#idleThresholdSeconds 4070 ); 4071 lazy.logConsole.debug("Idle observer registered."); 4072 4073 lazy.logConsole.debug(`Registering Places observer`); 4074 4075 this.#placesObserver = new PlacesWeakCallbackWrapper( 4076 this.onPlacesEvents.bind(this) 4077 ); 4078 PlacesObservers.addListener( 4079 ["history-cleared", "page-removed", "bookmark-removed"], 4080 this.#placesObserver 4081 ); 4082 4083 lazy.AddonManager.addAddonListener(this); 4084 4085 Services.obs.addObserver(this.#observer, "passwordmgr-storage-changed"); 4086 Services.obs.addObserver(this.#observer, "formautofill-storage-changed"); 4087 Services.obs.addObserver(this.#observer, "sanitizer-sanitization-complete"); 4088 Services.obs.addObserver(this.#observer, "perm-changed"); 4089 Services.obs.addObserver(this.#observer, "cookie-changed"); 4090 Services.obs.addObserver(this.#observer, "session-cookie-changed"); 4091 Services.obs.addObserver(this.#observer, "newtab-linkBlocked"); 4092 Services.obs.addObserver(this.#observer, "quit-application-granted"); 4093 Services.prefs.addObserver(SANITIZE_ON_SHUTDOWN_PREF_NAME, this.#observer); 4094 } 4095 4096 /** 4097 * Uninitializes the backup scheduling system. 4098 */ 4099 uninitBackupScheduler() { 4100 if (!this.#backupSchedulerInitted) { 4101 lazy.logConsole.warn( 4102 "Tried to uninitBackupScheduler when it wasn't yet enabled." 4103 ); 4104 return; 4105 } 4106 4107 lazy.idleService.removeIdleObserver( 4108 this.#observer, 4109 this.#idleThresholdSeconds 4110 ); 4111 4112 PlacesObservers.removeListener( 4113 ["history-cleared", "page-removed", "bookmark-removed"], 4114 this.#placesObserver 4115 ); 4116 4117 lazy.AddonManager.removeAddonListener(this); 4118 4119 Services.obs.removeObserver(this.#observer, "passwordmgr-storage-changed"); 4120 Services.obs.removeObserver(this.#observer, "formautofill-storage-changed"); 4121 Services.obs.removeObserver( 4122 this.#observer, 4123 "sanitizer-sanitization-complete" 4124 ); 4125 Services.obs.removeObserver(this.#observer, "perm-changed"); 4126 Services.obs.removeObserver(this.#observer, "cookie-changed"); 4127 Services.obs.removeObserver(this.#observer, "session-cookie-changed"); 4128 Services.obs.removeObserver(this.#observer, "newtab-linkBlocked"); 4129 Services.obs.removeObserver(this.#observer, "quit-application-granted"); 4130 Services.prefs.removeObserver( 4131 SANITIZE_ON_SHUTDOWN_PREF_NAME, 4132 this.#observer 4133 ); 4134 this.#observer = null; 4135 4136 this.#regenerationDebouncer.disarm(); 4137 this.#backupWriteAbortController.abort(); 4138 } 4139 4140 /** 4141 * Called by this.#observer on idle from the nsIUserIdleService or 4142 * quit-application-granted from the nsIObserverService. Exposed as a public 4143 * method mainly for ease in testing. 4144 * 4145 * @param {nsISupports|null} subject 4146 * The nsIUserIdleService for the idle notification, and null for the 4147 * quit-application-granted topic. 4148 * @param {string} topic 4149 * The topic that the notification belongs to. 4150 * @param {string} data 4151 * Optional data that was included with the notification. 4152 */ 4153 onObserve(subject, topic, data) { 4154 switch (topic) { 4155 case "idle": { 4156 this.onIdle(); 4157 break; 4158 } 4159 case "quit-application-granted": { 4160 this.uninitBackupScheduler(); 4161 this.uninitStatusObservers(); 4162 break; 4163 } 4164 case "passwordmgr-storage-changed": { 4165 if (data == "removeLogin" || data == "removeAllLogins") { 4166 this.#debounceRegeneration(); 4167 } 4168 break; 4169 } 4170 case "formautofill-storage-changed": { 4171 if ( 4172 data == "remove" && 4173 (subject.wrappedJSObject.collectionName == "creditCards" || 4174 subject.wrappedJSObject.collectionName == "addresses") 4175 ) { 4176 this.#debounceRegeneration(); 4177 } 4178 break; 4179 } 4180 case "newtab-linkBlocked": 4181 // Intentional fall-through 4182 case "sanitizer-sanitization-complete": { 4183 this.#debounceRegeneration(); 4184 break; 4185 } 4186 case "perm-changed": { 4187 if (data == "deleted") { 4188 this.#debounceRegeneration(); 4189 } 4190 break; 4191 } 4192 case "cookie-changed": 4193 // Intentional fall-through 4194 case "session-cookie-changed": { 4195 let notification = subject.QueryInterface(Ci.nsICookieNotification); 4196 // A browsingContextId value of 0 means that this deletion was caused by 4197 // chrome UI cookie deletion, which is what we care about. If it's not 4198 // 0, then a site deleted its own cookie, which we ignore. 4199 if ( 4200 (notification.action == Ci.nsICookieNotification.COOKIE_DELETED || 4201 notification.action == 4202 Ci.nsICookieNotification.ALL_COOKIES_CLEARED) && 4203 !notification.browsingContextId 4204 ) { 4205 this.#debounceRegeneration(); 4206 } 4207 break; 4208 } 4209 case "nsPref:changed": { 4210 if (data == SANITIZE_ON_SHUTDOWN_PREF_NAME) { 4211 this.#debounceRegeneration(); 4212 } 4213 } 4214 } 4215 } 4216 4217 /** 4218 * Makes this instance responsible for monitoring the conditions that can 4219 * cause backups or restores to be unavailable. 4220 * 4221 * When one arrives, observers of the 'backup-service-status-changed' topic 4222 * will be notified and telemetry will be emitted. 4223 * 4224 * This is not done by default since that would cause N emissions of that 4225 * topic per change for N instances, which can be a problem with testing. The 4226 * global BackupService has status observers by default. 4227 */ 4228 initStatusObservers() { 4229 if (this.#statusPrefObserver != null) { 4230 return; 4231 } 4232 4233 // We don't use this.#observer since any changes to the prefs or nimbus should 4234 // immediately reflect across any observers, instead of waiting on idle. 4235 this.#statusPrefObserver = () => { 4236 // Wrap in an arrow function so 'this' is preserved. 4237 this.#handleStatusChange(); 4238 }; 4239 4240 for (let pref of BackupService.STATUS_OBSERVER_PREFS) { 4241 Services.prefs.addObserver(pref, this.#statusPrefObserver); 4242 } 4243 lazy.NimbusFeatures.backupService.onUpdate(this.#statusPrefObserver); 4244 this.#handleStatusChange(); 4245 } 4246 4247 /** 4248 * Removes the observers configured by initStatusObservers. 4249 * 4250 * This is done automatically on shutdown, but you can do it earlier if you'd 4251 * like that instance to stop emitting events. 4252 */ 4253 uninitStatusObservers() { 4254 if (this.#statusPrefObserver == null) { 4255 return; 4256 } 4257 4258 for (let pref of BackupService.STATUS_OBSERVER_PREFS) { 4259 Services.prefs.removeObserver(pref, this.#statusPrefObserver); 4260 } 4261 lazy.NimbusFeatures.backupService.offUpdate(this.#statusPrefObserver); 4262 this.#statusPrefObserver = null; 4263 } 4264 4265 /** 4266 * Performs tasks required whenever archive or restore change their status 4267 * 4268 * 1. Notifies any observers that a change has taken place 4269 * 2. If archive is disabled, clean up any backup files 4270 */ 4271 #handleStatusChange() { 4272 const archiveStatus = this.archiveEnabledStatus; 4273 const restoreStatus = this.restoreEnabledStatus; 4274 // Update the BackupService state before notifying observers about the 4275 // state change 4276 this.#_state.archiveEnabledStatus = this.archiveEnabledStatus.enabled; 4277 this.#_state.restoreEnabledStatus = this.restoreEnabledStatus.enabled; 4278 4279 this.#updateGleanEnablement(archiveStatus, restoreStatus); 4280 if ( 4281 archiveStatus.enabled != this.#lastSeenArchiveStatus || 4282 restoreStatus.enabled != this.#lastSeenRestoreStatus 4283 ) { 4284 this.#lastSeenArchiveStatus = archiveStatus.enabled; 4285 this.#lastSeenRestoreStatus = restoreStatus.enabled; 4286 this.#notifyStatusObservers(); 4287 } 4288 if (!archiveStatus.enabled) { 4289 // We won't wait for this promise to accept/reject since rejections are 4290 // ignored anyways 4291 this.cleanupBackupFiles(); 4292 } 4293 } 4294 4295 #updateGleanEnablement(archiveStatus, restoreStatus) { 4296 Glean.browserBackup.archiveEnabled.set(archiveStatus.enabled); 4297 Glean.browserBackup.restoreEnabled.set(restoreStatus.enabled); 4298 if (!archiveStatus.enabled) { 4299 this.#wasArchivePreviouslyDisabled = true; 4300 Glean.browserBackup.archiveDisabledReason.set( 4301 archiveStatus.internalReason 4302 ); 4303 } else if (this.#wasArchivePreviouslyDisabled) { 4304 Glean.browserBackup.archiveDisabledReason.set("reenabled"); 4305 } 4306 if (!restoreStatus.enabled) { 4307 this.#wasRestorePreviouslyDisabled = true; 4308 Glean.browserBackup.restoreDisabledReason.set( 4309 restoreStatus.internalReason 4310 ); 4311 } else if (this.#wasRestorePreviouslyDisabled) { 4312 Glean.browserBackup.restoreDisabledReason.set("reenabled"); 4313 } 4314 } 4315 4316 /** 4317 * Notify any listeners about the availability of the backup service, then 4318 * update relevant telemetry metrics. 4319 */ 4320 #notifyStatusObservers() { 4321 lazy.logConsole.log( 4322 "Notifying observers about a BackupService state change" 4323 ); 4324 4325 Services.obs.notifyObservers(null, "backup-service-status-updated"); 4326 } 4327 4328 async cleanupBackupFiles() { 4329 lazy.logConsole.debug("Cleaning up backup data"); 4330 try { 4331 if (this.state.encryptionEnabled) { 4332 await this.disableEncryption(); 4333 } 4334 this.deleteLastBackup(); 4335 } catch (e) { 4336 // Ignore any exceptions 4337 lazy.logConsole.error( 4338 "There was an error when cleaning up backup files: ", 4339 e 4340 ); 4341 } 4342 } 4343 4344 /** 4345 * Called when the last known backup should be deleted and a new one 4346 * created. This uses the #regenerationDebouncer to debounce clusters of 4347 * events that might cause such a regeneration to occur. 4348 */ 4349 #debounceRegeneration() { 4350 this.#regenerationDebouncer.disarm(); 4351 this.#regenerationDebouncer.arm(); 4352 } 4353 4354 /** 4355 * Called when the nsIUserIdleService reports that user input events have 4356 * not been sent to the application for at least 4357 * IDLE_THRESHOLD_SECONDS_PREF_NAME seconds. 4358 */ 4359 async onIdle() { 4360 lazy.logConsole.debug("Saw idle callback"); 4361 if (!this.#takenMeasurements) { 4362 this.takeMeasurements(); 4363 this.#takenMeasurements = true; 4364 } 4365 4366 if (lazy.scheduledBackupsPref && this.archiveEnabledStatus.enabled) { 4367 lazy.logConsole.debug("Scheduled backups enabled."); 4368 let now = Math.floor(Date.now() / 1000); 4369 let lastBackupDate = this.#_state.lastBackupDate; 4370 if (lastBackupDate && lastBackupDate > now) { 4371 lazy.logConsole.error( 4372 "Last backup was somehow in the future. Resetting the preference." 4373 ); 4374 lastBackupDate = null; 4375 this.#_state.lastBackupDate = null; 4376 this.stateUpdate(); 4377 } 4378 4379 if (!lastBackupDate) { 4380 lazy.logConsole.debug("No last backup time recorded in prefs."); 4381 } else { 4382 lazy.logConsole.debug( 4383 "Last backup was: ", 4384 new Date(lastBackupDate * 1000) 4385 ); 4386 } 4387 4388 if ( 4389 !lastBackupDate || 4390 now - lastBackupDate > lazy.minimumTimeBetweenBackupsSeconds 4391 ) { 4392 lazy.logConsole.debug( 4393 "Last backup exceeded minimum time between backups. Queueing a " + 4394 "backup via idleDispatch." 4395 ); 4396 4397 // Just because the user hasn't sent us events in a while doesn't mean 4398 // that the browser itself isn't busy. It might be, for example, playing 4399 // video or doing a complex calculation that the user is actively 4400 // waiting to complete, and we don't want to draw resources from that. 4401 // Instead, we'll use ChromeUtils.idleDispatch to wait until the event 4402 // loop in the parent process isn't so busy with higher priority things. 4403 let expectedBackupTime = 4404 lastBackupDate + lazy.minimumTimeBetweenBackupsSeconds; 4405 try { 4406 await this.createBackupOnIdleDispatch({ 4407 reason: 4408 expectedBackupTime < this._startupTimeUnixSeconds 4409 ? "missed" 4410 : "idle", 4411 }); 4412 } catch (e) { 4413 lazy.logConsole.error( 4414 "createBackupOnIdleDispatch promise rejected", 4415 e 4416 ); 4417 } 4418 } else { 4419 lazy.logConsole.debug( 4420 "Last backup was too recent. Not creating one for now." 4421 ); 4422 } 4423 } 4424 } 4425 4426 /** 4427 * Gets the time that Firefox started as milliseconds since the Unix epoch. 4428 * 4429 * This is in a getter to make it easier for tests to stub it out. 4430 */ 4431 get _startupTimeUnixSeconds() { 4432 let startupTimeMs = Services.startup.getStartupInfo().process.getTime(); 4433 return Math.floor(startupTimeMs / 1000); 4434 } 4435 4436 /** 4437 * Decide whether we should attempt a backup now. 4438 * 4439 * @returns {boolean} 4440 */ 4441 shouldAttemptBackup() { 4442 let now = Math.floor(Date.now() / 1000); 4443 const debugInfoStr = Services.prefs.getStringPref( 4444 BACKUP_DEBUG_INFO_PREF_NAME, 4445 "" 4446 ); 4447 4448 let parsed = null; 4449 if (debugInfoStr) { 4450 try { 4451 parsed = JSON.parse(debugInfoStr); 4452 } catch (e) { 4453 lazy.logConsole.warn( 4454 "Invalid backup debug-info pref; ignoring and allowing backup attempt.", 4455 e 4456 ); 4457 parsed = null; 4458 } 4459 } 4460 4461 const lastBackupAttempt = parsed?.lastBackupAttempt; 4462 const hasErroredLastAttempt = Number.isFinite(lastBackupAttempt); 4463 4464 if (!hasErroredLastAttempt) { 4465 lazy.logConsole.debug( 4466 `There have been no errored last attempts, let's do a backup` 4467 ); 4468 return true; 4469 } 4470 4471 const secondsSinceLastAttempt = now - lastBackupAttempt; 4472 4473 if (lazy.isRetryDisabledOnIdle) { 4474 // Let's add a buffer before restarting the retries. Dividing by 2 4475 // since currently minimumTimeBetweenBackupsSeconds is set to 24 hours 4476 // We want to approximately keep a backup for each day, so let's retry 4477 // in about 12 hours again. 4478 if (secondsSinceLastAttempt < lazy.minimumTimeBetweenBackupsSeconds / 2) { 4479 lazy.logConsole.debug( 4480 `Retrying is disabled, we have to wait for ${lazy.minimumTimeBetweenBackupsSeconds / 2}s to retry` 4481 ); 4482 return false; 4483 } 4484 // Looks like we've waited enough, reset the retry states and try to create 4485 // a backup again. 4486 BackupService.#errorRetries = 0; 4487 Services.prefs.clearUserPref(DISABLED_ON_IDLE_RETRY_PREF_NAME); 4488 4489 return true; 4490 } 4491 4492 // Exponential backoff guard, avoids throttling the same error again and again 4493 if (secondsSinceLastAttempt < BackupService.backoffSeconds()) { 4494 lazy.logConsole.debug( 4495 `backoff: elapsed ${secondsSinceLastAttempt}s < backoff ${BackupService.backoffSeconds()}s` 4496 ); 4497 return false; 4498 } 4499 4500 return true; 4501 } 4502 4503 /** 4504 * Calls BackupService.createBackup at the next moment when the event queue 4505 * is not busy with higher priority events. This is intentionally broken out 4506 * into its own method to make it easier to stub out in tests. 4507 * 4508 * @param {object} [options] 4509 * @param {boolean} [options.deletePreviousBackup] 4510 * @param {string} [options.reason] 4511 * 4512 * @returns {Promise} A backup promise to hold onto 4513 */ 4514 createBackupOnIdleDispatch({ deletePreviousBackup = true, reason }) { 4515 if (!this.shouldAttemptBackup()) { 4516 return Promise.resolve(); 4517 } 4518 4519 // Determine path to old backup file 4520 const oldBackupFile = this.#_state.lastBackupFileName; 4521 const isScheduledBackupsEnabled = lazy.scheduledBackupsPref; 4522 4523 let { backupPromise, resolve } = Promise.withResolvers(); 4524 ChromeUtils.idleDispatch(async () => { 4525 lazy.logConsole.debug( 4526 "idleDispatch fired. Attempting to create a backup." 4527 ); 4528 let oldBackupFilePath; 4529 if (await this.#infalliblePathExists(lazy.backupDirPref)) { 4530 oldBackupFilePath = PathUtils.join(lazy.backupDirPref, oldBackupFile); 4531 } 4532 4533 try { 4534 if (isScheduledBackupsEnabled) { 4535 await this.createBackup({ reason }); 4536 } 4537 } catch (e) { 4538 lazy.logConsole.debug( 4539 `There was an error creating backup on idle dispatch: ${e}` 4540 ); 4541 4542 BackupService.#errorRetries += 1; 4543 if (BackupService.#errorRetries > lazy.backupRetryLimit) { 4544 Services.prefs.setBoolPref(DISABLED_ON_IDLE_RETRY_PREF_NAME, true); 4545 // Next retry will be 24 hours later (backoffSeconds = 2^(11) * 60s), 4546 // let's just restart our backoff heuristic 4547 BackupService.#errorRetries = 0; 4548 Glean.browserBackup.backupThrottled.record(); 4549 } 4550 } finally { 4551 // Now delete the old backup file, if it exists 4552 if (deletePreviousBackup && oldBackupFilePath) { 4553 lazy.logConsole.log( 4554 "Attempting to delete last backup file at ", 4555 oldBackupFilePath 4556 ); 4557 await IOUtils.remove(oldBackupFilePath, { 4558 ignoreAbsent: true, 4559 retryReadonly: true, 4560 }); 4561 resolve(); 4562 } 4563 } 4564 }); 4565 return backupPromise; 4566 } 4567 4568 /** 4569 * Handler for events coming in through our PlacesObserver. 4570 * 4571 * @param {PlacesEvent[]} placesEvents 4572 * One or more batched events that are of a type that we subscribed to. 4573 */ 4574 onPlacesEvents(placesEvents) { 4575 // Note that if any of the events that we iterate result in a regeneration 4576 // being queued, we simply return without the processing the rest, as there 4577 // is not really a point. 4578 for (let event of placesEvents) { 4579 switch (event.type) { 4580 case "page-removed": { 4581 // We will get a page-removed event if a page has been deleted both 4582 // manually by a user, but also automatically if the page has "aged 4583 // out" of the Places database. We only want to regenerate backups 4584 // in the manual case (REASON_DELETED). 4585 if (event.reason == PlacesVisitRemoved.REASON_DELETED) { 4586 this.#debounceRegeneration(); 4587 return; 4588 } 4589 break; 4590 } 4591 case "bookmark-removed": 4592 // Intentional fall-through 4593 case "history-cleared": { 4594 this.#debounceRegeneration(); 4595 return; 4596 } 4597 } 4598 } 4599 } 4600 4601 /** 4602 * This method is the only method of the AddonListener interface that 4603 * BackupService implements and is called by AddonManager when an addon 4604 * is uninstalled. 4605 * 4606 * @param {AddonInternal} _addon 4607 * The addon being uninstalled. 4608 */ 4609 onUninstalled(_addon) { 4610 this.#debounceRegeneration(); 4611 } 4612 4613 /** 4614 * Gets a sample from a given backup file and sets a subset of that as 4615 * the backupFileInfo in the backup service state. 4616 * 4617 * Called when getting a info for an archive to potentially restore. 4618 * 4619 * @param {string} backupFilePath path to the backup file to sample. 4620 */ 4621 async getBackupFileInfo(backupFilePath) { 4622 lazy.logConsole.debug(`Getting info from backup file at ${backupFilePath}`); 4623 4624 this.#_state.restoreID = Services.uuid.generateUUID().toString(); 4625 this.#_state.backupFileInfo = null; 4626 this.#_state.backupFileToRestore = backupFilePath; 4627 this.#_state.backupFileCoarseLocation = 4628 this.classifyLocationForTelemetry(backupFilePath); 4629 4630 try { 4631 let { archiveJSON, isEncrypted } = 4632 await this.sampleArchive(backupFilePath); 4633 this.#_state.backupFileInfo = { 4634 isEncrypted, 4635 date: archiveJSON?.meta?.date, 4636 deviceName: archiveJSON?.meta?.deviceName, 4637 appName: archiveJSON?.meta?.appName, 4638 appVersion: archiveJSON?.meta?.appVersion, 4639 buildID: archiveJSON?.meta?.buildID, 4640 osName: archiveJSON?.meta?.osName, 4641 osVersion: archiveJSON?.meta?.osVersion, 4642 healthTelemetryEnabled: archiveJSON?.meta?.healthTelemetryEnabled, 4643 legacyClientID: archiveJSON?.meta?.legacyClientID, 4644 }; 4645 4646 // Clear any existing recovery error from state since we've successfully 4647 // got our file info. Make sure to do this last, since it will cause 4648 // state change observers to fire. 4649 this.setRecoveryError(ERRORS.NONE); 4650 } catch (error) { 4651 // Nullify the file info when we catch errors that indicate the file is invalid 4652 this.#_state.backupFileInfo = null; 4653 this.#_state.backupFileToRestore = null; 4654 4655 // Notify observers of the error last, after we have set the state. 4656 this.setRecoveryError(error.cause); 4657 } 4658 } 4659 4660 /** 4661 * TEST ONLY: reset's lastBackup state's for testing purposes 4662 */ 4663 resetLastBackupInternalState() { 4664 this.#_state.backupFileToRestore = null; 4665 this.#_state.lastBackupFileName = ""; 4666 this.#_state.lastBackupDate = null; 4667 this.stateUpdate(); 4668 } 4669 4670 /** 4671 * TEST ONLY: reset's the defaultParent state for testing purposes 4672 */ 4673 resetDefaultParentInternalState() { 4674 this.#_state.defaultParent = {}; 4675 this.stateUpdate(); 4676 } 4677 4678 /* 4679 * Attempts to open a native file explorer window at the last backup file's 4680 * location on the filesystem. 4681 */ 4682 async showBackupLocation() { 4683 let backupFilePath = PathUtils.join( 4684 lazy.backupDirPref, 4685 lazy.lastBackupFileName 4686 ); 4687 if (await IOUtils.exists(backupFilePath)) { 4688 new lazy.nsLocalFile(backupFilePath).reveal(); 4689 } else { 4690 let archiveDestFolderPath = await this.resolveArchiveDestFolderPath( 4691 lazy.backupDirPref 4692 ); 4693 new lazy.nsLocalFile(archiveDestFolderPath).reveal(); 4694 } 4695 } 4696 4697 /** 4698 * Searches for a valid backup file in the default backup folder. 4699 * 4700 * This function checks the possible backup directory's for `.html` backup files. 4701 * If multiple backups are present and `multipleFiles` is false, it will not select one. 4702 * Optionally validates each candidate file before selecting it. 4703 * 4704 * @param {object} [options={}] - Configuration options. 4705 * @param {boolean} [options.validateFile=true] - Whether to validate each backup file before selecting it. 4706 * @param {boolean} [options.multipleFiles=false] - Whether to allow selecting when multiple backup files are found. 4707 * @param {boolean} [options.speedUpHeuristic=false] - Whether we want to avoid performance bottlenecks in exchange for 4708 * possibly missing valid files. 4709 * 4710 * @returns {Promise<object>} A result object with the following properties: 4711 * - {boolean} multipleBackupsFound — True if more than one backup candidate was found and `multipleFiles` is false. 4712 */ 4713 async findIfABackupFileExists({ 4714 validateFile = true, 4715 multipleFiles = false, 4716 speedUpHeuristic = false, 4717 } = {}) { 4718 // Do we already have a backup for this browser? if so, we don't need to do any searching! 4719 if (lazy.lastBackupFileName) { 4720 return { 4721 found: true, 4722 multipleBackupsFound: false, 4723 }; 4724 } 4725 4726 try { 4727 // During the first startup, the browser's backup location is often left 4728 // unconfigured; therefore, it defaults to predefined locations to look 4729 // for existing backup files. 4730 let defaultPath = PathUtils.join( 4731 BackupService.DEFAULT_PARENT_DIR_PATH, 4732 BackupService.BACKUP_DIR_NAME 4733 ); 4734 let files = await IOUtils.getChildren( 4735 this.#_state.backupDirPath ? this.#_state.backupDirPath : defaultPath, 4736 { 4737 ignoreAbsent: true, 4738 } 4739 ); 4740 // filtering is an O(N) operation, we can return early if there's too many files 4741 // in this folder to filter to avoid a performance bottleneck 4742 if (speedUpHeuristic && files && files.length > 1000) { 4743 return { 4744 multipleBackupsFound: false, 4745 }; 4746 } 4747 4748 // The backup is always a html file and starts with "FirefoxBackup_" 4749 // disregard any other files in the folder 4750 let maybeBackupFiles = files.filter(f => { 4751 let name = PathUtils.filename(f); 4752 4753 // Note: The Firefox backup filename is localized (see BackupService.BACKUP_FILE_NAME). 4754 // For now, we use a hardcoded regex string directly for performance reasons. 4755 return /^FirefoxBackup_.*\.html$/.test(name); 4756 }); 4757 4758 // if we aren't validating files, and there's more than 1 html file, we decide 4759 // that there's no valid backup file found 4760 if (!multipleFiles && maybeBackupFiles.length > 1 && !validateFile) { 4761 return { multipleBackupsFound: true }; 4762 } 4763 4764 // Sort the files by the timestamp at the end of the filename, 4765 // so the newest valid file is selected as the file to restore 4766 if (multipleFiles && maybeBackupFiles.length > 1 && validateFile) { 4767 maybeBackupFiles.sort((a, b) => { 4768 let nameA = PathUtils.filename(a); 4769 let nameB = PathUtils.filename(b); 4770 const match = /_(\d{8}-\d{4})\.html$/; 4771 let timestampA = nameA.match(match)?.[1]; 4772 let timestampB = nameB.match(match)?.[1]; 4773 4774 // If either file doesn't match the expected pattern, maintain the original order 4775 if (!timestampA || !timestampB) { 4776 return 0; 4777 } 4778 4779 return timestampB.localeCompare(timestampA); 4780 }); 4781 } 4782 4783 for (const file of maybeBackupFiles) { 4784 if (validateFile) { 4785 try { 4786 await this.getBackupFileInfo(file); 4787 } catch (e) { 4788 lazy.logConsole.log( 4789 "Not a valid backup file in the default folder", 4790 file, 4791 e 4792 ); 4793 4794 // If this was previously selected but is no longer valid, unbind it 4795 if (this.#_state.backupFileToRestore === file) { 4796 this.#_state.backupFileToRestore = null; 4797 this.#_state.backupFileInfo = null; 4798 this.stateUpdate(); 4799 } 4800 4801 // let's move on to finding another file 4802 continue; 4803 } 4804 } 4805 4806 this.#_state.backupFileToRestore = file; 4807 this.stateUpdate(); 4808 4809 // In the case that multiple files were found, 4810 // but we also validated files to set the newest backup file as the file to restore, 4811 // we still want to return that multiple backups were found. 4812 if (multipleFiles && maybeBackupFiles.length > 1 && validateFile) { 4813 return { multipleBackupsFound: true }; 4814 } 4815 4816 // TODO: support multiple valid backups for different profiles. 4817 // Currently, we break out of the loop and select the first profile that works. 4818 // We want to eventually support showing multiple valid profiles to the user. 4819 return { multipleBackupsFound: false }; 4820 } 4821 } catch (e) { 4822 lazy.logConsole.error( 4823 "There was an error while looking for backups: ", 4824 e 4825 ); 4826 } 4827 4828 return { multipleBackupsFound: false }; 4829 } 4830 4831 /** 4832 * Searches for backup files in predefined "well-known" locations. 4833 * 4834 * This function wraps findIfABackupFileExists to present the result 4835 * in an object for processing in the frontend. 4836 * 4837 * Assumptions: 4838 * - Intended to be called before `about:welcome` opens. 4839 * - Clears any existing `lastBackupFileName` and `backupFileToRestore` 4840 * in the internal state prior to searching. 4841 * 4842 * @param {object} [options] - Configuration options. 4843 * @param {boolean} [options.validateFile=false] - Whether to validate each backup file 4844 * before selecting it. 4845 * @param {boolean} [options.multipleFiles=false] - Whether to allow selecting a file 4846 * when multiple files are found 4847 * 4848 * @returns {Promise<object>} A result object with the following properties: 4849 * - {boolean} found — Whether a backup file was found. 4850 * - {string|null} backupFileToRestore — Path or identifier of the backup file (if found). 4851 * - {boolean} multipleBackupsFound — Currently always `false`, reserved for future use. 4852 */ 4853 async findBackupsInWellKnownLocations({ 4854 validateFile = false, 4855 multipleFiles = false, 4856 } = {}) { 4857 this.#_state.backupFileToRestore = null; 4858 4859 let { multipleBackupsFound } = await this.findIfABackupFileExists({ 4860 validateFile, 4861 multipleFiles, 4862 }); 4863 4864 // if a valid backup file was found, backupFileToRestore should be set 4865 if (this.#_state.backupFileToRestore) { 4866 return { 4867 found: true, 4868 backupFileToRestore: this.#_state.backupFileToRestore, 4869 multipleBackupsFound, 4870 }; 4871 } 4872 return { found: false, backupFileToRestore: null, multipleBackupsFound }; 4873 } 4874 4875 /** 4876 * Shows a native folder picker to set the location to write the single-file 4877 * archive files. 4878 * 4879 * @param {ChromeWindow} window 4880 * The top-level browsing window to associate the file picker with. 4881 * @returns {Promise<undefined>} 4882 */ 4883 async editBackupLocation(window) { 4884 let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); 4885 let mode = Ci.nsIFilePicker.modeGetFolder; 4886 fp.init(window.browsingContext, "", mode); 4887 4888 let currentBackupDirPathParent = PathUtils.parent( 4889 this.#_state.backupDirPath 4890 ); 4891 if (await IOUtils.exists(currentBackupDirPathParent)) { 4892 fp.displayDirectory = await IOUtils.getDirectory( 4893 currentBackupDirPathParent 4894 ); 4895 } 4896 4897 let result = await new Promise(resolve => fp.open(resolve)); 4898 4899 if (result === Ci.nsIFilePicker.returnCancel) { 4900 return; 4901 } 4902 4903 let path = fp.file.path; 4904 4905 // If the same parent directory was chosen, this is a no-op. 4906 if ( 4907 PathUtils.join(path, BackupService.BACKUP_DIR_NAME) == lazy.backupDirPref 4908 ) { 4909 return; 4910 } 4911 4912 // If the location changed, delete the last backup there if one exists. 4913 try { 4914 await this.deleteLastBackup(); 4915 } catch { 4916 lazy.logConsole.error( 4917 "Error deleting last backup while editing the backup location." 4918 ); 4919 // Fall through so the new backup directory is set. 4920 } 4921 this.setParentDirPath(path); 4922 } 4923 4924 /** 4925 * Will attempt to delete the last created single-file archive if it exists. 4926 * Once done, this method will also check the parent folder to see if it's 4927 * empty. If so, then the folder is removed. 4928 * 4929 * @returns {Promise<undefined>} 4930 */ 4931 async deleteLastBackup() { 4932 if (!lazy.scheduledBackupsPref) { 4933 lazy.logConsole.debug( 4934 "Not deleting last backup, as scheduled backups are disabled." 4935 ); 4936 return undefined; 4937 } 4938 4939 return locks.request( 4940 BackupService.WRITE_BACKUP_LOCK_NAME, 4941 { signal: this.#backupWriteAbortController.signal }, 4942 async () => { 4943 if (lazy.lastBackupFileName) { 4944 if (await this.#infalliblePathExists(lazy.backupDirPref)) { 4945 let backupFilePath = PathUtils.join( 4946 lazy.backupDirPref, 4947 lazy.lastBackupFileName 4948 ); 4949 4950 lazy.logConsole.log( 4951 "Attempting to delete last backup file at ", 4952 backupFilePath 4953 ); 4954 await IOUtils.remove(backupFilePath, { 4955 ignoreAbsent: true, 4956 retryReadonly: true, 4957 }); 4958 } 4959 4960 Services.prefs.clearUserPref(LAST_BACKUP_FILE_NAME_PREF_NAME); 4961 } else { 4962 lazy.logConsole.log( 4963 "Not deleting last backup file, since none is known about." 4964 ); 4965 } 4966 4967 if (await this.#infalliblePathExists(lazy.backupDirPref)) { 4968 // See if there are any other files lingering around in the destination 4969 // folder. If not, delete that folder too. 4970 let children = await IOUtils.getChildren(lazy.backupDirPref); 4971 if (!children.length) { 4972 await IOUtils.remove(lazy.backupDirPref, { retryReadony: true }); 4973 } 4974 } 4975 } 4976 ); 4977 } 4978 4979 /** 4980 * Wraps an IOUtils.exists in a try/catch and returns true iff the passed 4981 * path actually exists on the file system. Returns false if the path doesn't 4982 * exist or is an invalid path. 4983 * 4984 * @param {string} path 4985 * The path to check for existence. 4986 * @returns {Promise<boolean>} 4987 */ 4988 async #infalliblePathExists(path) { 4989 let exists = false; 4990 try { 4991 exists = await IOUtils.exists(path); 4992 } catch (e) { 4993 lazy.logConsole.warn("Path failed existence check :", path); 4994 return false; 4995 } 4996 return exists; 4997 } 4998 }