multiplatform: bound shared file URI copies (#7075)

* Bound shared file URI copies

* test: cover multi-read path in bounded URI file copy

* android, desktop: bound URI copies by sender badge file limit

* android, desktop: show file-too-large alert on bounded URI copy overrun

---------

Co-authored-by: Paul Bottinelli <paul.bottinelli@trailofbits.com>
This commit is contained in:
sh
2026-07-11 12:18:05 +04:00
committed by GitHub
parent f329744c3b
commit 5d54362ca8
3 changed files with 105 additions and 4 deletions
@@ -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)
}
@@ -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))
@@ -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<FileTooLargeException> {
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<FileTooLargeException> {
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))
}
}