diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/crashreporter/CrashSubmit.sys.mjs | 666 |
1 files changed, 666 insertions, 0 deletions
diff --git a/toolkit/crashreporter/CrashSubmit.sys.mjs b/toolkit/crashreporter/CrashSubmit.sys.mjs new file mode 100644 index 0000000000..28647c5a83 --- /dev/null +++ b/toolkit/crashreporter/CrashSubmit.sys.mjs @@ -0,0 +1,666 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs"; + +const SUCCESS = "success"; +const FAILED = "failed"; +const SUBMITTING = "submitting"; + +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SUBMISSION_REGEX = + /^bp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// TODO: this is still synchronous; need an async INI parser to make it async +function parseINIStrings(path) { + let file = new FileUtils.File(path); + let factory = Cc["@mozilla.org/xpcom/ini-parser-factory;1"].getService( + Ci.nsIINIParserFactory + ); + let parser = factory.createINIParser(file); + let obj = {}; + for (let key of parser.getKeys("Strings")) { + obj[key] = parser.getString("Strings", key); + } + return obj; +} + +// Since we're basically re-implementing (with async) part of the crashreporter +// client here, we'll just steal the strings we need from crashreporter.ini +async function getL10nStrings() { + let path = PathUtils.join( + Services.dirsvc.get("GreD", Ci.nsIFile).path, + "crashreporter.ini" + ); + let pathExists = await IOUtils.exists(path); + + if (!pathExists) { + // we if we're on a mac + let parentDir = PathUtils.parent(path); + path = PathUtils.join( + parentDir, + "MacOS", + "crashreporter.app", + "Contents", + "Resources", + "crashreporter.ini" + ); + + let pathExists = await IOUtils.exists(path); + + if (!pathExists) { + // This happens on Android where everything is in an APK. + // Android users can't see the contents of the submitted files + // anyway, so just hardcode some fallback strings. + return { + crashid: "Crash ID: %s", + reporturl: "You can view details of this crash at %s", + }; + } + } + + let crstrings = parseINIStrings(path); + let strings = { + crashid: crstrings.CrashID, + reporturl: crstrings.CrashDetailsURL, + }; + + path = PathUtils.join( + Services.dirsvc.get("XCurProcD", Ci.nsIFile).path, + "crashreporter-override.ini" + ); + pathExists = await IOUtils.exists(path); + + if (pathExists) { + crstrings = parseINIStrings(path); + + if ("CrashID" in crstrings) { + strings.crashid = crstrings.CrashID; + } + + if ("CrashDetailsURL" in crstrings) { + strings.reporturl = crstrings.CrashDetailsURL; + } + } + + return strings; +} + +function getDir(name) { + let uAppDataPath = Services.dirsvc.get("UAppData", Ci.nsIFile).path; + return PathUtils.join(uAppDataPath, "Crash Reports", name); +} + +async function writeFileAsync(dirName, fileName, data) { + let dirPath = getDir(dirName); + let filePath = PathUtils.join(dirPath, fileName); + await IOUtils.makeDirectory(dirPath, { permissions: 0o700 }); + await IOUtils.writeUTF8(filePath, data); +} + +function getPendingMinidump(id) { + let pendingDir = getDir("pending"); + + return [".dmp", ".extra", ".memory.json.gz"].map(suffix => { + return PathUtils.join(pendingDir, `${id}${suffix}`); + }); +} + +async function writeSubmittedReportAsync(crashID, viewURL) { + let strings = await getL10nStrings(); + let data = strings.crashid.replace("%s", crashID); + + if (viewURL) { + data += "\n" + strings.reporturl.replace("%s", viewURL); + } + + await writeFileAsync("submitted", `${crashID}.txt`, data); +} + +// the Submitter class represents an individual submission. +function Submitter(id, recordSubmission, noThrottle, extraExtraKeyVals) { + this.id = id; + this.recordSubmission = recordSubmission; + this.noThrottle = noThrottle; + this.additionalDumps = []; + this.extraKeyVals = extraExtraKeyVals; + // mimic deferred Promise behavior + this.submitStatusPromise = new Promise((resolve, reject) => { + this.resolveSubmitStatusPromise = resolve; + this.rejectSubmitStatusPromise = reject; + }); +} + +Submitter.prototype = { + submitSuccess: async function Submitter_submitSuccess(ret) { + // Write out the details file to submitted + await writeSubmittedReportAsync(ret.CrashID, ret.ViewURL); + + try { + let toDelete = [this.dump, this.extra]; + + if (this.memory) { + toDelete.push(this.memory); + } + + for (let entry of this.additionalDumps) { + toDelete.push(entry.dump); + } + + await Promise.all( + toDelete.map(path => { + return IOUtils.remove(path, { ignoreAbsent: true }); + }) + ); + } catch (ex) { + console.error(ex); + } + + this.notifyStatus(SUCCESS, ret); + this.cleanup(); + }, + + cleanup: function Submitter_cleanup() { + // drop some references just to be nice + this.iframe = null; + this.dump = null; + this.extra = null; + this.memory = null; + this.additionalDumps = null; + // remove this object from the list of active submissions + let idx = CrashSubmit._activeSubmissions.indexOf(this); + if (idx != -1) { + CrashSubmit._activeSubmissions.splice(idx, 1); + } + }, + + parseResponse: function Submitter_parseResponse(response) { + let parsedResponse = {}; + + for (let line of response.split("\n")) { + let data = line.split("="); + + if ( + (data.length == 2 && + data[0] == "CrashID" && + SUBMISSION_REGEX.test(data[1])) || + data[0] == "ViewURL" + ) { + parsedResponse[data[0]] = data[1]; + } + } + + return parsedResponse; + }, + + submitForm: function Submitter_submitForm() { + if (!("ServerURL" in this.extraKeyVals)) { + return false; + } + let serverURL = this.extraKeyVals.ServerURL; + delete this.extraKeyVals.ServerURL; + + // Override the submission URL from the environment + let envOverride = Services.env.get("MOZ_CRASHREPORTER_URL"); + if (envOverride != "") { + serverURL = envOverride; + } + + let xhr = new XMLHttpRequest(); + xhr.open("POST", serverURL, true); + + let formData = new FormData(); + + // tell the server not to throttle this if requested + this.extraKeyVals.Throttleable = this.noThrottle ? "0" : "1"; + + // add the data + let payload = Object.assign({}, this.extraKeyVals); + let json = new Blob([JSON.stringify(payload)], { + type: "application/json", + }); + formData.append("extra", json); + + // add the minidumps + let promises = [ + File.createFromFileName(this.dump, { + type: "application/octet-stream", + }).then(file => { + formData.append("upload_file_minidump", file); + }), + ]; + + if (this.memory) { + promises.push( + File.createFromFileName(this.memory, { + type: "application/gzip", + }).then(file => { + formData.append("memory_report", file); + }) + ); + } + + if (this.additionalDumps.length) { + let names = []; + for (let i of this.additionalDumps) { + names.push(i.name); + promises.push( + File.createFromFileName(i.dump, { + type: "application/octet-stream", + }).then(file => { + formData.append("upload_file_minidump_" + i.name, file); + }) + ); + } + } + + let manager = Services.crashmanager; + let submissionID = manager.generateSubmissionID(); + + xhr.addEventListener("readystatechange", evt => { + if (xhr.readyState == 4) { + let ret = + xhr.status === 200 ? this.parseResponse(xhr.responseText) : {}; + let submitted = !!ret.CrashID; + let p = Promise.resolve(); + + if (this.recordSubmission) { + let result = submitted + ? manager.SUBMISSION_RESULT_OK + : manager.SUBMISSION_RESULT_FAILED; + p = manager.addSubmissionResult( + this.id, + submissionID, + new Date(), + result + ); + if (submitted) { + manager.setRemoteCrashID(this.id, ret.CrashID); + } + } + + p.then(() => { + if (submitted) { + this.submitSuccess(ret); + } else { + this.notifyStatus(FAILED); + this.cleanup(); + } + }); + } + }); + + let p = Promise.all(promises); + let id = this.id; + + if (this.recordSubmission) { + p = p.then(() => { + return manager.addSubmissionAttempt(id, submissionID, new Date()); + }); + } + p.then(() => { + xhr.send(formData); + }); + return true; + }, + + notifyStatus: function Submitter_notify(status, ret) { + let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + propBag.setPropertyAsAString("minidumpID", this.id); + if (status == SUCCESS) { + propBag.setPropertyAsAString("serverCrashID", ret.CrashID); + } + + let extraKeyValsBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + for (let key in this.extraKeyVals) { + extraKeyValsBag.setPropertyAsAString(key, this.extraKeyVals[key]); + } + propBag.setPropertyAsInterface("extra", extraKeyValsBag); + + Services.obs.notifyObservers(propBag, "crash-report-status", status); + + switch (status) { + case SUCCESS: + this.resolveSubmitStatusPromise(ret.CrashID); + break; + case FAILED: + this.rejectSubmitStatusPromise(FAILED); + break; + default: + // no callbacks invoked. + } + }, + + readAnnotations: async function Submitter_readAnnotations(extra) { + // These annotations are used only by the crash reporter client and should + // not be submitted to Socorro. + const strippedAnnotations = [ + "StackTraces", + "TelemetryClientId", + "TelemetrySessionId", + "TelemetryServerURL", + ]; + let extraKeyVals = await IOUtils.readJSON(extra); + + this.extraKeyVals = { ...extraKeyVals, ...this.extraKeyVals }; + strippedAnnotations.forEach(key => delete this.extraKeyVals[key]); + }, + + submit: async function Submitter_submit() { + if (this.recordSubmission) { + await Services.crashmanager.ensureCrashIsPresent(this.id); + } + + let [dump, extra, memory] = getPendingMinidump(this.id); + let [dumpExists, extraExists, memoryExists] = await Promise.all([ + IOUtils.exists(dump), + IOUtils.exists(extra), + IOUtils.exists(memory), + ]); + + if (!dumpExists || !extraExists) { + this.notifyStatus(FAILED); + this.cleanup(); + return this.submitStatusPromise; + } + + this.dump = dump; + this.extra = extra; + this.memory = memoryExists ? memory : null; + await this.readAnnotations(extra); + + let additionalDumps = []; + + if ("additional_minidumps" in this.extraKeyVals) { + let dumpsExistsPromises = []; + let names = this.extraKeyVals.additional_minidumps.split(","); + + for (let name of names) { + let [dump /* , extra, memory */] = getPendingMinidump( + this.id + "-" + name + ); + + dumpsExistsPromises.push(IOUtils.exists(dump)); + additionalDumps.push({ name, dump }); + } + + let dumpsExist = await Promise.all(dumpsExistsPromises); + let allDumpsExist = dumpsExist.every(exists => exists); + + if (!allDumpsExist) { + this.notifyStatus(FAILED); + this.cleanup(); + return this.submitStatusPromise; + } + } + + this.notifyStatus(SUBMITTING); + this.additionalDumps = additionalDumps; + + if (!(await this.submitForm())) { + this.notifyStatus(FAILED); + this.cleanup(); + } + + return this.submitStatusPromise; + }, +}; + +// =================================== +// External API goes here +export var CrashSubmit = { + // A set of strings representing how a user subnmitted a given crash + SUBMITTED_FROM_AUTO: "Auto", + SUBMITTED_FROM_INFOBAR: "Infobar", + SUBMITTED_FROM_ABOUT_CRASHES: "AboutCrashes", + SUBMITTED_FROM_CRASH_TAB: "CrashedTab", + + /** + * Submit the crash report named id.dmp from the "pending" directory. + * + * @param id + * Filename (minus .dmp extension) of the minidump to submit. + * @param submittedFrom + * One of the SUBMITTED_FROM_* constants representing how the + * user submitted this crash. + * @param params + * An object containing any of the following optional parameters: + * - recordSubmission + * If true, a submission event is recorded in CrashManager. + * - noThrottle + * If true, this crash report should be submitted with + * the Throttleable annotation set to "0" indicating that + * it should be processed right away. This should be set + * when the report is being submitted and the user expects + * to see the results immediately. Defaults to false. + * - extraExtraKeyVals + * An object whose key-value pairs will be merged with the data from + * the ".extra" file submitted with the report. The properties of + * this object will override properties of the same name in the + * .extra file. + * + * @return a Promise that is fulfilled with the server crash ID when the + * submission succeeds and rejected otherwise. + */ + submit: function CrashSubmit_submit(id, submittedFrom, params) { + params = params || {}; + let recordSubmission = false; + let noThrottle = false; + let extraExtraKeyVals = {}; + + if ("recordSubmission" in params) { + recordSubmission = params.recordSubmission; + } + + if ("noThrottle" in params) { + noThrottle = params.noThrottle; + } + + if ("extraExtraKeyVals" in params) { + extraExtraKeyVals = params.extraExtraKeyVals; + } + + extraExtraKeyVals.SubmittedFrom = submittedFrom; + + let submitter = new Submitter( + id, + recordSubmission, + noThrottle, + extraExtraKeyVals + ); + CrashSubmit._activeSubmissions.push(submitter); + return submitter.submit(); + }, + + /** + * Delete the minidup from the "pending" directory. + * + * @param id + * Filename (minus .dmp extension) of the minidump to delete. + * + * @return a Promise that is fulfilled when the minidump is deleted and + * rejected otherwise + */ + delete: async function CrashSubmit_delete(id) { + await Promise.all( + getPendingMinidump(id).map(path => { + return IOUtils.remove(path); + }) + ); + }, + + /** + * Add a .dmg.ignore file along side the .dmp file to indicate that the user + * shouldn't be prompted to submit this crash report again. + * + * @param id + * Filename (minus .dmp extension) of the report to ignore + * + * @return a Promise that is fulfilled when (if) the .dmg.ignore is created + * and rejected otherwise. + */ + ignore: async function CrashSubmit_ignore(id) { + let [dump /* , extra, memory */] = getPendingMinidump(id); + const ignorePath = `${dump}.ignore`; + await IOUtils.writeUTF8(ignorePath, "", { mode: "create" }); + }, + + /** + * Get the list of pending crash IDs, excluding those marked to be ignored + * @param minFileDate + * A Date object. Any files last modified before that date will be ignored + * + * @return a Promise that is fulfilled with an array of string, each + * being an ID as expected to be passed to submit() or ignore() + */ + pendingIDs: async function CrashSubmit_pendingIDs(minFileDate) { + let ids = []; + let pendingDir = getDir("pending"); + + if (!(await IOUtils.exists(pendingDir))) { + return ids; + } + + let children; + try { + children = await IOUtils.getChildren(pendingDir); + } catch (ex) { + console.error(ex); + throw ex; + } + + try { + const entries = Object.create(null); + const ignored = Object.create(null); + + for (const child of children) { + const info = await IOUtils.stat(child); + + if (info.type !== "directory") { + const name = PathUtils.filename(child); + const matches = name.match(/(.+)\.dmp$/); + if (matches) { + const id = matches[1]; + + if (UUID_REGEX.test(id)) { + entries[id] = info; + } + } else { + // maybe it's a .ignore file + const matchesIgnore = name.match(/(.+)\.dmp.ignore$/); + if (matchesIgnore) { + const id = matchesIgnore[1]; + + if (UUID_REGEX.test(id)) { + ignored[id] = true; + } + } + } + } + } + + for (const [id, info] of Object.entries(entries)) { + const accessDate = new Date(info.lastAccessed); + if (!(id in ignored) && accessDate > minFileDate) { + ids.push(id); + } + } + } catch (ex) { + console.error(ex); + throw ex; + } + + return ids; + }, + + /** + * Prune the saved dumps. + * + * @return a Promise that is fulfilled when the daved dumps are deleted and + * rejected otherwise + */ + pruneSavedDumps: async function CrashSubmit_pruneSavedDumps() { + const KEEP = 10; + + let dirEntries = []; + let pendingDir = getDir("pending"); + + let children; + try { + children = await IOUtils.getChildren(pendingDir); + } catch (ex) { + if (DOMException.isInstance(ex) && ex.name === "NotFoundError") { + return []; + } + + throw ex; + } + + for (const path of children) { + let infoPromise; + try { + infoPromise = IOUtils.stat(path); + } catch (ex) { + console.error(ex); + throw ex; + } + + const name = PathUtils.filename(path); + + if (name.match(/(.+)\.extra$/)) { + dirEntries.push({ + name, + path, + infoPromise, + }); + } + } + + dirEntries.sort(async (a, b) => { + let dateA = (await a.infoPromise).lastModified; + let dateB = (await b.infoPromise).lastModified; + + if (dateA < dateB) { + return -1; + } + + if (dateB < dateA) { + return 1; + } + + return 0; + }); + + if (dirEntries.length > KEEP) { + let toDelete = []; + + for (let i = 0; i < dirEntries.length - KEEP; ++i) { + let extra = dirEntries[i]; + let matches = extra.leafName.match(/(.+)\.extra$/); + + if (matches) { + let pathComponents = PathUtils.split(extra.path); + pathComponents[pathComponents.length - 1] = matches[1]; + let path = PathUtils.join(...pathComponents); + + toDelete.push(extra.path, `${path}.dmp`, `${path}.memory.json.gz`); + } + } + + await Promise.all( + toDelete.map(path => { + return IOUtils.remove(path, { ignoreAbsent: true }); + }) + ); + } + }, + + // List of currently active submit objects + _activeSubmissions: [], +}; |