diff --git a/CHANGELOG.md b/CHANGELOG.md index 663c224..d8e429f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ - Added `Body.size` for exposing known body byte lengths without consuming the body. +- Fixed `Blob` byte snapshot semantics so byte-backed parts and read buffers are + copied consistently across native, `dart:io`, and js wrappers. ## 0.5.0 diff --git a/lib/src/fetch/blob.js.dart b/lib/src/fetch/blob.js.dart index dabc192..a5d6986 100644 --- a/lib/src/fetch/blob.js.dart +++ b/lib/src/fetch/blob.js.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:block/block.dart' as block; import 'package:web/web.dart' as web; @@ -25,10 +27,17 @@ class Blob extends native.Blob implements block.Block { static Object _normalizePart(native.BlobPart part) { return switch (part) { - final Blob blob => blob, - final native.Blob blob => blob, + final ByteBuffer buffer => Uint8List.fromList(buffer.asUint8List()), + final ByteData data => Uint8List.fromList( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ), + final Uint8List bytes => Uint8List.fromList(bytes), + final List bytes => Uint8List.fromList(bytes), + final String text => text, + final Blob blob => native.blobBacking(blob), + final native.Blob blob => native.blobBacking(blob), final web.Blob blob => blob, - _ => native.Blob([part]), + _ => native.blobBacking(native.Blob([part])), }; } } diff --git a/lib/src/fetch/blob.native.dart b/lib/src/fetch/blob.native.dart index db89c57..8a3629c 100644 --- a/lib/src/fetch/blob.native.dart +++ b/lib/src/fetch/blob.native.dart @@ -41,10 +41,12 @@ class Blob implements block.Block { @override int get size => _host.size; - Future bytes() => _host.arrayBuffer(); + Future bytes() => arrayBuffer(); @override - Future arrayBuffer() => _host.arrayBuffer(); + Future arrayBuffer() async { + return Uint8List.fromList(await _host.arrayBuffer()); + } @override Future text() => _host.text(); @@ -55,7 +57,13 @@ class Blob implements block.Block { throw ArgumentError.value(chunkSize, 'chunkSize', 'Must be > 0'); } - return _host.stream(chunkSize: chunkSize); + return _stream(chunkSize); + } + + Stream _stream(int chunkSize) async* { + await for (final chunk in _host.stream(chunkSize: chunkSize)) { + yield Uint8List.fromList(chunk); + } } @override @@ -74,8 +82,8 @@ class Blob implements block.Block { return switch (part) { final Blob blob => blob._host, final block.Block blockPart => blockPart, - final ByteBuffer buffer => ByteData.sublistView(buffer.asUint8List()), - final ByteData data => ByteData.sublistView( + final ByteBuffer buffer => Uint8List.fromList(buffer.asUint8List()), + final ByteData data => Uint8List.fromList( data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), ), final Uint8List bytes => Uint8List.fromList(bytes), @@ -90,6 +98,12 @@ class Blob implements block.Block { } } +/// Returns the backing block for Blob-part normalization. +/// +/// Platform wrappers use this to preserve Blob byte-sequence semantics without +/// calling overridable read methods on Blob subclasses. +block.Block blobBacking(Blob blob) => blob._host; + /// Normalizes Blob/File MIME type inputs using the File API rules. String normalizeBlobType(String type) { StringBuffer? buffer; diff --git a/test/blob_test.dart b/test/blob_test.dart new file mode 100644 index 0000000..889b4c5 --- /dev/null +++ b/test/blob_test.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:ht/src/fetch/blob.dart' as platform_blob; +import 'package:test/test.dart'; + +void main() { + group('Blob', () { + test('snapshots byte parts at construction', () async { + final typedBytes = Uint8List.fromList(utf8.encode('ab')); + final bufferBytes = Uint8List.fromList(utf8.encode('cd')); + final dataBytes = Uint8List.fromList(utf8.encode('-ef-')); + final data = ByteData.sublistView(dataBytes, 1, 3); + final listBytes = [...utf8.encode('gh')]; + + final blob = platform_blob.Blob([ + typedBytes, + bufferBytes.buffer, + data, + listBytes, + ]); + + typedBytes[0] = 'x'.codeUnitAt(0); + bufferBytes[0] = 'y'.codeUnitAt(0); + dataBytes[1] = 'z'.codeUnitAt(0); + listBytes[0] = 'w'.codeUnitAt(0); + + expect(await blob.text(), 'abcdefgh'); + + final singleBufferBytes = Uint8List.fromList(utf8.encode('ij')); + final singleBufferBlob = platform_blob.Blob([ + singleBufferBytes.buffer, + ]); + singleBufferBytes[0] = 'x'.codeUnitAt(0); + expect(await singleBufferBlob.text(), 'ij'); + + final singleDataBytes = Uint8List.fromList(utf8.encode('-kl-')); + final singleDataBlob = platform_blob.Blob([ + ByteData.sublistView(singleDataBytes, 1, 3), + ]); + singleDataBytes[1] = 'y'.codeUnitAt(0); + expect(await singleDataBlob.text(), 'kl'); + }); + + test('arrayBuffer and bytes return defensive copies', () async { + final blob = platform_blob.Blob([ + Uint8List.fromList(utf8.encode('abc')), + ]); + + final buffer = await blob.arrayBuffer(); + buffer[0] = 'x'.codeUnitAt(0); + + expect(await blob.text(), 'abc'); + + final bytes = await blob.bytes(); + bytes[1] = 'y'.codeUnitAt(0); + + expect(await blob.text(), 'abc'); + }); + + test('stream chunks do not expose mutable backing', () async { + final blob = platform_blob.Blob([ + Uint8List.fromList(utf8.encode('abcd')), + ]); + + expect(() => blob.stream(chunkSize: 0), throwsArgumentError); + + final chunks = await blob.stream(chunkSize: 2).toList(); + chunks.first[0] = 'x'.codeUnitAt(0); + + expect(await blob.text(), 'abcd'); + expect( + await blob.stream(chunkSize: 2).map(utf8.decode).toList(), + ['ab', 'cd'], + ); + }); + + test('Blob parts use Blob backing instead of read overrides', () async { + final blob = platform_blob.Blob([ + _ReadOverridingBlob(), + ]); + + expect(await blob.text(), 'base'); + }); + }); +} + +class _ReadOverridingBlob extends platform_blob.Blob { + _ReadOverridingBlob() : super(['base'], 'text/plain'); + + @override + Future arrayBuffer() async { + return Uint8List.fromList(utf8.encode('override')); + } + + @override + Stream stream({int chunkSize = 16 * 1024}) { + return Stream.value(Uint8List.fromList(utf8.encode('override'))); + } + + @override + Future text() async => 'override'; +}