Repository: OSGeo/gdal (2026-05-29T00:39:01Z)

Issue:
Lerc compressed MRF is not readable when using external liblerc
### What is the bug?

Using the attached MRF image (as exported from an ESRI ImageServer, using the exportImage API). If I use a self-compiled GDAL with GDAL_USE_LERC_INTERNAL=ON, the image is readable (as tested with `gdalinfo exportImage -hist`)

But if I compile using the system lerc library via GDAL_USE_LERC_INTERNAL=OFF (Fedora 44, currently liblerc 4.0) then I get errors:

```
ERROR 1: MRF: Error decoding Lerc
ERROR 1: exportImage, band 1: IReadBlock failed at X offset 0, Y offset 0: MRF: Error decoding Lerc
```

This looks strange to me -- from the git history the internal liblerc version is quite far behind upstream, so I'd have thought using the upstream library would result in LESS issues!

Is this something that can be handled in GDAL? Or is upstream liblerc broken wrt to handling this file?

[exportImage.zip](https://github.com/user-attachments/files/28327092/exportImage.zip)

### Steps to reproduce the issue

1. Build using GDAL_USE_LERC_INTERNAL=OFF
2. Make sure there's no .aux files left over from previous tests
3. Run `gdalinfo exportImage -hist`

### Versions and provenance

Master GDAL, liblerc 4.0

### Additional context

_No response_


Discussion:
CC @lucianpls 
CC @tmaurer3
As far as I can tell it is a Lerc2 v3, it should work fine
@tmaurer3 I did a git bisect session in liblerc, and decoding works fine with liblerc v2.2.1, but not with v3.0. More precisely this commit https://github.com/esri/lerc/commit/9e33d7fcfaabfaf2385bd4b161ba875f037e2718 is the offending one. To be noted we pass nMasks = 0 to lerc_decode() since this image has no noData value
With the following patch applied on latest liblerc master I manage to get GDAL to decode the image
```
diff --git a/src/LercLib/Lerc.cpp b/src/LercLib/Lerc.cpp
index e65d173..901c42b 100644
--- a/src/LercLib/Lerc.cpp
+++ b/src/LercLib/Lerc.cpp
@@ -404,7 +404,7 @@ ErrCode Lerc::DecodeTempl(T* pData, const Byte* pLercBlob, unsigned int numBytes
       return errCode;
 
     // caller must provide enough space for the masks that are there (e.g., if they differ between bands)
-    if (nMasks < lercInfo.nMasks)    // 0, 1, or nBands
+    if (nMasks > 0 && nMasks < lercInfo.nMasks)    // 0, 1, or nBands
       return ErrCode::WrongParam;
 
     // caller cannot ask for more bands than are there
```
Basically current liblerc requires the caller to provide a buffer for an output mask even if it doesn't want one. Is that intended?
And is it expected that the MRF driver doesn't pass a buffer for an output mask when there's actually one in the LERC blob?
I think the problem is that the code in MRF does not expect to decode LERC2 blobs not written by MRF, which make use of some of the newer Lerc2 API options.
The naked Lerc blob reader in MRF was intended for reading Lerc V1 from esri terrain tiles, not for reading all flavors of Lerc2. Would disabling naked Lerc2 blob decoding by MRF be acceptable?
@rouault  Thanks for looking at it! I did too, here are the properties of the compressed Lerc2 blob: 

version = 3, dataType = 6, nDepth = 1, nCols = 1287, nRows = 1217, nBands = 1, nValidPixels = 784242, blobSize = 2808756, nMasks = 1, nDepth = 1, nUsesNoDataValue = 0
codec = 3, nDepth = 1, w = 1287, h = 1217, nBands = 1, nMasks = 1, usesNoData = 0, dt = 6, min =   331.6648, max =         541.4592, time =    6 ms,  ok :  exportImage.lerc2
Total decode time = 6 ms

Lerc2 usually does not use a noData value internally. It uses a bit mask to encode which pixels are valid. Here, only about half of the pixels are valid. The noData value is only used inside Lerc2 if each pixel has an array of values (or nDepth > 1), AND such arrays contain a mix of valid and invalid values at the same pixel (which is really special). This is for 3 or 4 dimensional data cubes. It was one of the main ideas behind Lerc to avoid the noData value whenever possible. Masks are much better. They avoid many issues that noData values can give you esp together with lossy compression. If needed, the raster noData value should be passed on the raster dataset level, not the tile level. If you need to populate the invalid pixels with the noData value again on the decoder side (e.g., because you want to write it to a tiff which does not support a valid / invalid mask), the decoder needs to do this. 

The bug here seems to be that when the Lerc API changed from major version 2 to 3 (which was done to support higher dimensional data), the calls into the Lerc API were not updated properly. You can use "lerc_getBlobInfo(...)" on the binary Lerc2 blob before you call decode(). See the print out above. It says nMasks = 1. From this info the arrays can be allocated and then passed to the decode() call. 

CC @lucianpls 
> Would disabling naked Lerc2 blob decoding by MRF be acceptable?

That wouldn't help @nyalldawson  in his task to decode those blobs...

> you can use "lerc_getBlobInfo(...)" on the binary Lerc2 blob before you call decode().

thanks. done in candidate fix in https://github.com/OSGeo/gdal/pull/14681
>  (e.g., because you want to write it to a tiff which does not support a valid / invalid mask)

This issue is about MRF, but this comment caught my eye. I am not sure if you meant that if user wants to write a tiff without a mask, or if you saud that the TIFF format does not not support mask. If the latter, TIFF does support mask.

https://gdal.org/en/stable/drivers/raster/gtiff.html#internal-nodata-masks
https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/[tiff6.pdf](https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf)

> PhotometricInterpretation
> ...
> 4 = Transparency Mask.
> This means that the image is used to define an irregularly shaped region of another
> image in the same TIFF file. SamplesPerPixel and BitsPerSample must be 1.
> PackBits compression is recommended. The 1-bits define the interior of the re
> gion; the 0-bits define the exterior of the region.

I do not know if GDAL automatically converts a Lerc image with a mask into a GeoTIFF with a mask, but maybe it should. I am not sure, though, because by my experience other programs do not always handle TIFFs with a mask band right, and alpha band or nodata may be safer to use when delivering data for public. 
> I do not know if GDAL automatically converts a Lerc image with a mask into a GeoTIFF with a mask, but maybe it should

no, it doesn't.  on the writing side, GDAL does convert a GDAL RGBA image when PlanarConfig=Contig (ie INTERLEAVE=PIXEL) and the alpha band contains only 0 and 255 values into a Lerc RGB with Lerc mask. But a  GDAL RGB + GDAL mask band are converted into 2 separate TIFF IFDs. The reason is mostly because creating a Lerc blob with a mask from that would seriously complicate the GeoTIFF driver, and also because at the TIFF level, when opening a TIFF file we would lack some "signaling" (typically a TIFF tag) that Lerc blobs have a embedded mask (currently that would require accessing a TIFF tile, which would be hard to locate efficiently if the file is sparse and remote), and thus that application couldn't advertize its presence.
I'm not convinced that this functionality belongs in MRF, a stand-alone Lerc driver would be better.
> I'm not convinced that this functionality belongs in MRF, a stand-alone Lerc driver would be better.

That would probably be cleaner indeed. That said support for Lerc blobs dates back to af343b212842a1899cc03b90ea74121803fe6b3d and we have users depending on it, so we can't just remove it without having the replacement available.
> That said support for Lerc blobs dates back to https://github.com/OSGeo/gdal/commit/af343b212842a1899cc03b90ea74121803fe6b3d  

At that time there was only one version of the Lerc2 library, now there are 4 or 5, with more capabilities and different APIs.
@lucianpls  You can use the latest Lerc API to decode any Lerc blob ever written. Decode is backwards compatible and supports all versions. 
> a stand-alone Lerc driver would be better.

ticketed as https://github.com/OSGeo/gdal/issues/14690
> You can use the latest Lerc API to decode any Lerc blob ever written. Decode is backwards compatible and supports all versions.

Except in this particular case, where a call that worked on some data in one version of the library stops working in a newer one, with the same exact data, except if the decoding does something different. That is not backwards compatibility, despite the statement above.
@rouault 
How does the GTiff deal with this type of issue? Does it check the library version and adapts? Would it be "broken" with malicious input?
> Does it check the library version and adapts?

it has to, similarly to what the MRF driver does (now) since the API is slightly different with liblerc >= 3 that requires the number of expected masks to be provided, whereas before only the pointer was needed. It uses lerc_getBlobInfo too

> Would it be "broken" with malicious input?

I don't think I have an answer to that... 
Guys, when an API has a major version change then the calling code might need updating. I'll have to do this, too, with 3rd party software that I use, such as OpenCV (not in Lerc but in other projects). That should not happen often. But that is what major version update is for. The backward compatible is for the data. Old data can always be read. 



Patch:
diff --git a/frmts/mrf/LERC_band.cpp b/frmts/mrf/LERC_band.cpp
index 368c6633d247..3e7a2fb4f3ac 100644
--- a/frmts/mrf/LERC_band.cpp
+++ b/frmts/mrf/LERC_band.cpp
@@ -44,6 +44,8 @@ Authors:  Lucian Plesea
 #define L2NS LercNS
 #endif
 
+#define INFOIDX(T) static_cast<size_t>(L2NS::InfoArrOrder::T)
+
 USING_NAMESPACE_LERC1
 NAMESPACE_MRF_START
 
@@ -576,8 +578,27 @@ CPLErr LERC_Band::Decompress(buf_mgr &dst, buf_mgr &src)
     auto stride = static_cast<int>(img.pagesize.c);
 
     std::vector<Lerc1NS::Byte> bm;
+    int nMasks = 0;
+#if LERC_AT_LEAST_VERSION(3, 0, 0)
+    {
+        // Determine the number of masks
+        std::vector<unsigned int> info(INFOIDX(nMasks) + 1);
+        auto status =
+            lerc_getBlobInfo(reinterpret_cast<Lerc1NS::Byte *>(src.buffer),
+                             static_cast<unsigned int>(src.size), info.data(),
+                             nullptr, static_cast<int>(info.size()), 0);
+        if (L2NS::ErrCode::Ok == static_cast<L2NS::ErrCode>(status) &&
+            1 == info[INFOIDX(nBands)])
+        {
+            nMasks = info[INFOIDX(nMasks)];
+        }
+    }
+#else
     if (img.hasNoData)
-        bm.resize(static_cast<size_t>(w) * static_cast<size_t>(h));
+        nMasks = 1;
+#endif
+    if (nMasks > 0)
+        bm.resize(static_cast<size_t>(w) * static_cast<size_t>(h) * nMasks);
     auto pbm = bm.data();
     if (bm.empty())
         pbm = nullptr;
@@ -588,13 +609,14 @@ CPLErr LERC_Band::Decompress(buf_mgr &dst, buf_mgr &src)
         lerc_decode(reinterpret_cast<Lerc1NS::Byte *>(src.buffer),
                     static_cast<unsigned int>(src.size),
 #if LERC_AT_LEAST_VERSION(3, 0, 0)
-                    pbm ? 1 : 0,
+                    nMasks,
 #endif
                     pbm, stride, w, h, 1,
                     static_cast<unsigned int>(GDTtoL2(img.dt)), dst.buffer);
     if (L2NS::ErrCode::Ok != static_cast<L2NS::ErrCode>(status))
     {
-        CPLError(CE_Failure, CPLE_AppDefined, "MRF: Error decoding Lerc");
+        CPLError(CE_Failure, CPLE_AppDefined,
+                 "MRF: Error decoding Lerc: status=%d", status);
         return CE_Failure;
     }
 
@@ -683,8 +705,6 @@ CPLXMLNode *LERC_Band::GetMRFConfig(GDALOpenInfo *poOpenInfo)
         GByte *buffer = nullptr;
         vsi_l_offset l2size;
 
-#define INFOIDX(T) static_cast<size_t>(L2NS::InfoArrOrder::T)
-
         if (VSIIngestFile(nullptr, poOpenInfo->pszFilename, &buffer, &l2size,
                           MAX_L2SIZE))
         {


Test patch:
diff --git a/autotest/gdrivers/mrf.py b/autotest/gdrivers/mrf.py
index 6014874fc999..34dacaaaab39 100755
--- a/autotest/gdrivers/mrf.py
+++ b/autotest/gdrivers/mrf.py
@@ -663,6 +663,22 @@ def test_mrf_setspatialref():
     gdal.GetDriverByName("MRF").Delete(filename)
 
 
+@pytest.mark.network
+def test_mrf_read_naked_lerc2_with_mask():
+    """Test case https://github.com/OSGeo/gdal/issues/14676"""
+
+    gdaltest.download_or_skip(
+        "https://github.com/user-attachments/files/28327092/exportImage.zip",
+        "exportImage.zip",
+        force_download=True,
+    )
+
+    ds = gdal.Open("/vsizip/tmp/cache/exportImage.zip/exportImage")
+    # 13269: internal liblerc
+    # 43345: liblerc v4
+    assert ds.GetRasterBand(1).Checksum() in (13269, 43345)
+
+
 def test_mrf_cleanup():
 
     files = (