Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions lib/providers/navigation_geometry_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ Future<NavigationGeometryMap> loadNavigationGeometry(MapValue map,
));
}

NavigationGeometryMap _decodeNavigation(
Future<NavigationGeometryMap> _decodeNavigation(
({
MapValue map,
Uint8List bytes,
Offset defenseOffset,
}) source) {
final decoded = jsonDecode(utf8.decode(decodeWorldGzip(source.bytes)));
}) source) async {
final decoded = jsonDecode(utf8.decode(await inflateWorldGzip(source.bytes)));
if (decoded is! Map<String, dynamic> || decoded['map'] != source.map.name) {
throw const FormatException('Navigation geometry map mismatch.');
}
Expand Down
4 changes: 2 additions & 2 deletions lib/providers/svg_height_runtime_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,8 @@ Future<SvgHeightVisibility> _loadSide(Uint8List source, List<int> artworkBytes,
return model;
}

Map<String, dynamic> _decodeModel(Uint8List source) {
final decoded = jsonDecode(utf8.decode(decodeWorldGzip(source)));
Future<Map<String, dynamic>> _decodeModel(Uint8List source) async {
final decoded = jsonDecode(utf8.decode(await inflateWorldGzip(source)));
if (decoded is! Map<String, dynamic>) {
throw const FormatException('SVG sightline asset must be an object.');
}
Expand Down
23 changes: 18 additions & 5 deletions lib/view_cone/vision_world_gzip.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import 'dart:typed_data';

import 'package:archive/archive.dart';

import 'vision_world_gzip_web.dart'
if (dart.library.io) 'vision_world_gzip_io.dart' as platform;

/// archive uses native zlib on desktop and its Dart decoder on web. The SDK
/// accepts partial streams, so untrusted blocks also need their complete footer.
Uint8List decodeWorldGzip(Uint8List compressed, {bool verifyChecksum = true}) {
if (compressed.length < 18 ||
compressed[0] != 0x1f ||
compressed[1] != 0x8b) {
throw const FormatException('Invalid world gzip header.');
}
checkWorldGzipHeader(compressed);
final decoded = const GZipDecoder().decodeBytes(compressed, verify: true);
final footer = ByteData.sublistView(compressed, compressed.length - 8);
if (footer.getUint32(4, Endian.little) != decoded.length ||
Expand All @@ -19,3 +18,17 @@ Uint8List decodeWorldGzip(Uint8List compressed, {bool verifyChecksum = true}) {
}
return decoded;
}

/// [decodeWorldGzip] with the same checks. On the web the browser's own gzip
/// inflates and verifies the data: there the Dart decoder and CRC run on the
/// UI thread and take about 0.4 s per side of a large map.
Future<Uint8List> inflateWorldGzip(Uint8List compressed) =>
platform.inflateWorldGzip(compressed);

void checkWorldGzipHeader(Uint8List compressed) {
if (compressed.length < 18 ||
compressed[0] != 0x1f ||
compressed[1] != 0x8b) {
throw const FormatException('Invalid world gzip header.');
}
}
7 changes: 7 additions & 0 deletions lib/view_cone/vision_world_gzip_io.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'dart:typed_data';

import 'vision_world_gzip.dart';

/// Desktop's zlib is native and fast; it runs in a background isolate.
Future<Uint8List> inflateWorldGzip(Uint8List compressed) async =>
decodeWorldGzip(compressed);
47 changes: 47 additions & 0 deletions lib/view_cone/vision_world_gzip_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'dart:typed_data';

import 'vision_world_gzip.dart';

@JS('DecompressionStream')
extension type _DecompressionStream._(JSObject _) implements JSObject {
external factory _DecompressionStream(String format);
}

extension type _ReadableStream._(JSObject _) implements JSObject {
external _ReadableStream pipeThrough(JSObject transform);
}

@JS('Response')
extension type _Response._(JSObject _) implements JSObject {
external factory _Response(JSAny body);
external _ReadableStream get body;
external JSPromise<JSArrayBuffer> arrayBuffer();
}

/// The browser's gzip checks what [decodeWorldGzip] checks: it rejects a
/// wrong CRC-32 or length, a stream that ends before its footer, and bytes
/// after it. The length is compared again here so a short read cannot pass.
Future<Uint8List> inflateWorldGzip(Uint8List compressed) async {
// Firefox before 113 and Safari before 16.4 lack it; they keep the slow path.
if (!globalContext.has('DecompressionStream')) {
return decodeWorldGzip(compressed);
Comment on lines +26 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Add browser gzip tests

The new browser decompression path and its older-browser fallback have no committed browser test. Both paths decoded valid gzip and rejected damaged input in Chrome, but that check is not part of the test suite. A later regression in either path could therefore go unnoticed. Please commit browser tests for valid and damaged input through inflateWorldGzip, including the fallback. This is a non-blocking coverage concern.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Authored Chrome gzip probe source

  • The exact temporary test source calls the existing decoder and new browser entry point with valid, bad-CRC, and truncated input, then removes DecompressionStream to exercise the fallback.

Authored Chrome probe command

  • The exact authored command runs the two focused Chrome test phases, captures their output and exit codes, and removes the temporary test copy.

Committed test coverage inspection

  • Git commands list the PR files and committed test references, showing no committed test of the new browser entry point or DecompressionStream branch.

Chrome result with the existing Dart decoder

  • The baseline Chrome run decoded valid gzip and rejected bad-CRC and truncated input, establishing comparison behavior with exit code 0.

Chrome result with the new stream and simulated older-browser fallback

  • The Chrome run decoded valid gzip and rejected damaged input through both the browser stream and the feature-detected fallback, with exit code 0.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it's a gap, but a committed browser test wouldn't close it today. CI's web job only builds the app (flutter build web), and nothing in CI runs @TestOn('browser') tests: the existing one, test/web/web_codecs_video_encoder_test.dart, is run by hand. On this machine flutter test --platform chrome doesn't load at all. So a new browser test would sit unexecuted. Instead, the browser path was checked in Chromium:

  • 55 fixtures (concatenated members, trailing data, wrong CRC with correct ISIZE, wrong ISIZE, footer truncation, empty and short input, optional header flags, bad FHCRC), compiled with both dart2js and dart2wasm. Every failure was a FormatException.
  • All 26 bundled assets came out byte-identical to the old decoder.
  • The fallback path was exercised with DecompressionStream removed.

Running browser tests in CI is worth doing, but as its own change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. A committed @TestOn('browser') test would not provide regression coverage while CI neither runs browser tests nor can reliably launch them, so it wouldn’t close the practical gap today. The Chromium validation is substantially broader than the proposed test—55 fixtures across dart2js and dart2wasm, all bundled assets checked byte-for-byte, and the fallback exercised—so I’m withdrawing this finding for PR #255. Running browser tests in CI is worthwhile, but belongs in a separate change.

}
checkWorldGzipHeader(compressed);
final Uint8List decoded;
try {
final inflated = _Response(compressed.toJS)
.body
.pipeThrough(_DecompressionStream('gzip'));
decoded =
(await _Response(inflated).arrayBuffer().toDart).toDart.asUint8List();
} on Object {
throw const FormatException('Invalid world gzip checksum or length.');
}
final footer = ByteData.sublistView(compressed, compressed.length - 8);
if (footer.getUint32(4, Endian.little) != decoded.length) {
throw const FormatException('Invalid world gzip checksum or length.');
}
return decoded;
}
Loading