tor-browser

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

source.js (22883B)


      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 const { Actor } = require("resource://devtools/shared/protocol.js");
      8 const { sourceSpec } = require("resource://devtools/shared/specs/source.js");
      9 
     10 const {
     11  setBreakpointAtEntryPoints,
     12 } = require("resource://devtools/server/actors/breakpoint.js");
     13 const {
     14  getSourcemapBaseURL,
     15 } = require("resource://devtools/server/actors/utils/source-map-utils.js");
     16 const {
     17  getDebuggerSourceURL,
     18 } = require("resource://devtools/server/actors/utils/source-url.js");
     19 loader.lazyRequireGetter(
     20  this,
     21  "ArrayBufferActor",
     22  "resource://devtools/server/actors/array-buffer.js",
     23  true
     24 );
     25 loader.lazyRequireGetter(
     26  this,
     27  "LongStringActor",
     28  "resource://devtools/server/actors/string.js",
     29  true
     30 );
     31 
     32 loader.lazyRequireGetter(
     33  this,
     34  "DevToolsUtils",
     35  "resource://devtools/shared/DevToolsUtils.js"
     36 );
     37 
     38 ChromeUtils.defineESModuleGetters(
     39  this,
     40  {
     41    ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
     42  },
     43  { global: "contextual" }
     44 );
     45 
     46 const windowsDrive = /^([a-zA-Z]:)/;
     47 
     48 function resolveSourceURL(sourceURL, targetActor) {
     49  if (sourceURL) {
     50    let baseURL;
     51    if (targetActor.window) {
     52      baseURL = targetActor.window.location?.href;
     53    }
     54    // For worker, we don't have easy access to location,
     55    // so pull extra information directly from the target actor.
     56    if (targetActor.workerUrl) {
     57      baseURL = targetActor.workerUrl;
     58    }
     59    const parsedURL = URL.parse(sourceURL, baseURL);
     60    if (parsedURL) {
     61      return parsedURL.href;
     62    }
     63  }
     64 
     65  return null;
     66 }
     67 function getSourceURL(source, targetActor) {
     68  // Some eval sources have URLs, but we want to explicitly ignore those because
     69  // they are generally useless strings like "eval" or "debugger eval code".
     70  let resourceURL = getDebuggerSourceURL(source) || "";
     71 
     72  // Strip out eventual stack trace stored in Source's url.
     73  // (not clear if that still happens)
     74  resourceURL = resourceURL.split(" -> ").pop();
     75 
     76  // Debugger.Source.url attribute may be of the form:
     77  //   "http://example.com/foo line 10 > inlineScript"
     78  // because of the following function `js::FormatIntroducedFilename`:
     79  // https://searchfox.org/mozilla-central/rev/253ae246f642fe9619597f44de3b087f94e45a2d/js/src/vm/JSScript.cpp#1816-1846
     80  // This isn't so easy to reproduce, but browser_dbg-breakpoints-popup.js's testPausedInTwoPopups covers this
     81  resourceURL = resourceURL.replace(/ line \d+ > .*$/, "");
     82 
     83  // A "//# sourceURL=" pragma should basically be treated as a source file's
     84  // full URL, so that is what we want to use as the base if it is present.
     85  // If this is not an absolute URL, this will mean the maps in the file
     86  // will not have a valid base URL, but that is up to tooling that
     87  let result = resolveSourceURL(source.displayURL, targetActor);
     88  if (!result) {
     89    result = resolveSourceURL(resourceURL, targetActor) || resourceURL;
     90 
     91    // In XPCShell tests, the source URL isn't actually a URL, it's a file path.
     92    // That causes issues because "C:/folder/file.js" is parsed as a URL with
     93    // "c:" as the URL scheme, which causes the drive letter to be unexpectedly
     94    // lower-cased when the parsed URL is re-serialized. To avoid that, we
     95    // detect that case and re-uppercase it again. This is a bit gross and
     96    // ideally it seems like XPCShell tests should use file:// URLs for files,
     97    // but alas they do not.
     98    if (
     99      resourceURL &&
    100      resourceURL.match(windowsDrive) &&
    101      result.slice(0, 2) == resourceURL.slice(0, 2).toLowerCase()
    102    ) {
    103      result = resourceURL.slice(0, 2) + result.slice(2);
    104    }
    105  }
    106 
    107  // Avoid returning empty string and return null if no URL is found
    108  return result || null;
    109 }
    110 
    111 /**
    112 * A SourceActor provides information about the source of a script. Source
    113 * actors are 1:1 with Debugger.Source objects.
    114 *
    115 * @param Debugger.Source source
    116 *        The source object we are representing.
    117 * @param ThreadActor thread
    118 *        The current thread actor.
    119 */
    120 class SourceActor extends Actor {
    121  constructor({ source, thread }) {
    122    super(thread.conn, sourceSpec);
    123 
    124    this._threadActor = thread;
    125    this._url = undefined;
    126    this._source = source;
    127    this.__isInlineSource = undefined;
    128  }
    129 
    130  get _isInlineSource() {
    131    const source = this._source;
    132    if (this.__isInlineSource === undefined) {
    133      // If the source has a usable displayURL, the source is treated as not
    134      // inlined because it has its own URL.
    135      // Also consider sources loaded from <iframe srcdoc> as independant sources,
    136      // because we can't easily fetch the full html content of the srcdoc attribute.
    137      this.__isInlineSource =
    138        source.introductionType === "inlineScript" &&
    139        !resolveSourceURL(source.displayURL, this.threadActor.targetActor) &&
    140        !this.url.startsWith("about:srcdoc");
    141    }
    142    return this.__isInlineSource;
    143  }
    144 
    145  get threadActor() {
    146    return this._threadActor;
    147  }
    148  get sourcesManager() {
    149    return this._threadActor.sourcesManager;
    150  }
    151  get dbg() {
    152    return this.threadActor.dbg;
    153  }
    154  get breakpointActorMap() {
    155    return this.threadActor.breakpointActorMap;
    156  }
    157  get url() {
    158    if (this._url === undefined) {
    159      this._url = getSourceURL(this._source, this.threadActor.targetActor);
    160    }
    161    return this._url;
    162  }
    163 
    164  get extensionName() {
    165    if (this._extensionName === undefined) {
    166      this._extensionName = null;
    167 
    168      // Cu is not available for workers and so we are not able to get a
    169      // WebExtensionPolicy object
    170      if (!isWorker && ExtensionUtils.isExtensionUrl(this.url)) {
    171        try {
    172          const extURI = Services.io.newURI(this.url);
    173          const policy = WebExtensionPolicy.getByURI(extURI);
    174          if (policy) {
    175            this._extensionName = policy.name;
    176          }
    177        } catch (e) {
    178          console.warn(`Failed to find extension name for ${this.url} : ${e}`);
    179        }
    180      }
    181    }
    182 
    183    return this._extensionName;
    184  }
    185 
    186  get internalSourceId() {
    187    return this._source.id;
    188  }
    189 
    190  form() {
    191    const source = this._source;
    192 
    193    let introductionType = source.introductionType;
    194    if (
    195      introductionType === "srcScript" ||
    196      introductionType === "inlineScript" ||
    197      introductionType === "injectedScript"
    198    ) {
    199      // These three used to be one single type, so here we combine them all
    200      // so that clients don't see any change in behavior.
    201      introductionType = "scriptElement";
    202    }
    203 
    204    // NOTE: Debugger.Source.prototype.startColumn is 1-based.
    205    //       Convert to 0-based, while keeping the wasm's column (1) as is.
    206    //       (bug 1863878)
    207    const columnBase = source.introductionType === "wasm" ? 0 : 1;
    208 
    209    return {
    210      actor: this.actorID,
    211      extensionName: this.extensionName,
    212      url: this.url,
    213      isBlackBoxed: this.sourcesManager.isBlackBoxed(this.url),
    214      sourceMapBaseURL: getSourcemapBaseURL(
    215        this.url,
    216        this.threadActor.targetActor.window
    217      ),
    218      sourceMapURL: source.sourceMapURL,
    219      introductionType,
    220      isInlineSource: this._isInlineSource,
    221      sourceStartLine: source.startLine,
    222      sourceStartColumn: source.startColumn - columnBase,
    223      sourceLength: source.text?.length,
    224    };
    225  }
    226 
    227  destroy() {
    228    const parent = this.getParent();
    229    if (parent && parent.sourceActors) {
    230      delete parent.sourceActors[this.actorID];
    231    }
    232    super.destroy();
    233  }
    234 
    235  get _isWasm() {
    236    return this._source.introductionType === "wasm";
    237  }
    238 
    239  async _getSourceText() {
    240    if (this._isWasm) {
    241      const wasm = this._source.binary;
    242      const buffer = wasm.buffer;
    243      DevToolsUtils.assert(
    244        wasm.byteOffset === 0 && wasm.byteLength === buffer.byteLength,
    245        "Typed array from wasm source binary must cover entire buffer"
    246      );
    247      return {
    248        content: buffer,
    249        contentType: "text/wasm",
    250      };
    251    }
    252 
    253    // Use `source.text` if it exists, is not the "no source" string, and
    254    // the source isn't one that is inlined into some larger file.
    255    // It will be "no source" if the Debugger API wasn't able to load
    256    // the source because sources were discarded
    257    // (javascript.options.discardSystemSource == true).
    258    //
    259    // For inline source, we do something special and ignore individual source content.
    260    // Instead, each inline source will return the full HTML page content where
    261    // the inline source is (i.e. `<script> js source </script>`).
    262    //
    263    // When using srcdoc attribute on iframes:
    264    //   <iframe srcdoc="<script> js source </script>"></iframe>
    265    // The whole iframe source is going to be considered as an inline source because displayURL is null
    266    // and introductionType is inlineScript. But Debugger.Source.text is the only way
    267    // to retrieve the source content.
    268    if (this._source.text !== "[no source]" && !this._isInlineSource) {
    269      return {
    270        content: this.actualText(),
    271        contentType: "text/javascript",
    272      };
    273    }
    274 
    275    return this.sourcesManager.urlContents(
    276      this.url,
    277      /* partial */ false,
    278      /* canUseCache */ this._isInlineSource
    279    );
    280  }
    281 
    282  // Get the actual text of this source, padded so that line numbers will match
    283  // up with the source itself.
    284  actualText() {
    285    // If the source doesn't start at line 1, line numbers in the client will
    286    // not match up with those in the source. Pad the text with blank lines to
    287    // fix this. This can show up for sources associated with inline scripts
    288    // in HTML created via document.write() calls: the script's source line
    289    // number is relative to the start of the written HTML, but we show the
    290    // source's content by itself.
    291    const padding = this._source.startLine
    292      ? "\n".repeat(this._source.startLine - 1)
    293      : "";
    294    return padding + this._source.text;
    295  }
    296 
    297  // Return whether the specified fetched contents includes the actual text of
    298  // this source in the expected position.
    299  contentMatches(fileContents) {
    300    const lineBreak = /\r\n?|\n|\u2028|\u2029/;
    301    const contentLines = fileContents.content.split(lineBreak);
    302    const sourceLines = this._source.text.split(lineBreak);
    303    let line = this._source.startLine - 1;
    304    for (const sourceLine of sourceLines) {
    305      const contentLine = contentLines[line++] || "";
    306      if (!contentLine.includes(sourceLine)) {
    307        return false;
    308      }
    309    }
    310    return true;
    311  }
    312 
    313  getBreakableLines() {
    314    const positions = this._getBreakpointPositions();
    315    const lines = new Set();
    316    for (const position of positions) {
    317      if (!lines.has(position.line)) {
    318        lines.add(position.line);
    319      }
    320    }
    321 
    322    return Array.from(lines);
    323  }
    324 
    325  // Get all toplevel scripts in the source. Transitive child scripts must be
    326  // found by traversing the child script tree.
    327  _getTopLevelDebuggeeScripts() {
    328    if (this._scripts) {
    329      return this._scripts;
    330    }
    331 
    332    let scripts = this.dbg.findScripts({ source: this._source });
    333 
    334    if (!this._isWasm) {
    335      // There is no easier way to get the top-level scripts right now, so
    336      // we have to build that up the list manually.
    337      // Note: It is not valid to simply look for scripts where
    338      // `.isFunction == false` because a source may have executed multiple
    339      // where some have been GCed and some have not (bug 1627712).
    340      const allScripts = new Set(scripts);
    341      for (const script of allScripts) {
    342        for (const child of script.getChildScripts()) {
    343          allScripts.delete(child);
    344        }
    345      }
    346      scripts = [...allScripts];
    347    }
    348 
    349    this._scripts = scripts;
    350    return scripts;
    351  }
    352 
    353  resetDebuggeeScripts() {
    354    this._scripts = null;
    355  }
    356 
    357  // Get toplevel scripts which contain all breakpoint positions for the source.
    358  // This is different from _scripts if we detected that some scripts have been
    359  // GC'ed and reparsed the source contents.
    360  _getTopLevelBreakpointPositionScripts() {
    361    if (this._breakpointPositionScripts) {
    362      return this._breakpointPositionScripts;
    363    }
    364 
    365    let scripts = this._getTopLevelDebuggeeScripts();
    366 
    367    // We need to find all breakpoint positions, even if scripts associated with
    368    // this source have been GC'ed. We detect this by looking for a script which
    369    // does not have a function: a source will typically have a top level
    370    // non-function script. If this top level script still exists, then it keeps
    371    // all its child scripts alive and we will find all breakpoint positions by
    372    // scanning the existing scripts. If the top level script has been GC'ed
    373    // then we won't find its breakpoint positions, and inner functions may have
    374    // been GC'ed as well. In this case we reparse the source and generate a new
    375    // and complete set of scripts to look for the breakpoint positions.
    376    // Note that in some cases like "new Function(stuff)" there might not be a
    377    // top level non-function script, but if there is a non-function script then
    378    // it must be at the top level and will keep all other scripts in the source
    379    // alive.
    380    if (!this._isWasm && !scripts.some(script => !script.isFunction)) {
    381      let newScript;
    382      try {
    383        newScript = this._source.reparse();
    384      } catch (e) {
    385        // reparse() will throw if the source is not valid JS. This can happen
    386        // if this source is the resurrection of a GC'ed source and there are
    387        // parse errors in the refetched contents.
    388      }
    389      if (newScript) {
    390        scripts = [newScript];
    391      }
    392    }
    393 
    394    this._breakpointPositionScripts = scripts;
    395    return scripts;
    396  }
    397 
    398  // Get all scripts in this source that might include content in the range
    399  // specified by the given query.
    400  _findDebuggeeScripts(query, forBreakpointPositions) {
    401    const scripts = forBreakpointPositions
    402      ? this._getTopLevelBreakpointPositionScripts()
    403      : this._getTopLevelDebuggeeScripts();
    404 
    405    const {
    406      start: { line: startLine = 0, column: startColumn = 0 } = {},
    407      end: { line: endLine = Infinity, column: endColumn = Infinity } = {},
    408    } = query || {};
    409 
    410    const rv = [];
    411    addMatchingScripts(scripts);
    412    return rv;
    413 
    414    function scriptMatches(script) {
    415      // These tests are approximate, as we can't easily get the script's end
    416      // column.
    417      let lineCount;
    418      try {
    419        lineCount = script.lineCount;
    420      } catch (err) {
    421        // Accessing scripts which were optimized out during parsing can throw
    422        // an exception. Tolerate these so that we can still get positions for
    423        // other scripts in the source.
    424        return false;
    425      }
    426 
    427      // NOTE: Debugger.Script.prototype.startColumn is 1-based.
    428      //       Convert to 0-based, while keeping the wasm's column (1) as is.
    429      //       (bug 1863878)
    430      const columnBase = script.format === "wasm" ? 0 : 1;
    431      if (
    432        script.startLine > endLine ||
    433        script.startLine + lineCount <= startLine ||
    434        (script.startLine == endLine &&
    435          script.startColumn - columnBase > endColumn)
    436      ) {
    437        return false;
    438      }
    439 
    440      if (
    441        lineCount == 1 &&
    442        script.startLine == startLine &&
    443        script.startColumn - columnBase + script.sourceLength <= startColumn
    444      ) {
    445        return false;
    446      }
    447 
    448      return true;
    449    }
    450 
    451    function addMatchingScripts(childScripts) {
    452      for (const script of childScripts) {
    453        if (scriptMatches(script)) {
    454          rv.push(script);
    455          if (script.format === "js") {
    456            addMatchingScripts(script.getChildScripts());
    457          }
    458        }
    459      }
    460    }
    461  }
    462 
    463  _getBreakpointPositions(query) {
    464    const scripts = this._findDebuggeeScripts(
    465      query,
    466      /* forBreakpointPositions */ true
    467    );
    468 
    469    const positions = [];
    470    for (const script of scripts) {
    471      this._addScriptBreakpointPositions(query, script, positions);
    472    }
    473 
    474    return (
    475      positions
    476        // Sort the items by location.
    477        .sort((a, b) => {
    478          const lineDiff = a.line - b.line;
    479          return lineDiff === 0 ? a.column - b.column : lineDiff;
    480        })
    481    );
    482  }
    483 
    484  _addScriptBreakpointPositions(query, script, positions) {
    485    const {
    486      start: { line: startLine = 0, column: startColumn = 0 } = {},
    487      end: { line: endLine = Infinity, column: endColumn = Infinity } = {},
    488    } = query || {};
    489 
    490    // NOTE: Debugger.Script.prototype.startColumn is 1-based.
    491    //       Convert to 0-based, while keeping the wasm's column (1) as is.
    492    //       (bug 1863878)
    493    const columnBase = script.format === "wasm" ? 0 : 1;
    494 
    495    const offsets = script.getPossibleBreakpoints();
    496    for (const { lineNumber, columnNumber } of offsets) {
    497      if (
    498        lineNumber < startLine ||
    499        (lineNumber === startLine && columnNumber - columnBase < startColumn) ||
    500        lineNumber > endLine ||
    501        (lineNumber === endLine && columnNumber - columnBase >= endColumn)
    502      ) {
    503        continue;
    504      }
    505 
    506      positions.push({
    507        line: lineNumber,
    508        column: columnNumber - columnBase,
    509      });
    510    }
    511  }
    512 
    513  getBreakpointPositionsCompressed(query) {
    514    const items = this._getBreakpointPositions(query);
    515    const compressed = {};
    516    for (const { line, column } of items) {
    517      if (!compressed[line]) {
    518        compressed[line] = [];
    519      }
    520      compressed[line].push(column);
    521    }
    522    return compressed;
    523  }
    524 
    525  /**
    526   * Handler for the "onSource" packet.
    527   *
    528   * @return Object
    529   *         The return of this function contains a field `contentType`, and
    530   *         a field `source`. `source` can either be an ArrayBuffer or
    531   *         a LongString.
    532   */
    533  async source() {
    534    try {
    535      const { content, contentType } = await this._getSourceText();
    536      if (
    537        typeof content === "object" &&
    538        content &&
    539        content.constructor &&
    540        content.constructor.name === "ArrayBuffer"
    541      ) {
    542        return {
    543          source: new ArrayBufferActor(this.threadActor.conn, content),
    544          contentType,
    545        };
    546      }
    547 
    548      return {
    549        source: new LongStringActor(this.threadActor.conn, content),
    550        contentType,
    551      };
    552    } catch (error) {
    553      throw new Error(
    554        "Could not load the source for " +
    555          this.url +
    556          ".\n" +
    557          DevToolsUtils.safeErrorString(error)
    558      );
    559    }
    560  }
    561 
    562  /**
    563   * Handler for the "blackbox" packet.
    564   */
    565  blackbox(range) {
    566    this.sourcesManager.blackBox(this.url, range);
    567    if (
    568      this.threadActor.state == "paused" &&
    569      this.threadActor.youngestFrame &&
    570      this.threadActor.youngestFrame.script.url == this.url
    571    ) {
    572      return true;
    573    }
    574    return false;
    575  }
    576 
    577  /**
    578   * Handler for the "unblackbox" packet.
    579   */
    580  unblackbox(range) {
    581    this.sourcesManager.unblackBox(this.url, range);
    582  }
    583 
    584  /**
    585   * Handler for the "setPausePoints" packet.
    586   *
    587   * @param Array pausePoints
    588   *        A dictionary of pausePoint objects
    589   *
    590   *        type PausePoints = {
    591   *          line: {
    592   *            column: { break?: boolean, step?: boolean }
    593   *          }
    594   *        }
    595   */
    596  setPausePoints(pausePoints) {
    597    const uncompressed = {};
    598    const points = {
    599      0: {},
    600      1: { break: true },
    601      2: { step: true },
    602      3: { break: true, step: true },
    603    };
    604 
    605    for (const line in pausePoints) {
    606      uncompressed[line] = {};
    607      for (const col in pausePoints[line]) {
    608        uncompressed[line][col] = points[pausePoints[line][col]];
    609      }
    610    }
    611 
    612    this.pausePoints = uncompressed;
    613  }
    614 
    615  /**
    616   * Ensure the given BreakpointActor is set as a breakpoint handler on all
    617   * scripts that match its location in the generated source.
    618   *
    619   * @param BreakpointActor actor
    620   *        The BreakpointActor to be set as a breakpoint handler.
    621   *
    622   * @returns A Promise that resolves to the given BreakpointActor.
    623   */
    624  async applyBreakpoint(actor) {
    625    const { line, column } = actor.location;
    626 
    627    // Find all entry points that correspond to the given location.
    628    const entryPoints = [];
    629    if (column === undefined) {
    630      // Find all scripts that match the given source actor and line
    631      // number.
    632      const query = { start: { line }, end: { line } };
    633      const scripts = this._findDebuggeeScripts(query).filter(
    634        script => !actor.hasScript(script)
    635      );
    636 
    637      // NOTE: Debugger.Script.prototype.getPossibleBreakpoints returns
    638      //       columnNumber in 1-based.
    639      //       The following code uses columnNumber only for comparing against
    640      //       other columnNumber, and we don't need to convert to 0-based.
    641 
    642      // This is a line breakpoint, so we add a breakpoint on the first
    643      // breakpoint on the line.
    644      const lineMatches = [];
    645      for (const script of scripts) {
    646        const possibleBreakpoints = script.getPossibleBreakpoints({ line });
    647        for (const possibleBreakpoint of possibleBreakpoints) {
    648          lineMatches.push({ ...possibleBreakpoint, script });
    649        }
    650      }
    651      lineMatches.sort((a, b) => a.columnNumber - b.columnNumber);
    652 
    653      if (lineMatches.length) {
    654        // A single Debugger.Source may have _multiple_ Debugger.Scripts
    655        // at the same position from multiple evaluations of the source,
    656        // so we explicitly want to take all of the matches for the matched
    657        // column number.
    658        const firstColumn = lineMatches[0].columnNumber;
    659        const firstColumnMatches = lineMatches.filter(
    660          m => m.columnNumber === firstColumn
    661        );
    662 
    663        for (const { script, offset } of firstColumnMatches) {
    664          entryPoints.push({ script, offsets: [offset] });
    665        }
    666      }
    667    } else {
    668      // Find all scripts that match the given source actor, line,
    669      // and column number.
    670      const query = { start: { line, column }, end: { line, column } };
    671      const scripts = this._findDebuggeeScripts(query).filter(
    672        script => !actor.hasScript(script)
    673      );
    674 
    675      for (const script of scripts) {
    676        // NOTE: getPossibleBreakpoints's minColumn/maxColumn parameters are
    677        //       1-based.
    678        //       Convert to 1-based, while keeping the wasm's column (1) as is.
    679        //       (bug 1863878)
    680        const columnBase = script.format === "wasm" ? 0 : 1;
    681 
    682        // Check to see if the script contains a breakpoint position at
    683        // this line and column.
    684        const possibleBreakpoint = script
    685          .getPossibleBreakpoints({
    686            line,
    687            minColumn: column + columnBase,
    688            maxColumn: column + columnBase + 1,
    689          })
    690          .pop();
    691 
    692        if (possibleBreakpoint) {
    693          const { offset } = possibleBreakpoint;
    694          entryPoints.push({ script, offsets: [offset] });
    695        }
    696      }
    697    }
    698 
    699    setBreakpointAtEntryPoints(actor, entryPoints);
    700  }
    701 }
    702 
    703 exports.SourceActor = SourceActor;