Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ object FileNameValidator {

fun checkParentRemotePaths(filePaths: List<OCFile>, 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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,7 @@ private Triple<FileLock, RemoteOperationResult, FileChannel> 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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 41 additions & 8 deletions app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,41 @@ public static boolean isValidExtFilename(String name) {
}

/**
* Checks whether the given character is valid in an extended file name.
* <p>
* Reference: <a href="https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/os/FileUtils.java;l=997">
* android.os.FileUtils#isValidExtFilenameChar(char)
* </a> 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;
}
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
63 changes: 63 additions & 0 deletions app/src/test/java/com/nextcloud/utils/FileNameValidatorUnitTest.kt
Original file line number Diff line number Diff line change
@@ -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<Context> {
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)
}
}