diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 5580424a17..fb00603166 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -838,7 +838,7 @@ fun ComposeView( if (remoteHost == null) saveAnimImage(it.uri) else CryptoFile.desktopPlain(it.uri) is UploadContent.Video -> - if (remoteHost == null) saveFileFromUri(it.uri, hiddenFileNamePrefix = "video") + if (remoteHost == null) saveFileFromUri(it.uri, cs.maxFileSize, hiddenFileNamePrefix = "video") else CryptoFile.desktopPlain(it.uri) } if (file != null) { @@ -887,7 +887,7 @@ fun ComposeView( } is ComposePreview.FilePreview -> { val file = if (remoteHost == null) { - saveFileFromUri(preview.uri) + saveFileFromUri(preview.uri, cs.maxFileSize) } else { CryptoFile.desktopPlain(preview.uri) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 86f2f13313..eee97eeca9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -256,6 +256,7 @@ expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) fun saveFileFromUri( uri: URI, + maxBytes: Long, withAlertOnException: Boolean = true, hiddenFileNamePrefix: String? = null ): CryptoFile? { @@ -277,7 +278,7 @@ fun saveFileFromUri( val destFile = File(getAppFilePath(destFileName)) if (encrypted) { createTmpFileAndDelete { tmpFile -> - Files.copy(inputStream, tmpFile.toPath()) + copyInputStreamToFile(inputStream, tmpFile, maxBytes) try { val args = encryptCryptoFile(tmpFile.absolutePath, destFile.absolutePath) CryptoFile(destFileName, args) @@ -288,7 +289,7 @@ fun saveFileFromUri( } } } else { - Files.copy(inputStream, destFile.toPath()) + copyInputStreamToFile(inputStream, destFile, maxBytes) CryptoFile.plain(destFileName) } } else { @@ -297,6 +298,15 @@ fun saveFileFromUri( null } + } catch (e: FileTooLargeException) { + Log.e(TAG, "Util.kt saveFileFromUri file too large: ${e.message}") + if (withAlertOnException) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.large_file), + String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxBytes)) + ) + } + null } catch (e: Exception) { Log.e(TAG, "Util.kt saveFileFromUri error: ${e.stackTraceToString()}") if (withAlertOnException) showWrongUriAlert() @@ -305,6 +315,27 @@ fun saveFileFromUri( } } +class FileTooLargeException(maxBytes: Long) : IOException("file exceeds $maxBytes bytes") + +fun copyInputStreamToFile(inputStream: InputStream, destFile: File, maxBytes: Long) { + try { + destFile.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var copied = 0L + while (true) { + val read = inputStream.read(buffer) + if (read < 0) break + if (copied > maxBytes - read) throw FileTooLargeException(maxBytes) + output.write(buffer, 0, read) + copied += read + } + } + } catch (e: Throwable) { + destFile.delete() + throw e + } +} + fun saveWallpaperFile(uri: URI): String? { val destFileName = generateNewFileName("wallpaper", "jpg", File(getWallpaperFilePath(""))) val destFile = File(getWallpaperFilePath(destFileName)) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/UtilsFileCopyTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/UtilsFileCopyTest.kt new file mode 100644 index 0000000000..3757b81565 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/UtilsFileCopyTest.kt @@ -0,0 +1,70 @@ +package chat.simplex.app + +import chat.simplex.common.views.helpers.FileTooLargeException +import chat.simplex.common.views.helpers.copyInputStreamToFile +import java.io.ByteArrayInputStream +import java.io.InputStream +import kotlin.io.path.createTempFile +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UtilsFileCopyTest { + @Test + fun testCopyInputStreamAllowsLimitBoundary() { + val dest = createTempFile().toFile() + val data = ByteArray(4) { it.toByte() } + + copyInputStreamToFile(ByteArrayInputStream(data), dest, maxBytes = 4) + + assertTrue(dest.exists()) + assertContentEquals(data, dest.readBytes()) + dest.delete() + } + + @Test + fun testCopyInputStreamRejectsAndDeletesOversizedOutput() { + val dest = createTempFile().toFile() + val data = ByteArray(5) { it.toByte() } + + assertFailsWith { + copyInputStreamToFile(ByteArrayInputStream(data), dest, maxBytes = 4) + } + + assertFalse(dest.exists()) + } + + @Test + fun testCopyInputStreamCopiesAcrossMultipleReads() { + val dest = createTempFile().toFile() + val data = ByteArray(20) { it.toByte() } + + // Delivered 3 bytes per read, so the copy loop runs many iterations and accumulates copied > 0 + copyInputStreamToFile(chunkedStream(data, 3), dest, maxBytes = 20) + + assertTrue(dest.exists()) + assertContentEquals(data, dest.readBytes()) + dest.delete() + } + + @Test + fun testCopyInputStreamRejectsAndDeletesWhenLimitCrossedMidStream() { + val dest = createTempFile().toFile() + val data = ByteArray(10) { it.toByte() } + + // With 3-byte reads and a 7-byte limit, 6 bytes are written before the next read would exceed it + assertFailsWith { + copyInputStreamToFile(chunkedStream(data, 3), dest, maxBytes = 7) + } + + assertFalse(dest.exists()) + } + + // Returns at most chunkSize bytes per read to force the copy loop to iterate, regardless of buffer size + private fun chunkedStream(data: ByteArray, chunkSize: Int): InputStream = + object : ByteArrayInputStream(data) { + override fun read(b: ByteArray, off: Int, len: Int): Int = super.read(b, off, minOf(len, chunkSize)) + } +}