Move deobfuscation tools to the SDK repo

This is an initial implementation of the dart deobfuscator tool.

Let me know your thoughts on the package name. I used to have this named as
`package:deobfuscate`, but it feels like we will want to add more tools that are
not about deobfuscation in the future, so I picked `package:dart2js_tools`
instead. That also gives us the opportunity to move over the dart2js_info code
here too.

Change-Id: I2ff948982969c9c76bc84cdc78cbe237abc87378
Reviewed-on: https://dart-review.googlesource.com/69243
Reviewed-by: Stephen Adams <sra@google.com>
Commit-Queue: Sigmund Cherem <sigmund@google.com>
This commit is contained in:
Sigmund Cherem 2018-08-09 22:55:04 +00:00 committed by commit-bot@chromium.org
parent 666c8c1a89
commit 7c77ed04f4
12 changed files with 748 additions and 0 deletions

View file

@ -29,6 +29,7 @@ convert:third_party/pkg/convert/lib
crypto:third_party/pkg/crypto/lib
csslib:third_party/pkg/csslib/lib
dart2js_info:third_party/pkg/dart2js_info/lib
dart2js_tools:pkg/dart2js_tools/lib
dart_internal:pkg/dart_internal/lib
dart_messages:pkg/dart_messages/lib
dart_style:third_party/pkg_tested/dart_style/lib

View file

@ -0,0 +1,10 @@
### dart2js\_tools
This package collects tools used with dart2js.
For now, this contains scripts useful to work with obfuscated stack traces in
production and reading data from the extensions added to source-maps by dart2js
(like minified names and inlined stack frames).
In the future we plan to merge here tools in the dart2js\_info package as well.

View file

@ -0,0 +1,151 @@
// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:io';
import 'package:source_maps/source_maps.dart';
import 'package:source_maps/src/utils.dart';
import 'package:dart2js_tools/src/trace.dart';
import 'package:dart2js_tools/src/sourcemap_helper.dart';
import 'package:dart2js_tools/src/name_decoder.dart';
import 'package:dart2js_tools/src/dart2js_mapping.dart';
import 'package:dart2js_tools/src/util.dart';
/// Script that deobuscates a stack-trace given in a text file.
///
/// To run this script you need 3 or more files:
///
/// * A stacktrace file
/// * The deployed .js file
/// * The corresponding .map file
///
/// There might be more than one .js/.map file if your app is divided in
/// deferred chunks.
///
/// The stack trace file contains a copy/paste of a JavaScript stack trace, of
/// this form:
///
/// at aB.a20 (main.dart.js:71969:32)
/// at aNk.goV (main.dart.js:72040:52)
/// at aNk.gfK (main.dart.js:72038:27)
/// at FE.gtn (main.dart.js:72640:24)
/// at aBZ.ghN (main.dart.js:72642:24)
/// at inheritance (main.dart.js:105334:0)
/// at FE (main.dart.js:5037:18)
///
/// If you download the stacktrace from a production service, you can keep the
/// full URL (including http://....) and this script will simply try to match
/// the name of the file at the end with a file in the current working
/// directory.
///
/// The .js file must contain a `//# sourceMappingURL=` line at the end, which
/// tells this script how to determine the name of the source-map file.
main(List<String> args) {
if (args.length != 1) {
print('usage: deobfuscate.dart <stack-trace-file>');
exit(1);
}
var sb = new StringBuffer();
try {
deobfuscate(new File(args[0]).readAsStringSync(), sb);
} finally {
print('$sb');
}
}
void deobfuscate(trace, StringBuffer sb) {
String error = extractErrorMessage(trace);
String translatedError;
var provider = new CachingFileProvider();
List<StackTraceLine> jsStackTrace = parseStackTrace(trace);
for (StackTraceLine line in jsStackTrace) {
var uri = resolveUri(line.fileName);
var mapping = provider.mappingFor(uri);
if (mapping == null) {
printPadded('no mapping', line.inlineString, sb);
continue;
}
TargetEntry targetEntry = findColumn(line.lineNo - 1, line.columnNo - 1,
findLine(mapping.sourceMap, line.lineNo - 1));
if (targetEntry == null) {
printPadded('no entry', line.inlineString, sb);
continue;
}
if (translatedError == null) {
translatedError = translate(error, mapping, line, targetEntry);
if (translatedError == null) translatedError = '<no error message found>';
printPadded(translatedError, error, sb);
}
int offset =
provider.fileFor(uri).getOffset(line.lineNo - 1, line.columnNo - 1);
String nameOf(id) => id != 0 ? mapping.sourceMap.names[id] : null;
String urlOf(id) => id != 0 ? mapping.sourceMap.urls[id] : null;
String fileName = urlOf(targetEntry.sourceUrlId ?? 0);
int targetLine = (targetEntry.sourceLine ?? 0) + 1;
int targetColumn = (targetEntry.sourceColumn ?? 0) + 1;
// Expand inlined frames.
Map<int, List<FrameEntry>> frames = mapping.frames;
List<int> index = mapping.frameIndex;
int key = binarySearch(index, (i) => i > offset) - 1;
int depth = 0;
outer:
while (key >= 0) {
for (var frame in frames[index[key]].reversed) {
if (frame.isEmpty) break outer;
if (frame.isPush) {
if (depth <= 0) {
var mappedLine = new StackTraceLine(
frame.inlinedMethodName + "(inlined)",
fileName,
targetLine,
targetColumn);
printPadded(mappedLine.inlineString, "", sb);
fileName = frame.callUri;
targetLine = (frame.callLine ?? 0) + 1;
targetColumn = (frame.callColumn ?? 0) + 1;
} else {
depth--;
}
}
if (frame.isPop) {
depth++;
}
}
key--;
}
var functionEntry = findEnclosingFunction(provider, uri, offset);
String methodName = nameOf(functionEntry.sourceNameId ?? 0);
var mappedLine =
new StackTraceLine(methodName, fileName, targetLine, targetColumn);
printPadded(mappedLine.inlineString, line.inlineString, sb);
}
}
final green = stdout.hasTerminal ? '' : '';
final none = stdout.hasTerminal ? '' : '';
printPadded(String mapping, String original, sb) {
var len = mapping.length;
var s = mapping.indexOf('\n');
if (s >= 0) len -= s + 1;
var pad = ' ' * (50 - len);
sb.writeln('$green$mapping$none$pad ... $original');
}
Uri resolveUri(String filename) {
var uri = Uri.base.resolve(filename);
if (uri.scheme == 'http' || uri.scheme == 'https') {
filename = uri.path.substring(uri.path.lastIndexOf('/') + 1);
uri = Uri.base.resolve(filename);
}
return uri;
}

View file

@ -0,0 +1,33 @@
import 'dart:io';
import 'dart:convert';
import 'package:source_maps/source_maps.dart';
main(List<String> args) {
if (args.length < 2) {
print('usage: read.dart <source-map-file> <name>');
exit(1);
}
var sourcemapFile = new File.fromUri(Uri.base.resolve(args[0]));
if (!sourcemapFile.existsSync()) {
print('no source-map-file in ${args[0]}');
exit(1);
}
var name = args[1];
var json = jsonDecode(sourcemapFile.readAsStringSync());
SingleMapping mapping = parseJson(json);
var extensions = json['x_org_dartlang_dart2js'];
if (extensions == null) {
print('source-map file has no dart2js extensions');
exit(1);
}
var minifiedNames = extensions['minified_names'];
if (minifiedNames == null) {
print('source-map file has no minified names in the dart2js extensions');
exit(1);
}
var gid = minifiedNames['global'][name];
if (gid != null) print('$name => ${mapping.names[gid]} (a global name)');
var iid = minifiedNames['instance'][name];
if (iid != null) print('$name => ${mapping.names[iid]} (an instance name)');
if (gid == null && iid == null) print('Name \'$name\' not found.');
}

View file

@ -0,0 +1,17 @@
import 'dart:io';
import 'dart:convert';
import 'package:source_maps/source_maps.dart';
main(List<String> args) {
if (args.length != 1) {
print('usage: read.dart <source-map-file>');
exit(1);
}
var sourcemapFile = new File.fromUri(Uri.base.resolve(args[0]));
if (!sourcemapFile.existsSync()) {
print('no source-map-file in ${args[0]}');
}
var bytes = sourcemapFile.readAsBytesSync();
parse(utf8.decode(bytes));
}

View file

@ -0,0 +1,89 @@
// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:io';
import 'package:dart2js_tools/src/util.dart';
/// Script to show a text representation of the inlining data attached to
/// source-map files.
///
/// This expands the push/pop operations and checks simple invariants (e.g. that
/// the stack is always empty at the beginning of a function).
main(List<String> args) {
if (args.length != 1) {
print('usage: show_inline_data.dart <js-file>');
exit(1);
}
var uri = Uri.base.resolve(args[0]);
var provider = new CachingFileProvider();
var mapping = provider.mappingFor(uri);
var starts = functionStarts(provider.sourcesFor(uri));
var file = provider.fileFor(uri);
var frames = mapping.frames;
var offsets = frames.keys.toList()..sort();
var sb = new StringBuffer();
int depth = 0;
int lastFunctionStart = null;
for (var offset in offsets) {
int functionStart = nextFunctionStart(starts, offset, lastFunctionStart);
if (lastFunctionStart == null || functionStart > lastFunctionStart) {
sb.write('\n${location(starts[functionStart], file)}: function start\n');
if (depth != 0) {
sb.write(
"[invalid] function start with non-zero depth: $depth\n");
}
lastFunctionStart = functionStart;
}
var offsetPrefix = '${location(offset, file)}:';
var pad = ' ' * offsetPrefix.length;
sb.write(offsetPrefix);
bool first = true;
for (var frame in frames[offset]) {
if (!first) sb.write('$pad');
sb.write(' $frame\n');
first = false;
if (frame.isPush) depth++;
if (frame.isPop) depth--;
if (frame.isEmpty && depth != 0) {
sb.write("[invalid] pop-empty with non-zero depth: $depth\n");
}
if (!frame.isEmpty && depth == 0) {
sb.write("[invalid] non-empty pop with zero depth: $depth\n");
}
if (depth < 0) {
sb.write("[invalid] negative depth: $depth\n");
}
}
}
print('$sb');
}
var _functionDeclarationRegExp = new RegExp(r':( )?function\(');
List<int> functionStarts(String sources) {
List<int> result = [];
int index = sources.indexOf(_functionDeclarationRegExp);
while (index != -1) {
result.add(index + 2);
index = sources.indexOf(_functionDeclarationRegExp, index + 1);
}
return result;
}
int nextFunctionStart(List<int> starts, int offset, int last) {
int j = last ?? 0;
for (; j < starts.length && starts[j] <= offset; j++);
return j - 1;
}
String location(int offset, file) {
var line = file.getLine(offset) + 1;
var column = file.getColumn(offset) + 1;
var location = '$offset ($line:$column)';
return location + (' ' * (16 - location.length));
}

View file

@ -0,0 +1,133 @@
// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Representation of a source-map file with dart2js-specific extensions, and
/// helper functions to parse them.
import 'dart:convert';
import 'dart:io';
import 'package:source_maps/source_maps.dart';
import 'util.dart';
/// Representation of a source-map file with dart2js-specific extensions.
///
/// Dart2js adds a special section that provides: tables of minified names and a
/// table of inlining frame data.
class Dart2jsMapping {
final SingleMapping sourceMap;
final Map<String, String> globalNames = {};
final Map<String, String> instanceNames = {};
final Map<int, List<FrameEntry>> frames = {};
List<int> _frameIndex;
List<int> get frameIndex {
if (_frameIndex == null) {
_frameIndex = frames.keys.toList()..sort();
}
return _frameIndex;
}
Dart2jsMapping(this.sourceMap, Map json) {
var extensions = json['x_org_dartlang_dart2js'];
if (extensions == null) return;
var minifiedNames = extensions['minified_names'];
if (minifiedNames != null) {
minifiedNames['global'].forEach((minifiedName, id) {
globalNames[minifiedName] = sourceMap.names[id];
});
minifiedNames['instance'].forEach((minifiedName, id) {
instanceNames[minifiedName] = sourceMap.names[id];
});
}
List jsonFrames = extensions['frames'];
if (jsonFrames != null) {
for (List values in jsonFrames) {
if (values.length < 2) {
warn("warning: incomplete frame data: $values");
continue;
}
int offset = values[0];
List<FrameEntry> entries = frames[offset] ??= [];
if (entries.length > 0) {
warn("warning: duplicate entries for $offset");
continue;
}
for (int i = 1; i < values.length; i++) {
var current = values[i];
if (current == -1) {
entries.add(new FrameEntry.pop(false));
} else if (current == 0) {
entries.add(new FrameEntry.pop(true));
} else {
if (current is List) {
if (current.length == 4) {
entries.add(new FrameEntry.push(sourceMap.urls[current[0]],
current[1], current[2], sourceMap.names[current[3]]));
} else {
warn("warning: unexpected entry $current");
}
} else {
warn("warning: unexpected entry $current");
}
}
}
}
}
}
}
class FrameEntry {
final String callUri;
final int callLine;
final int callColumn;
final String inlinedMethodName;
final bool isEmpty;
FrameEntry.push(
this.callUri, this.callLine, this.callColumn, this.inlinedMethodName)
: isEmpty = false;
FrameEntry.pop(this.isEmpty)
: callUri = null,
callLine = null,
callColumn = null,
inlinedMethodName = null;
bool get isPush => callUri != null;
bool get isPop => callUri == null;
toString() {
if (isPush)
return "push $inlinedMethodName @ $callUri:$callLine:$callColumn";
return isEmpty ? 'pop: empty' : 'pop';
}
}
const _marker = "\n//# sourceMappingURL=";
Dart2jsMapping parseMappingFor(Uri uri) {
var file = new File.fromUri(uri);
if (!file.existsSync()) {
warn('Error: no such file: $uri');
return null;
}
var contents = file.readAsStringSync();
var urlIndex = contents.indexOf(_marker);
var sourcemapPath;
if (urlIndex != -1) {
sourcemapPath = contents.substring(urlIndex + _marker.length).trim();
} else {
warn('Error: source-map url marker not found in $uri\n'
' trying $uri.map');
sourcemapPath = '${uri.pathSegments.last}.map';
}
assert(!sourcemapPath.contains('\n'));
var sourcemapFile = new File.fromUri(uri.resolve(sourcemapPath));
if (!sourcemapFile.existsSync()) {
warn('Error: no such file: $sourcemapFile');
return null;
}
var json = jsonDecode(sourcemapFile.readAsStringSync());
return new Dart2jsMapping(parseJson(json), json);
}

View file

@ -0,0 +1,78 @@
// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Logic to deobfuscate minified names that appear in error messages.
import 'package:source_maps/source_maps.dart';
import 'dart2js_mapping.dart';
import 'trace.dart';
String translate(String error, Dart2jsMapping mapping, StackTraceLine line,
TargetEntry entry) {
for (var decoder in _errorMapDecoders) {
var result = decoder.decode(error, mapping, line, entry);
// More than one decoder might be applied on a single error message. This
// can be useful, for example, if an error contains details about a member
// and the type.
if (result != null) error = result;
}
return error;
}
/// A decoder that matches an error against a regular expression and
/// uses data from the source-file and source-map file to translate minified
/// names to un-minified names.
abstract class ErrorMapDecoder {
RegExp get _matcher;
/// Decode [error] that was reported in [line] and has a corresponding [entry]
/// in the source-map file. The provided [mapping] includes additional
/// minification data that may be used to decode the error message.
String decode(String error, Dart2jsMapping mapping, StackTraceLine line,
TargetEntry entry) {
if (error == null) return null;
var match = _matcher.firstMatch(error);
if (match == null) return null;
var result = _decodeInternal(match, mapping, line, entry);
if (result == null) return null;
return '${error.substring(0, match.start)}'
'$result${error.substring(match.end, error.length)}';
}
String _decodeInternal(Match match, Dart2jsMapping mapping,
StackTraceLine line, TargetEntry entry);
}
typedef String ErrorDecoder(Match match, Dart2jsMapping mapping,
StackTraceLine line, TargetEntry entry);
class MinifiedNameDecoder extends ErrorMapDecoder {
final RegExp _matcher = new RegExp("minified:([a-zA-Z]*)");
String _decodeInternal(Match match, Dart2jsMapping mapping,
StackTraceLine line, TargetEntry entry) {
var minifiedName = match.group(1);
var name = mapping.globalNames[minifiedName];
if (name == null) return null;
return name;
}
}
class CannotReadPropertyDecoder extends ErrorMapDecoder {
final RegExp _matcher = new RegExp("Cannot read property '([^']*)' of");
String _decodeInternal(Match match, Dart2jsMapping mapping,
StackTraceLine line, TargetEntry entry) {
var minifiedName = match.group(1);
var name = mapping.instanceNames[minifiedName];
if (name == null) return null;
return "Cannot read property '$name' of";
}
}
List<ErrorMapDecoder> _errorMapDecoders = [
new MinifiedNameDecoder(),
new CannotReadPropertyDecoder()
];

View file

@ -0,0 +1,50 @@
// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Utility functions to make it easier to work with source-map files.
import 'package:source_span/source_span.dart';
import 'package:source_maps/source_maps.dart';
import 'package:source_maps/src/utils.dart';
import 'util.dart' show FileProvider;
/// Search backwards in [sources] for a function declaration that includes the
/// [start] offset.
TargetEntry findEnclosingFunction(FileProvider provider, Uri uri, int start) {
String sources = provider.sourcesFor(uri);
if (sources == null) return null;
int index = sources.lastIndexOf(': function(', start);
if (index < 0) index = sources.lastIndexOf(':function(', start);
if (index < 0) return null;
index += 2;
SourceFile file = provider.fileFor(uri);
SingleMapping mapping = provider.mappingFor(uri).sourceMap;
var line = file.getLine(index);
var lineEntry = findLine(mapping, line);
return findColumn(line, file.getColumn(index), lineEntry);
}
/// Returns [TargetLineEntry] which includes the location in the target [line]
/// number. In particular, the resulting entry is the last entry whose line
/// number is lower or equal to [line].
///
/// Copied from [SingleMapping._findLine].
TargetLineEntry findLine(SingleMapping sourceMap, int line) {
int index = binarySearch(sourceMap.lines, (e) => e.line > line);
return (index <= 0) ? null : sourceMap.lines[index - 1];
}
/// Returns [TargetEntry] which includes the location denoted by
/// [line], [column]. If [lineEntry] corresponds to [line], then this will be
/// the last entry whose column is lower or equal than [column]. If
/// [lineEntry] corresponds to a line prior to [line], then the result will be
/// the very last entry on that line.
///
/// Copied from [SingleMapping._findColumn].
TargetEntry findColumn(int line, int column, TargetLineEntry lineEntry) {
if (lineEntry == null || lineEntry.entries.length == 0) return null;
if (lineEntry.line != line) return lineEntry.entries.last;
var entries = lineEntry.entries;
int index = binarySearch(entries, (e) => e.column > column);
return (index <= 0) ? null : entries[index - 1];
}

View file

@ -0,0 +1,146 @@
// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Representation of stack traces and logic to parse d8 stack traces.
// TODO(sigmund): we should delete this implementation and instead:
// - switch to use the stack_trace package
// - add support non-d8 frames
// - add support for secondary regexps to detect stranger frames (like eval frames)
import 'package:path/path.dart' as p;
import 'util.dart';
/// Represents a stack trace line.
class StackTraceLine {
String methodName;
String fileName;
int lineNo;
int columnNo;
StackTraceLine(this.methodName, this.fileName, this.lineNo, this.columnNo);
/// Creates a [StackTraceLine] by parsing a d8 stack trace line [text]. The
/// expected formats are
///
/// at <methodName>(<fileName>:<lineNo>:<columnNo>)
/// at <methodName>(<fileName>:<lineNo>)
/// at <methodName>(<fileName>)
/// at <fileName>:<lineNo>:<columnNo>
/// at <fileName>:<lineNo>
/// at <fileName>
///
factory StackTraceLine.fromText(String text) {
text = text.trim();
assert(text.startsWith('at '));
text = text.substring('at '.length);
String methodName;
int endParen = text.indexOf(')');
if (endParen > 0) {
int nameEnd = text.indexOf('(');
if (nameEnd != -1) {
methodName = text.substring(0, nameEnd).trim();
text = text.substring(nameEnd + 1, endParen).trim();
} else {
warn('Missing left-paren in: $text');
}
}
int lineNo;
int columnNo;
String fileName;
int lastColon = text.lastIndexOf(':');
if (lastColon != -1) {
int lastValue = int.tryParse(text.substring(lastColon + 1));
if (lastValue != null) {
int secondToLastColon = text.lastIndexOf(':', lastColon - 1);
if (secondToLastColon != -1) {
int secondToLastValue =
int.tryParse(text.substring(secondToLastColon + 1, lastColon));
if (secondToLastValue != null) {
lineNo = secondToLastValue;
columnNo = lastValue;
fileName = text.substring(0, secondToLastColon);
} else {
lineNo = lastValue;
fileName = text.substring(0, lastColon);
}
} else {
lineNo = lastValue;
fileName = text.substring(0, lastColon);
}
} else {
fileName = text;
}
} else {
fileName = text;
}
return new StackTraceLine(methodName, fileName, lineNo, columnNo ?? 1);
}
String toString() {
StringBuffer sb = new StringBuffer();
sb.write(' at ');
if (methodName != null) {
sb.write(methodName);
sb.write(' (');
sb.write(fileName ?? '?');
sb.write(':');
sb.write(lineNo);
sb.write(':');
sb.write(columnNo);
sb.write(')');
} else {
sb.write(fileName ?? '?');
sb.write(':');
sb.write(lineNo);
sb.write(':');
sb.write(columnNo);
}
return sb.toString();
}
String get inlineString {
StringBuffer sb = new StringBuffer();
var padding = 20;
if (methodName != null) {
sb.write(methodName);
padding -= (methodName.length);
if (padding <= 0) {
sb.write('\n');
padding = 20;
}
}
sb.write(' ' * padding);
if (fileName != null) {
sb.write(p.url.basename(fileName));
sb.write(' ');
sb.write(lineNo);
sb.write(':');
sb.write(columnNo);
}
return sb.toString();
}
}
List<StackTraceLine> parseStackTrace(String trace) {
List<String> lines = trace.split(new RegExp(r'(\r|\n|\r\n)'));
List<StackTraceLine> jsStackTrace = <StackTraceLine>[];
for (String line in lines) {
line = line.trim();
if (line.startsWith('at ')) {
jsStackTrace.add(new StackTraceLine.fromText(line));
}
}
return jsStackTrace;
}
/// Returns the portion of the output that corresponds to the error message.
///
/// Note: some errors can span multiple lines.
String extractErrorMessage(String trace) {
var firstStackFrame = trace.indexOf(new RegExp('\n +at'));
if (firstStackFrame == -1) return null;
var errorMarker = trace.indexOf('^') + 1;
return trace.substring(errorMarker, firstStackFrame).trim();
}

View file

@ -0,0 +1,31 @@
import 'dart:io';
import 'package:source_span/source_span.dart';
import 'dart2js_mapping.dart';
abstract class FileProvider {
String sourcesFor(Uri uri);
SourceFile fileFor(Uri uri);
Dart2jsMapping mappingFor(Uri uri);
}
class CachingFileProvider implements FileProvider {
final Map<Uri, String> _sources = {};
final Map<Uri, SourceFile> _files = {};
final Map<Uri, Dart2jsMapping> _mappings = {};
String sourcesFor(Uri uri) =>
_sources[uri] ??= new File.fromUri(uri).readAsStringSync();
SourceFile fileFor(Uri uri) =>
_files[uri] ??= new SourceFile.fromString(sourcesFor(uri));
Dart2jsMapping mappingFor(Uri uri) => _mappings[uri] ??= parseMappingFor(uri);
}
warn(String message) {
if (_seenMessages.add(message)) {
print(message);
}
}
Set<String> _seenMessages = new Set<String>();

View file

@ -0,0 +1,9 @@
name: dart2js_tools
version: 0.0.1
description: >
Collection of tools used with dart2js including analyzing compilation
information, deobfuscation of stack-traces and minified names.
dependencies:
source_maps: ^0.10.7
environment:
sdk: '>=2.0.0 <3.0.0'