Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import com.infomaniak.mail.data.models.draft.ScheduleDraftResult
import com.infomaniak.mail.data.models.draft.SendDraftResult
import com.infomaniak.mail.data.models.extensions.computeFirstAndLastName
import com.infomaniak.mail.data.models.extensions.getJsonRequestBody
import com.infomaniak.mail.data.models.extensions.safeName
import com.infomaniak.mail.data.models.getMessages.ActivitiesResult
import com.infomaniak.mail.data.models.getMessages.GetMessagesByUidsResult
import com.infomaniak.mail.data.models.getMessages.MessageFlags
Expand Down Expand Up @@ -613,7 +614,7 @@ object ApiRepository : ApiRepositoryCore() {
@OptIn(ManualAuthorizationRequired::class)
val headers = HttpUtils.getHeaders(contentType = null).newBuilder()
.set("Authorization", "Bearer $userApiToken")
.addUnsafeNonAscii("x-ws-attachment-filename", attachment.name)
.addUnsafeNonAscii("x-ws-attachment-filename", attachment.safeName)
.add("x-ws-attachment-mime-type", attachment.mimeType)
.add("x-ws-attachment-disposition", "attachment")
.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ import com.infomaniak.mail.utils.AccountUtils
import com.infomaniak.mail.utils.AttachableMimeTypeUtils
import com.infomaniak.mail.utils.LocalStorageUtils
import com.infomaniak.mail.utils.Utils
import com.infomaniak.mail.utils.resolveContainedFileName
import com.infomaniak.mail.utils.toSafeFileName
import java.io.File

val Attachable.downloadUrl get() = ApiRoutes.resource(resource!!)

val Attachable.safeMimeType get() = if (mimeType == Utils.MIMETYPE_UNKNOWN) name.guessMimeType() else mimeType
val Attachable.safeName get() = name.toSafeFileName()

val Attachable.safeMimeType get() = if (mimeType == Utils.MIMETYPE_UNKNOWN) safeName.guessMimeType() else mimeType

fun Attachable.getFileTypeFromMimeType(): AttachmentType = AttachableMimeTypeUtils.getFileTypeFromMimeType(safeMimeType)

Expand All @@ -45,24 +49,24 @@ fun Attachable.hasUsableCache(
): Boolean = when (this) {
is Attachment -> {
val cachedFile = file ?: getCacheFile(context, userId, mailboxId)
cachedFile.length() > 0 && cachedFile.canRead()
cachedFile != null && cachedFile.length() > 0 && cachedFile.canRead()
}
is SwissTransferFile -> false
}

fun Attachable.isInlineCachedFile(context: Context): Boolean = when (this) {
is Attachment -> getCacheFile(context).exists() && disposition == AttachmentDisposition.INLINE
is Attachment -> getCacheFile(context)?.exists() == true && disposition == AttachmentDisposition.INLINE
is SwissTransferFile -> false
}

fun Attachable.getCacheFile(
context: Context,
userId: Int = AccountUtils.currentUserId,
mailboxId: Int = AccountUtils.currentMailboxId,
): File = when (this) {
): File? = when (this) {
is Attachment -> {
val cacheFolder = LocalStorageUtils.getAttachmentsCacheDir(context, extractPathFromResource(), userId, mailboxId)
File(cacheFolder, name)
cacheFolder?.resolveContainedFileName(safeName)
}
is SwissTransferFile -> File("")
}
3 changes: 2 additions & 1 deletion app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import com.infomaniak.mail.utils.SharedUtils.Companion.updateSignatures
import com.infomaniak.mail.utils.Utils
import com.infomaniak.mail.utils.Utils.EML_CONTENT_TYPE
import com.infomaniak.mail.utils.coroutineContext
import com.infomaniak.mail.utils.toSafeFileName
import com.infomaniak.mail.utils.extensions.MergedContactDictionary
import com.infomaniak.mail.utils.extensions.allFailed
import com.infomaniak.mail.utils.extensions.appContext
Expand Down Expand Up @@ -856,7 +857,7 @@ class MainViewModel @Inject constructor(
val userBearerToken = AccountUtils.currentUser?.apiToken?.accessToken
DownloadManagerUtils.launchDownload(
url = downloadUrl,
name = filename,
name = filename.toSafeFileName(),
userAgent = HttpUtils.getUserAgent,
userBearerToken = userBearerToken,
onError = { resourceStringId ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.infomaniak.mail.R
import com.infomaniak.mail.data.models.Attachable
import com.infomaniak.mail.data.models.extensions.safeName
import com.infomaniak.mail.databinding.ItemAttachmentBinding
import com.infomaniak.mail.ui.main.thread.AttachmentAdapter.AttachmentViewHolder
import com.infomaniak.mail.utils.Utils.runCatchingRealm
Expand Down Expand Up @@ -55,7 +56,7 @@ class AttachmentAdapter(

if (shouldDisplayCloseButton) {
attachmentCloseButton.apply {
contentDescription = context.getString(R.string.contentDescriptionButtonDelete, attachment.name)
contentDescription = context.getString(R.string.contentDescriptionButtonDelete, attachment.safeName)
setOnClickListener {
val index = attachments.indexOf(attachment)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ abstract class MessageWebViewClient(
if (url?.scheme.equals(CID_SCHEME, ignoreCase = true)) {
val cid = url.schemeSpecificPart
return cidDictionary[cid]?.let { attachment ->
val cacheFile = attachment.getCacheFile(context)
val cacheFile = attachment.getCacheFile(context) ?: return@runCatchingRealm null

val data = if (attachment.hasUsableCache(context, cacheFile)) {
cacheFile.inputStream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import com.infomaniak.mail.utils.extensions.AttachmentExt.findSpecificAttachment
import com.infomaniak.mail.utils.extensions.appContext
import com.infomaniak.mail.utils.extensions.htmlToText
import com.infomaniak.mail.utils.extensions.valueOrEmpty
import com.infomaniak.mail.utils.toSafeFileName
import com.infomaniak.mail.utils.uploadAttachmentsWithMutex
import dagger.hilt.android.lifecycle.HiltViewModel
import io.realm.kotlin.MutableRealm
Expand Down Expand Up @@ -803,18 +804,19 @@ class NewMessageViewModel @Inject constructor(
private suspend fun importAttachment(uri: Uri, availableSpace: Long): Pair<Attachment?, Boolean> {

val (fileName, fileSize) = getFileNameAndSize(uri) ?: return null to false
val safeFileName = fileName.toSafeFileName()
val attachment = Attachment()

return LocalStorageUtils.saveAttachmentToUploadDir(
context = appContext,
uri = uri,
fileName = fileName,
fileName = safeFileName,
draftLocalUuid = draftLocalUuid!!,
attachmentLocalUuid = attachment.localUuid,
snackbarManager = snackbarManager,
)?.let { file ->
Pair(
attachment.initLocalValues(fileName, file.length(), file.path.guessMimeType(), file.toUri().toString()),
attachment.initLocalValues(safeFileName, file.length(), file.path.guessMimeType(), file.toUri().toString()),
fileSize > availableSpace,
)
} ?: (null to false)
Expand Down
103 changes: 103 additions & 0 deletions app/src/main/java/com/infomaniak/mail/utils/FileNameUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Infomaniak Mail - Android
* Copyright (C) 2026 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.mail.utils

import io.sentry.Sentry
import java.io.File
import java.nio.charset.StandardCharsets
import java.text.Normalizer

/** Android’s filesystems limit each path component to 255 UTF-8 bytes.
* 240 leaves 15 bytes for prefixes/suffixes.
* The upload prefix (Int.hashCode() plus ) needs at most 12 bytes, yielding at most 252 bytes.
Comment thread
FabianDevel marked this conversation as resolved.
Outdated
* The remaining 3 bytes are conservative headroom. */
private const val MAX_FILE_NAME_SIZE_BYTES = 240
private const val MAX_PRESERVED_EXTENSION_SIZE_BYTES = 32
private const val DEFAULT_FILE_NAME = "attachment"
private val invalidFileNameCharacters = Regex("[\\\\/:*?\"<>|%\\p{Cc}]")

fun String.toSafeFileName(): String {
val normalizedName = Normalizer.normalize(this, Normalizer.Form.NFC)
Comment thread
FabianDevel marked this conversation as resolved.
Outdated
.replace(invalidFileNameCharacters, "_")
.trim()
.takeUnless { it.isEmpty() || it == "." || it == ".." }
?: DEFAULT_FILE_NAME

return if (normalizedName.utf8Size <= MAX_FILE_NAME_SIZE_BYTES) normalizedName else computeTruncatedFileName(normalizedName)
}

fun File.resolveContainedPath(untrustedPath: String): File? = runCatching {
if (File(untrustedPath).isAbsolute) throw SecurityException("Absolute paths are not allowed")

val canonicalRoot = canonicalFile
val resolvedFile = File(canonicalRoot, untrustedPath).canonicalFile
if (resolvedFile != canonicalRoot && !resolvedFile.toPath().startsWith(canonicalRoot.toPath())) {
throw SecurityException("Resolved path escapes its allowed root")
}
return resolvedFile
Comment thread
FabianDevel marked this conversation as resolved.
Outdated
}.getOrElse {
Sentry.captureException(it)
null
}

fun File.resolveContainedFileName(untrustedName: String): File? {
val resolvedFile = File(this, untrustedName.toSafeFileName())
return if (resolvedFile.parentFile != this) {
Sentry.captureException(SecurityException("Resolved file escapes its allowed directory"))
null
} else {
resolvedFile
}
}

private fun computeTruncatedFileName(normalizedName: String): String {
val extensionStart = normalizedName.lastIndexOf('.').takeIf { it in 1 until normalizedName.lastIndex }
val extension = extensionStart?.let(normalizedName::substring)
?.takeIf { it.utf8Size <= MAX_PRESERVED_EXTENSION_SIZE_BYTES }
.orEmpty()
val fileNameWithoutExtension = normalizedName.dropLast(extension.length)
val truncatedFileName = fileNameWithoutExtension.takeUtf8Bytes(MAX_FILE_NAME_SIZE_BYTES - extension.utf8Size)

return truncatedFileName.ifEmpty { DEFAULT_FILE_NAME } + extension
}

private inline val String.utf8Size: Int get() = toByteArray(StandardCharsets.UTF_8).size

/**
* Compute with utf8 size because Kotlin manipulate utf16 entities
* This avoids character needing 2 bytes like 'é' to count only for 1 in a take(MAX_FILE_NAME_SIZE_BYTES) instead of 2.
*/
private fun String.takeUtf8Bytes(maxBytes: Int): String {
if (maxBytes <= 0) return ""
if (utf8Size <= maxBytes) return this

val result = StringBuilder()
var byteCount = 0
var index = 0
while (index < length) {
val codePoint = codePointAt(index)
val character = String(Character.toChars(codePoint))
val characterSize = character.utf8Size
if (byteCount + characterSize > maxBytes) break

result.append(character)
byteCount += characterSize
index += Character.charCount(codePoint)
}
return result.toString()
}
14 changes: 9 additions & 5 deletions app/src/main/java/com/infomaniak/mail/utils/LocalStorageUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ object LocalStorageUtils {
attachmentPath: String,
userId: Int = AccountUtils.currentUserId,
mailboxId: Int = AccountUtils.currentMailboxId,
): File {
return File(generateRootDir(context.attachmentsCacheRootDir, userId, mailboxId), attachmentPath)
): File? {
val cacheRoot = generateRootDir(context.attachmentsCacheRootDir, userId, mailboxId)
return cacheRoot.resolveContainedPath(attachmentPath)
}

suspend fun downloadThenSaveAttachmentToCacheDir(context: Context, localAttachment: Attachment): Boolean {
Expand All @@ -75,7 +76,10 @@ object LocalStorageUtils {
val attachment = runCatching {
localAttachment.resource?.let { ApiRepository.downloadAttachment(it) }
}.cancellable().getOrNull()
return attachment?.saveAttachmentTo(localAttachment.getCacheFile(context)) == true

val cacheFile = localAttachment.getCacheFile(context) ?: return false

return attachment?.saveAttachmentTo(cacheFile) == true
}

/**
Expand Down Expand Up @@ -128,7 +132,7 @@ object LocalStorageUtils {
return context.contentResolver.openInputStream(uri)?.use { inputStream ->
val attachmentsUploadDir = getAttachmentUploadDir(context, draftLocalUuid, attachmentLocalUuid)
attachmentsUploadDir.mkdirs()
val hashedFileName = "${uri.toString().substringAfter("document/").hashCode()}_$fileName"
val hashedFileName = "${uri.toString().substringAfter("document/").hashCode()}_${fileName.toSafeFileName()}"

return@use getFileToUpload(context, uri, snackbarManager, attachmentsUploadDir, hashedFileName, inputStream)
} ?: run {
Expand All @@ -149,7 +153,7 @@ object LocalStorageUtils {
hashedFileName: String,
inputStream: InputStream,
): File? {
val file = File(attachmentsUploadDir, hashedFileName)
val file = attachmentsUploadDir.resolveContainedFileName(hashedFileName) ?: return null
val isSuccess = runCatching {
FileOutputStream(file).use(inputStream::copyTo)
true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import com.infomaniak.mail.data.models.extensions.getUploadLocalFile
import com.infomaniak.mail.data.models.extensions.hasUsableCache
import com.infomaniak.mail.data.models.extensions.isInlineCachedFile
import com.infomaniak.mail.data.models.extensions.safeMimeType
import com.infomaniak.mail.data.models.extensions.safeName
import com.infomaniak.mail.data.models.mailbox.Mailbox
import com.infomaniak.mail.ui.main.SnackbarManager
import com.infomaniak.mail.ui.main.thread.actions.DownloadAttachmentProgressDialogArgs
Expand All @@ -61,8 +62,8 @@ object AttachmentExt {
const val DOWNLOAD_ATTACHMENT_RESULT = "download_attachment_result"

//region Intent
private fun Attachment.saveToDriveIntent(context: Context): Intent {
val fileFromCache = getCacheFile(context)
private fun Attachment.saveToDriveIntent(context: Context): Intent? {
val fileFromCache = getCacheFile(context) ?: return null
val lastModifiedDate = fileFromCache.lastModified()
val uri = FileProvider.getUriForFile(context, context.getString(R.string.ATTACHMENTS_AUTHORITY), fileFromCache)

Expand All @@ -75,8 +76,8 @@ object AttachmentExt {
}
}

private fun Attachment.openWithIntent(context: Context): Intent {
val file = getUploadLocalFile() ?: getCacheFile(context)
private fun Attachment.openWithIntent(context: Context): Intent? {
val file = getUploadLocalFile() ?: getCacheFile(context) ?: return null
val uri = FileProvider.getUriForFile(context, context.getString(R.string.ATTACHMENTS_AUTHORITY), file)

return Intent().apply {
Expand Down Expand Up @@ -113,7 +114,7 @@ object AttachmentExt {
navigateToDownloadProgressDialog: (Attachment, AttachmentIntentType) -> Unit,
snackbarManager: SnackbarManager,
) {
if (openWithIntent(context).hasSupportedApplications(context)) {
if (openWithIntent(context)?.hasSupportedApplications(context) == true) {
executeIntent(context, OPEN_WITH, navigateToDownloadProgressDialog)
} else {
snackbarManager.setValue(context.getString(RCore.string.errorNoSupportingAppFound))
Expand All @@ -123,7 +124,7 @@ object AttachmentExt {
fun Attachment.createDownloadDialogNavArgs(intentType: AttachmentIntentType): Bundle {
return DownloadAttachmentProgressDialogArgs(
attachmentLocalUuid = localUuid,
attachmentName = name,
attachmentName = safeName,
attachmentType = getFileTypeFromMimeType(),
intentType = intentType,
).toBundle()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.infomaniak.core.ui.view.extension.setMarginsRelative
import com.infomaniak.mail.R
import com.infomaniak.mail.data.models.Attachable
import com.infomaniak.mail.data.models.extensions.getFileTypeFromMimeType
import com.infomaniak.mail.data.models.extensions.safeName
import com.infomaniak.mail.databinding.ViewAttachmentDetailsBinding
import com.infomaniak.core.legacy.R as RCore

Expand Down Expand Up @@ -65,7 +66,7 @@ class AttachmentDetailsView @JvmOverloads constructor(
}

fun setDetails(attachment: Attachable) = with(binding) {
fileName.text = attachment.name
fileName.text = attachment.safeName
fileSize.text = context.formatShortFileSize(attachment.size)
icon.load(attachment.getFileTypeFromMimeType().icon)
}
Expand Down
Loading
Loading