tor-browser

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

custom-formatters.js (16849B)


      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 "use strict";
      6 
      7 loader.lazyRequireGetter(
      8  this,
      9  "createValueGripForTarget",
     10  "resource://devtools/server/actors/object/utils.js",
     11  true
     12 );
     13 
     14 loader.lazyRequireGetter(
     15  this,
     16  "ObjectUtils",
     17  "resource://devtools/server/actors/object/utils.js"
     18 );
     19 
     20 const _invalidCustomFormatterHooks = new WeakSet();
     21 function addInvalidCustomFormatterHooks(hook) {
     22  if (!hook) {
     23    return;
     24  }
     25 
     26  try {
     27    _invalidCustomFormatterHooks.add(hook);
     28  } catch (e) {
     29    console.error("Couldn't add hook to the WeakSet", hook);
     30  }
     31 }
     32 
     33 // Custom exception used between customFormatterHeader and processFormatterForHeader
     34 class FormatterError extends Error {
     35  constructor(message, script) {
     36    super(message);
     37    this.script = script;
     38  }
     39 }
     40 
     41 /**
     42 * Handle a protocol request to get the custom formatter header for an object.
     43 * This is typically returned into ObjectActor's form if custom formatters are enabled.
     44 *
     45 * @param {ObjectActor} objectActor
     46 *
     47 * @returns {object} Data related to the custom formatter header:
     48 *          - {boolean} useCustomFormatter, indicating if a custom formatter is used.
     49 *          - {Array} header JsonML of the output header.
     50 *          - {boolean} hasBody True in case the custom formatter has a body.
     51 *          - {Object} formatter The devtoolsFormatters item that was being used to format
     52 *                               the object.
     53 */
     54 function customFormatterHeader(objectActor) {
     55  const { rawObj } = objectActor;
     56  const globalWrapper = Cu.getGlobalForObject(rawObj);
     57  const global = globalWrapper?.wrappedJSObject;
     58 
     59  // We expect a `devtoolsFormatters` global attribute and it to be an array
     60  if (!global || !Array.isArray(global.devtoolsFormatters)) {
     61    return null;
     62  }
     63 
     64  const customFormatterTooDeep =
     65    (objectActor.hooks.customFormatterObjectTagDepth || 0) > 20;
     66  if (customFormatterTooDeep) {
     67    logCustomFormatterError(
     68      globalWrapper,
     69      `Too deep hierarchy of inlined custom previews`
     70    );
     71    return null;
     72  }
     73 
     74  const { targetActor } = objectActor.threadActor;
     75 
     76  const {
     77    customFormatterConfigDbgObj: configDbgObj,
     78    customFormatterObjectTagDepth,
     79  } = objectActor.hooks;
     80 
     81  const valueDbgObj = objectActor.obj;
     82 
     83  for (const [
     84    customFormatterIndex,
     85    formatter,
     86  ] of global.devtoolsFormatters.entries()) {
     87    // If the message for the erroneous formatter already got logged,
     88    // skip logging it again.
     89    if (_invalidCustomFormatterHooks.has(formatter)) {
     90      continue;
     91    }
     92 
     93    // TODO: Any issues regarding the implementation will be covered in https://bugzil.la/1776611.
     94    try {
     95      const rv = processFormatterForHeader({
     96        configDbgObj,
     97        customFormatterObjectTagDepth,
     98        formatter,
     99        targetActor,
    100        valueDbgObj,
    101      });
    102      // Return the first valid formatter value
    103      if (rv) {
    104        return rv;
    105      }
    106    } catch (e) {
    107      logCustomFormatterError(
    108        globalWrapper,
    109        e instanceof FormatterError
    110          ? `devtoolsFormatters[${customFormatterIndex}].${e.message}`
    111          : `devtoolsFormatters[${customFormatterIndex}] couldn't be run: ${e.message}`,
    112        // If the exception is FormatterError, this comes with a script attribute
    113        e.script
    114      );
    115      addInvalidCustomFormatterHooks(formatter);
    116    }
    117  }
    118 
    119  return null;
    120 }
    121 exports.customFormatterHeader = customFormatterHeader;
    122 
    123 /**
    124 * Handle one precise custom formatter.
    125 * i.e. one element of the window.customFormatters Array.
    126 *
    127 * @param {object} options
    128 * @param {Debugger.Object} options.configDbgObj
    129 *        The Debugger.Object of the config object.
    130 * @param {number} options.customFormatterObjectTagDepth
    131 *        See buildJsonMlFromCustomFormatterHookResult JSDoc.
    132 * @param {object} options.formatter
    133 *        The raw formatter object (coming from "customFormatter" array).
    134 * @param {BrowsingContextTargetActor} options.targetActor
    135 *        See buildJsonMlFromCustomFormatterHookResult JSDoc.
    136 * @param {Debugger.Object} options.valueDbgObj
    137 *        The Debugger.Object of rawObj.
    138 *
    139 * @returns {object} See customFormatterHeader jsdoc, it returns the same object.
    140 */
    141 // eslint-disable-next-line complexity
    142 function processFormatterForHeader({
    143  configDbgObj,
    144  customFormatterObjectTagDepth,
    145  formatter,
    146  targetActor,
    147  valueDbgObj,
    148 }) {
    149  const headerType = typeof formatter?.header;
    150  if (headerType !== "function") {
    151    throw new FormatterError(`header should be a function, got ${headerType}`);
    152  }
    153 
    154  // Call the formatter's header attribute, which should be a function.
    155  const formatterHeaderDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
    156    valueDbgObj,
    157    formatter.header
    158  );
    159  const header = formatterHeaderDbgValue.call(
    160    formatterHeaderDbgValue.boundThis,
    161    valueDbgObj,
    162    configDbgObj
    163  );
    164 
    165  // If the header returns null, the custom formatter isn't used for that object
    166  if (header?.return === null) {
    167    return null;
    168  }
    169 
    170  // The header has to be an Array, all other cases are errors
    171  if (header?.return?.class !== "Array") {
    172    let errorMsg = "";
    173    if (header == null) {
    174      errorMsg = `header was not run because it has side effects`;
    175    } else if ("return" in header) {
    176      let type = typeof header.return;
    177      if (type === "object") {
    178        type = header.return?.class;
    179      }
    180      errorMsg = `header should return an array, got ${type}`;
    181    } else if ("throw" in header) {
    182      errorMsg = `header threw: ${header.throw.getProperty("message")?.return}`;
    183    }
    184 
    185    throw new FormatterError(errorMsg, formatterHeaderDbgValue?.script);
    186  }
    187 
    188  const rawHeader = header.return.unsafeDereference();
    189  if (rawHeader.length === 0) {
    190    throw new FormatterError(
    191      `header returned an empty array`,
    192      formatterHeaderDbgValue?.script
    193    );
    194  }
    195 
    196  const sanitizedHeader = buildJsonMlFromCustomFormatterHookResult(
    197    header.return,
    198    customFormatterObjectTagDepth,
    199    targetActor
    200  );
    201 
    202  let hasBody = false;
    203  const hasBodyType = typeof formatter?.hasBody;
    204  if (hasBodyType === "function") {
    205    const formatterHasBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
    206      valueDbgObj,
    207      formatter.hasBody
    208    );
    209    hasBody = formatterHasBodyDbgValue.call(
    210      formatterHasBodyDbgValue.boundThis,
    211      valueDbgObj,
    212      configDbgObj
    213    );
    214 
    215    if (hasBody == null) {
    216      throw new FormatterError(
    217        `hasBody was not run because it has side effects`,
    218        formatterHasBodyDbgValue?.script
    219      );
    220    } else if ("throw" in hasBody) {
    221      throw new FormatterError(
    222        `hasBody threw: ${hasBody.throw.getProperty("message")?.return}`,
    223        formatterHasBodyDbgValue?.script
    224      );
    225    }
    226  } else if (hasBodyType !== "undefined") {
    227    throw new FormatterError(
    228      `hasBody should be a function, got ${hasBodyType}`
    229    );
    230  }
    231 
    232  return {
    233    useCustomFormatter: true,
    234    header: sanitizedHeader,
    235    hasBody: !!hasBody?.return,
    236    formatter,
    237  };
    238 }
    239 
    240 /**
    241 * Handle a protocol request to get the custom formatter body for an object
    242 *
    243 * @param {ObjectActor} objectActor
    244 * @param {object} formatter: The global.devtoolsFormatters entry that was used in customFormatterHeader
    245 *                            for this object.
    246 *
    247 * @returns {object} Data related to the custom formatter body:
    248 *          - {*} customFormatterBody Data of the custom formatter body.
    249 */
    250 async function customFormatterBody(objectActor, formatter) {
    251  const { rawObj } = objectActor;
    252  const globalWrapper = Cu.getGlobalForObject(rawObj);
    253  const global = globalWrapper?.wrappedJSObject;
    254 
    255  const customFormatterIndex = global.devtoolsFormatters.indexOf(formatter);
    256 
    257  const { targetActor } = objectActor.threadActor;
    258  try {
    259    const { customFormatterConfigDbgObj, customFormatterObjectTagDepth } =
    260      objectActor.hooks;
    261 
    262    if (_invalidCustomFormatterHooks.has(formatter)) {
    263      return {
    264        customFormatterBody: null,
    265      };
    266    }
    267 
    268    const bodyType = typeof formatter.body;
    269    if (bodyType !== "function") {
    270      logCustomFormatterError(
    271        globalWrapper,
    272        `devtoolsFormatters[${customFormatterIndex}].body should be a function, got ${bodyType}`
    273      );
    274      addInvalidCustomFormatterHooks(formatter);
    275      return {
    276        customFormatterBody: null,
    277      };
    278    }
    279 
    280    const formatterBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
    281      objectActor.obj,
    282      formatter.body
    283    );
    284    const body = formatterBodyDbgValue.call(
    285      formatterBodyDbgValue.boundThis,
    286      objectActor.obj,
    287      customFormatterConfigDbgObj
    288    );
    289    if (body?.return?.class === "Array") {
    290      const rawBody = body.return.unsafeDereference();
    291      if (rawBody.length === 0) {
    292        logCustomFormatterError(
    293          globalWrapper,
    294          `devtoolsFormatters[${customFormatterIndex}].body returned an empty array`,
    295          formatterBodyDbgValue?.script
    296        );
    297        addInvalidCustomFormatterHooks(formatter);
    298        return {
    299          customFormatterBody: null,
    300        };
    301      }
    302 
    303      const customFormatterBodyJsonMl =
    304        buildJsonMlFromCustomFormatterHookResult(
    305          body.return,
    306          customFormatterObjectTagDepth,
    307          targetActor
    308        );
    309 
    310      return {
    311        customFormatterBody: customFormatterBodyJsonMl,
    312      };
    313    }
    314 
    315    let errorMsg = "";
    316    if (body == null) {
    317      errorMsg = `devtoolsFormatters[${customFormatterIndex}].body was not run because it has side effects`;
    318    } else if ("return" in body) {
    319      let type = body.return === null ? "null" : typeof body.return;
    320      if (type === "object") {
    321        type = body.return?.class;
    322      }
    323      errorMsg = `devtoolsFormatters[${customFormatterIndex}].body should return an array, got ${type}`;
    324    } else if ("throw" in body) {
    325      errorMsg = `devtoolsFormatters[${customFormatterIndex}].body threw: ${
    326        body.throw.getProperty("message")?.return
    327      }`;
    328    }
    329 
    330    logCustomFormatterError(
    331      globalWrapper,
    332      errorMsg,
    333      formatterBodyDbgValue?.script
    334    );
    335    addInvalidCustomFormatterHooks(formatter);
    336  } catch (e) {
    337    logCustomFormatterError(
    338      globalWrapper,
    339      `Custom formatter with index ${customFormatterIndex} couldn't be run: ${e.message}`
    340    );
    341  }
    342 
    343  return {};
    344 }
    345 exports.customFormatterBody = customFormatterBody;
    346 
    347 /**
    348 * Log an error caused by a fault in a custom formatter to the web console.
    349 *
    350 * @param {Window} window The related global where we should log this message.
    351 *                        This should be the xray wrapper in order to expose windowGlobalChild.
    352 *                        The unwrapped, unpriviledged won't expose this attribute.
    353 * @param {string} errorMsg Message to log to the console.
    354 * @param {DebuggerObject} [script] The script causing the error.
    355 */
    356 function logCustomFormatterError(window, errorMsg, script) {
    357  const scriptErrorClass = Cc["@mozilla.org/scripterror;1"];
    358  const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError);
    359  const { url, startLine, startColumn } = script ?? {};
    360 
    361  scriptError.initWithWindowID(
    362    `Custom formatter failed: ${errorMsg}`,
    363    url,
    364    startLine,
    365    startColumn,
    366    Ci.nsIScriptError.errorFlag,
    367    "devtoolsFormatter",
    368    window.windowGlobalChild.innerWindowId
    369  );
    370  Services.console.logMessage(scriptError);
    371 }
    372 
    373 /**
    374 * Return a ready to use JsonMl object, safe to be sent to the client.
    375 * This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]`
    376 * with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`)
    377 * if the referenced object gets custom formatted as well.
    378 *
    379 * @param {DebuggerObject} jsonMlDbgObj: The debugger object representing a jsonMl object returned
    380 *                         by a custom formatter hook.
    381 * @param {number} customFormatterObjectTagDepth: See `processObjectTag`.
    382 * @param {BrowsingContextTargetActor} targetActor: The actor that will be managing any
    383 *                                     created ObjectActor.
    384 * @returns {Array|null} Returns null if the passed object is a not DebuggerObject representing an Array
    385 */
    386 function buildJsonMlFromCustomFormatterHookResult(
    387  jsonMlDbgObj,
    388  customFormatterObjectTagDepth,
    389  targetActor
    390 ) {
    391  const tagName = jsonMlDbgObj.getProperty(0)?.return;
    392  if (typeof tagName !== "string") {
    393    const tagNameType =
    394      tagName?.class || (tagName === null ? "null" : typeof tagName);
    395    throw new Error(`tagName should be a string, got ${tagNameType}`);
    396  }
    397 
    398  // Fetch the other items of the jsonMl
    399  const rest = [];
    400  const dbgObjLength = jsonMlDbgObj.getProperty("length")?.return || 0;
    401  for (let i = 1; i < dbgObjLength; i++) {
    402    rest.push(jsonMlDbgObj.getProperty(i)?.return);
    403  }
    404 
    405  // The second item of the array can either be an object holding the attributes
    406  // for the element or the first child element.
    407  const attributesDbgObj =
    408    rest[0] && rest[0].class === "Object" ? rest[0] : null;
    409  const childrenDbgObj = attributesDbgObj ? rest.slice(1) : rest;
    410 
    411  // If the tagName is "object", we need to replace the entry with the grip representing
    412  // this object (that may or may not be custom formatted).
    413  if (tagName == "object") {
    414    if (!attributesDbgObj) {
    415      throw new Error(`"object" tag should have attributes`);
    416    }
    417 
    418    // TODO: We could emit a warning if `childrenDbgObj` isn't empty as we're going to
    419    // ignore them here.
    420    return processObjectTag(
    421      attributesDbgObj,
    422      customFormatterObjectTagDepth,
    423      targetActor
    424    );
    425  }
    426 
    427  const jsonMl = [tagName, {}];
    428  if (attributesDbgObj) {
    429    // For non "object" tags, we only care about the style property
    430    jsonMl[1].style = attributesDbgObj.getProperty("style")?.return;
    431  }
    432 
    433  // Handle children, which could be simple primitives or JsonML objects
    434  for (const childDbgObj of childrenDbgObj) {
    435    const childDbgObjType = typeof childDbgObj;
    436    if (childDbgObj?.class === "Array") {
    437      // `childDbgObj` probably holds a JsonMl item, sanitize it.
    438      jsonMl.push(
    439        buildJsonMlFromCustomFormatterHookResult(
    440          childDbgObj,
    441          customFormatterObjectTagDepth,
    442          targetActor
    443        )
    444      );
    445    } else if (childDbgObjType == "object" && childDbgObj !== null) {
    446      // If we don't have an array, match Chrome implementation.
    447      jsonMl.push("[object Object]");
    448    } else {
    449      // Here `childDbgObj` is a primitive. Create a grip so we can handle all the types
    450      // we can stringify easily (e.g. `undefined`, `bigint`, …).
    451      const grip = createValueGripForTarget(targetActor, childDbgObj);
    452      if (grip !== null) {
    453        jsonMl.push(grip);
    454      }
    455    }
    456  }
    457  return jsonMl;
    458 }
    459 
    460 /**
    461 * Return a ready to use JsonMl object, safe to be sent to the client.
    462 * This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]`
    463 * with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`)
    464 * if the referenced object gets custom formatted as well.
    465 *
    466 * @param {DebuggerObject} attributesDbgObj: The debugger object representing the "attributes"
    467 *                         of a jsonMl item (e.g. the second item in the array).
    468 * @param {number} customFormatterObjectTagDepth: As "object" tag can reference custom
    469 *                 formatted data, we track the number of time we go through this function
    470 *                 from the "root" object so we don't have an infinite loop.
    471 * @param {BrowsingContextTargetActor} targetActor: The actor that will be managin any
    472 *                                     created ObjectActor.
    473 * @returns {object} Returns a grip representing the underlying object
    474 */
    475 function processObjectTag(
    476  attributesDbgObj,
    477  customFormatterObjectTagDepth,
    478  targetActor
    479 ) {
    480  const objectDbgObj = attributesDbgObj.getProperty("object")?.return;
    481  if (typeof objectDbgObj == "undefined") {
    482    throw new Error(
    483      `attribute of "object" tag should have an "object" property`
    484    );
    485  }
    486 
    487  // We need to replace the "object" tag with the actual `attribute.object` object,
    488  // which might be also custom formatted.
    489  // We create the grip so the custom formatter hooks can be called on this object, or
    490  // we'd get an object grip that we can consume to display an ObjectInspector on the client.
    491  const configRv = attributesDbgObj.getProperty("config");
    492  const grip = createValueGripForTarget(targetActor, objectDbgObj, 0, {
    493    // Store the config so we can pass it when calling custom formatter hooks for this object.
    494    customFormatterConfigDbgObj: configRv?.return,
    495    customFormatterObjectTagDepth: (customFormatterObjectTagDepth || 0) + 1,
    496  });
    497 
    498  return grip;
    499 }