From 794282ae2c54b939cf35b3c5a6ebcf0d848b66b2 Mon Sep 17 00:00:00 2001 From: denbond7 Date: Mon, 31 Aug 2026 17:47:33 +0300 Subject: [PATCH] fix: handle Gmail raw response streams correctly --- .../GMailRawAttachmentFilterInputStream.kt | 27 +----- .../GMailRawMIMEMessageFilterInputStream.kt | 28 +----- .../api/GMailRawResponseFilterInputStream.kt | 86 ++++++++++++++++ .../GMailRawResponseFilterInputStreamTest.kt | 97 +++++++++++++++++++ 4 files changed, 191 insertions(+), 47 deletions(-) create mode 100644 FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawResponseFilterInputStream.kt create mode 100644 FlowCrypt/src/test/java/com/flowcrypt/email/api/email/gmail/api/GMailRawResponseFilterInputStreamTest.kt diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawAttachmentFilterInputStream.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawAttachmentFilterInputStream.kt index a7cc741745..44d69cde4e 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawAttachmentFilterInputStream.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawAttachmentFilterInputStream.kt @@ -1,37 +1,18 @@ /* * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 + * Contributors: denbond7 */ package com.flowcrypt.email.api.email.gmail.api -import java.io.FilterInputStream import java.io.InputStream /** * @author Denys Bondarenko */ class GMailRawAttachmentFilterInputStream(inputStream: InputStream) : - FilterInputStream(inputStream) { - init { - skip(9) - } - - /** - * Via this method we trim the end of the stream - */ - override fun read(b: ByteArray, off: Int, len: Int): Int { - val tempBuffer = b.copyOf() - val i = super.read(tempBuffer, off, len) - //find index of the last '"' char - val indexEndStart = tempBuffer.indexOf(34) - - return if (indexEndStart != -1) { - tempBuffer.copyInto(b, endIndex = indexEndStart) - indexEndStart - } else { - tempBuffer.copyInto(b) - i - } + GMailRawResponseFilterInputStream(inputStream, JSON_PREFIX_LENGTH) { + private companion object { + const val JSON_PREFIX_LENGTH = 9L } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawMIMEMessageFilterInputStream.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawMIMEMessageFilterInputStream.kt index 47fd3942e9..3514c5018e 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawMIMEMessageFilterInputStream.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawMIMEMessageFilterInputStream.kt @@ -1,38 +1,18 @@ /* * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 + * Contributors: denbond7 */ package com.flowcrypt.email.api.email.gmail.api -import java.io.FilterInputStream import java.io.InputStream /** * @author Denys Bondarenko */ class GMailRawMIMEMessageFilterInputStream(inputStream: InputStream) : - FilterInputStream(inputStream) { - init { - // we should skip first 12 bytes to begin to read raw MIME message as a stream - skip(12) - } - - /** - * Via this method we trim the end of the stream - */ - override fun read(b: ByteArray, off: Int, len: Int): Int { - val tempBuffer = b.copyOf() - val i = super.read(tempBuffer, off, len) - //find index of the last '"' char - val indexEndStart = tempBuffer.indexOf(34) - - return if (indexEndStart != -1) { - tempBuffer.copyInto(b, endIndex = indexEndStart) - indexEndStart - } else { - tempBuffer.copyInto(b) - i - } + GMailRawResponseFilterInputStream(inputStream, JSON_PREFIX_LENGTH) { + private companion object { + const val JSON_PREFIX_LENGTH = 12L } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawResponseFilterInputStream.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawResponseFilterInputStream.kt new file mode 100644 index 0000000000..c59e66a5e3 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/email/gmail/api/GMailRawResponseFilterInputStream.kt @@ -0,0 +1,86 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: denbond7 + */ + +package com.flowcrypt.email.api.email.gmail.api + +import java.io.EOFException +import java.io.FilterInputStream +import java.io.InputStream + +/** + * Exposes the Base64URL value from a Gmail API JSON response as an input stream. + * + * The opening JSON bytes are removed by [prefixLength]. The first quote after the prefix terminates + * the value because the Base64URL alphabet cannot contain quotes. + */ +open class GMailRawResponseFilterInputStream( + inputStream: InputStream, + prefixLength: Long +) : FilterInputStream(inputStream) { + private var endReached = false + + init { + skipFully(prefixLength) + } + + override fun read(): Int { + if (endReached) return -1 + + return when (val value = super.read()) { + -1 -> -1 + JSON_STRING_DELIMITER -> { + endReached = true + -1 + } + + else -> value + } + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + checkBounds(b, off, len) + if (len == 0) return 0 + if (endReached) return -1 + + val readCount = super.read(b, off, len) + if (readCount <= 0) return readCount + + for (index in off until off + readCount) { + if (b[index].toInt() == JSON_STRING_DELIMITER) { + endReached = true + val valueLength = index - off + return if (valueLength == 0) -1 else valueLength + } + } + + return readCount + } + + private fun skipFully(byteCount: Long) { + var remaining = byteCount + while (remaining > 0) { + val skipped = super.skip(remaining) + if (skipped > 0) { + remaining -= skipped + } else if (super.read() == -1) { + throw EOFException("Unexpected end of Gmail API response while skipping JSON prefix") + } else { + remaining-- + } + } + } + + private fun checkBounds(buffer: ByteArray, offset: Int, length: Int) { + if (offset < 0 || length < 0 || length > buffer.size - offset) { + throw IndexOutOfBoundsException( + "offset=$offset, length=$length, bufferSize=${buffer.size}" + ) + } + } + + private companion object { + const val JSON_STRING_DELIMITER = '"'.code + } +} diff --git a/FlowCrypt/src/test/java/com/flowcrypt/email/api/email/gmail/api/GMailRawResponseFilterInputStreamTest.kt b/FlowCrypt/src/test/java/com/flowcrypt/email/api/email/gmail/api/GMailRawResponseFilterInputStreamTest.kt new file mode 100644 index 0000000000..55e2afa78b --- /dev/null +++ b/FlowCrypt/src/test/java/com/flowcrypt/email/api/email/gmail/api/GMailRawResponseFilterInputStreamTest.kt @@ -0,0 +1,97 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: denbond7 + */ + +package com.flowcrypt.email.api.email.gmail.api + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.EOFException + +class GMailRawResponseFilterInputStreamTest { + @Test + fun testAttachmentResponseReadInSmallChunks() { + val stream = GMailRawAttachmentFilterInputStream( + """{"data":"YWJjZA=="}""".byteInputStream() + ) + + assertEquals("YWJjZA==", stream.readBytesWithChunkSize(2).decodeToString()) + assertEquals(-1, stream.read()) + } + + @Test + fun testMimeResponseReadOneByteAtATime() { + val stream = GMailRawMIMEMessageFilterInputStream( + "{\n \"raw\": \"YWJjZA==\"\n}\n".byteInputStream() + ) + + val result = buildList { + while (true) { + val value = stream.read() + if (value == -1) break + add(value.toByte()) + } + }.toByteArray() + + assertEquals("YWJjZA==", result.decodeToString()) + assertEquals(-1, stream.read()) + } + + @Test + fun testBulkReadHonorsOffsetAndIgnoresExistingBufferContent() { + val stream = GMailRawAttachmentFilterInputStream( + """{"data":"YWJj"}""".byteInputStream() + ) + val buffer = ByteArray(10) { '"'.code.toByte() } + + val count = stream.read(buffer, 3, 4) + + assertEquals(4, count) + assertArrayEquals("YWJj".toByteArray(), buffer.copyOfRange(3, 7)) + assertEquals(-1, stream.read(buffer, 3, 4)) + } + + @Test + fun testPrefixIsFullyConsumedWhenDelegateSkipMakesPartialProgress() { + val response = """{"data":"YWJj"}""".toByteArray() + val delegate = object : ByteArrayInputStream(response) { + override fun skip(n: Long): Long = super.skip(n.coerceAtMost(1)) + } + + val stream = GMailRawAttachmentFilterInputStream(delegate) + + assertEquals("YWJj", stream.readBytes().decodeToString()) + } + + @Test + fun testTruncatedPrefixThrows() { + assertThrows(EOFException::class.java) { + GMailRawAttachmentFilterInputStream("{}".byteInputStream()) + } + } + + @Test + fun testEmptyValueReturnsEndOfStream() { + val stream = GMailRawAttachmentFilterInputStream("""{"data":""}""".byteInputStream()) + + assertEquals(-1, stream.read(ByteArray(8), 0, 8)) + assertEquals(-1, stream.read()) + } + + private fun GMailRawResponseFilterInputStream.readBytesWithChunkSize( + chunkSize: Int + ): ByteArray { + val result = mutableListOf() + val buffer = ByteArray(chunkSize) + while (true) { + val count = read(buffer, 0, buffer.size) + if (count == -1) break + result.addAll(buffer.copyOf(count).toList()) + } + return result.toByteArray() + } +}