diff --git a/platform/default/src/mbgl/storage/pmtiles_file_source.cpp b/platform/default/src/mbgl/storage/pmtiles_file_source.cpp index a93733e08..438c66fd7 100644 --- a/platform/default/src/mbgl/storage/pmtiles_file_source.cpp +++ b/platform/default/src/mbgl/storage/pmtiles_file_source.cpp @@ -166,7 +166,18 @@ public: response.etag = tileResponse.etag; if (header.tile_compression == pmtiles::COMPRESSION_GZIP) { - response.data = std::make_shared(util::decompress(*tileResponse.data)); + // Support uncompressed tiles + if (util::is_compressed(*tileResponse.data)) { + try { + response.data = std::make_shared(util::decompress(*tileResponse.data)); + } catch (const std::exception& e) { + response.error = std::make_unique( + Response::Error::Reason::Other, + std::string("Error decompressing PMTiles tile: ") + e.what()); + } + } else { + response.data = tileResponse.data; + } } ref.invoke(&FileSourceRequest::setResponse, response); diff --git a/test/fixtures/storage/pmtiles/corrupt-gzip-tile.pmtiles b/test/fixtures/storage/pmtiles/corrupt-gzip-tile.pmtiles new file mode 100644 index 000000000..796d8fcbe Binary files /dev/null and b/test/fixtures/storage/pmtiles/corrupt-gzip-tile.pmtiles differ diff --git a/test/fixtures/storage/pmtiles/corrupt-gzip-tile.py b/test/fixtures/storage/pmtiles/corrupt-gzip-tile.py new file mode 100644 index 000000000..a0a6dff49 --- /dev/null +++ b/test/fixtures/storage/pmtiles/corrupt-gzip-tile.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Generate corrupt-gzip-tile.pmtiles fixture. + +This fixture is a minimal PMTiles v3 archive with: + - tile_compression = COMPRESSION_GZIP (0x2) + - Tile data starts with gzip magic bytes (0x1f 0x8b) but is otherwise corrupt + +Used by test PMTilesFileSource.CorruptGzipTile to verify that decompression +failures are converted to error responses instead of propagating as exceptions. +""" + +import struct + + +def write_varint(value): + result = bytearray() + while value >= 0x80: + result.append((value & 0x7f) | 0x80) + value >>= 7 + result.append(value) + return bytes(result) + + +# Tile data: starts with gzip magic bytes (0x1f 0x8b) so is_compressed() returns True, +# but the rest is garbage so util::decompress() throws. +TILE_DATA = b'\x1f\x8b' + b'\xde\xad\xbe\xef' * 4 # 18 bytes of corrupt "gzip" + +tile_length = len(TILE_DATA) +n_entries = 1 + +# Serialize directory: 1 entry, tile_id=0 (z=0,x=0,y=0 → hilbert id 0) +dir_data = write_varint(n_entries) +dir_data += write_varint(0) # tile_id delta +dir_data += write_varint(1) # run_length = 1 +dir_data += write_varint(tile_length) # length +dir_data += write_varint(0 + 1) # offset encoded as offset+1 + +root_dir_offset = 127 +root_dir_bytes = len(dir_data) +json_meta_offset = root_dir_offset + root_dir_bytes +json_meta_bytes = 0 +leaf_dirs_offset = json_meta_offset +leaf_dirs_bytes = 0 +tile_data_offset = json_meta_offset +tile_data_bytes = tile_length + +hdr = b'PMTiles' +hdr += struct.pack('= 0x80: + result.append((value & 0x7f) | 0x80) + value >>= 7 + result.append(value) + return bytes(result) + + +# Tile data: PNG signature bytes (0x89 0x50...) so is_compressed() returns false. +TILE_DATA = b'\x89PNG\r\n\x1a\n' + b'\x00' * 20 # 28 bytes + +tile_length = len(TILE_DATA) +n_entries = 1 + +# Serialize directory: 1 entry, tile_id=0 (z=0,x=0,y=0 → hilbert id 0) +dir_data = write_varint(n_entries) +dir_data += write_varint(0) # tile_id delta (0 - 0 = 0) +dir_data += write_varint(1) # run_length = 1 +dir_data += write_varint(tile_length) # length +dir_data += write_varint(0 + 1) # offset = 0, encoded as offset+1 = 1 (first entry) + +root_dir_offset = 127 +root_dir_bytes = len(dir_data) +json_meta_offset = root_dir_offset + root_dir_bytes +json_meta_bytes = 0 +leaf_dirs_offset = json_meta_offset +leaf_dirs_bytes = 0 +tile_data_offset = json_meta_offset # tile data immediately after directory +tile_data_bytes = tile_length + +hdr = b'PMTiles' +hdr += struct.pack(' req = pmtiles.request( + Resource::tile(toAbsoluteURL("corrupt-gzip-tile.pmtiles"), 1.0, 0, 0, 0, Tileset::Scheme::XYZ), + [&](Response res) { + req.reset(); + ASSERT_NE(nullptr, res.error); + EXPECT_EQ(Response::Error::Reason::Other, res.error->reason); + EXPECT_NE(res.error->message.find("Error decompressing PMTiles tile:"), std::string::npos); + loop.stop(); + }); + + loop.run(); +} + +// An archive whose header flags tile_compression=GZIP but whose individual tiles are stored +// without compression must be served without error (uncompressed bytes passed through as-is). +TEST(PMTilesFileSource, UncompressedTile) { + util::RunLoop loop; + + PMTilesFileSource pmtiles(ResourceOptions::Default(), ClientOptions()); + + std::unique_ptr req = pmtiles.request( + Resource::tile(toAbsoluteURL("uncompressed-tiles.pmtiles"), 1.0, 0, 0, 0, Tileset::Scheme::XYZ), + [&](Response res) { + req.reset(); + EXPECT_EQ(nullptr, res.error); + ASSERT_TRUE(res.data.get()); + ASSERT_EQ(res.noContent, false); + loop.stop(); + }); + + loop.run(); +}