diff --git a/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt b/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt index 04b5dba55ae9..56b61d377d9a 100644 --- a/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/utils/FileStorageUtilsIT.kt @@ -135,12 +135,33 @@ class FileStorageUtilsIT : AbstractIT() { @Test fun testPathToUserFriendlyDisplay() { assertEquals("/", pathToUserFriendlyDisplay("/")) - assertEquals("/sdcard/", pathToUserFriendlyDisplay("/sdcard/")) - assertEquals("/sdcard/test/1/", pathToUserFriendlyDisplay("/sdcard/test/1/")) + assertPathEquals("/sdcard/", "sdcard/", pathToUserFriendlyDisplay("/sdcard/")) + assertPathEquals("/sdcard/test/1/", "sdcard/test/1/", pathToUserFriendlyDisplay("/sdcard/test/1/")) assertEquals("Internal storage/Movies/", pathToUserFriendlyDisplay("/storage/emulated/0/Movies/")) assertEquals("Internal storage/", pathToUserFriendlyDisplay("/storage/emulated/0/")) } + @Test + fun testSanitizedAndroidLocalPathCanBeCreatedOnDevice() { + val rawRemotePath = "/test : test/London -> Brussel/file?name>.txt" + val sanitizedPath = FileStorageUtils.sanitizeAndroidLocalPath(rawRemotePath) + val targetFile = openFile("android-local-path-test/${sanitizedPath.trimStart('/')}") + + try { + assertFalse(FileStorageUtils.isValidAndroidLocalPath(rawRemotePath)) + assertTrue(FileStorageUtils.isValidAndroidLocalPath(sanitizedPath)) + assertTrue(targetFile.parentFile?.mkdirs() == true || targetFile.parentFile?.isDirectory == true) + assertTrue(targetFile.createNewFile()) + assertEquals("file_name_.txt", targetFile.name) + } finally { + targetFile.parentFile?.parentFile?.deleteRecursively() + } + } + private fun pathToUserFriendlyDisplay(path: String): String = pathToUserFriendlyDisplay(path, targetContext, targetContext.resources) + + private fun assertPathEquals(unrecognizedPath: String, recognizedPath: String, actualPath: String) { + assertTrue(actualPath == unrecognizedPath || actualPath == recognizedPath) + } } diff --git a/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt index 7b96e925ae9a..01e79444fb82 100644 --- a/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt +++ b/app/src/main/java/com/nextcloud/utils/fileNameValidator/FileNameValidator.kt @@ -115,9 +115,9 @@ object FileNameValidator { fun checkParentRemotePaths(filePaths: List, capability: OCCapability, context: Context): Boolean = filePaths.all { - if (it.parentRemotePath != StringConstants.SLASH) { - val parentFolderName = it.parentRemotePath.replace(StringConstants.SLASH, "") - checkFileName(parentFolderName, capability, context) == null + val parentRemotePath = it.parentRemotePath + if (parentRemotePath != null && parentRemotePath != StringConstants.SLASH) { + checkFolderPath(parentRemotePath, capability, context) } else { true } diff --git a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.kt b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.kt index 3c4a50978c0d..8b6bcb4dfddc 100644 --- a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.kt +++ b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.kt @@ -120,7 +120,7 @@ class DownloadFileOperation( if (cancellationRequested.get()) return RemoteOperationResult(OperationCancelledException()) } - if (!FileStorageUtils.isValidExtFilename(file.fileName)) { + if (!FileStorageUtils.isValidAndroidLocalPath(file.remotePath)) { mainThreadHandler.post { context.get()?.showToast(R.string.download_download_invalid_local_file_name) } return RemoteOperationResult(RemoteOperationResult.ResultCode.INVALID_CHARACTER_IN_NAME) } diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index bb11eb490dda..5c0002e8de40 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -752,7 +752,7 @@ private Triple initFileChannel(Rem // this basically means that the file is on SD card // try to copy file to temporary dir if it doesn't exist String temporalPath = FileStorageUtils.getInternalTemporalPath(user.getAccountName(), mContext) + - mFile.getRemotePath(); + FileStorageUtils.sanitizeAndroidLocalPath(mFile.getRemotePath()); mFile.setStoragePath(temporalPath); e2eFiles.setTemporalFile(new File(temporalPath)); diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.kt b/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.kt index 981f5842be42..11ecb01e8358 100644 --- a/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.kt +++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/CopyAndUploadContentUrisTask.kt @@ -87,7 +87,7 @@ class CopyAndUploadContentUrisTask( val remotePath = remotePaths[index] val lastModified = queryLastModified(contentResolver, uri) - currentTempPath = "${FileStorageUtils.getTemporalPath(user.accountName)}$remotePath" + currentTempPath = FileStorageUtils.getTemporalPathForRemotePath(user.accountName, remotePath) val cacheFile = File(currentTempPath) cacheFile.parentFile?.takeIf { !it.exists() }?.let { diff --git a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java index 014f4e12d64e..077165e77572 100644 --- a/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java @@ -139,16 +139,41 @@ public static boolean isValidExtFilename(String name) { } /** - * Checks whether the given character is valid in an extended file name. - *

- * Reference: - * android.os.FileUtils#isValidExtFilenameChar(char) - * from the Android Open Source Project. - * - * @param c the character to validate - * @return true if the character is valid in a filename, false otherwise + * Validate each path segment before using a remote path as a local Android path. */ + public static boolean isValidAndroidLocalPath(String path) { + String[] segments = path.split(OCFile.PATH_SEPARATOR, -1); + for (String segment : segments) { + if (!segment.isEmpty() && !isValidExtFilename(segment)) { + return false; + } + } + return true; + } + + public static String sanitizeAndroidLocalPath(String path) { + StringBuilder sanitizedPath = new StringBuilder(path.length()); + String[] segments = path.split(OCFile.PATH_SEPARATOR, -1); + for (int segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) { + if (segmentIndex > 0) { + sanitizedPath.append(OCFile.PATH_SEPARATOR); + } + sanitizedPath.append(sanitizeAndroidLocalFilename(segments[segmentIndex])); + } + return sanitizedPath.toString(); + } + + private static String sanitizeAndroidLocalFilename(String name) { + StringBuilder sanitizedName = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + sanitizedName.append(isValidExtFilenameChar(c) ? c : '_'); + } + return sanitizedName.toString(); + } + private static boolean isValidExtFilenameChar(char c) { + // Match Android's hidden FAT filename rules for external storage. if ((int) c <= 0x1F) { return false; } @@ -199,6 +224,14 @@ public static String getTemporalPath(String accountName) { // that can be in the accountName since 0.1.190B } + public static String getTemporalPathForRemotePath(String accountName, String remotePath) { + String sanitizedRemotePath = sanitizeAndroidLocalPath(remotePath); + if (sanitizedRemotePath.startsWith(OCFile.PATH_SEPARATOR)) { + return getTemporalPath(accountName) + sanitizedRemotePath; + } + return getTemporalPath(accountName) + OCFile.PATH_SEPARATOR + sanitizedRemotePath; + } + public static String getTemporalEncryptedFolderPath(String accountName) { return MainApp .getAppContext() diff --git a/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt b/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt index 56441d2da535..b566b1a4f7e0 100644 --- a/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt +++ b/app/src/test/java/com/nextcloud/client/utils/FileStorageUtilsTest.kt @@ -38,6 +38,26 @@ class FileStorageUtilsTest { assertFalse(FileStorageUtils.isValidExtFilename("file?name")) } + @Test + fun testValidAndroidLocalPath() { + assertTrue(FileStorageUtils.isValidAndroidLocalPath("/Documents/Holidays/file.txt")) + assertTrue(FileStorageUtils.isValidAndroidLocalPath("Documents/Holidays/file.txt")) + } + + @Test + fun testInvalidAndroidLocalPathWithInvalidParentSegment() { + assertFalse(FileStorageUtils.isValidAndroidLocalPath("/test : test/file.txt")) + assertFalse(FileStorageUtils.isValidAndroidLocalPath("/Documents/London -> Brussel/file.txt")) + } + + @Test + fun testSanitizeAndroidLocalPathReplacesInvalidFilenameCharacters() { + assertEquals( + "/test _ test/London -_ Brussel/file_name_.txt", + FileStorageUtils.sanitizeAndroidLocalPath("/test : test/London -> Brussel/file?name>.txt") + ) + } + @Test fun testFilenamesWithControlCharacters() { assertFalse(FileStorageUtils.isValidExtFilename("file\u0001name")) diff --git a/app/src/test/java/com/nextcloud/utils/FileNameValidatorUnitTest.kt b/app/src/test/java/com/nextcloud/utils/FileNameValidatorUnitTest.kt new file mode 100644 index 000000000000..a0e951ea3eaf --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/FileNameValidatorUnitTest.kt @@ -0,0 +1,63 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import android.content.Context +import com.nextcloud.utils.fileNameValidator.FileNameValidator +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.status.CapabilityBooleanType +import com.owncloud.android.lib.resources.status.NextcloudVersion +import com.owncloud.android.lib.resources.status.OCCapability +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FileNameValidatorUnitTest { + + private val context = mockk { + every { + getString(R.string.file_name_validator_error_reserved_names, "con") + } returns "reserved con" + } + + private val capability = OCCapability().apply { + versionMayor = NextcloudVersion.nextcloud_32.majorVersionNumber + isWCFEnabled = CapabilityBooleanType.TRUE + forbiddenFilenameBaseNamesJson = """["con"]""" + } + + @Test + fun checkParentRemotePathsRejectsReservedPathSegment() { + val file = OCFile("/CON/Folder/document.txt") + + val result = FileNameValidator.checkParentRemotePaths(listOf(file), capability, context) + + assertFalse(result) + } + + @Test + fun checkParentRemotePathsAcceptsValidNestedParentPath() { + val file = OCFile("/Projects/Notes/document.txt") + + val result = FileNameValidator.checkParentRemotePaths(listOf(file), capability, context) + + assertTrue(result) + } + + @Test + fun checkParentRemotePathsAcceptsRootParentPath() { + val file = OCFile("/document.txt") + + val result = FileNameValidator.checkParentRemotePaths(listOf(file), capability, context) + + assertTrue(result) + } +}