Bug 1852754: part 9) Add tests for dynamically loading <link rel="prefetch"> elements...
[gecko.git] / toolkit / crashreporter / CrashSubmit.sys.mjs
blob28647c5a83ca47955ee18d70f908ac62fcac96f0
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/. */
5 import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
7 const SUCCESS = "success";
8 const FAILED = "failed";
9 const SUBMITTING = "submitting";
11 const UUID_REGEX =
12   /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13 const SUBMISSION_REGEX =
14   /^bp-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16 // TODO: this is still synchronous; need an async INI parser to make it async
17 function parseINIStrings(path) {
18   let file = new FileUtils.File(path);
19   let factory = Cc["@mozilla.org/xpcom/ini-parser-factory;1"].getService(
20     Ci.nsIINIParserFactory
21   );
22   let parser = factory.createINIParser(file);
23   let obj = {};
24   for (let key of parser.getKeys("Strings")) {
25     obj[key] = parser.getString("Strings", key);
26   }
27   return obj;
30 // Since we're basically re-implementing (with async) part of the crashreporter
31 // client here, we'll just steal the strings we need from crashreporter.ini
32 async function getL10nStrings() {
33   let path = PathUtils.join(
34     Services.dirsvc.get("GreD", Ci.nsIFile).path,
35     "crashreporter.ini"
36   );
37   let pathExists = await IOUtils.exists(path);
39   if (!pathExists) {
40     // we if we're on a mac
41     let parentDir = PathUtils.parent(path);
42     path = PathUtils.join(
43       parentDir,
44       "MacOS",
45       "crashreporter.app",
46       "Contents",
47       "Resources",
48       "crashreporter.ini"
49     );
51     let pathExists = await IOUtils.exists(path);
53     if (!pathExists) {
54       // This happens on Android where everything is in an APK.
55       // Android users can't see the contents of the submitted files
56       // anyway, so just hardcode some fallback strings.
57       return {
58         crashid: "Crash ID: %s",
59         reporturl: "You can view details of this crash at %s",
60       };
61     }
62   }
64   let crstrings = parseINIStrings(path);
65   let strings = {
66     crashid: crstrings.CrashID,
67     reporturl: crstrings.CrashDetailsURL,
68   };
70   path = PathUtils.join(
71     Services.dirsvc.get("XCurProcD", Ci.nsIFile).path,
72     "crashreporter-override.ini"
73   );
74   pathExists = await IOUtils.exists(path);
76   if (pathExists) {
77     crstrings = parseINIStrings(path);
79     if ("CrashID" in crstrings) {
80       strings.crashid = crstrings.CrashID;
81     }
83     if ("CrashDetailsURL" in crstrings) {
84       strings.reporturl = crstrings.CrashDetailsURL;
85     }
86   }
88   return strings;
91 function getDir(name) {
92   let uAppDataPath = Services.dirsvc.get("UAppData", Ci.nsIFile).path;
93   return PathUtils.join(uAppDataPath, "Crash Reports", name);
96 async function writeFileAsync(dirName, fileName, data) {
97   let dirPath = getDir(dirName);
98   let filePath = PathUtils.join(dirPath, fileName);
99   await IOUtils.makeDirectory(dirPath, { permissions: 0o700 });
100   await IOUtils.writeUTF8(filePath, data);
103 function getPendingMinidump(id) {
104   let pendingDir = getDir("pending");
106   return [".dmp", ".extra", ".memory.json.gz"].map(suffix => {
107     return PathUtils.join(pendingDir, `${id}${suffix}`);
108   });
111 async function writeSubmittedReportAsync(crashID, viewURL) {
112   let strings = await getL10nStrings();
113   let data = strings.crashid.replace("%s", crashID);
115   if (viewURL) {
116     data += "\n" + strings.reporturl.replace("%s", viewURL);
117   }
119   await writeFileAsync("submitted", `${crashID}.txt`, data);
122 // the Submitter class represents an individual submission.
123 function Submitter(id, recordSubmission, noThrottle, extraExtraKeyVals) {
124   this.id = id;
125   this.recordSubmission = recordSubmission;
126   this.noThrottle = noThrottle;
127   this.additionalDumps = [];
128   this.extraKeyVals = extraExtraKeyVals;
129   // mimic deferred Promise behavior
130   this.submitStatusPromise = new Promise((resolve, reject) => {
131     this.resolveSubmitStatusPromise = resolve;
132     this.rejectSubmitStatusPromise = reject;
133   });
136 Submitter.prototype = {
137   submitSuccess: async function Submitter_submitSuccess(ret) {
138     // Write out the details file to submitted
139     await writeSubmittedReportAsync(ret.CrashID, ret.ViewURL);
141     try {
142       let toDelete = [this.dump, this.extra];
144       if (this.memory) {
145         toDelete.push(this.memory);
146       }
148       for (let entry of this.additionalDumps) {
149         toDelete.push(entry.dump);
150       }
152       await Promise.all(
153         toDelete.map(path => {
154           return IOUtils.remove(path, { ignoreAbsent: true });
155         })
156       );
157     } catch (ex) {
158       console.error(ex);
159     }
161     this.notifyStatus(SUCCESS, ret);
162     this.cleanup();
163   },
165   cleanup: function Submitter_cleanup() {
166     // drop some references just to be nice
167     this.iframe = null;
168     this.dump = null;
169     this.extra = null;
170     this.memory = null;
171     this.additionalDumps = null;
172     // remove this object from the list of active submissions
173     let idx = CrashSubmit._activeSubmissions.indexOf(this);
174     if (idx != -1) {
175       CrashSubmit._activeSubmissions.splice(idx, 1);
176     }
177   },
179   parseResponse: function Submitter_parseResponse(response) {
180     let parsedResponse = {};
182     for (let line of response.split("\n")) {
183       let data = line.split("=");
185       if (
186         (data.length == 2 &&
187           data[0] == "CrashID" &&
188           SUBMISSION_REGEX.test(data[1])) ||
189         data[0] == "ViewURL"
190       ) {
191         parsedResponse[data[0]] = data[1];
192       }
193     }
195     return parsedResponse;
196   },
198   submitForm: function Submitter_submitForm() {
199     if (!("ServerURL" in this.extraKeyVals)) {
200       return false;
201     }
202     let serverURL = this.extraKeyVals.ServerURL;
203     delete this.extraKeyVals.ServerURL;
205     // Override the submission URL from the environment
206     let envOverride = Services.env.get("MOZ_CRASHREPORTER_URL");
207     if (envOverride != "") {
208       serverURL = envOverride;
209     }
211     let xhr = new XMLHttpRequest();
212     xhr.open("POST", serverURL, true);
214     let formData = new FormData();
216     // tell the server not to throttle this if requested
217     this.extraKeyVals.Throttleable = this.noThrottle ? "0" : "1";
219     // add the data
220     let payload = Object.assign({}, this.extraKeyVals);
221     let json = new Blob([JSON.stringify(payload)], {
222       type: "application/json",
223     });
224     formData.append("extra", json);
226     // add the minidumps
227     let promises = [
228       File.createFromFileName(this.dump, {
229         type: "application/octet-stream",
230       }).then(file => {
231         formData.append("upload_file_minidump", file);
232       }),
233     ];
235     if (this.memory) {
236       promises.push(
237         File.createFromFileName(this.memory, {
238           type: "application/gzip",
239         }).then(file => {
240           formData.append("memory_report", file);
241         })
242       );
243     }
245     if (this.additionalDumps.length) {
246       let names = [];
247       for (let i of this.additionalDumps) {
248         names.push(i.name);
249         promises.push(
250           File.createFromFileName(i.dump, {
251             type: "application/octet-stream",
252           }).then(file => {
253             formData.append("upload_file_minidump_" + i.name, file);
254           })
255         );
256       }
257     }
259     let manager = Services.crashmanager;
260     let submissionID = manager.generateSubmissionID();
262     xhr.addEventListener("readystatechange", evt => {
263       if (xhr.readyState == 4) {
264         let ret =
265           xhr.status === 200 ? this.parseResponse(xhr.responseText) : {};
266         let submitted = !!ret.CrashID;
267         let p = Promise.resolve();
269         if (this.recordSubmission) {
270           let result = submitted
271             ? manager.SUBMISSION_RESULT_OK
272             : manager.SUBMISSION_RESULT_FAILED;
273           p = manager.addSubmissionResult(
274             this.id,
275             submissionID,
276             new Date(),
277             result
278           );
279           if (submitted) {
280             manager.setRemoteCrashID(this.id, ret.CrashID);
281           }
282         }
284         p.then(() => {
285           if (submitted) {
286             this.submitSuccess(ret);
287           } else {
288             this.notifyStatus(FAILED);
289             this.cleanup();
290           }
291         });
292       }
293     });
295     let p = Promise.all(promises);
296     let id = this.id;
298     if (this.recordSubmission) {
299       p = p.then(() => {
300         return manager.addSubmissionAttempt(id, submissionID, new Date());
301       });
302     }
303     p.then(() => {
304       xhr.send(formData);
305     });
306     return true;
307   },
309   notifyStatus: function Submitter_notify(status, ret) {
310     let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
311       Ci.nsIWritablePropertyBag2
312     );
313     propBag.setPropertyAsAString("minidumpID", this.id);
314     if (status == SUCCESS) {
315       propBag.setPropertyAsAString("serverCrashID", ret.CrashID);
316     }
318     let extraKeyValsBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
319       Ci.nsIWritablePropertyBag2
320     );
321     for (let key in this.extraKeyVals) {
322       extraKeyValsBag.setPropertyAsAString(key, this.extraKeyVals[key]);
323     }
324     propBag.setPropertyAsInterface("extra", extraKeyValsBag);
326     Services.obs.notifyObservers(propBag, "crash-report-status", status);
328     switch (status) {
329       case SUCCESS:
330         this.resolveSubmitStatusPromise(ret.CrashID);
331         break;
332       case FAILED:
333         this.rejectSubmitStatusPromise(FAILED);
334         break;
335       default:
336       // no callbacks invoked.
337     }
338   },
340   readAnnotations: async function Submitter_readAnnotations(extra) {
341     // These annotations are used only by the crash reporter client and should
342     // not be submitted to Socorro.
343     const strippedAnnotations = [
344       "StackTraces",
345       "TelemetryClientId",
346       "TelemetrySessionId",
347       "TelemetryServerURL",
348     ];
349     let extraKeyVals = await IOUtils.readJSON(extra);
351     this.extraKeyVals = { ...extraKeyVals, ...this.extraKeyVals };
352     strippedAnnotations.forEach(key => delete this.extraKeyVals[key]);
353   },
355   submit: async function Submitter_submit() {
356     if (this.recordSubmission) {
357       await Services.crashmanager.ensureCrashIsPresent(this.id);
358     }
360     let [dump, extra, memory] = getPendingMinidump(this.id);
361     let [dumpExists, extraExists, memoryExists] = await Promise.all([
362       IOUtils.exists(dump),
363       IOUtils.exists(extra),
364       IOUtils.exists(memory),
365     ]);
367     if (!dumpExists || !extraExists) {
368       this.notifyStatus(FAILED);
369       this.cleanup();
370       return this.submitStatusPromise;
371     }
373     this.dump = dump;
374     this.extra = extra;
375     this.memory = memoryExists ? memory : null;
376     await this.readAnnotations(extra);
378     let additionalDumps = [];
380     if ("additional_minidumps" in this.extraKeyVals) {
381       let dumpsExistsPromises = [];
382       let names = this.extraKeyVals.additional_minidumps.split(",");
384       for (let name of names) {
385         let [dump /* , extra, memory */] = getPendingMinidump(
386           this.id + "-" + name
387         );
389         dumpsExistsPromises.push(IOUtils.exists(dump));
390         additionalDumps.push({ name, dump });
391       }
393       let dumpsExist = await Promise.all(dumpsExistsPromises);
394       let allDumpsExist = dumpsExist.every(exists => exists);
396       if (!allDumpsExist) {
397         this.notifyStatus(FAILED);
398         this.cleanup();
399         return this.submitStatusPromise;
400       }
401     }
403     this.notifyStatus(SUBMITTING);
404     this.additionalDumps = additionalDumps;
406     if (!(await this.submitForm())) {
407       this.notifyStatus(FAILED);
408       this.cleanup();
409     }
411     return this.submitStatusPromise;
412   },
415 // ===================================
416 // External API goes here
417 export var CrashSubmit = {
418   // A set of strings representing how a user subnmitted a given crash
419   SUBMITTED_FROM_AUTO: "Auto",
420   SUBMITTED_FROM_INFOBAR: "Infobar",
421   SUBMITTED_FROM_ABOUT_CRASHES: "AboutCrashes",
422   SUBMITTED_FROM_CRASH_TAB: "CrashedTab",
424   /**
425    * Submit the crash report named id.dmp from the "pending" directory.
426    *
427    * @param id
428    *        Filename (minus .dmp extension) of the minidump to submit.
429    * @param submittedFrom
430    *        One of the SUBMITTED_FROM_* constants representing how the
431    *        user submitted this crash.
432    * @param params
433    *        An object containing any of the following optional parameters:
434    *        - recordSubmission
435    *          If true, a submission event is recorded in CrashManager.
436    *        - noThrottle
437    *          If true, this crash report should be submitted with
438    *          the Throttleable annotation set to "0" indicating that
439    *          it should be processed right away. This should be set
440    *          when the report is being submitted and the user expects
441    *          to see the results immediately. Defaults to false.
442    *        - extraExtraKeyVals
443    *          An object whose key-value pairs will be merged with the data from
444    *          the ".extra" file submitted with the report.  The properties of
445    *          this object will override properties of the same name in the
446    *          .extra file.
447    *
448    *  @return a Promise that is fulfilled with the server crash ID when the
449    *          submission succeeds and rejected otherwise.
450    */
451   submit: function CrashSubmit_submit(id, submittedFrom, params) {
452     params = params || {};
453     let recordSubmission = false;
454     let noThrottle = false;
455     let extraExtraKeyVals = {};
457     if ("recordSubmission" in params) {
458       recordSubmission = params.recordSubmission;
459     }
461     if ("noThrottle" in params) {
462       noThrottle = params.noThrottle;
463     }
465     if ("extraExtraKeyVals" in params) {
466       extraExtraKeyVals = params.extraExtraKeyVals;
467     }
469     extraExtraKeyVals.SubmittedFrom = submittedFrom;
471     let submitter = new Submitter(
472       id,
473       recordSubmission,
474       noThrottle,
475       extraExtraKeyVals
476     );
477     CrashSubmit._activeSubmissions.push(submitter);
478     return submitter.submit();
479   },
481   /**
482    * Delete the minidup from the "pending" directory.
483    *
484    * @param id
485    *        Filename (minus .dmp extension) of the minidump to delete.
486    *
487    * @return a Promise that is fulfilled when the minidump is deleted and
488    *         rejected otherwise
489    */
490   delete: async function CrashSubmit_delete(id) {
491     await Promise.all(
492       getPendingMinidump(id).map(path => {
493         return IOUtils.remove(path);
494       })
495     );
496   },
498   /**
499    * Add a .dmg.ignore file along side the .dmp file to indicate that the user
500    * shouldn't be prompted to submit this crash report again.
501    *
502    * @param id
503    *        Filename (minus .dmp extension) of the report to ignore
504    *
505    * @return a Promise that is fulfilled when (if) the .dmg.ignore is created
506    *         and rejected otherwise.
507    */
508   ignore: async function CrashSubmit_ignore(id) {
509     let [dump /* , extra, memory */] = getPendingMinidump(id);
510     const ignorePath = `${dump}.ignore`;
511     await IOUtils.writeUTF8(ignorePath, "", { mode: "create" });
512   },
514   /**
515    * Get the list of pending crash IDs, excluding those marked to be ignored
516    * @param minFileDate
517    *     A Date object. Any files last modified before that date will be ignored
518    *
519    * @return a Promise that is fulfilled with an array of string, each
520    *         being an ID as expected to be passed to submit() or ignore()
521    */
522   pendingIDs: async function CrashSubmit_pendingIDs(minFileDate) {
523     let ids = [];
524     let pendingDir = getDir("pending");
526     if (!(await IOUtils.exists(pendingDir))) {
527       return ids;
528     }
530     let children;
531     try {
532       children = await IOUtils.getChildren(pendingDir);
533     } catch (ex) {
534       console.error(ex);
535       throw ex;
536     }
538     try {
539       const entries = Object.create(null);
540       const ignored = Object.create(null);
542       for (const child of children) {
543         const info = await IOUtils.stat(child);
545         if (info.type !== "directory") {
546           const name = PathUtils.filename(child);
547           const matches = name.match(/(.+)\.dmp$/);
548           if (matches) {
549             const id = matches[1];
551             if (UUID_REGEX.test(id)) {
552               entries[id] = info;
553             }
554           } else {
555             // maybe it's a .ignore file
556             const matchesIgnore = name.match(/(.+)\.dmp.ignore$/);
557             if (matchesIgnore) {
558               const id = matchesIgnore[1];
560               if (UUID_REGEX.test(id)) {
561                 ignored[id] = true;
562               }
563             }
564           }
565         }
566       }
568       for (const [id, info] of Object.entries(entries)) {
569         const accessDate = new Date(info.lastAccessed);
570         if (!(id in ignored) && accessDate > minFileDate) {
571           ids.push(id);
572         }
573       }
574     } catch (ex) {
575       console.error(ex);
576       throw ex;
577     }
579     return ids;
580   },
582   /**
583    * Prune the saved dumps.
584    *
585    * @return a Promise that is fulfilled when the daved dumps are deleted and
586    *         rejected otherwise
587    */
588   pruneSavedDumps: async function CrashSubmit_pruneSavedDumps() {
589     const KEEP = 10;
591     let dirEntries = [];
592     let pendingDir = getDir("pending");
594     let children;
595     try {
596       children = await IOUtils.getChildren(pendingDir);
597     } catch (ex) {
598       if (DOMException.isInstance(ex) && ex.name === "NotFoundError") {
599         return [];
600       }
602       throw ex;
603     }
605     for (const path of children) {
606       let infoPromise;
607       try {
608         infoPromise = IOUtils.stat(path);
609       } catch (ex) {
610         console.error(ex);
611         throw ex;
612       }
614       const name = PathUtils.filename(path);
616       if (name.match(/(.+)\.extra$/)) {
617         dirEntries.push({
618           name,
619           path,
620           infoPromise,
621         });
622       }
623     }
625     dirEntries.sort(async (a, b) => {
626       let dateA = (await a.infoPromise).lastModified;
627       let dateB = (await b.infoPromise).lastModified;
629       if (dateA < dateB) {
630         return -1;
631       }
633       if (dateB < dateA) {
634         return 1;
635       }
637       return 0;
638     });
640     if (dirEntries.length > KEEP) {
641       let toDelete = [];
643       for (let i = 0; i < dirEntries.length - KEEP; ++i) {
644         let extra = dirEntries[i];
645         let matches = extra.leafName.match(/(.+)\.extra$/);
647         if (matches) {
648           let pathComponents = PathUtils.split(extra.path);
649           pathComponents[pathComponents.length - 1] = matches[1];
650           let path = PathUtils.join(...pathComponents);
652           toDelete.push(extra.path, `${path}.dmp`, `${path}.memory.json.gz`);
653         }
654       }
656       await Promise.all(
657         toDelete.map(path => {
658           return IOUtils.remove(path, { ignoreAbsent: true });
659         })
660       );
661     }
662   },
664   // List of currently active submit objects
665   _activeSubmissions: [],