diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 01d5d79b8..b2ac017d2 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -2860,6 +2860,16 @@ class CanvasModel with ChangeNotifier { } } +// Bound integer cache keys and raster allocation: 4096 squared RGBA is 64 MiB. +const _maxCursorRasterSize = 4096; + +bool _validCursorRasterSize(double width, double height, + {double rasterScale = 1}) => + width.isFinite && height.isFinite && rasterScale.isFinite && + width > 0 && height > 0 && rasterScale > 0 && + width.ceilToDouble() * rasterScale <= _maxCursorRasterSize && + height.ceilToDouble() * rasterScale <= _maxCursorRasterSize; + // Scale the host's bitmap and hotspot together. Incorrect source geometry // must be fixed in host capture, independently of the client's sizing policy. class CursorData { @@ -2899,16 +2909,19 @@ class CursorData { // Keep the minimum-size policy here. Native callers let the plugin rasterize // the original ui.Image; Web keeps the encoded-image resizing path. - double _checkUpdateScale(double scale, - {bool resizeImage = true, bool useLegacyMinimum = true}) { - double oldScale = this.scale; + double? _validatedScale(double scale, + {required bool useLegacyMinimum, required double rasterScale}) { + if (!scale.isFinite || scale <= 0) { + debugPrint('Rejected cursor $id: invalid scale $scale'); + return null; + } if (!useLegacyMinimum) { scale = max(scale, kMinCursorSize / max(width, height)); } if (useLegacyMinimum && scale != 1.0) { // Update data if scale changed. - final tgtWidth = (width * scale).toInt(); - final tgtHeight = (height * scale).toInt(); + final tgtWidth = width * scale; + final tgtHeight = height * scale; if (tgtWidth < kMinCursorSize || tgtHeight < kMinCursorSize) { double sw = kMinCursorSize.toDouble() / width; double sh = kMinCursorSize.toDouble() / height; @@ -2916,6 +2929,17 @@ class CursorData { } } + if (!_validCursorRasterSize(width * scale, height * scale, + rasterScale: rasterScale)) { + debugPrint('Rejected cursor $id: raster ${width * scale}x${height * scale} ' + 'at pixel ratio $rasterScale exceeds $_maxCursorRasterSize'); + return null; + } + return scale; + } + + double _checkUpdateScale(double scale, {required bool resizeImage}) { + double oldScale = this.scale; // Web's long-edge minimum can round a thin axis below one raster pixel. final webWidth = max(1, (width * scale).round()); final webHeight = max(1, (height * scale).round()); @@ -2954,11 +2978,14 @@ class CursorData { return scale; } - String updateGetKey(double scale, - {bool resizeImage = true, bool useLegacyMinimum = true}) { - scale = _checkUpdateScale(scale, - resizeImage: resizeImage, useLegacyMinimum: useLegacyMinimum); - return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}'; + String? updateGetKey(double scale, + {bool resizeImage = true, bool useLegacyMinimum = true, + double rasterScale = 1}) { + final effectiveScale = _validatedScale(scale, + useLegacyMinimum: useLegacyMinimum, rasterScale: rasterScale); + if (effectiveScale == null) return null; + _checkUpdateScale(effectiveScale, resizeImage: resizeImage); + return '${peerId}_${id}_${_doubleToInt(width * effectiveScale)}_${_doubleToInt(height * effectiveScale)}'; } } @@ -3467,9 +3494,12 @@ class CursorModel with ChangeNotifier { final hoty = double.parse(evt['hoty']); final width = int.parse(evt['width']); final height = int.parse(evt['height']); - final pixelRatio = double.parse(evt['scale'] ?? '0'); - if (!pixelRatio.isFinite || pixelRatio < 0) { - throw FormatException('Invalid cursor pixel ratio: $pixelRatio'); + final pixelRatio = double.tryParse(evt['scale'] ?? '0'); + if (pixelRatio == null || !pixelRatio.isFinite || pixelRatio < 0 || + (pixelRatio > 0 && + !_validCursorRasterSize(width / pixelRatio, height / pixelRatio))) { + debugPrint('Rejected cursor $id: invalid pixel ratio ${evt['scale']}'); + return; } List colors = json.decode(evt['colors']); final rgba = Uint8List.fromList(colors.map((s) => s as int).toList()); diff --git a/flutter/lib/native/custom_cursor.dart b/flutter/lib/native/custom_cursor.dart index 4f069a6fb..83e9eeca0 100644 --- a/flutter/lib/native/custom_cursor.dart +++ b/flutter/lib/native/custom_cursor.dart @@ -34,7 +34,11 @@ MouseCursor buildCursorOfCache( final effectiveScale = !legacyMinimum && isWindows ? math.max(scale, kMinCursorSize * dpr / math.max(cache.width, cache.height)) : scale; - final key = '${cache.updateGetKey(effectiveScale, resizeImage: false, useLegacyMinimum: legacyMinimum)}_$dpr'; + final cacheKey = cache.updateGetKey(effectiveScale, resizeImage: false, + useLegacyMinimum: legacyMinimum, + rasterScale: isWindows ? 1 : (isLinux ? dpr.ceilToDouble() : dpr)); + if (cacheKey == null) return MouseCursor.defer; + final key = '${cacheKey}_$dpr'; if (!cursor.cachedKeys.contains(key)) { debugPrint( "Register custom cursor with key $key (${cache.hotx},${cache.hoty})"); diff --git a/flutter/lib/web/custom_cursor.dart b/flutter/lib/web/custom_cursor.dart index ac0350fcf..239096cec 100644 --- a/flutter/lib/web/custom_cursor.dart +++ b/flutter/lib/web/custom_cursor.dart @@ -109,6 +109,7 @@ MouseCursor buildCursorOfCache( // A short-edge minimum can enlarge thin artwork beyond CSS cursor limits. // Keep unzoomed images unchanged and use the long edge when resizing. final key = cache.updateGetKey(scale, useLegacyMinimum: scale == 1.0); + if (key == null) return MouseCursor.defer; if (!cursor.cachedKeys.contains(key)) { // data should be checked here, because it may be changed after `updateGetKey()` final data = cache.data; diff --git a/flutter/test/cursor_density_validation_test.dart b/flutter/test/cursor_density_validation_test.dart new file mode 100644 index 000000000..bc3c662ec --- /dev/null +++ b/flutter/test/cursor_density_validation_test.dart @@ -0,0 +1,207 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_custom_cursor/cursor_manager.dart' show CursorManager; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/common.dart' as common; +import 'package:flutter_hbb/desktop/pages/remote_page.dart'; +import 'package:flutter_hbb/models/input_model.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/native/custom_cursor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:image/image.dart' as img; +import 'package:provider/provider.dart'; + +const _viewport = Size(200, 160); + +class _Image extends ChangeNotifier implements ImageModel { + @override + bool get useTextureRender => false; + @override + ui.Image? get image => null; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Canvas extends ChangeNotifier implements CanvasModel { + _Canvas(this.devicePixelRatio, {required this.style, required this.scale}); + + final String style; + + @override + final double devicePixelRatio; + @override + final imageOverflow = false.obs; + @override + late final viewStyle = ViewStyle( + style: style, + width: _viewport.width, + height: _viewport.height, + displayWidth: 400, + displayHeight: 320, + ); + @override + bool get cursorEmbedded => false; + @override + ScrollStyle get scrollStyle => ScrollStyle.scrollauto; + @override + Size get size => _viewport; + @override + final double scale; + @override + double get x => 0; + @override + double get y => 0; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Input extends Fake implements InputModel { + @override + final relativeMouseMode = false.obs; +} + +class _Peer extends Fake implements FfiModel { + @override + final pi = PeerInfo(); + @override + bool isPeerLinux = false; +} + +class _FFI extends Fake implements FFI { + _FFI(this.canvasModel); + + @override + final CanvasModel canvasModel; + @override + final inputModel = _Input(); + @override + final _Peer ffiModel = _Peer(); +} + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + _rasterBoundsTests(); + final channel = common.isWindows + ? SystemChannels.mouseCursor + : const MethodChannel('flutter_custom_cursor'); + final registrations = >[]; + setUp(() { + registrations.clear(); + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, + (call) async { + if (!call.method.startsWith('createCustomCursor')) return null; + final args = call.arguments as Map; + registrations.add(args); + return args['name']; + }); + }); + tearDown(() => + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null)); + for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) { + for (final density in ['0', '1', '2', '1e-300', '0.001']) { + testWidgets( + 'density boundary $style density=$density', + (tester) => tester.runAsync( + () => checkDensity(tester, (style, density), registrations))); + } + } +} + +void _rasterBoundsTests() { + for (final (width, height, scale, legacy, rasterScale) in [ + (32, 32, 1e300, false, 1.0), + (32, 32, 129.0, false, 1.0), + (32, 32, 100.0, false, 2.0), + (512, 1, 0.1, true, 1.0), + ]) { + test('rejects raster ${width}x$height scale=$scale before key creation', + () async { + final image = await createTestImage(width: width, height: height); + addTearDown(image.dispose); + final data = CursorData( + peerId: 'bounds', + id: 'bounds', + image: img.Image(width: width, height: height, numChannels: 4), + nativeImage: image, + scale: 1, + data: null, + hotxOrigin: 0, + hotyOrigin: 0, + width: width, + height: height); + final initial = data.updateGetKey(1, resizeImage: false); + expect( + data.updateGetKey(scale, + resizeImage: false, + useLegacyMinimum: legacy, + rasterScale: rasterScale), + isNull); + expect(data.scale, 1); + expect(data.updateGetKey(1, resizeImage: false), initial); + }); + } +} + +Future checkDensity(WidgetTester tester, (String, String) input, + List> registrations) async { + final (style, density) = input; + final id = '$style-$density'; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + final canvas = _Canvas(1, style: style, scale: 0.5); + final ffi = _FFI(canvas)..ffiModel.pi.platform = kPeerPlatformMacOS; + final cursor = CursorModel(WeakReference(ffi))..id = id; + addTearDown(() async { + expect(cursor.parent.target, same(ffi)); + for (final key in cursor.cachedKeys) { + await deleteCustomCursor(key); + } + cursor.disposeImages(); + cursor.dispose(); + canvas.dispose(); + }); + await cursor.updateCursorData(_cursorEvent(id, density)); + final rejected = density == '1e-300' || density == '0.001'; + if (rejected) { + expect(cursor.cache, isNull, + reason: 'Reject density before publishing a cursor'); + await cursor.updateCursorData(_cursorEvent(id, '2')); + } + expect(cursor.cache!.pixelRatio, rejected ? 2 : double.parse(density)); + await tester.pumpWidget(MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1), + child: MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => _Image()), + ChangeNotifierProvider.value(value: canvas), + ChangeNotifierProvider.value(value: cursor), + ], + child: ImagePaint( + ffi: ffi, + id: 'density-review', + zoomCursor: false.obs, + cursorOverImage: true.obs, + keyboardEnabled: true.obs, + remoteCursorMoved: false.obs)), + )); + expect(tester.takeException(), isNull); + for (final key in cursor.cachedKeys) { + await CursorManager.instance.ensureCursorRegistered(key); + } + expect(registrations, hasLength(1)); + await tester.pumpWidget(const SizedBox.shrink()); +} + +Map _cursorEvent(String id, String density) => { + 'id': id, + 'width': '32', + 'height': '32', + 'hotx': '8', + 'hoty': '12', + 'scale': density, + 'colors': jsonEncode(List.filled(32 * 32 * 4, 255)), + };