Skip to content

Commit ac751cc

Browse files
author
Jonah Williams
authored
[flutter_tools] verify checksum of downloaded artifacts (flutter#67839)
All of the network requests from google cloud storage include an x-goog-hash header which contains an MD5 checksum. If present, use to validate that the downloaded binary is valid. This will rule out corrupt files as the cause of getting started crashers in the flutter_tool. flutter#38980 This does not fully resolve the above issue, because while we can check if the checksum matches what was expected from cloud storage, this A) may not necessarily be present and B) may not match up to what should be uploaded as part of the engine build process. But when life gives you lemons you hash those lemons using an outdated hashing algorithm.
1 parent eb24393 commit ac751cc

File tree

2 files changed

+168
-3
lines changed

2 files changed

+168
-3
lines changed

packages/flutter_tools/lib/src/cache.dart

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:async';
6+
57
import 'package:archive/archive.dart';
8+
import 'package:crypto/crypto.dart';
69
import 'package:file/memory.dart';
710
import 'package:meta/meta.dart';
811
import 'package:package_config/package_config.dart';
@@ -12,12 +15,13 @@ import 'android/gradle_utils.dart';
1215
import 'base/common.dart';
1316
import 'base/error_handling_io.dart';
1417
import 'base/file_system.dart';
15-
import 'base/io.dart' show HttpClient, HttpClientRequest, HttpClientResponse, HttpStatus, ProcessException, SocketException;
18+
import 'base/io.dart' show HttpClient, HttpClientRequest, HttpClientResponse, HttpHeaders, HttpStatus, ProcessException, SocketException;
1619
import 'base/logger.dart';
1720
import 'base/net.dart';
1821
import 'base/os.dart' show OperatingSystemUtils;
1922
import 'base/platform.dart';
2023
import 'base/process.dart';
24+
import 'convert.dart';
2125
import 'dart/package_map.dart';
2226
import 'dart/pub.dart';
2327
import 'features.dart';
@@ -1610,7 +1614,7 @@ class ArtifactUpdater {
16101614
retries -= 1;
16111615
if (retries == 0) {
16121616
throwToolExit(
1613-
'Failed to download $url. Ensure you have network connectivity and then try again.',
1617+
'Failed to download $url. Ensure you have network connectivity and then try again.\n$err',
16141618
);
16151619
}
16161620
continue;
@@ -1656,15 +1660,69 @@ class ArtifactUpdater {
16561660
}
16571661

16581662
/// Download bytes from [url], throwing non-200 responses as an exception.
1663+
///
1664+
/// Validates that the md5 of the content bytes matches the provided
1665+
/// `x-goog-hash` header, if present. This header should contain an md5 hash
1666+
/// if the download source is Google cloud storage.
1667+
///
1668+
/// See also:
1669+
/// * https://cloud.google.com/storage/docs/xml-api/reference-headers#xgooghash
16591670
Future<void> _download(Uri url, File file) async {
16601671
final HttpClientRequest request = await _httpClient.getUrl(url);
16611672
final HttpClientResponse response = await request.close();
16621673
if (response.statusCode != HttpStatus.ok) {
16631674
throw Exception(response.statusCode);
16641675
}
1676+
1677+
final String md5Hash = _expectedMd5(response.headers);
1678+
ByteConversionSink inputSink;
1679+
StreamController<Digest> digests;
1680+
if (md5Hash != null) {
1681+
_logger.printTrace('Content $url md5 hash: $md5Hash');
1682+
digests = StreamController<Digest>();
1683+
inputSink = md5.startChunkedConversion(digests);
1684+
}
1685+
final RandomAccessFile randomAccessFile = file.openSync(mode: FileMode.writeOnly);
16651686
await response.forEach((List<int> chunk) {
1666-
file.writeAsBytesSync(chunk, mode: FileMode.append);
1687+
inputSink?.add(chunk);
1688+
randomAccessFile.writeFromSync(chunk);
16671689
});
1690+
randomAccessFile.closeSync();
1691+
if (inputSink != null) {
1692+
inputSink.close();
1693+
final Digest digest = await digests.stream.last;
1694+
final String rawDigest = base64.encode(digest.bytes);
1695+
if (rawDigest != md5Hash) {
1696+
throw Exception(''
1697+
'Expected $url to have md5 checksum $md5Hash, but was $rawDigest. This '
1698+
'may indicate a problem with your connection to the Flutter backend servers. '
1699+
'Please re-try the download after confirming that your network connection is '
1700+
'stable.'
1701+
);
1702+
}
1703+
}
1704+
}
1705+
1706+
String _expectedMd5(HttpHeaders httpHeaders) {
1707+
final List<String> values = httpHeaders['x-goog-hash'];
1708+
if (values == null) {
1709+
return null;
1710+
}
1711+
final String rawMd5Hash = values.firstWhere((String value) {
1712+
return value.startsWith('md5=');
1713+
}, orElse: () => null);
1714+
if (rawMd5Hash == null) {
1715+
return null;
1716+
}
1717+
final List<String> segments = rawMd5Hash.split('md5=');
1718+
if (segments.length < 2) {
1719+
return null;
1720+
}
1721+
final String md5Hash = segments[1];
1722+
if (md5Hash.isEmpty) {
1723+
return null;
1724+
}
1725+
return md5Hash;
16681726
}
16691727

16701728
/// Create a temporary file and invoke [onTemporaryFile] with the file as

packages/flutter_tools/test/general.shard/artifact_updater_test.dart

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,97 @@ void main() {
4444
expect(fileSystem.file('out/test'), exists);
4545
});
4646

47+
testWithoutContext('ArtifactUpdater will not validate the md5 hash if the '
48+
'x-goog-hash header is present but missing an md5 entry', () async {
49+
final MockOperatingSystemUtils operatingSystemUtils = MockOperatingSystemUtils();
50+
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
51+
final BufferLogger logger = BufferLogger.test();
52+
final MockHttpClient client = MockHttpClient();
53+
client.testRequest.testResponse.headers = FakeHttpHeaders(<String, List<String>>{
54+
'x-goog-hash': <String>[],
55+
});
56+
57+
final ArtifactUpdater artifactUpdater = ArtifactUpdater(
58+
fileSystem: fileSystem,
59+
logger: logger,
60+
operatingSystemUtils: operatingSystemUtils,
61+
platform: testPlatform,
62+
httpClient: client,
63+
tempStorage: fileSystem.currentDirectory.childDirectory('temp')
64+
..createSync(),
65+
);
66+
67+
await artifactUpdater.downloadZipArchive(
68+
'test message',
69+
Uri.parse('http:///test.zip'),
70+
fileSystem.currentDirectory.childDirectory('out'),
71+
);
72+
expect(logger.statusText, contains('test message'));
73+
expect(fileSystem.file('out/test'), exists);
74+
});
75+
76+
testWithoutContext('ArtifactUpdater will validate the md5 hash if the '
77+
'x-goog-hash header is present', () async {
78+
final MockOperatingSystemUtils operatingSystemUtils = MockOperatingSystemUtils();
79+
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
80+
final BufferLogger logger = BufferLogger.test();
81+
final MockHttpClient client = MockHttpClient();
82+
client.testRequest.testResponse.headers = FakeHttpHeaders(<String, List<String>>{
83+
'x-goog-hash': <String>[
84+
'foo-bar-baz',
85+
'md5=k7iFrf4NoInN9jSQT9WfcQ=='
86+
],
87+
});
88+
89+
final ArtifactUpdater artifactUpdater = ArtifactUpdater(
90+
fileSystem: fileSystem,
91+
logger: logger,
92+
operatingSystemUtils: operatingSystemUtils,
93+
platform: testPlatform,
94+
httpClient: client,
95+
tempStorage: fileSystem.currentDirectory.childDirectory('temp')
96+
..createSync(),
97+
);
98+
99+
await artifactUpdater.downloadZipArchive(
100+
'test message',
101+
Uri.parse('http:///test.zip'),
102+
fileSystem.currentDirectory.childDirectory('out'),
103+
);
104+
expect(logger.statusText, contains('test message'));
105+
expect(fileSystem.file('out/test'), exists);
106+
});
107+
108+
testWithoutContext('ArtifactUpdater will validate the md5 hash if the '
109+
'x-goog-hash header is present and throw if it does not match', () async {
110+
final MockOperatingSystemUtils operatingSystemUtils = MockOperatingSystemUtils();
111+
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
112+
final BufferLogger logger = BufferLogger.test();
113+
final MockHttpClient client = MockHttpClient();
114+
client.testRequest.testResponse.headers = FakeHttpHeaders(<String, List<String>>{
115+
'x-goog-hash': <String>[
116+
'foo-bar-baz',
117+
'md5=k7iFrf4SQT9WfcQ=='
118+
],
119+
});
120+
121+
final ArtifactUpdater artifactUpdater = ArtifactUpdater(
122+
fileSystem: fileSystem,
123+
logger: logger,
124+
operatingSystemUtils: operatingSystemUtils,
125+
platform: testPlatform,
126+
httpClient: client,
127+
tempStorage: fileSystem.currentDirectory.childDirectory('temp')
128+
..createSync(),
129+
);
130+
131+
await expectLater(() async => await artifactUpdater.downloadZipArchive(
132+
'test message',
133+
Uri.parse('http:///test.zip'),
134+
fileSystem.currentDirectory.childDirectory('out'),
135+
), throwsToolExit(message: 'k7iFrf4SQT9WfcQ==')); // validate that the hash mismatch message is included.
136+
});
137+
47138
testWithoutContext('ArtifactUpdater will restart the status ticker if it needs to retry the download', () async {
48139
final MockOperatingSystemUtils operatingSystemUtils = MockOperatingSystemUtils();
49140
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
@@ -353,6 +444,7 @@ class MockHttpClient extends Mock implements HttpClient {
353444
return testRequest;
354445
}
355446
}
447+
356448
class MockHttpClientRequest extends Mock implements HttpClientRequest {
357449
final MockHttpClientResponse testResponse = MockHttpClientResponse();
358450

@@ -361,13 +453,28 @@ class MockHttpClientRequest extends Mock implements HttpClientRequest {
361453
return testResponse;
362454
}
363455
}
456+
364457
class MockHttpClientResponse extends Mock implements HttpClientResponse {
365458
@override
366459
int statusCode = HttpStatus.ok;
367460

461+
@override
462+
HttpHeaders headers = FakeHttpHeaders(<String, List<String>>{});
463+
368464
@override
369465
Future<void> forEach(void Function(List<int> element) action) async {
370466
action(<int>[0]);
371467
return;
372468
}
373469
}
470+
471+
class FakeHttpHeaders extends Fake implements HttpHeaders {
472+
FakeHttpHeaders(this.values);
473+
474+
final Map<String, List<String>> values;
475+
476+
@override
477+
List<String> operator [](String key) {
478+
return values[key];
479+
}
480+
}

0 commit comments

Comments
 (0)