diff --git a/lib/matrix.dart b/lib/matrix.dart index cca0e0272..94eee502c 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -72,6 +72,7 @@ export 'src/models/receipts.dart'; export 'src/utils/sync_update_extension.dart'; export 'src/utils/to_device_event.dart'; export 'src/utils/uia_request.dart'; +export 'src/utils/content_scanner_config.dart'; export 'src/utils/uri_extension.dart'; export 'src/models/login_type.dart'; export 'src/models/power_level.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index 912cafc89..bcc36c839 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -125,6 +125,13 @@ class Client extends MatrixApi { MatrixImageFileResizeArguments, )? customImageResizer; + /// Optional matrix-content-scanner proxy configuration. + /// + /// When set, media URL helpers resolve `mxc://` URIs to scanner URLs, and + /// attachment downloads use the scanner for network requests. Cached media is + /// trusted and is not scanned again. + MatrixContentScannerConfig? contentScannerConfig; + /// The compare function how the rooms should be sorted internally. /// The [defaultRoomSorter] is used if no custom room sorter is provided. RoomSorter? _customRoomSorter; @@ -219,6 +226,7 @@ class Client extends MatrixApi { this.enableLatexMarkdown = true, this.dehydratedDeviceDisplayName = 'Dehydrated Device', RoomSorter? customRoomSorter, + this.contentScannerConfig, }) : _database = database, syncFilter = syncFilter ?? Filter( diff --git a/lib/src/event.dart b/lib/src/event.dart index 2938f8e99..4e26a285c 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -673,7 +673,7 @@ class Event extends MatrixEvent { /// Throws an exception if the scheme is not `mxc` or the homeserver is not /// set. /// - /// Important! To use this link you have to set a http header like this: + /// Scanner and authenticated media URLs may need an authorization header: /// `headers: {"authorization": "Bearer ${client.accessToken}"}` Future getAttachmentUri({ bool getThumbnail = false, @@ -731,9 +731,13 @@ class Event extends MatrixEvent { /// Throws an exception if the scheme is not `mxc` or the homeserver is not /// set. /// - /// Important! To use this link you have to set a http header like this: + /// Deprecated: scanner-unaware. Use [getAttachmentUri] instead. + /// + /// This URL may need an authorization header: /// `headers: {"authorization": "Bearer ${client.accessToken}"}` - @Deprecated('Use getAttachmentUri() instead') + @Deprecated( + 'Use getAttachmentUri() instead. This legacy helper is scanner-unaware.', + ) Uri? getAttachmentUrl({ bool getThumbnail = false, bool useThumbnailMxcUrl = false, @@ -809,6 +813,10 @@ class Event extends MatrixEvent { /// true to download the thumbnail instead. Set [fromLocalStoreOnly] to true /// if you want to retrieve the attachment from the local store only without /// making http request. + /// + /// With `Client.contentScannerConfig`, network downloads use the scanner. + /// Scanner errors throw [ContentScannerException]. For encrypted scanner + /// downloads, [downloadCallback] is ignored. Future downloadAndDecryptAttachment({ bool getThumbnail = false, Future Function(Uri)? downloadCallback, @@ -842,26 +850,38 @@ class Event extends MatrixEvent { final thisInfoMapSize = thisInfoMap.tryGet('size'); var storeable = thisInfoMapSize != null && thisInfoMapSize <= database.maxFileSize; - Uint8List? uint8list; if (storeable) { uint8list = await room.client.database.getFile(mxcUrl); } // Download the file + final scanner = room.client.contentScannerConfig; + final useScannerForEncrypted = scanner != null && isEncrypted; final canDownloadFileFromServer = uint8list == null && !fromLocalStoreOnly; if (canDownloadFileFromServer) { - final httpClient = room.client.httpClient; - downloadCallback ??= (Uri url) async { - final request = http.Request('GET', url); - request.headers['authorization'] = 'Bearer ${room.client.accessToken}'; - - final response = await httpClient.send(request); - - return await response.stream.toBytesWithProgress(onDownloadProgress); - }; - uint8list = - await downloadCallback(await mxcUrl.getDownloadUri(room.client)); + if (useScannerForEncrypted) { + final fileMap = getThumbnail + ? infoMap.tryGetMap('thumbnail_file') + : content.tryGetMap('file'); + if (fileMap == null) throw ('No encrypted file info found'); + + uint8list = await _downloadEncryptedAttachmentViaScanner( + scanner: scanner, + fileMap: fileMap, + ); + } else { + final downloadUri = await mxcUrl.getDownloadUri(room.client); + if (downloadCallback != null) { + uint8list = await downloadCallback(downloadUri); + } else { + uint8list = await _downloadAttachmentBytes( + downloadUri, + scanner: scanner, + onDownloadProgress: onDownloadProgress, + ); + } + } storeable = storeable && uint8list.lengthInBytes < database.maxFileSize; if (storeable) { await database.storeFile( @@ -874,7 +894,7 @@ class Event extends MatrixEvent { throw ('Unable to download file from local store.'); } - // Decrypt the file + // Scanner downloads still return encrypted media bytes. if (isEncrypted) { final fileMap = getThumbnail ? infoMap.tryGetMap('thumbnail_file') @@ -914,6 +934,47 @@ class Event extends MatrixEvent { ); } + Future _downloadEncryptedAttachmentViaScanner({ + required MatrixContentScannerConfig scanner, + required Map fileMap, + }) async { + final request = http.Request('POST', scanner.downloadEncryptedUri); + request.headers['content-type'] = 'application/json'; + if (scanner.withAuthHeader) { + request.headers['authorization'] = 'Bearer ${room.client.accessToken}'; + } + request.body = jsonEncode({'file': fileMap}); + + final streamed = await room.client.httpClient.send(request); + final response = await http.Response.fromStream(streamed); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw parseContentScannerError(response); + } + + return response.bodyBytes; + } + + Future _downloadAttachmentBytes( + Uri url, { + required MatrixContentScannerConfig? scanner, + void Function(int)? onDownloadProgress, + }) async { + final request = http.Request('GET', url); + if (scanner == null || scanner.withAuthHeader) { + request.headers['authorization'] = 'Bearer ${room.client.accessToken}'; + } + + final response = await room.client.httpClient.send(request); + if (scanner != null && + (response.statusCode < 200 || response.statusCode >= 300)) { + throw parseContentScannerError( + await http.Response.fromStream(response), + ); + } + + return response.stream.toBytesWithProgress(onDownloadProgress); + } + /// Returns if this is a known event type. bool get isEventTypeKnown => EventLocalizations.localizationsMap.containsKey(type); diff --git a/lib/src/utils/content_scanner_config.dart b/lib/src/utils/content_scanner_config.dart new file mode 100644 index 000000000..78104e455 --- /dev/null +++ b/lib/src/utils/content_scanner_config.dart @@ -0,0 +1,114 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2026 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Scanner endpoints used for Matrix media downloads. +class MatrixContentScannerConfig { + /// Base URI for media downloads. `{serverName}/{mediaId}` is appended. + final Uri downloadUri; + + /// Base URI for thumbnails. Thumbnail query parameters are preserved. + final Uri downloadThumbnailUri; + + /// Full URI of the scanner's `download_encrypted` endpoint. + final Uri downloadEncryptedUri; + + /// Whether scanner requests should include the Matrix access token. + final bool withAuthHeader; + + /// Preview hint from scanner discovery. Stored for app-level decisions. + final bool scanBeforePreview; + + MatrixContentScannerConfig({ + required Uri downloadUri, + required Uri downloadThumbnailUri, + required this.downloadEncryptedUri, + this.withAuthHeader = true, + this.scanBeforePreview = false, + }) : downloadUri = _ensureTrailingSlash(downloadUri), + downloadThumbnailUri = _ensureTrailingSlash(downloadThumbnailUri); + + factory MatrixContentScannerConfig.fromJson(Map json) => + MatrixContentScannerConfig( + downloadUri: Uri.parse(json['download_uri']! as String), + downloadThumbnailUri: + Uri.parse(json['download_thumbnail_uri']! as String), + downloadEncryptedUri: Uri.parse(json['download_encrypted']! as String), + withAuthHeader: (json['with_auth_header'] as bool?) ?? true, + scanBeforePreview: (json['scan_before_preview'] as bool?) ?? false, + ); + + Map toJson() => { + 'download_uri': downloadUri.toString(), + 'download_thumbnail_uri': downloadThumbnailUri.toString(), + 'download_encrypted': downloadEncryptedUri.toString(), + 'with_auth_header': withAuthHeader, + 'scan_before_preview': scanBeforePreview, + }; + + static Uri _ensureTrailingSlash(Uri uri) { + if (uri.path.endsWith('/')) return uri; + return uri.replace(path: '${uri.path}/'); + } +} + +/// Thrown when the content scanner returns a non-2xx response. +class ContentScannerException implements Exception { + /// Scanner error code, or `M_UNKNOWN` when the body cannot be parsed. + final String reason; + + /// Human-readable scanner error. + final String info; + + /// HTTP status code. + final int statusCode; + + const ContentScannerException({ + required this.reason, + required this.info, + required this.statusCode, + }); + + @override + String toString() => 'ContentScannerException($statusCode $reason): $info'; +} + +/// Parses scanner and Matrix-style error responses. +ContentScannerException parseContentScannerError(http.Response response) { + var reason = 'M_UNKNOWN'; + var info = response.reasonPhrase ?? response.body; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final r = decoded['reason'] ?? decoded['errcode']; + final i = decoded['info'] ?? decoded['error']; + if (r is String) reason = r; + if (i is String) info = i; + } + } catch (_) { + // Body is not JSON - keep defaults. + } + return ContentScannerException( + reason: reason, + info: info, + statusCode: response.statusCode, + ); +} diff --git a/lib/src/utils/uri_extension.dart b/lib/src/utils/uri_extension.dart index 95b9fa601..f29063949 100644 --- a/lib/src/utils/uri_extension.dart +++ b/lib/src/utils/uri_extension.dart @@ -27,9 +27,16 @@ extension MxcUriExtension on Uri { /// Throws an exception if the scheme is not `mxc` or the homeserver is not /// set. /// - /// Important! To use this link you have to set a http header like this: + /// Scanner and authenticated media URLs may need an authorization header: /// `headers: {"authorization": "Bearer ${client.accessToken}"}` Future getDownloadUri(Client client) async { + if (!isScheme('mxc')) return Uri(); + + final scanner = client.contentScannerConfig; + if (scanner != null) { + return _appendMxcTo(scanner.downloadUri); + } + String uriPath; if (await client.authenticatedMediaSupported()) { @@ -40,11 +47,18 @@ extension MxcUriExtension on Uri { '_matrix/media/v3/download/$host${hasPort ? ':$port' : ''}$path'; } - return isScheme('mxc') - ? client.homeserver != null - ? client.homeserver?.resolve(uriPath) ?? Uri() - : Uri() - : Uri(); + final homeserver = client.homeserver; + if (homeserver == null) return Uri(); + + return homeserver.resolve(uriPath); + } + + Uri _appendMxcTo(Uri base) { + final reference = '$host${hasPort ? ':$port' : ''}$path'; + final trimmed = + reference.startsWith('/') ? reference.substring(1) : reference; + final basePath = base.path.endsWith('/') ? base.path : '${base.path}/'; + return base.replace(path: '$basePath$trimmed'); } /// Transforms this `mxc://` Uri into a `http` resource, which can be used @@ -57,7 +71,7 @@ extension MxcUriExtension on Uri { /// Throws an exception if the scheme is not `mxc` or the homeserver is not /// set. /// - /// Important! To use this link you have to set a http header like this: + /// Scanner and authenticated media URLs may need an authorization header: /// `headers: {"authorization": "Bearer ${client.accessToken}"}` Future getThumbnailUri( Client client, { @@ -67,6 +81,20 @@ extension MxcUriExtension on Uri { bool? animated = false, }) async { if (!isScheme('mxc')) return Uri(); + + final queryParameters = { + if (width != null) 'width': width.round().toString(), + if (height != null) 'height': height.round().toString(), + if (method != null) 'method': method.toString().split('.').last, + if (animated != null) 'animated': animated.toString(), + }; + + final scanner = client.contentScannerConfig; + if (scanner != null) { + return _appendMxcTo(scanner.downloadThumbnailUri) + .replace(queryParameters: queryParameters); + } + final homeserver = client.homeserver; if (homeserver == null) { return Uri(); @@ -86,16 +114,13 @@ extension MxcUriExtension on Uri { host: homeserver.host, path: requestPath, port: homeserver.port, - queryParameters: { - if (width != null) 'width': width.round().toString(), - if (height != null) 'height': height.round().toString(), - if (method != null) 'method': method.toString().split('.').last, - if (animated != null) 'animated': animated.toString(), - }, + queryParameters: queryParameters, ); } - @Deprecated('Use `getDownloadUri()` instead') + @Deprecated( + 'Use `getDownloadUri()` instead. This legacy helper is scanner-unaware.', + ) Uri getDownloadLink(Client matrix) => isScheme('mxc') ? matrix.homeserver != null ? matrix.homeserver?.resolve( @@ -110,7 +135,9 @@ extension MxcUriExtension on Uri { /// `ThumbnailMethod.scale` and defaults to `ThumbnailMethod.scale`. /// If `animated` (default false) is set to true, an animated thumbnail is requested /// as per MSC2705. Thumbnails only animate if the media repository supports that. - @Deprecated('Use `getThumbnailUri()` instead') + @Deprecated( + 'Use `getThumbnailUri()` instead. This legacy helper is scanner-unaware.', + ) Uri getThumbnail( Client matrix, { num? width, diff --git a/test/content_scanner_test.dart b/test/content_scanner_test.dart new file mode 100644 index 000000000..493b7fb5c --- /dev/null +++ b/test/content_scanner_test.dart @@ -0,0 +1,566 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2026 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +import 'package:matrix/matrix.dart'; +import 'fake_database.dart'; + +const _mxc = 'mxc://example.org/abcd1234'; +const _encryptedMxc = 'mxc://example.com/file'; +const _accessToken = 'test-token'; +const _encryptedFileKey = '7aPRNIDPeUAUqD6SPR3vVX5W9liyMG98NexVJ9udnCc'; +const _encryptedFileIv = 'Wdsf+tnOHIoAAAAAAAAAAA'; +const _encryptedFileSha256 = 'WgC7fw2alBC5t+xDx+PFlZxfFJXtIstQCg+j0WDaXxE'; + +MatrixContentScannerConfig _config({ + bool withAuthHeader = true, + bool scanBeforePreview = false, +}) => + MatrixContentScannerConfig( + downloadUri: Uri.parse( + 'https://scanner.example/_matrix/media_proxy/unstable/download/', + ), + downloadThumbnailUri: Uri.parse( + 'https://scanner.example/_matrix/media_proxy/unstable/thumbnail/', + ), + downloadEncryptedUri: Uri.parse( + 'https://scanner.example/_matrix/media_proxy/unstable/download_encrypted', + ), + withAuthHeader: withAuthHeader, + scanBeforePreview: scanBeforePreview, + ); + +class _ScannerTestClient extends Client { + _ScannerTestClient({ + required DatabaseApi database, + required http.Client httpClient, + MatrixContentScannerConfig? scanner, + NativeImplementations nativeImplementations = NativeImplementations.dummy, + this.encryptionEnabledForTest = false, + }) : super( + 'scanner-test', + database: database, + httpClient: httpClient, + contentScannerConfig: scanner, + nativeImplementations: nativeImplementations, + ); + + final bool encryptionEnabledForTest; + + @override + bool get encryptionEnabled => encryptionEnabledForTest; +} + +Future _freshClient({ + required http.Client httpClient, + MatrixContentScannerConfig? scanner, + NativeImplementations nativeImplementations = NativeImplementations.dummy, + bool encryptionEnabled = false, +}) async { + final client = _ScannerTestClient( + database: await getDatabase(), + httpClient: httpClient, + scanner: scanner, + nativeImplementations: nativeImplementations, + encryptionEnabledForTest: encryptionEnabled, + ); + client.accessToken = _accessToken; + return client; +} + +class _DecryptingNativeImplementations extends NativeImplementationsDummy { + _DecryptingNativeImplementations({ + required this.expectedEncryptedBytes, + required this.decryptedBytes, + }); + + final Uint8List expectedEncryptedBytes; + final Uint8List decryptedBytes; + EncryptedFile? seenFile; + + @override + Future decryptFile( + EncryptedFile file, { + bool retryInDummy = true, + }) async { + seenFile = file; + return _bytesEqual(file.data, expectedEncryptedBytes) && + file.k == _encryptedFileKey && + file.iv == _encryptedFileIv && + file.sha256 == _encryptedFileSha256 + ? decryptedBytes + : null; + } +} + +bool _bytesEqual(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +void main() { + Logs().level = Level.error; + + group('MatrixContentScannerConfig', () { + test('fromJson / toJson roundtrip', () { + final json = { + 'download_uri': + 'https://scanner.example/_matrix/media_proxy/unstable/download/', + 'download_thumbnail_uri': + 'https://scanner.example/_matrix/media_proxy/unstable/thumbnail/', + 'download_encrypted': + 'https://scanner.example/_matrix/media_proxy/unstable/download_encrypted', + 'with_auth_header': true, + 'scan_before_preview': true, + }; + final cfg = MatrixContentScannerConfig.fromJson(json); + expect(cfg.downloadUri.toString(), json['download_uri']); + expect( + cfg.downloadThumbnailUri.toString(), + json['download_thumbnail_uri'], + ); + expect(cfg.downloadEncryptedUri.toString(), json['download_encrypted']); + expect(cfg.withAuthHeader, true); + expect(cfg.scanBeforePreview, true); + expect(cfg.toJson(), json); + }); + + test('fromJson defaults optional flags', () { + final cfg = MatrixContentScannerConfig.fromJson({ + 'download_uri': 'https://s.example/download/', + 'download_thumbnail_uri': 'https://s.example/thumbnail/', + 'download_encrypted': 'https://s.example/download_encrypted', + }); + expect(cfg.withAuthHeader, true); + expect(cfg.scanBeforePreview, false); + }); + + test('ensures trailing slash on base URIs', () { + final cfg = MatrixContentScannerConfig( + downloadUri: Uri.parse('https://s.example/download'), + downloadThumbnailUri: Uri.parse('https://s.example/thumb'), + downloadEncryptedUri: Uri.parse('https://s.example/download_encrypted'), + ); + expect(cfg.downloadUri.toString(), 'https://s.example/download/'); + expect(cfg.downloadThumbnailUri.toString(), 'https://s.example/thumb/'); + }); + }); + + group('URI routing', () { + test('getDownloadUri routes through scanner when config is set', () async { + final client = await _freshClient( + httpClient: MockClient((_) async => http.Response('', 404)), + scanner: _config(), + ); + final uri = await Uri.parse(_mxc).getDownloadUri(client); + expect( + uri.toString(), + 'https://scanner.example/_matrix/media_proxy/unstable/download/example.org/abcd1234', + ); + await client.dispose(closeDatabase: true); + }); + + test('getThumbnailUri routes through scanner and preserves query params', + () async { + final client = await _freshClient( + httpClient: MockClient((_) async => http.Response('', 404)), + scanner: _config(), + ); + final uri = await Uri.parse(_mxc).getThumbnailUri( + client, + width: 80, + height: 60, + method: ThumbnailMethod.scale, + animated: true, + ); + expect( + uri.toString(), + 'https://scanner.example/_matrix/media_proxy/unstable/thumbnail/example.org/abcd1234' + '?width=80&height=60&method=scale&animated=true', + ); + await client.dispose(closeDatabase: true); + }); + + test('getDownloadUri preserves MXC port in server part', () async { + final client = await _freshClient( + httpClient: MockClient((_) async => http.Response('', 404)), + scanner: _config(), + ); + final uri = await Uri.parse('mxc://example.org:8448/media123') + .getDownloadUri(client); + expect( + uri.toString(), + 'https://scanner.example/_matrix/media_proxy/unstable/download/example.org:8448/media123', + ); + await client.dispose(closeDatabase: true); + }); + }); + + group('ContentScannerException', () { + test('parses reason and info from JSON error body', () { + final resp = http.Response( + jsonEncode({'reason': 'MCS_MEDIA_NOT_CLEAN', 'info': '***VIRUS***'}), + 403, + ); + final ex = parseContentScannerError(resp); + expect(ex.reason, 'MCS_MEDIA_NOT_CLEAN'); + expect(ex.info, '***VIRUS***'); + expect(ex.statusCode, 403); + expect( + ex.toString(), + contains('ContentScannerException(403 MCS_MEDIA_NOT_CLEAN)'), + ); + }); + + test('falls back to M_UNKNOWN on non-JSON body', () { + final resp = http.Response('500', 500, reasonPhrase: 'oops'); + final ex = parseContentScannerError(resp); + expect(ex.reason, 'M_UNKNOWN'); + expect(ex.info, 'oops'); + expect(ex.statusCode, 500); + }); + + test('accepts Matrix-style {errcode, error} shape for router 404s', () { + final resp = http.Response( + jsonEncode({ + 'errcode': 'M_UNRECOGNIZED', + 'error': 'Unrecognized request', + }), + 404, + ); + final ex = parseContentScannerError(resp); + expect(ex.reason, 'M_UNRECOGNIZED'); + expect(ex.info, 'Unrecognized request'); + expect(ex.statusCode, 404); + }); + }); + + group('downloadAndDecryptAttachment + scanner', () { + Event buildEncryptedEvent(Room room) => Event.fromJson( + { + 'type': EventTypes.Message, + 'event_id': '\$evt1', + 'sender': '@alice:example.org', + 'origin_server_ts': 0, + 'content': { + 'msgtype': 'm.file', + 'body': 'secret.bin', + 'filename': 'secret.bin', + 'info': {'mimetype': 'application/octet-stream', 'size': 5}, + 'file': { + 'v': 'v2', + 'url': _encryptedMxc, + 'key': { + 'alg': 'A256CTR', + 'ext': true, + 'key_ops': ['encrypt', 'decrypt'], + 'kty': 'oct', + 'k': _encryptedFileKey, + }, + 'iv': _encryptedFileIv, + 'hashes': {'sha256': _encryptedFileSha256}, + }, + }, + }, + room, + ); + + test('POSTs to download_encrypted with file JSON and auth header', + () async { + http.Request? seenRequest; + final mockHttp = MockClient((req) async { + seenRequest = req; + return http.Response( + jsonEncode({'reason': 'MCS_MEDIA_REQUEST_FAILED', 'info': 'boom'}), + 502, + ); + }); + final client = await _freshClient( + httpClient: mockHttp, + scanner: _config(), + encryptionEnabled: true, + ); + final room = Room(id: '!room:example.org', client: client); + final event = buildEncryptedEvent(room); + + await expectLater( + event.downloadAndDecryptAttachment(), + throwsA(isA()), + ); + + expect(seenRequest, isNotNull); + expect(seenRequest!.method, 'POST'); + expect( + seenRequest!.url.toString(), + 'https://scanner.example/_matrix/media_proxy/unstable/download_encrypted', + ); + expect(seenRequest!.headers['content-type'], 'application/json'); + expect(seenRequest!.headers['authorization'], 'Bearer $_accessToken'); + final body = jsonDecode(seenRequest!.body) as Map; + final fileMap = body['file'] as Map; + expect(fileMap['url'], _encryptedMxc); + expect(fileMap['iv'], _encryptedFileIv); + expect(fileMap['v'], 'v2'); + + await client.dispose(closeDatabase: true); + }); + + test('decrypts encrypted bytes returned by download_encrypted', () async { + http.Request? seenRequest; + final encryptedBytes = Uint8List.fromList([0x3B, 0x6B, 0xB2, 0x8C, 0xAF]); + final decryptedBytes = Uint8List.fromList([0x74, 0x65, 0x73, 0x74, 0x0A]); + final nativeImplementations = _DecryptingNativeImplementations( + expectedEncryptedBytes: encryptedBytes, + decryptedBytes: decryptedBytes, + ); + final mockHttp = MockClient((req) async { + seenRequest = req; + return http.Response.bytes(encryptedBytes, 200); + }); + final client = await _freshClient( + httpClient: mockHttp, + scanner: _config(), + nativeImplementations: nativeImplementations, + encryptionEnabled: true, + ); + final room = Room(id: '!room:example.org', client: client); + final event = buildEncryptedEvent(room); + + final file = await event.downloadAndDecryptAttachment(); + + expect(file.bytes, decryptedBytes); + expect(seenRequest!.method, 'POST'); + expect( + seenRequest!.url.toString(), + 'https://scanner.example/_matrix/media_proxy/unstable/download_encrypted', + ); + expect(seenRequest!.headers['content-type'], 'application/json'); + expect(seenRequest!.headers['authorization'], 'Bearer $_accessToken'); + final body = jsonDecode(seenRequest!.body) as Map; + final fileMap = body['file'] as Map; + expect(fileMap['url'], _encryptedMxc); + expect(fileMap['iv'], _encryptedFileIv); + expect(fileMap['hashes'], {'sha256': _encryptedFileSha256}); + expect(nativeImplementations.seenFile, isNotNull); + expect(nativeImplementations.seenFile!.data, encryptedBytes); + expect(nativeImplementations.seenFile!.k, _encryptedFileKey); + expect(nativeImplementations.seenFile!.iv, _encryptedFileIv); + expect(nativeImplementations.seenFile!.sha256, _encryptedFileSha256); + + await client.dispose(closeDatabase: true); + }); + + test('omits Authorization when withAuthHeader is false', () async { + http.Request? seenRequest; + final mockHttp = MockClient((req) async { + seenRequest = req; + return http.Response( + jsonEncode({'reason': 'MCS_MEDIA_REQUEST_FAILED', 'info': 'boom'}), + 502, + ); + }); + final client = await _freshClient( + httpClient: mockHttp, + scanner: _config(withAuthHeader: false), + encryptionEnabled: true, + ); + final room = Room(id: '!room:example.org', client: client); + final event = buildEncryptedEvent(room); + + await expectLater( + event.downloadAndDecryptAttachment(), + throwsA(isA()), + ); + expect(seenRequest!.headers.containsKey('authorization'), false); + + await client.dispose(closeDatabase: true); + }); + + test('unencrypted file GETs scanner download URL with auth header', + () async { + http.BaseRequest? seenRequest; + final payload = Uint8List.fromList([7, 7, 7]); + final mockHttp = MockClient((req) async { + seenRequest = req; + return http.Response.bytes(payload, 200); + }); + final client = await _freshClient( + httpClient: mockHttp, + scanner: _config(), + ); + final room = Room(id: '!room:example.org', client: client); + final event = Event.fromJson( + { + 'type': EventTypes.Message, + 'event_id': '\$evt2', + 'sender': '@alice:example.org', + 'origin_server_ts': 0, + 'content': { + 'msgtype': 'm.file', + 'body': 'note.txt', + 'filename': 'note.txt', + 'info': {'mimetype': 'text/plain', 'size': 3}, + 'url': _mxc, + }, + }, + room, + ); + + final file = await event.downloadAndDecryptAttachment(); + expect(file.bytes, payload); + expect(seenRequest!.method, 'GET'); + expect( + seenRequest!.url.toString(), + 'https://scanner.example/_matrix/media_proxy/unstable/download/example.org/abcd1234', + ); + expect(seenRequest!.headers['authorization'], 'Bearer $_accessToken'); + + await client.dispose(closeDatabase: true); + }); + + test('unencrypted file omits Authorization when withAuthHeader is false', + () async { + http.BaseRequest? seenRequest; + final payload = Uint8List.fromList([7, 7, 7]); + final mockHttp = MockClient((req) async { + seenRequest = req; + return http.Response.bytes(payload, 200); + }); + final client = await _freshClient( + httpClient: mockHttp, + scanner: _config(withAuthHeader: false), + ); + final room = Room(id: '!room:example.org', client: client); + final event = Event.fromJson( + { + 'type': EventTypes.Message, + 'event_id': '\$evt3', + 'sender': '@alice:example.org', + 'origin_server_ts': 0, + 'content': { + 'msgtype': 'm.file', + 'body': 'note.txt', + 'filename': 'note.txt', + 'info': {'mimetype': 'text/plain', 'size': 3}, + 'url': _mxc, + }, + }, + room, + ); + + final file = await event.downloadAndDecryptAttachment(); + expect(file.bytes, payload); + expect(seenRequest!.method, 'GET'); + expect(seenRequest!.headers.containsKey('authorization'), false); + + await client.dispose(closeDatabase: true); + }); + + test('unencrypted non-2xx response throws ContentScannerException', + () async { + final mockHttp = MockClient((req) async { + return http.Response( + jsonEncode({ + 'reason': 'MCS_MEDIA_NOT_CLEAN', + 'info': 'virus detected', + }), + 403, + ); + }); + final client = await _freshClient( + httpClient: mockHttp, + scanner: _config(), + ); + final room = Room(id: '!room:example.org', client: client); + final event = Event.fromJson( + { + 'type': EventTypes.Message, + 'event_id': '\$evt4', + 'sender': '@alice:example.org', + 'origin_server_ts': 0, + 'content': { + 'msgtype': 'm.file', + 'body': 'note.txt', + 'filename': 'note.txt', + 'info': {'mimetype': 'text/plain', 'size': 3}, + 'url': _mxc, + }, + }, + room, + ); + + await expectLater( + event.downloadAndDecryptAttachment(), + throwsA( + isA() + .having((e) => e.reason, 'reason', 'MCS_MEDIA_NOT_CLEAN') + .having((e) => e.info, 'info', 'virus detected') + .having((e) => e.statusCode, 'statusCode', 403), + ), + ); + + await client.dispose(closeDatabase: true); + }); + + test('non-2xx response throws ContentScannerException', () async { + final nativeImplementations = _DecryptingNativeImplementations( + expectedEncryptedBytes: Uint8List.fromList([1, 2, 3]), + decryptedBytes: Uint8List.fromList([4, 5, 6]), + ); + final mockHttp = MockClient((req) async { + return http.Response( + jsonEncode({ + 'reason': 'MCS_MEDIA_NOT_CLEAN', + 'info': 'virus detected', + }), + 403, + ); + }); + final client = await _freshClient( + httpClient: mockHttp, + scanner: _config(), + nativeImplementations: nativeImplementations, + encryptionEnabled: true, + ); + final room = Room(id: '!room:example.org', client: client); + final event = buildEncryptedEvent(room); + + await expectLater( + event.downloadAndDecryptAttachment(), + throwsA( + isA() + .having((e) => e.reason, 'reason', 'MCS_MEDIA_NOT_CLEAN') + .having((e) => e.info, 'info', 'virus detected') + .having((e) => e.statusCode, 'statusCode', 403), + ), + ); + expect(nativeImplementations.seenFile, isNull); + + await client.dispose(closeDatabase: true); + }); + }); +} diff --git a/test/tool/content_scanner_proxy.dart b/test/tool/content_scanner_proxy.dart new file mode 100755 index 000000000..ed5749bba --- /dev/null +++ b/test/tool/content_scanner_proxy.dart @@ -0,0 +1,605 @@ +#!/usr/bin/env dart +// ignore_for_file: avoid_print + +// Starts a local scanner proxy and runs an encrypted Matrix media smoke test +// using the real SDK Client. The test exercises MatrixContentScannerConfig and +// downloadAndDecryptAttachment against /download_encrypted on a live scanner. +// +// The password is used only for SDK login. It is not written to disk or passed +// to Docker. +// +// Optional environment variables: +// CONTENT_SCANNER_HOMESERVER=https://example.invalid +// CONTENT_SCANNER_PORT=18083 +// CONTENT_SCANNER_CONTAINER=mcs-proxy +// CONTENT_SCANNER_IMAGE=vectorim/matrix-content-scanner:v1.3.0 +// CONTENT_SCANNER_DOCKER_DNS=9.9.9.9,1.1.1.1 +// CONTENT_SCANNER_DATA_DIR=/tmp/mcs-proxy +// CONTENT_SCANNER_VERBOSE=true +// MATRIX_USER_ID=test-user +// MATRIX_PASSWORD=... +// CONTENT_SCANNER_ROOM_ID=!room:example.invalid +// +// Usage: +// dart tool/content_scanner_proxy.dart + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:path/path.dart' as path_joiner; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:vodozemac/vodozemac.dart' as vod; + +import 'package:matrix/matrix.dart'; + +const _defaultPort = '8080'; +const _defaultContainerName = 'mcs-proxy'; +const _defaultImage = 'vectorim/matrix-content-scanner:v1.3.0'; + +Future main() async { + try { + await _run(); + } catch (e) { + stderr.writeln('ERROR: $e'); + exitCode = 1; + } +} + +Future _run() async { + final homeserverInput = _homeserverFromEnvOrPrompt(); + final userId = _userIdFromEnvOrPrompt(); + final password = _passwordFromEnvOrPrompt(); + final port = Platform.environment['CONTENT_SCANNER_PORT'] ?? _defaultPort; + final containerName = Platform.environment['CONTENT_SCANNER_CONTAINER'] ?? + _defaultContainerName; + final image = Platform.environment['CONTENT_SCANNER_IMAGE'] ?? _defaultImage; + + final mcsData = _mcsDataDirectory(containerName); + final baseUrl = 'http://localhost:$port/_matrix/media_proxy/unstable'; + + print('=== matrix-content-scanner SDK integration smoke test ===\n'); + + await _preflightHomeserver(homeserverInput); + + // Init Vodozemac before creating the client so encryption is available for + // encrypted rooms. Aborts if init fails — encrypted rooms require it. + await _initVodozemac(); + + // Create SDK Client with in-memory DB (maxFileSize: 0 so every download must + // hit the scanner — no cache bypass). + final tmpDbDir = await Directory( + path_joiner.join( + Directory.systemTemp.path, + 'mcs-smoke-${DateTime.now().microsecondsSinceEpoch}', + ), + ).create(recursive: true); + sqfliteFfiInit(); + final db = await MatrixSdkDatabase.init( + 'scanner-smoke', + database: await databaseFactoryFfi.openDatabase( + ':memory:', + options: OpenDatabaseOptions(singleInstance: false), + ), + sqfliteFactory: databaseFactoryFfi, + fileStorageLocation: tmpDbDir.uri, + maxFileSize: 0, + ); + + final scannerConfig = MatrixContentScannerConfig( + downloadUri: Uri.parse('$baseUrl/download/'), + downloadThumbnailUri: Uri.parse('$baseUrl/thumbnail/'), + downloadEncryptedUri: Uri.parse('$baseUrl/download_encrypted'), + withAuthHeader: true, + ); + + final client = Client( + 'scanner-smoke', + database: db, + contentScannerConfig: scannerConfig, + ); + + try { + // Resolve homeserver via SDK (well-known skipped: tool takes concrete URL). + await client.checkHomeserver( + Uri.parse(homeserverInput), + checkWellKnown: false, + ); + final resolvedHomeserver = client.homeserver!.toString(); + + // Write scanner config.yaml using the SDK-resolved homeserver URL. + await _ensureMcsData(mcsData, resolvedHomeserver); + print('Scanner data directory: ${mcsData.path}'); + + await _replaceContainer( + containerName: containerName, + image: image, + port: port, + mcsData: mcsData, + ); + await _waitForScanner(baseUrl); + + print('Proxy is running as container `$containerName`.\n'); + print('Use this SDK config:'); + print(''' +contentScannerConfig: MatrixContentScannerConfig( + downloadUri: Uri.parse('$baseUrl/download/'), + downloadThumbnailUri: Uri.parse('$baseUrl/thumbnail/'), + downloadEncryptedUri: Uri.parse('$baseUrl/download_encrypted'), + withAuthHeader: true, +),'''); + print('\nStop it with: docker stop $containerName'); + + print('\nLogging in as $userId...'); + await client.login( + LoginType.mLoginPassword, + identifier: AuthenticationUserIdentifier(user: userId), + password: password, + ); + print('Logged in as ${client.userID}.'); + + final room = await _pickRoom(client); + if (room == null) { + print('\nNo encrypted room selected; proxy is still running.'); + return; + } + + await _runEncryptedSmokeTest(client: client, room: room); + } finally { + try { + await client.logout(); + print('Logged out test session.'); + } catch (_) {} + await client.dispose(closeDatabase: true); + try { + await tmpDbDir.delete(recursive: true); + } catch (_) {} + await _printScannerLogs(containerName); + } +} + +Future _initVodozemac() async { + try { + await vod.init( + wasmPath: './pkg/', + libraryPath: './rust/target/debug/', + ); + print('Vodozemac initialised (encryption available).'); + } catch (e) { + throw Exception('Vodozemac init failed: $e'); + } +} + +Future _pickRoom(Client client) async { + final fromEnv = Platform.environment['CONTENT_SCANNER_ROOM_ID']?.trim(); + if (fromEnv != null && fromEnv.isNotEmpty) { + final room = client.getRoomById(fromEnv); + if (room == null) { + _die('CONTENT_SCANNER_ROOM_ID "$fromEnv" not found in joined rooms.'); + } + if (!room.encrypted) { + _die('CONTENT_SCANNER_ROOM_ID "$fromEnv" is not an encrypted room.'); + } + return room; + } + + if (!stdin.hasTerminal) return null; + + final rooms = client.rooms.where((room) => room.encrypted).toList() + ..sort((a, b) => (a.displayname).compareTo(b.displayname)); + + if (rooms.isEmpty) { + print('No encrypted joined rooms found after sync.'); + return null; + } + + print('\nChoose an encrypted room to send a test m.file event:'); + for (var i = 0; i < rooms.length; i++) { + final r = rooms[i]; + print(' ${i + 1}. ${r.displayname} (${r.id}) [encrypted]'); + } + stdout.write('Room number or room ID (Enter to skip): '); + final choice = stdin.readLineSync()?.trim(); + if (choice == null || choice.isEmpty) return null; + + final selectedIndex = int.tryParse(choice); + if (selectedIndex != null && + selectedIndex >= 1 && + selectedIndex <= rooms.length) { + return rooms[selectedIndex - 1]; + } + final room = client.getRoomById(choice); + if (room != null && !room.encrypted) { + _die('Room "$choice" is not encrypted.'); + } + return room; +} + +Future _runEncryptedSmokeTest({ + required Client client, + required Room room, +}) async { + print('\n--- Encrypted room smoke test ---'); + + if (!client.encryptionEnabled) { + _die( + 'Room is encrypted but client.encryptionEnabled is false. ' + 'Ensure Vodozemac initialised correctly.', + ); + } + + final plaintext = Uint8List.fromList(_testPayload()); + const filename = 'content-scanner-smoke-enc.bin'; + const contentType = 'application/octet-stream'; + + print('Encrypting test file...'); + final encrypted = + await MatrixFile(bytes: plaintext, name: filename).encrypt(); + + print('Uploading encrypted bytes via client.uploadContent...'); + final mxc = await client.uploadContent( + encrypted.data, + filename: 'crypt', + contentType: 'application/octet-stream', + ); + print(' mxc: $mxc'); + + final fileMap = { + 'v': 'v2', + 'url': mxc.toString(), + 'mimetype': contentType, + 'key': { + 'alg': 'A256CTR', + 'ext': true, + 'k': encrypted.k, + 'key_ops': ['encrypt', 'decrypt'], + 'kty': 'oct', + }, + 'iv': encrypted.iv, + 'hashes': {'sha256': encrypted.sha256}, + }; + + print('Sending m.file event with encrypted file map...'); + final eventId = await room.sendEvent( + { + 'msgtype': 'm.file', + 'body': filename, + 'filename': filename, + 'file': fileMap, + 'info': {'mimetype': contentType, 'size': plaintext.length}, + }, + displayPendingEvent: false, + ); + if (eventId == null) _die('room.sendEvent returned null — send failed.'); + print(' event_id: $eventId'); + + print('Downloading via event.downloadAndDecryptAttachment (SDK path)...'); + final event = Event.fromJson( + { + 'type': EventTypes.Message, + 'event_id': eventId, + 'sender': client.userID, + 'origin_server_ts': DateTime.now().millisecondsSinceEpoch, + 'content': { + 'msgtype': 'm.file', + 'body': filename, + 'file': fileMap, + 'info': {'mimetype': contentType, 'size': plaintext.length}, + }, + }, + room, + ); + final downloaded = await event.downloadAndDecryptAttachment(); + + if (!_bytesEqual(plaintext, downloaded.bytes)) { + _die( + 'Decrypted bytes do not match original plaintext ' + '(${downloaded.bytes.length} != ${plaintext.length}).', + ); + } + + print( + 'Smoke test passed: ${downloaded.bytes.length} bytes via scanner POST /download_encrypted.', + ); +} + +List _testPayload() { + final random = Random.secure(); + final randomBytes = List.generate(24, (_) => random.nextInt(256)); + final body = [ + 'matrix-dart-sdk content scanner SDK smoke test', + 'created_at: ${DateTime.now().toUtc().toIso8601String()}', + 'random_base64: ${base64Encode(randomBytes)}', + '', + ].join('\n'); + return utf8.encode(body); +} + +bool _bytesEqual(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Infrastructure (unchanged from original) +// --------------------------------------------------------------------------- + +Future _preflightHomeserver(String homeserverUrl) async { + final homeserver = Uri.parse(homeserverUrl); + if (!homeserver.hasScheme || homeserver.host.isEmpty) { + _die('Invalid CONTENT_SCANNER_HOMESERVER: $homeserverUrl'); + } + + try { + final addresses = await InternetAddress.lookup(homeserver.host); + if (addresses.isEmpty) { + _die('Could not resolve ${homeserver.host}. Connect VPN/internal DNS.'); + } + } on SocketException catch (e) { + _die( + 'Could not resolve ${homeserver.host}. Connect VPN/internal DNS, then ' + 'rerun this script.\n$e', + ); + } +} + +Future _ensureMcsData(Directory mcsData, String homeserver) async { + await mcsData.create(recursive: true); + await Process.run('chmod', ['700', mcsData.path]); + + final tmpDir = Directory('${mcsData.path}/tmp'); + await tmpDir.create(recursive: true); + + await File('${mcsData.path}/config.yaml').writeAsString(''' +web: + host: 0.0.0.0 + port: 8080 + +scan: + script: /data/scan.sh + temp_directory: /data/tmp + removal_command: rm + +download: + base_homeserver_url: "$homeserver" + +crypto: + request_secret_path: /data/request_secret + +result_cache: + max_size: 128 + ttl: "10m" +'''); + + final scanScript = File('${mcsData.path}/scan.sh'); + await scanScript.writeAsString(''' +#!/bin/sh +exit 0 +'''); + await Process.run('chmod', ['+x', scanScript.path]); + + final secret = File('${mcsData.path}/request_secret'); + if (!await secret.exists()) { + await secret.writeAsString('${_generateRequestSecret()}\n'); + } +} + +String _generateRequestSecret() { + final random = Random.secure(); + final bytes = List.generate(32, (_) => random.nextInt(256)); + return base64Encode(bytes); +} + +Directory _mcsDataDirectory(String containerName) { + final configured = Platform.environment['CONTENT_SCANNER_DATA_DIR']?.trim(); + if (configured != null && configured.isNotEmpty) { + return Directory(configured); + } + + final safeContainerName = containerName.replaceAll( + RegExp(r'[^a-zA-Z0-9_.-]'), + '_', + ); + return Directory( + '${Directory.systemTemp.path}/matrix-dart-sdk-$safeContainerName', + ); +} + +Future _replaceContainer({ + required String containerName, + required String image, + required String port, + required Directory mcsData, +}) async { + await Process.run('docker', ['rm', '-f', containerName]); + + print('Starting scanner container...'); + final result = await Process.run('docker', [ + 'run', + '--rm', + '--name', + containerName, + '--platform', + 'linux/amd64', + ..._dockerDnsArgs(), + '-d', + '-p', + '$port:8080', + '-v', + '${mcsData.path}:/data', + image, + ]); + if (result.exitCode != 0) { + _die('docker run failed:\n${result.stderr}'); + } +} + +Future _waitForScanner(String baseUrl) async { + print('Waiting for scanner to be ready...'); + for (var i = 0; i < 30; i++) { + await Future.delayed(const Duration(seconds: 1)); + try { + final response = await _getText('$baseUrl/public_key'); + if (response.statusCode == 200) { + print('Scanner ready.\n'); + return; + } + } catch (_) {} + } + _die('Scanner did not become healthy within 30 s'); +} + +String _homeserverFromEnvOrPrompt() { + final fromEnv = Platform.environment['CONTENT_SCANNER_HOMESERVER']?.trim(); + final input = fromEnv != null && fromEnv.isNotEmpty + ? fromEnv + : _prompt('Homeserver URL'); + return _normalizeHomeserver(input); +} + +String _userIdFromEnvOrPrompt() { + final fromEnv = Platform.environment['MATRIX_USER_ID']?.trim(); + if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv; + return _prompt('User ID or localpart'); +} + +String _passwordFromEnvOrPrompt() { + final fromEnv = Platform.environment['MATRIX_PASSWORD']; + if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv; + return _promptPassword('Password'); +} + +String _prompt(String label) { + if (!stdin.hasTerminal) { + _die( + '$label is required. Provide it via environment variable in ' + 'non-interactive mode.', + ); + } + + stdout.write('$label: '); + final input = stdin.readLineSync()?.trim() ?? ''; + if (input.isEmpty) _die('$label is required.'); + return input; +} + +String _promptPassword(String label) { + if (!stdin.hasTerminal) { + _die( + '$label is required. Provide MATRIX_PASSWORD in non-interactive mode.', + ); + } + + stdout.write('$label: '); + _setTerminalEcho(enabled: false); + late final String input; + try { + input = stdin.readLineSync() ?? ''; + } finally { + _setTerminalEcho(enabled: true); + stdout.writeln(); + } + if (input.isEmpty) _die('$label is required.'); + return input; +} + +void _setTerminalEcho({required bool enabled}) { + if (!stdin.hasTerminal) return; + Process.runSync('stty', [enabled ? 'echo' : '-echo']); +} + +String _normalizeHomeserver(String homeserver) { + final trimmed = homeserver.trim(); + final withScheme = + trimmed.startsWith('http://') || trimmed.startsWith('https://') + ? trimmed + : 'https://$trimmed'; + return withScheme.endsWith('/') + ? withScheme.substring(0, withScheme.length - 1) + : withScheme; +} + +Future<_Response> _getText(String url) async { + final httpClient = HttpClient(); + try { + final request = await httpClient.getUrl(Uri.parse(url)); + final response = await request.close(); + return _Response(response.statusCode, await _readBody(response)); + } finally { + httpClient.close(); + } +} + +Future> _readBody(HttpClientResponse response) async { + final body = []; + await for (final chunk in response) { + body.addAll(chunk); + } + return body; +} + +List _dockerDnsArgs() { + final configured = Platform.environment['CONTENT_SCANNER_DOCKER_DNS']; + if (configured == null || configured.trim().isEmpty) return const []; + + final args = []; + for (final dns in configured.split(',')) { + final trimmed = dns.trim(); + if (trimmed.isEmpty) continue; + args + ..add('--dns') + ..add(trimmed); + } + return args; +} + +Future _printScannerLogs(String containerName) async { + if (!_verboseLogsEnabled()) return; + + final result = await Process.run('docker', [ + 'logs', + '--tail', + '200', + containerName, + ]); + if (result.exitCode != 0) { + print('\nCould not read scanner logs:\n${result.stderr}'); + return; + } + + final stdoutText = (result.stdout as String).trim(); + final stderrText = (result.stderr as String).trim(); + final logs = [ + if (stdoutText.isNotEmpty) stdoutText, + if (stderrText.isNotEmpty) stderrText, + ].join('\n'); + + print('\n--- scanner container logs: $containerName ---'); + if (logs.isEmpty) { + print('(no logs yet)'); + } else { + print(logs); + } + print('--- end scanner container logs ---'); +} + +bool _verboseLogsEnabled() { + final configured = Platform.environment['CONTENT_SCANNER_VERBOSE']; + if (configured == null || configured.trim().isEmpty) return true; + return !{'0', 'false', 'no', 'off'}.contains(configured.toLowerCase().trim()); +} + +Never _die(String message) => throw Exception(message); + +class _Response { + final int statusCode; + final List bodyBytes; + + const _Response(this.statusCode, this.bodyBytes); + + String get body => utf8.decode(bodyBytes, allowMalformed: true); +}