From 9a4fd44c3a46a93d83d680ddaadb7575ef9f1c44 Mon Sep 17 00:00:00 2001 From: Tim Sylvester Date: Wed, 18 Mar 2026 16:08:46 -0700 Subject: [PATCH] core: better handle tile compression in PMTiles sources (#4159) Co-authored-by: Bart Louwers --- .../src/mbgl/storage/pmtiles_file_source.cpp | 13 ++- .../storage/pmtiles/corrupt-gzip-tile.pmtiles | Bin 0 -> 150 bytes .../storage/pmtiles/corrupt-gzip-tile.py | 88 +++++++++++++++++ .../pmtiles/uncompressed-tiles.pmtiles | Bin 0 -> 160 bytes .../storage/pmtiles/uncompressed-tiles.py | 91 ++++++++++++++++++ test/storage/pmtiles_file_source.test.cpp | 40 ++++++++ 6 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/storage/pmtiles/corrupt-gzip-tile.pmtiles create mode 100644 test/fixtures/storage/pmtiles/corrupt-gzip-tile.py create mode 100644 test/fixtures/storage/pmtiles/uncompressed-tiles.pmtiles create mode 100644 test/fixtures/storage/pmtiles/uncompressed-tiles.py 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 0000000000000000000000000000000000000000..796d8fcbeb01917e914cb5c7d8389c5f1b305d33 GIT binary patch literal 150 zcmWIW4av+&EoQD~fB;q~-2$ay6fV9HQ~@Innt_pt2`HtvZ3=^U^eKi*p4kj9mzcs# TWng4r6k?R`zPEPYdt?j%= 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(); +}