diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3d022906..a2b3df8ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- restoring to missing Download subdir
+- crash when cataloguing PNG with large chunks
## [v1.7.0] - 2022-09-19
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt
index 06add40ae..8f6672ce9 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt
@@ -67,6 +67,7 @@ object Helper {
val metadata = when (fileType) {
FileType.Jpeg -> safeReadJpeg(inputStream)
+ FileType.Png -> safeReadPng(inputStream)
FileType.Tiff,
FileType.Arw,
FileType.Cr2,
@@ -95,6 +96,10 @@ object Helper {
return metadata
}
+ private fun safeReadPng(input: InputStream): com.drew.metadata.Metadata {
+ return SafePngMetadataReader.readMetadata(input)
+ }
+
@Throws(IOException::class, TiffProcessingException::class)
fun safeReadTiff(input: InputStream): com.drew.metadata.Metadata {
val reader = RandomAccessStreamReader(input, RandomAccessStreamReader.DEFAULT_CHUNK_LENGTH, safeReadStreamLength)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt
new file mode 100644
index 000000000..b0baa7973
--- /dev/null
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt
@@ -0,0 +1,302 @@
+package deckers.thibault.aves.metadata.metadataextractor
+
+import android.util.Log
+import com.drew.imaging.png.*
+import com.drew.imaging.tiff.TiffProcessingException
+import com.drew.imaging.tiff.TiffReader
+import com.drew.lang.*
+import com.drew.lang.annotations.NotNull
+import com.drew.metadata.ErrorDirectory
+import com.drew.metadata.Metadata
+import com.drew.metadata.StringValue
+import com.drew.metadata.exif.ExifTiffHandler
+import com.drew.metadata.icc.IccReader
+import com.drew.metadata.png.PngChromaticitiesDirectory
+import com.drew.metadata.png.PngDirectory
+import com.drew.metadata.xmp.XmpReader
+import deckers.thibault.aves.utils.LogUtils
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.util.zip.InflaterInputStream
+import java.util.zip.ZipException
+
+// adapted from `PngMetadataReader` to prevent reading OOM from large chunks
+// as of `metadata-extractor` v2.18.0, there is no way to customize the reader
+// without copying `desiredChunkTypes` and the whole `processChunk` function
+object SafePngMetadataReader {
+ private val LOG_TAG = LogUtils.createTag()
+
+ // arbitrary size to detect chunks that may yield an OOM
+ private const val chunkSizeDangerThreshold = SafeXmpReader.segmentTypeSizeDangerThreshold
+
+ private val latin1Encoding = Charsets.ISO_8859_1
+ private val desiredChunkTypes: Set = hashSetOf(
+ PngChunkType.IHDR,
+ PngChunkType.PLTE,
+ PngChunkType.tRNS,
+ PngChunkType.cHRM,
+ PngChunkType.sRGB,
+ PngChunkType.gAMA,
+ PngChunkType.iCCP,
+ PngChunkType.bKGD,
+ PngChunkType.tEXt,
+ PngChunkType.zTXt,
+ PngChunkType.iTXt,
+ PngChunkType.tIME,
+ PngChunkType.pHYs,
+ PngChunkType.sBIT,
+ PngChunkType.eXIf,
+ )
+
+ @Throws(IOException::class, PngProcessingException::class)
+ fun readMetadata(inputStream: InputStream): Metadata {
+ val chunks = PngChunkReader().extract(StreamReader(inputStream), desiredChunkTypes)
+ val metadata = Metadata()
+ for (chunk in chunks) {
+ try {
+ processChunk(metadata, chunk)
+ } catch (e: Exception) {
+ metadata.addDirectory(ErrorDirectory("Exception reading PNG chunk: " + e.message))
+ }
+ }
+ return metadata
+ }
+
+ @Throws(PngProcessingException::class, IOException::class)
+ private fun processChunk(@NotNull metadata: Metadata, @NotNull chunk: PngChunk) {
+ val chunkType = chunk.type
+ val bytes = chunk.bytes
+
+ // TLAD insert start
+ if (bytes.size > chunkSizeDangerThreshold) {
+ Log.w(LOG_TAG, "PNG chunk $chunkType is too large, with a size of ${bytes.size} B")
+ return
+ }
+ // TLAD insert end
+
+ if (chunkType == PngChunkType.IHDR) {
+ val header = PngHeader(bytes)
+ val directory = PngDirectory(PngChunkType.IHDR)
+ directory.setInt(PngDirectory.TAG_IMAGE_WIDTH, header.imageWidth)
+ directory.setInt(PngDirectory.TAG_IMAGE_HEIGHT, header.imageHeight)
+ directory.setInt(PngDirectory.TAG_BITS_PER_SAMPLE, header.bitsPerSample.toInt())
+ directory.setInt(PngDirectory.TAG_COLOR_TYPE, header.colorType.numericValue)
+ directory.setInt(PngDirectory.TAG_COMPRESSION_TYPE, header.compressionType.toInt() and 0xFF) // make sure it's unsigned
+ directory.setInt(PngDirectory.TAG_FILTER_METHOD, header.filterMethod.toInt())
+ directory.setInt(PngDirectory.TAG_INTERLACE_METHOD, header.interlaceMethod.toInt())
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.PLTE) {
+ val directory = PngDirectory(PngChunkType.PLTE)
+ directory.setInt(PngDirectory.TAG_PALETTE_SIZE, bytes.size / 3)
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.tRNS) {
+ val directory = PngDirectory(PngChunkType.tRNS)
+ directory.setInt(PngDirectory.TAG_PALETTE_HAS_TRANSPARENCY, 1)
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.sRGB) {
+ val srgbRenderingIntent = bytes[0].toInt()
+ val directory = PngDirectory(PngChunkType.sRGB)
+ directory.setInt(PngDirectory.TAG_SRGB_RENDERING_INTENT, srgbRenderingIntent)
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.cHRM) {
+ val chromaticities = PngChromaticities(bytes)
+ val directory = PngChromaticitiesDirectory()
+ directory.setInt(PngChromaticitiesDirectory.TAG_WHITE_POINT_X, chromaticities.whitePointX)
+ directory.setInt(PngChromaticitiesDirectory.TAG_WHITE_POINT_Y, chromaticities.whitePointY)
+ directory.setInt(PngChromaticitiesDirectory.TAG_RED_X, chromaticities.redX)
+ directory.setInt(PngChromaticitiesDirectory.TAG_RED_Y, chromaticities.redY)
+ directory.setInt(PngChromaticitiesDirectory.TAG_GREEN_X, chromaticities.greenX)
+ directory.setInt(PngChromaticitiesDirectory.TAG_GREEN_Y, chromaticities.greenY)
+ directory.setInt(PngChromaticitiesDirectory.TAG_BLUE_X, chromaticities.blueX)
+ directory.setInt(PngChromaticitiesDirectory.TAG_BLUE_Y, chromaticities.blueY)
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.gAMA) {
+ val gammaInt = ByteConvert.toInt32BigEndian(bytes)
+ SequentialByteArrayReader(bytes).int32
+ val directory = PngDirectory(PngChunkType.gAMA)
+ directory.setDouble(PngDirectory.TAG_GAMMA, gammaInt / 100000.0)
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.iCCP) {
+ val reader: SequentialReader = SequentialByteArrayReader(bytes)
+
+ // Profile Name is 1-79 bytes, followed by the 1 byte null character
+ val profileNameBytes = reader.getNullTerminatedBytes(79 + 1)
+ val directory = PngDirectory(PngChunkType.iCCP)
+ directory.setStringValue(PngDirectory.TAG_ICC_PROFILE_NAME, StringValue(profileNameBytes, latin1Encoding))
+ val compressionMethod = reader.int8
+ // Only compression method allowed by the spec is zero: deflate
+ if (compressionMethod.toInt() == 0) {
+ // bytes left for compressed text is:
+ // total bytes length - (profilenamebytes length + null byte + compression method byte)
+ val bytesLeft = bytes.size - (profileNameBytes.size + 1 + 1)
+ val compressedProfile = reader.getBytes(bytesLeft)
+ try {
+ val inflateStream = InflaterInputStream(ByteArrayInputStream(compressedProfile))
+ IccReader().extract(RandomAccessStreamReader(inflateStream), metadata, directory)
+ inflateStream.close()
+ } catch (zex: ZipException) {
+ directory.addError(String.format("Exception decompressing PNG iCCP chunk : %s", zex.message))
+ metadata.addDirectory(directory)
+ }
+ } else {
+ directory.addError("Invalid compression method value")
+ }
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.bKGD) {
+ val directory = PngDirectory(PngChunkType.bKGD)
+ directory.setByteArray(PngDirectory.TAG_BACKGROUND_COLOR, bytes)
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.tEXt) {
+ val reader: SequentialReader = SequentialByteArrayReader(bytes)
+
+ // Keyword is 1-79 bytes, followed by the 1 byte null character
+ val keywordsv = reader.getNullTerminatedStringValue(79 + 1, latin1Encoding)
+ val keyword = keywordsv.toString()
+
+ // bytes left for text is:
+ // total bytes length - (Keyword length + null byte)
+ val bytesLeft = bytes.size - (keywordsv.bytes.size + 1)
+ val value = reader.getNullTerminatedStringValue(bytesLeft, latin1Encoding)
+ val textPairs: MutableList = ArrayList()
+ textPairs.add(KeyValuePair(keyword, value))
+ val directory = PngDirectory(PngChunkType.tEXt)
+ directory.setObject(PngDirectory.TAG_TEXTUAL_DATA, textPairs)
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.zTXt) {
+ val reader: SequentialReader = SequentialByteArrayReader(bytes)
+
+ // Keyword is 1-79 bytes, followed by the 1 byte null character
+ val keywordsv = reader.getNullTerminatedStringValue(79 + 1, latin1Encoding)
+ val keyword = keywordsv.toString()
+ val compressionMethod = reader.int8
+
+ // bytes left for compressed text is:
+ // total bytes length - (Keyword length + null byte + compression method byte)
+ val bytesLeft = bytes.size - (keywordsv.bytes.size + 1 + 1)
+ var textBytes: ByteArray? = null
+ if (compressionMethod.toInt() == 0) {
+ try {
+ textBytes = StreamUtil.readAllBytes(InflaterInputStream(ByteArrayInputStream(bytes, bytes.size - bytesLeft, bytesLeft)))
+ } catch (zex: ZipException) {
+ val directory = PngDirectory(PngChunkType.zTXt)
+ directory.addError(String.format("Exception decompressing PNG zTXt chunk with keyword \"%s\": %s", keyword, zex.message))
+ metadata.addDirectory(directory)
+ }
+ } else {
+ val directory = PngDirectory(PngChunkType.zTXt)
+ directory.addError("Invalid compression method value")
+ metadata.addDirectory(directory)
+ }
+ if (textBytes != null) {
+ if (keyword == "XML:com.adobe.xmp") {
+ // NOTE in testing images, the XMP has parsed successfully, but we are not extracting tags from it as necessary
+ XmpReader().extract(textBytes, metadata)
+ } else {
+ val textPairs: MutableList = ArrayList()
+ textPairs.add(KeyValuePair(keyword, StringValue(textBytes, latin1Encoding)))
+ val directory = PngDirectory(PngChunkType.zTXt)
+ directory.setObject(PngDirectory.TAG_TEXTUAL_DATA, textPairs)
+ metadata.addDirectory(directory)
+ }
+ }
+ } else if (chunkType == PngChunkType.iTXt) {
+ val reader: SequentialReader = SequentialByteArrayReader(bytes)
+
+ // Keyword is 1-79 bytes, followed by the 1 byte null character
+ val keywordsv = reader.getNullTerminatedStringValue(79 + 1, latin1Encoding)
+ val keyword = keywordsv.toString()
+ val compressionFlag = reader.int8
+ val compressionMethod = reader.int8
+ // TODO we currently ignore languageTagBytes and translatedKeywordBytes
+ val languageTagBytes = reader.getNullTerminatedBytes(bytes.size)
+ val translatedKeywordBytes = reader.getNullTerminatedBytes(bytes.size)
+
+ // bytes left for compressed text is:
+ // total bytes length - (Keyword length + null byte + comp flag byte + comp method byte + lang length + null byte + translated length + null byte)
+ val bytesLeft = bytes.size - (keywordsv.bytes.size + 1 + 1 + 1 + languageTagBytes.size + 1 + translatedKeywordBytes.size + 1)
+ var textBytes: ByteArray? = null
+ if (compressionFlag.toInt() == 0) {
+ textBytes = reader.getNullTerminatedBytes(bytesLeft)
+ } else if (compressionFlag.toInt() == 1) {
+ if (compressionMethod.toInt() == 0) {
+ try {
+ textBytes = StreamUtil.readAllBytes(InflaterInputStream(ByteArrayInputStream(bytes, bytes.size - bytesLeft, bytesLeft)))
+ } catch (zex: ZipException) {
+ val directory = PngDirectory(PngChunkType.iTXt)
+ directory.addError(String.format("Exception decompressing PNG iTXt chunk with keyword \"%s\": %s", keyword, zex.message))
+ metadata.addDirectory(directory)
+ }
+ } else {
+ val directory = PngDirectory(PngChunkType.iTXt)
+ directory.addError("Invalid compression method value")
+ metadata.addDirectory(directory)
+ }
+ } else {
+ val directory = PngDirectory(PngChunkType.iTXt)
+ directory.addError("Invalid compression flag value")
+ metadata.addDirectory(directory)
+ }
+ if (textBytes != null) {
+ if (keyword == "XML:com.adobe.xmp") {
+ // NOTE in testing images, the XMP has parsed successfully, but we are not extracting tags from it as necessary
+ XmpReader().extract(textBytes, metadata)
+ } else {
+ val textPairs: MutableList = ArrayList()
+ textPairs.add(KeyValuePair(keyword, StringValue(textBytes, latin1Encoding)))
+ val directory = PngDirectory(PngChunkType.iTXt)
+ directory.setObject(PngDirectory.TAG_TEXTUAL_DATA, textPairs)
+ metadata.addDirectory(directory)
+ }
+ }
+ } else if (chunkType == PngChunkType.tIME) {
+ val reader = SequentialByteArrayReader(bytes)
+ val year = reader.uInt16
+ val month = reader.uInt8.toInt()
+ val day = reader.uInt8.toInt()
+ val hour = reader.uInt8.toInt()
+ val minute = reader.uInt8.toInt()
+ val second = reader.uInt8.toInt()
+ val directory = PngDirectory(PngChunkType.tIME)
+ if (DateUtil.isValidDate(year, month - 1, day) && DateUtil.isValidTime(hour, minute, second)) {
+ val dateString = String.format("%04d:%02d:%02d %02d:%02d:%02d", year, month, day, hour, minute, second)
+ directory.setString(PngDirectory.TAG_LAST_MODIFICATION_TIME, dateString)
+ } else {
+ directory.addError(
+ String.format(
+ "PNG tIME data describes an invalid date/time: year=%d month=%d day=%d hour=%d minute=%d second=%d",
+ year, month, day, hour, minute, second
+ )
+ )
+ }
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.pHYs) {
+ val reader = SequentialByteArrayReader(bytes)
+ val pixelsPerUnitX = reader.int32
+ val pixelsPerUnitY = reader.int32
+ val unitSpecifier = reader.int8
+ val directory = PngDirectory(PngChunkType.pHYs)
+ directory.setInt(PngDirectory.TAG_PIXELS_PER_UNIT_X, pixelsPerUnitX)
+ directory.setInt(PngDirectory.TAG_PIXELS_PER_UNIT_Y, pixelsPerUnitY)
+ directory.setInt(PngDirectory.TAG_UNIT_SPECIFIER, unitSpecifier.toInt())
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.sBIT) {
+ val directory = PngDirectory(PngChunkType.sBIT)
+ directory.setByteArray(PngDirectory.TAG_SIGNIFICANT_BITS, bytes)
+ metadata.addDirectory(directory)
+ } else if (chunkType == PngChunkType.eXIf) {
+ try {
+ val handler = ExifTiffHandler(metadata, null)
+ TiffReader().processTiff(ByteArrayReader(bytes), handler, 0)
+ } catch (ex: TiffProcessingException) {
+ val directory = PngDirectory(PngChunkType.eXIf)
+ directory.addError(ex.message)
+ metadata.addDirectory(directory)
+ } catch (ex: IOException) {
+ val directory = PngDirectory(PngChunkType.eXIf)
+ directory.addError(ex.message)
+ metadata.addDirectory(directory)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt
index c70c09ae2..db58da6d6 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt
@@ -135,7 +135,7 @@ class SafeXmpReader : XmpReader() {
private val LOG_TAG = LogUtils.createTag()
// arbitrary size to detect extended XMP that may yield an OOM
- private const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB
+ const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB
// tighter node limits for faster loading
val PARSE_OPTIONS: ParseOptions = ParseOptions().setXMPNodesToLimit(