Create a CountingInputStream utility to avoid duplication

Prevent INT overflow in BlossomUploader.kt
Connection Cleanup in ImageDownloader.kt
Added try-finally in Sha256Hasher.jvmAndroid.kt
This commit is contained in:
davotoula
2025-10-25 23:40:31 +02:00
parent 93994564f6
commit 2332623cde
5 changed files with 103 additions and 72 deletions

View File

@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256Stream import com.vitorpamplona.quartz.utils.sha256.sha256StreamWithCount
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -137,43 +137,25 @@ class ImageDownloader {
responseCode = huc.responseCode responseCode = huc.responseCode
} }
return@withContext if (responseCode in 200..300) { return@withContext try {
var totalBytes = 0L if (responseCode in 200..300) {
val (hash, totalBytes) =
// Wrap the input stream to count bytes while hashing huc.inputStream.use {
val countingStream = sha256StreamWithCount(it)
object : java.io.InputStream() {
val inner = huc.inputStream
override fun read(): Int {
val byte = inner.read()
if (byte != -1) totalBytes++
return byte
} }
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
val bytesRead = inner.read(b, off, len)
if (bytesRead > 0) totalBytes += bytesRead
return bytesRead
}
override fun close() = inner.close()
}
val hash = countingStream.use { sha256Stream(it).toHexKey() }
StreamVerification( StreamVerification(
hash = hash, hash = hash.toHexKey(),
size = totalBytes, size = totalBytes,
contentType = huc.headerFields.get("Content-Type")?.firstOrNull(), contentType = huc.headerFields.get("Content-Type")?.firstOrNull(),
) )
} else { } else {
null null
} }
} finally {
// Always disconnect to release connection resources
huc.disconnect()
}
} }
private suspend fun tryGetTheImage( private suspend fun tryGetTheImage(

View File

@@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256Stream import com.vitorpamplona.quartz.utils.sha256.sha256StreamWithCount
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -61,30 +61,8 @@ class BlossomUploader {
* to avoid loading the entire file into memory. * to avoid loading the entire file into memory.
*/ */
private fun calculateHashAndSize(inputStream: InputStream): StreamInfo { private fun calculateHashAndSize(inputStream: InputStream): StreamInfo {
var totalBytes = 0L val (hash, size) = sha256StreamWithCount(inputStream)
return StreamInfo(hash.toHexKey(), size)
// Wrap the input stream to count bytes while hashing
val countingStream =
object : InputStream() {
override fun read(): Int {
val byte = inputStream.read()
if (byte != -1) totalBytes++
return byte
}
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
val bytesRead = inputStream.read(b, off, len)
if (bytesRead > 0) totalBytes += bytesRead
return bytesRead
}
}
val hash = sha256Stream(countingStream).toHexKey()
return StreamInfo(hash, totalBytes)
} }
fun Context.getFileName(uri: Uri): String? = fun Context.getFileName(uri: Uri): String? =
@@ -132,10 +110,19 @@ class BlossomUploader {
checkNotNull(imageInputStream) { "Can't open the image input stream" } checkNotNull(imageInputStream) { "Can't open the image input stream" }
return imageInputStream.use { stream -> return imageInputStream.use { stream ->
// Validate file size to prevent overflow when converting to Int
val sizeInt =
streamInfo.size.let {
require(it <= Int.MAX_VALUE) {
"File too large: ${it / 1_048_576}MB exceeds maximum size of ${Int.MAX_VALUE / 1_048_576}MB"
}
it.toInt()
}
upload( upload(
stream, stream,
streamInfo.hash, streamInfo.hash,
streamInfo.size.toInt(), sizeInt,
fileName, fileName,
myContentType, myContentType,
alt, alt,

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.sha256
import java.io.FilterInputStream
import java.io.InputStream
class CountingInputStream(
inputStream: InputStream,
) : FilterInputStream(inputStream) {
var bytesRead: Long = 0
private set
override fun read(): Int {
val byte = super.read()
if (byte != -1) bytesRead++
return byte
}
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
val count = super.read(b, off, len)
if (count > 0) bytesRead += count
return count
}
override fun skip(n: Long): Long {
val skipped = super.skip(n)
bytesRead += skipped
return skipped
}
}

View File

@@ -27,15 +27,19 @@ val pool = Sha256Pool(5) // max parallel operations
actual fun sha256(data: ByteArray) = pool.hash(data) actual fun sha256(data: ByteArray) = pool.hash(data)
/** /**
* Calculate SHA256 hash by streaming the input in chunks. * Calculate SHA256 hash while counting bytes read from the stream.
* This avoids loading the entire input into memory at once. * Returns both the hash and the number of bytes processed.
* Useful for hashing large files without running out of memory. * This is more efficient than reading the stream twice.
* *
* @param inputStream The input stream to hash * @param inputStream The input stream to hash
* @param bufferSize Size of chunks to read (default 8KB) * @param bufferSize Size of chunks to read (default 8KB)
* @return SHA256 hash bytes * @return Pair of (hash bytes, bytes read count)
*/ */
fun sha256Stream( fun sha256StreamWithCount(
inputStream: InputStream, inputStream: InputStream,
bufferSize: Int = 8192, bufferSize: Int = 8192,
) = pool.hashStream(inputStream, bufferSize) ): Pair<ByteArray, Long> {
val countingStream = CountingInputStream(inputStream)
val hash = pool.hashStream(countingStream, bufferSize)
return Pair(hash, countingStream.bytesRead)
}

View File

@@ -45,12 +45,17 @@ class Sha256Hasher {
bufferSize: Int = 8192, bufferSize: Int = 8192,
): ByteArray { ): ByteArray {
val buffer = ByteArray(bufferSize) val buffer = ByteArray(bufferSize)
try {
var bytesRead: Int var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) { while (inputStream.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead) digest.update(buffer, 0, bytesRead)
} }
return digest.digest().also { digest.reset() } return digest.digest()
} finally {
// Always reset digest to prevent contaminating the pool on exceptions
digest.reset()
}
} }
} }