blob: f0d47719683e2446cf1ee67342f7e5287e2aa019 [file]
// Copyright 2017 The Chromium Authors. 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:async';
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
import 'package:stack_trace/stack_trace.dart';
import 'base/io.dart';
import 'globals.dart';
import 'usage.dart';
/// Tells crash backend that the error is from the Flutter CLI.
String _kProductId = 'Flutter_Tools';
/// Tells crash backend that this is a Dart error as opposed to, say, Java.
String _kDartTypeId = 'DartError';
/// Crash backend host.
String _kCrashServerHost = 'clients2.google.com';
/// Path to the crash servlet.
// TODO(yjbanov): switch to non-staging when production is ready.
String _kCrashEndpointPath = '/cr/staging_report';
/// The field corresponding to the multipart/form-data file attachment where
/// crash backend expects to find the Dart stack trace.
String _kStackTraceFileField = 'DartError';
/// The name of the file attached as [_kStackTraceFileField].
///
/// The precise value is not important. It is ignored by the crash back end, but
/// it must be supplied in the request.
String _kStackTraceFilename = 'stacktrace_file';
/// We only send crash reports in testing mode.
///
/// See [enterTestingMode] and [exitTestingMode].
bool _testing = false;
/// Sends crash reports to Google.
class CrashReportSender {
static final Uri _baseUri = new Uri(
scheme: 'https',
host: _kCrashServerHost,
port: 443,
path: _kCrashEndpointPath,
);
static CrashReportSender _instance;
CrashReportSender._(this._client);
static CrashReportSender get instance => _instance ?? new CrashReportSender._(new http.Client());
/// Overrides the default [http.Client] with [client] for testing purposes.
@visibleForTesting
static void initializeWith(http.Client client) {
_instance = new CrashReportSender._(client);
}
final http.Client _client;
final Usage _usage = Usage.instance;
/// Sends one crash report.
///
/// The report is populated from data in [error] and [stackTrace].
Future<Null> sendReport({
@required dynamic error,
@required dynamic stackTrace,
@required String flutterVersion,
}) async {
try {
// TODO(yjbanov): we only actually send crash reports in tests. When we
// iron out the process, we will remove this guard and report crashes
// when !flutterUsage.suppressAnalytics.
if (!_testing)
return null;
if (_usage.suppressAnalytics)
return null;
printStatus('Sending crash report to Google.');
final Uri uri = _baseUri.replace(
queryParameters: <String, String>{
'product': _kProductId,
'version': flutterVersion,
},
);
final _MultipartRequest req = new _MultipartRequest('POST', uri);
req.fields['product'] = _kProductId;
req.fields['version'] = flutterVersion;
req.fields['type'] = _kDartTypeId;
req.fields['error_runtime_type'] = '${error.runtimeType}';
final Chain chain = stackTrace is StackTrace
? new Chain.forTrace(stackTrace)
: new Chain.parse(stackTrace.toString());
req.files.add(new http.MultipartFile.fromString(
_kStackTraceFileField,
'${chain.terse}',
filename: _kStackTraceFilename,
));
final http.StreamedResponse resp = await _client.send(req);
if (resp.statusCode == 200) {
final String reportId = await new http.ByteStream(resp.stream)
.bytesToString();
printStatus('Crash report sent (report ID: $reportId)');
} else {
printError('Failed to send crash report. Server responded with HTTP status code ${resp.statusCode}');
}
} catch (sendError, sendStackTrace) {
if (sendError is SocketException) {
printError('Failed to send crash report due to a network error: $sendError');
} else {
// If the sender itself crashes, just print. We did our best.
printError('Crash report sender itself crashed: $sendError\n$sendStackTrace');
}
}
}
}
/// Enables testing mode.
@visibleForTesting
void enterTestingMode() {
_testing = true;
}
/// Disables testing mode.
@visibleForTesting
void exitTestingMode() {
_testing = false;
}
// Below is a patched version of the MultipartRequest class from package:http
// made to conform to Flutter's style guide and to comply with the crash
// reporting backend. Specifically, the backend does not correctly handle quoted
// boundary values. The implementation below:
// - reduces boundary character set to those that do not need quotes
// - compensates for the smaller set by generating a longer boundary value
final RegExp _newlineRegExp = new RegExp(r"\r\n|\r|\n");
/// A `multipart/form-data` request. Such a request has both string [fields],
/// which function as normal form fields, and (potentially streamed) binary
/// [files].
///
/// This request automatically sets the Content-Type header to
/// `multipart/form-data`. This value will override any value set by the user.
///
/// var uri = Uri.parse("http://pub.dartlang.org/packages/create");
/// var request = new http.MultipartRequest("POST", url);
/// request.fields['user'] = 'nweiz@google.com';
/// request.files.add(new http.MultipartFile.fromFile(
/// 'package',
/// new File('build/package.tar.gz'),
/// contentType: new MediaType('application', 'x-tar'));
/// request.send().then((response) {
/// if (response.statusCode == 200) print("Uploaded!");
/// });
class _MultipartRequest extends http.BaseRequest {
/// The total length of the multipart boundaries used when building the
/// request body. According to http://tools.ietf.org/html/rfc1341.html, this
/// can't be longer than 70.
static const int _BOUNDARY_LENGTH = 70;
static final Random _random = new Random();
/// The form fields to send for this request.
final Map<String, String> fields;
/// The private version of [files].
final List<http.MultipartFile> _files;
/// Creates a new [MultipartRequest].
_MultipartRequest(String method, Uri url)
: fields = <String, String>{},
_files = <http.MultipartFile>[],
super(method, url);
/// The list of files to upload for this request.
List<http.MultipartFile> get files => _files;
/// The total length of the request body, in bytes. This is calculated from
/// [fields] and [files] and cannot be set manually.
@override
int get contentLength {
int length = 0;
fields.forEach((String name, String value) {
length += "--".length + _BOUNDARY_LENGTH + "\r\n".length +
UTF8.encode(_headerForField(name, value)).length +
UTF8.encode(value).length + "\r\n".length;
});
for (http.MultipartFile file in _files) {
length += "--".length + _BOUNDARY_LENGTH + "\r\n".length +
UTF8.encode(_headerForFile(file)).length +
file.length + "\r\n".length;
}
return length + "--".length + _BOUNDARY_LENGTH + "--\r\n".length;
}
@override
set contentLength(int value) {
throw new UnsupportedError("Cannot set the contentLength property of "
"multipart requests.");
}
/// Freezes all mutable fields and returns a single-subscription [ByteStream]
/// that will emit the request body.
@override
http.ByteStream finalize() {
final String boundary = _boundaryString();
headers['content-type'] = 'multipart/form-data; boundary=$boundary';
super.finalize();
final StreamController<List<int>> controller = new StreamController<List<int>>(sync: true);
void writeAscii(String string) {
controller.add(UTF8.encode(string));
}
void writeUtf8(String string) {
controller.add(UTF8.encode(string));
}
void writeLine() {
controller.add(<int>[13, 10]); // \r\n
}
fields.forEach((String name, String value) {
writeAscii('--$boundary\r\n');
writeAscii(_headerForField(name, value));
writeUtf8(value);
writeLine();
});
Future.forEach(_files, (http.MultipartFile file) {
writeAscii('--$boundary\r\n');
writeAscii(_headerForFile(file));
return writeStreamToSink(file.finalize(), controller)
.then((dynamic _) => writeLine());
}).then<Null>((dynamic _) {
writeAscii('--$boundary--\r\n');
controller.close();
});
return new http.ByteStream(controller.stream);
}
/// Valid boundary character codes that do not need to be quoted. From
/// http://tools.ietf.org/html/rfc2046#section-5.1.1.
static const List<int> _BOUNDARY_CHARACTERS = const <int>[
// Digits
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
// Capital letters
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75,
76,
77,
78,
79,
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
// Small letters
97,
98,
99,
100,
101,
102,
103,
104,
105,
106,
107,
108,
109,
110,
111,
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
];
/// Returns the header string for a field. The return value is guaranteed to
/// contain only ASCII characters.
String _headerForField(String name, String value) {
String header =
'content-disposition: form-data; name="${_browserEncode(name)}"';
if (!isPlainAscii(value)) {
header = '$header\r\n'
'content-type: text/plain; charset=utf-8\r\n'
'content-transfer-encoding: binary';
}
return '$header\r\n\r\n';
}
/// Returns the header string for a file. The return value is guaranteed to
/// contain only ASCII characters.
String _headerForFile(http.MultipartFile file) {
String header = 'content-type: ${file.contentType}\r\n'
'content-disposition: form-data; name="${_browserEncode(file.field)}"';
if (file.filename != null) {
header = '$header; filename="${_browserEncode(file.filename)}"';
}
return '$header\r\n\r\n';
}
/// Encode [value] in the same way browsers do.
String _browserEncode(String value) {
// http://tools.ietf.org/html/rfc2388 mandates some complex encodings for
// field names and file names, but in practice user agents seem not to
// follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as
// `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII
// characters). We follow their behavior.
return value.replaceAll(_newlineRegExp, "%0D%0A").replaceAll('"', "%22");
}
/// Returns a randomly-generated multipart boundary string
String _boundaryString() {
final String prefix = "dart-";
final List<int> list = new List<int>.generate(_BOUNDARY_LENGTH - prefix.length,
(int index) =>
_BOUNDARY_CHARACTERS[_random.nextInt(_BOUNDARY_CHARACTERS.length)],
growable: false);
return "$prefix${new String.fromCharCodes(list)}";
}
}
/// Pipes all data and errors from [stream] into [sink]. Completes [Future] once
/// [stream] is done. Unlike [store], [sink] remains open after [stream] is
/// done.
Future<Null> writeStreamToSink<O, I extends O>(Stream<I> stream, EventSink<O> sink) {
final Completer<Null> completer = new Completer<Null>();
stream.listen(sink.add,
onError: sink.addError,
onDone: () => completer.complete());
return completer.future;
}
/// A regular expression that matches strings that are composed entirely of
/// ASCII-compatible characters.
final RegExp _kAsciiOnly = new RegExp(r"^[\x00-\x7F]+$");
/// Returns whether [string] is composed entirely of ASCII-compatible
/// characters.
bool isPlainAscii(String string) => _kAsciiOnly.hasMatch(string);