/* 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/. */ "use strict"; const { Actor } = require("resource://devtools/shared/protocol.js"); const { sourceSpec } = require("resource://devtools/shared/specs/source.js"); const { setBreakpointAtEntryPoints, } = require("resource://devtools/server/actors/breakpoint.js"); const { getSourcemapBaseURL, } = require("resource://devtools/server/actors/utils/source-map-utils.js"); const { getDebuggerSourceURL, } = require("resource://devtools/server/actors/utils/source-url.js"); loader.lazyRequireGetter( this, "ArrayBufferActor", "resource://devtools/server/actors/array-buffer.js", true ); loader.lazyRequireGetter( this, "LongStringActor", "resource://devtools/server/actors/string.js", true ); loader.lazyRequireGetter( this, "DevToolsUtils", "resource://devtools/shared/DevToolsUtils.js" ); const windowsDrive = /^([a-zA-Z]:)/; function resolveSourceURL(sourceURL, targetActor) { if (sourceURL) { try { let baseURL; if (targetActor.window) { baseURL = targetActor.window.location?.href; } // For worker, we don't have easy access to location, // so pull extra information directly from the target actor. if (targetActor.workerUrl) { baseURL = targetActor.workerUrl; } return new URL(sourceURL, baseURL || undefined).href; } catch (err) {} } return null; } function getSourceURL(source, targetActor) { // Some eval sources have URLs, but we want to explicitly ignore those because // they are generally useless strings like "eval" or "debugger eval code". let resourceURL = getDebuggerSourceURL(source) || ""; // Strip out eventual stack trace stored in Source's url. // (not clear if that still happens) resourceURL = resourceURL.split(" -> ").pop(); // Debugger.Source.url attribute may be of the form: // "http://example.com/foo line 10 > inlineScript" // because of the following function `js::FormatIntroducedFilename`: // https://searchfox.org/mozilla-central/rev/253ae246f642fe9619597f44de3b087f94e45a2d/js/src/vm/JSScript.cpp#1816-1846 // This isn't so easy to reproduce, but browser_dbg-breakpoints-popup.js's testPausedInTwoPopups covers this resourceURL = resourceURL.replace(/ line \d+ > .*$/, ""); // A "//# sourceURL=" pragma should basically be treated as a source file's // full URL, so that is what we want to use as the base if it is present. // If this is not an absolute URL, this will mean the maps in the file // will not have a valid base URL, but that is up to tooling that let result = resolveSourceURL(source.displayURL, targetActor); if (!result) { result = resolveSourceURL(resourceURL, targetActor) || resourceURL; // In XPCShell tests, the source URL isn't actually a URL, it's a file path. // That causes issues because "C:/folder/file.js" is parsed as a URL with // "c:" as the URL scheme, which causes the drive letter to be unexpectedly // lower-cased when the parsed URL is re-serialized. To avoid that, we // detect that case and re-uppercase it again. This is a bit gross and // ideally it seems like XPCShell tests should use file:// URLs for files, // but alas they do not. if ( resourceURL && resourceURL.match(windowsDrive) && result.slice(0, 2) == resourceURL.slice(0, 2).toLowerCase() ) { result = resourceURL.slice(0, 2) + result.slice(2); } } // Avoid returning empty string and return null if no URL is found return result || null; } /** * A SourceActor provides information about the source of a script. Source * actors are 1:1 with Debugger.Source objects. * * @param Debugger.Source source * The source object we are representing. * @param ThreadActor thread * The current thread actor. */ class SourceActor extends Actor { constructor({ source, thread }) { super(thread.conn, sourceSpec); this._threadActor = thread; this._url = undefined; this._source = source; this.__isInlineSource = undefined; } get _isInlineSource() { const source = this._source; if (this.__isInlineSource === undefined) { // If the source has a usable displayURL, the source is treated as not // inlined because it has its own URL. // Also consider sources loaded from // The whole iframe source is going to be considered as an inline source because displayURL is null // and introductionType is inlineScript. But Debugger.Source.text is the only way // to retrieve the source content. if (this._source.text !== "[no source]" && !this._isInlineSource) { return { content: this.actualText(), contentType: "text/javascript", }; } return this.sourcesManager.urlContents( this.url, /* partial */ false, /* canUseCache */ this._isInlineSource ); } // Get the actual text of this source, padded so that line numbers will match // up with the source itself. actualText() { // If the source doesn't start at line 1, line numbers in the client will // not match up with those in the source. Pad the text with blank lines to // fix this. This can show up for sources associated with inline scripts // in HTML created via document.write() calls: the script's source line // number is relative to the start of the written HTML, but we show the // source's content by itself. const padding = this._source.startLine ? "\n".repeat(this._source.startLine - 1) : ""; return padding + this._source.text; } // Return whether the specified fetched contents includes the actual text of // this source in the expected position. contentMatches(fileContents) { const lineBreak = /\r\n?|\n|\u2028|\u2029/; const contentLines = fileContents.content.split(lineBreak); const sourceLines = this._source.text.split(lineBreak); let line = this._source.startLine - 1; for (const sourceLine of sourceLines) { const contentLine = contentLines[line++] || ""; if (!contentLine.includes(sourceLine)) { return false; } } return true; } getBreakableLines() { const positions = this._getBreakpointPositions(); const lines = new Set(); for (const position of positions) { if (!lines.has(position.line)) { lines.add(position.line); } } return Array.from(lines); } // Get all toplevel scripts in the source. Transitive child scripts must be // found by traversing the child script tree. _getTopLevelDebuggeeScripts() { if (this._scripts) { return this._scripts; } let scripts = this.dbg.findScripts({ source: this._source }); if (!this._isWasm) { // There is no easier way to get the top-level scripts right now, so // we have to build that up the list manually. // Note: It is not valid to simply look for scripts where // `.isFunction == false` because a source may have executed multiple // where some have been GCed and some have not (bug 1627712). const allScripts = new Set(scripts); for (const script of allScripts) { for (const child of script.getChildScripts()) { allScripts.delete(child); } } scripts = [...allScripts]; } this._scripts = scripts; return scripts; } resetDebuggeeScripts() { this._scripts = null; } // Get toplevel scripts which contain all breakpoint positions for the source. // This is different from _scripts if we detected that some scripts have been // GC'ed and reparsed the source contents. _getTopLevelBreakpointPositionScripts() { if (this._breakpointPositionScripts) { return this._breakpointPositionScripts; } let scripts = this._getTopLevelDebuggeeScripts(); // We need to find all breakpoint positions, even if scripts associated with // this source have been GC'ed. We detect this by looking for a script which // does not have a function: a source will typically have a top level // non-function script. If this top level script still exists, then it keeps // all its child scripts alive and we will find all breakpoint positions by // scanning the existing scripts. If the top level script has been GC'ed // then we won't find its breakpoint positions, and inner functions may have // been GC'ed as well. In this case we reparse the source and generate a new // and complete set of scripts to look for the breakpoint positions. // Note that in some cases like "new Function(stuff)" there might not be a // top level non-function script, but if there is a non-function script then // it must be at the top level and will keep all other scripts in the source // alive. if (!this._isWasm && !scripts.some(script => !script.isFunction)) { let newScript; try { newScript = this._source.reparse(); } catch (e) { // reparse() will throw if the source is not valid JS. This can happen // if this source is the resurrection of a GC'ed source and there are // parse errors in the refetched contents. } if (newScript) { scripts = [newScript]; } } this._breakpointPositionScripts = scripts; return scripts; } // Get all scripts in this source that might include content in the range // specified by the given query. _findDebuggeeScripts(query, forBreakpointPositions) { const scripts = forBreakpointPositions ? this._getTopLevelBreakpointPositionScripts() : this._getTopLevelDebuggeeScripts(); const { start: { line: startLine = 0, column: startColumn = 0 } = {}, end: { line: endLine = Infinity, column: endColumn = Infinity } = {}, } = query || {}; const rv = []; addMatchingScripts(scripts); return rv; function scriptMatches(script) { // These tests are approximate, as we can't easily get the script's end // column. let lineCount; try { lineCount = script.lineCount; } catch (err) { // Accessing scripts which were optimized out during parsing can throw // an exception. Tolerate these so that we can still get positions for // other scripts in the source. return false; } if ( script.startLine > endLine || script.startLine + lineCount <= startLine || (script.startLine == endLine && script.startColumn > endColumn) ) { return false; } if ( lineCount == 1 && script.startLine == startLine && script.startColumn + script.sourceLength <= startColumn ) { return false; } return true; } function addMatchingScripts(childScripts) { for (const script of childScripts) { if (scriptMatches(script)) { rv.push(script); if (script.format === "js") { addMatchingScripts(script.getChildScripts()); } } } } } _getBreakpointPositions(query) { const scripts = this._findDebuggeeScripts( query, /* forBreakpointPositions */ true ); const positions = []; for (const script of scripts) { this._addScriptBreakpointPositions(query, script, positions); } return ( positions // Sort the items by location. .sort((a, b) => { const lineDiff = a.line - b.line; return lineDiff === 0 ? a.column - b.column : lineDiff; }) ); } _addScriptBreakpointPositions(query, script, positions) { const { start: { line: startLine = 0, column: startColumn = 0 } = {}, end: { line: endLine = Infinity, column: endColumn = Infinity } = {}, } = query || {}; const offsets = script.getPossibleBreakpoints(); for (const { lineNumber, columnNumber } of offsets) { if ( lineNumber < startLine || (lineNumber === startLine && columnNumber < startColumn) || lineNumber > endLine || (lineNumber === endLine && columnNumber >= endColumn) ) { continue; } positions.push({ line: lineNumber, column: columnNumber, }); } } getBreakpointPositionsCompressed(query) { const items = this._getBreakpointPositions(query); const compressed = {}; for (const { line, column } of items) { if (!compressed[line]) { compressed[line] = []; } compressed[line].push(column); } return compressed; } /** * Handler for the "onSource" packet. * @return Object * The return of this function contains a field `contentType`, and * a field `source`. `source` can either be an ArrayBuffer or * a LongString. */ async source() { try { const { content, contentType } = await this._getSourceText(); if ( typeof content === "object" && content && content.constructor && content.constructor.name === "ArrayBuffer" ) { return { source: new ArrayBufferActor(this.threadActor.conn, content), contentType, }; } return { source: new LongStringActor(this.threadActor.conn, content), contentType, }; } catch (error) { throw new Error( "Could not load the source for " + this.url + ".\n" + DevToolsUtils.safeErrorString(error) ); } } /** * Handler for the "blackbox" packet. */ blackbox(range) { this.sourcesManager.blackBox(this.url, range); if ( this.threadActor.state == "paused" && this.threadActor.youngestFrame && this.threadActor.youngestFrame.script.url == this.url ) { return true; } return false; } /** * Handler for the "unblackbox" packet. */ unblackbox(range) { this.sourcesManager.unblackBox(this.url, range); } /** * Handler for the "setPausePoints" packet. * * @param Array pausePoints * A dictionary of pausePoint objects * * type PausePoints = { * line: { * column: { break?: boolean, step?: boolean } * } * } */ setPausePoints(pausePoints) { const uncompressed = {}; const points = { 0: {}, 1: { break: true }, 2: { step: true }, 3: { break: true, step: true }, }; for (const line in pausePoints) { uncompressed[line] = {}; for (const col in pausePoints[line]) { uncompressed[line][col] = points[pausePoints[line][col]]; } } this.pausePoints = uncompressed; } /* * Ensure the given BreakpointActor is set as a breakpoint handler on all * scripts that match its location in the generated source. * * @param BreakpointActor actor * The BreakpointActor to be set as a breakpoint handler. * * @returns A Promise that resolves to the given BreakpointActor. */ async applyBreakpoint(actor) { const { line, column } = actor.location; // Find all entry points that correspond to the given location. const entryPoints = []; if (column === undefined) { // Find all scripts that match the given source actor and line // number. const query = { start: { line }, end: { line } }; const scripts = this._findDebuggeeScripts(query).filter( script => !actor.hasScript(script) ); // This is a line breakpoint, so we add a breakpoint on the first // breakpoint on the line. const lineMatches = []; for (const script of scripts) { const possibleBreakpoints = script.getPossibleBreakpoints({ line }); for (const possibleBreakpoint of possibleBreakpoints) { lineMatches.push({ ...possibleBreakpoint, script }); } } lineMatches.sort((a, b) => a.columnNumber - b.columnNumber); if (lineMatches.length) { // A single Debugger.Source may have _multiple_ Debugger.Scripts // at the same position from multiple evaluations of the source, // so we explicitly want to take all of the matches for the matched // column number. const firstColumn = lineMatches[0].columnNumber; const firstColumnMatches = lineMatches.filter( m => m.columnNumber === firstColumn ); for (const { script, offset } of firstColumnMatches) { entryPoints.push({ script, offsets: [offset] }); } } } else { // Find all scripts that match the given source actor, line, // and column number. const query = { start: { line, column }, end: { line, column } }; const scripts = this._findDebuggeeScripts(query).filter( script => !actor.hasScript(script) ); for (const script of scripts) { // Check to see if the script contains a breakpoint position at // this line and column. const possibleBreakpoint = script .getPossibleBreakpoints({ line, minColumn: column, maxColumn: column + 1, }) .pop(); if (possibleBreakpoint) { const { offset } = possibleBreakpoint; entryPoints.push({ script, offsets: [offset] }); } } } setBreakpointAtEntryPoints(actor, entryPoints); } } exports.SourceActor = SourceActor;