diff --git a/.github/workflows/android-main.yml b/.github/workflows/android-main.yml index ea0959f7ad..e1dcf8055b 100644 --- a/.github/workflows/android-main.yml +++ b/.github/workflows/android-main.yml @@ -16,9 +16,9 @@ jobs: name: Check spotless runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: "temurin" java-version: 17 @@ -28,20 +28,20 @@ jobs: ndk-version: r28c link-to-sdk: true local-cache: true + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 - name: Check formatting using spotless - uses: gradle/actions/setup-gradle@v3 - with: - arguments: spotlessCheck + run: ./gradlew spotlessCheck build: name: Build debug, Jacoco test and publish to codacy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: "temurin" java-version: 17 @@ -50,7 +50,7 @@ jobs: with: toolchain: stable - name: Cache Rust dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/bin/ @@ -78,14 +78,12 @@ jobs: echo "❌ .cargo/config.toml missing" exit 1 fi + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 - name: Build with Gradle - uses: gradle/actions/setup-gradle@v3 - with: - arguments: assembledebug + run: ./gradlew assembledebug - name: Run test cases - uses: gradle/actions/setup-gradle@v3 - with: - arguments: jacocoTestPlayDebugUnitTestReport + run: ./gradlew jacocoTestPlayDebugUnitTestReport - name: Publish test cases run: | export CODACY_PROJECT_TOKEN=${{ secrets.CODACY_TOKEN }} @@ -109,21 +107,52 @@ jobs: api-level: [ 21, 28 ] steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Java 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: "temurin" java-version: 17 + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + - name: Cache Rust dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + file_operations/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + - name: Setup Rust for Android + run: | + cd file_operations + chmod +x setup_rust_android.sh + ./setup_rust_android.sh + - name: Verify Rust setup + run: | + cd file_operations + echo "🔍 Verifying Rust Android targets..." + rustup target list --installed | grep android || echo "No Android targets found" + echo "🔍 Verifying cargo configuration..." + if [ -f .cargo/config.toml ]; then + echo "✅ .cargo/config.toml exists" + else + echo "❌ .cargo/config.toml missing" + exit 1 + fi - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Gradle cache - uses: gradle/actions/setup-gradle@v3 - name: AVD cache - uses: actions/cache@v4 + uses: actions/cache@v5 id: avd-cache with: path: | diff --git a/app/build.gradle b/app/build.gradle index c394440b3f..e226e61195 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,8 +19,8 @@ android { applicationId "com.amaze.filemanager" minSdkVersion libs.versions.minSdk.get().toInteger() targetSdkVersion libs.versions.targetSdk.get().toInteger() - versionCode 124 - versionName "3.11.2" + versionCode 125 + versionName "3.11.3" multiDexEnabled true vectorDrawables.useSupportLibrary = true diff --git a/app/proguard.cfg b/app/proguard.cfg index 738663b299..bfe3e59991 100644 --- a/app/proguard.cfg +++ b/app/proguard.cfg @@ -86,17 +86,14 @@ -keep class org.bouncycastle.crypto.prng.* {*;} -keep class org.bouncycastle.crypto.signers.* {*;} --keep class org.bouncycastle.jcajce.provider.asymmetric.* {*;} --keep class org.bouncycastle.jcajce.provider.asymmetric.util.* {*;} --keep class org.bouncycastle.jcajce.provider.asymmetric.dh.* {*;} --keep class org.bouncycastle.jcajce.provider.asymmetric.ec.* {*;} --keep class org.bouncycastle.jcajce.provider.asymmetric.rsa.* {*;} +-keep class org.bouncycastle.jcajce.provider.asymmetric.** {*;} -keep class org.bouncycastle.jcajce.provider.digest.** {*;} -keep class org.bouncycastle.jcajce.provider.keystore.** {*;} -keep class org.bouncycastle.jcajce.provider.symmetric.** {*;} -keep class org.bouncycastle.jcajce.spec.* {*;} -keep class org.bouncycastle.jce.** {*;} +-keep class org.bouncycastle.openssl.** {*;} -dontwarn org.bouncycastle.jsse.** -dontwarn org.bouncycastle.asn1.ASN1ApplicationSpecific @@ -127,4 +124,7 @@ } -keep class com.amaze.trashbin.** { *; } --dontwarn ch.qos.logback.core.net.* \ No newline at end of file +-dontwarn ch.qos.logback.core.net.* + +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt index 62bca738ed..436f7d5e47 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/test/StoragePermissionHelper.kt @@ -21,6 +21,8 @@ package com.amaze.filemanager.test import android.content.Context +import android.os.Build +import android.os.Build.VERSION_CODES import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click @@ -40,6 +42,11 @@ object StoragePermissionHelper { */ @JvmStatic fun grantManageStoragePermission() { + // Only need to run on Androids >= R + if (Build.VERSION.SDK_INT < VERSION_CODES.R) { + return + } + // Ensure that an activity that has the dialog is launched ActivityScenario.launch(MainActivity::class.java) @@ -47,9 +54,9 @@ object StoragePermissionHelper { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) val amazeResources = context.packageManager.getResourcesForApplication(context.packageName) - val grantPermissionExplanation = amazeResources.getString(R.string.grant_all_files_permission) + val grantPermissionHeader = amazeResources.getString(R.string.grantper) - if (device.hasObject(By.text(grantPermissionExplanation))) { + if (device.hasObject(By.text(grantPermissionHeader))) { // First press Amaze's grant button onView(withText(R.string.grant)).perform(click()) diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt index 1e831fec2f..1a6a749c8b 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/BackupPrefsFragmentTest.kt @@ -27,13 +27,13 @@ import android.content.SharedPreferences import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU +import android.os.Environment import androidx.lifecycle.Lifecycle import androidx.preference.PreferenceManager import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions -import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule @@ -43,19 +43,20 @@ import com.amaze.filemanager.ui.activities.PreferencesActivity import com.amaze.filemanager.ui.fragments.preferencefragments.BackupPrefsFragment import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken +import org.awaitility.Awaitility.await import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Assert.fail import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import java.io.File +import java.util.concurrent.TimeUnit @RunWith(AndroidJUnit4::class) class BackupPrefsFragmentTest { - var storagePath = "/storage/emulated/0" + var storagePath: String = Environment.getExternalStorageDirectory().absolutePath var fileName = "amaze_backup.json" @Rule @@ -93,6 +94,19 @@ class BackupPrefsFragmentTest { import(exportFile) } + /** + * Waits (with a timeout) for the given file to exist, since some writes to storage happen + * asynchronously on a background thread. + */ + private fun waitForFile( + file: File, + timeoutSeconds: Long = 5L, + ) { + await().atMost(timeoutSeconds, TimeUnit.SECONDS).until { + file.exists() && file.length() > 0L + } + } + /** * Test whether the exported file contains the expected preference values */ @@ -103,59 +117,71 @@ class BackupPrefsFragmentTest { val backupPrefsFragment = BackupPrefsFragment() val activityScenario = ActivityScenario.launch(PreferencesActivity::class.java) - activityScenario.moveToState(Lifecycle.State.STARTED) + // Espresso requires an activity to be RESUMED to dispatch view actions/clicks. + activityScenario.moveToState(Lifecycle.State.RESUMED) + + lateinit var preferenceSnapshot: Map - activityScenario.onActivity { - it.supportFragmentManager.beginTransaction() + activityScenario.onActivity { preferencesActivity -> + preferencesActivity.supportFragmentManager.beginTransaction() .add(backupPrefsFragment, null) .commitNow() + val preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) + preferenceSnapshot = HashMap(preferences.all) + backupPrefsFragment.exportPrefs() - } - val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") + val tempFile = File("${context.cacheDir.absolutePath}${File.separator}$fileName") - assertTrue(tempFile.exists()) + assertTrue(tempFile.exists()) + } - onView(withId(R.id.home)).perform(ViewActions.click()) + // Espresso's onView().perform() must run on the instrumentation/test thread, never from + // inside onActivity {} or runOnUiThread {} (both of which run on the main/UI thread). + // Espresso internally synchronizes with the UI thread itself; calling it from the UI + // thread can deadlock or throw IllegalStateException. + // exportPrefs() launches MainActivity with an ACTION_SEND intent, which shows a Snackbar + // with a "Save" action; that is the only view action needed here. onView(withText(R.string.save)).perform(ViewActions.click()) - assertTrue(exportFile.exists()) - - activityScenario.onActivity { preferencesActivity -> - val preferences = PreferenceManager.getDefaultSharedPreferences(preferencesActivity) - val preferenceMap: Map = preferences.all - - val inputString = - exportFile - .inputStream() - .bufferedReader() - .use { - it.readText() - } - - val type = object : TypeToken>() {}.type - - val importMap: Map = - GsonBuilder() - .create() - .fromJson( - inputString, - type, - ) - - for ((key, value) in preferenceMap) { - val importedValue = importMap[key] - val mapValue = - if (importedValue != null && importedValue::class.simpleName.equals("Double")) { - (importedValue as Double).toInt() // since Gson parses Integer as Double - } else { - importedValue - } - - assertEquals("Difference found at key $key", value, mapValue) + // The actual write to storagePath happens asynchronously (RxJava) after the "Save" click + // and after MainActivity finishes, so poll for the file instead of asserting immediately. + waitForFile(exportFile) + + val inputString = + exportFile + .inputStream() + .bufferedReader() + .use { + it.readText() + } + + val type = object : TypeToken>() {}.type + + // TODO This breaks the exported file's types, all Numbers get converted to Double + val importMap: Map = + GsonBuilder() + .create() + .fromJson( + inputString, + type, + ) + + for ((key, value) in preferenceSnapshot) { + val importedValue = importMap[key] + + if (value is Number) { + // HACK GsonBuilder().create().fromJson() breaks Number types + assertEquals("Difference found at key $key", value.toDouble(), importedValue as Double, 0.1) + } else { + assertEquals("Different type at key $key", value?.javaClass, importedValue?.javaClass) + + assertEquals("Difference found at key $key", value, importedValue) } } + + activityScenario.close() } /** @@ -172,7 +198,15 @@ class BackupPrefsFragmentTest { .add(backupPrefsFragment, null) .commitNow() - javaClass.getResourceAsStream("/$fileName")?.copyTo(exportFile.outputStream()) + val resourceStream = + requireNotNull(javaClass.getResourceAsStream("/$fileName")) { + "Missing test resource /$fileName" + } + resourceStream.use { input -> + exportFile.outputStream().use { output -> + input.copyTo(output) + } + } backupPrefsFragment.onActivityResult( BackupPrefsFragment.IMPORT_BACKUP_FILE, @@ -204,13 +238,14 @@ class BackupPrefsFragmentTest { assertFalse(preferenceMap.containsKey(null)) for ((k, v) in preferenceMap) { - // This cast tells the kotlin type checker that fail() never returns - val key = k ?: (fail() as Nothing) - val value = v ?: (fail() as Nothing) + val key = requireNotNull(k) { "Preference key unexpectedly null" } + val value = requireNotNull(v) { "Preference value unexpectedly null for $key" } assertTrue("checkPrefEqual($key) failed", checkPrefEqual(preferences, importMap, key, value)) } } + + activityScenario.close() } private fun checkPrefEqual( @@ -223,15 +258,14 @@ class BackupPrefsFragmentTest { "Boolean" -> return importMap[key] as Boolean == preferences.getBoolean(key, false) "Float" -> - importMap[key] as Float == + (importMap[key] as Number).toFloat() == preferences.getFloat(key, 0f) "Int" -> { - // since Gson parses Integer as Double - val toInt = (importMap[key] as Double).toInt() + val toInt = (importMap[key] as Number).toInt() return toInt == preferences.getInt(key, 0) } - "Long" -> return importMap[key] as Long == + "Long" -> return (importMap[key] as Number).toLong() == preferences.getLong(key, 0L) "String" -> return importMap[key] as String == preferences.getString(key, null) diff --git a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt index b26a65854a..d9e675b581 100644 --- a/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt +++ b/app/src/androidTest/java/com/amaze/filemanager/ui/fragments/TabFragmentTest.kt @@ -1,23 +1,46 @@ +/* + * Copyright (C) 2014-2025 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager 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 . + */ + package com.amaze.filemanager.ui.fragments import android.content.pm.ActivityInfo +import android.content.res.Configuration import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU +import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.swipeLeft import androidx.test.espresso.action.ViewActions.swipeRight import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.rule.ActivityTestRule import androidx.test.rule.GrantPermissionRule +import androidx.viewpager2.widget.ViewPager2 import com.amaze.filemanager.R import com.amaze.filemanager.test.StoragePermissionHelper import com.amaze.filemanager.ui.activities.MainActivity +import org.awaitility.Awaitility.await import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit /** * Tests for [TabFragment] functionality, mainly for @@ -28,9 +51,6 @@ import org.junit.runner.RunWith @Suppress("DEPRECATION") @RunWith(AndroidJUnit4::class) class TabFragmentTest { - @get:Rule - val activityRule = ActivityTestRule(MainActivity::class.java) - @Rule @JvmField val storagePermissionRule: GrantPermissionRule = @@ -52,24 +72,25 @@ class TabFragmentTest { } /** - * This test causes a rotation to happen while the MainFragment detaches, to check if it - * fails. This could happen in reality, but should be very rare + * This test saves state while a MainFragment is detached. */ @Test fun testFragmentStateSavingDuringDetachment() { - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - - // Get the TabFragment - InstrumentationRegistry.getInstrumentation().runOnMainSync { - val activity = activityRule.activity - val tabFragment = - activity.supportFragmentManager - .findFragmentById(R.id.content_frame) as TabFragment - - // Detach fragment through FragmentManager - activity.supportFragmentManager.beginTransaction().apply { - tabFragment.fragments.forEach { detach(it) } - commit() + withScenario { scenario -> + rotateScreen(scenario) + + swipeToItem(scenario, 1) + awaitTabFragment(scenario) + + scenario.onActivity { activity -> + val tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as TabFragment + + activity.supportFragmentManager.beginTransaction().apply { + tabFragment.fragments.firstOrNull { it.isAdded }?.let { detach(it) } + commitNow() + } } } } @@ -80,13 +101,14 @@ class TabFragmentTest { */ @Test fun testFragmentStateSavingDuringConfigChange() { - // First perform the swipe action - onView(withId(R.id.pager)).perform(swipeLeft()) - - // Force a configuration change by rotating the screen - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - Thread.sleep(1000) // Give time for the rotation to complete - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + withScenario { scenario -> + // First perform the swipe action + swipeToItem(scenario, 1) + // Then force a configuration change by rotating the screen + rotateScreen(scenario) + rotateScreen(scenario) + awaitCurrentItem(scenario, 1) + } } /** @@ -94,16 +116,17 @@ class TabFragmentTest { */ @Test fun testRapidTabSwitchingAndStateSaving() { - // Perform rapid tab switches - repeat(10) { - onView(withId(R.id.pager)).perform(swipeLeft()) - Thread.sleep(100) // Small delay to ensure swipe completes - onView(withId(R.id.pager)).perform(swipeRight()) - Thread.sleep(100) // Small delay to ensure swipe completes - } + withScenario { scenario -> + // Perform rapid tab switches + repeat(10) { + swipeToItem(scenario, 1) + swipeToItem(scenario, 0) + } - // Force a save state by rotating - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + // Then force a save state by rotating + rotateScreen(scenario) + awaitCurrentItem(scenario, 0) + } } /** @@ -111,24 +134,161 @@ class TabFragmentTest { */ @Test fun testFragmentDetachmentAndStateSaving() { - // First switch to a different tab - onView(withId(R.id.pager)).perform(swipeLeft()) - - // Get the TabFragment - InstrumentationRegistry.getInstrumentation().runOnMainSync { - val activity = activityRule.activity - val tabFragment = - activity.supportFragmentManager - .findFragmentById(R.id.content_frame) as TabFragment - - // Detach fragment through FragmentManager - activity.supportFragmentManager.beginTransaction().apply { - tabFragment.fragments.firstOrNull()?.let { detach(it) } - commit() + withScenario { scenario -> + swipeToItem(scenario, 1) + awaitTabFragment(scenario) + + scenario.onActivity { activity -> + val tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as TabFragment + + // Detach TabFragment through FragmentManager + activity.supportFragmentManager.beginTransaction().apply { + tabFragment.fragments.firstOrNull { it.isAdded }?.let { detach(it) } + commitNow() + } + } + + // Force state save through configuration change + rotateScreen(scenario) + } + } + + private fun withScenario(testBody: (ActivityScenario) -> Unit) { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + awaitPager(scenario) + testBody(scenario) + } + } + + private fun awaitPager(scenario: ActivityScenario): ViewPager2 { + var pager: ViewPager2? = null + + await().atMost(10, TimeUnit.SECONDS).until { + scenario.onActivity { activity -> + pager = activity.findViewById(R.id.pager) + } + + pager != null + } + + return requireNotNull(pager) + } + + private fun awaitTabFragment(scenario: ActivityScenario): TabFragment { + var tabFragment: TabFragment? = null + + await().atMost(10, TimeUnit.SECONDS).until { + runCatching { + scenario.onActivity { activity -> + tabFragment = + activity.supportFragmentManager + .findFragmentById(R.id.content_frame) as? TabFragment + } } + + tabFragment?.view != null && tabFragment?.fragments?.isNotEmpty() == true } - // Force state save through configuration change - activityRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + return requireNotNull(tabFragment) + } + + // Swipe to the other tab in the ViewPager2. + // Index 0 is the first tab, index 1 is the second tab. + private fun swipeToItem( + scenario: ActivityScenario, + index: Int, + ) { + awaitPager(scenario) + + when (index) { + 0 -> onView(withId(R.id.pager)).perform(swipeRight()) + 1 -> onView(withId(R.id.pager)).perform(swipeLeft()) + else -> error("Unsupported pager index: $index") + } + + awaitCurrentItem(scenario, index) + } + + private fun rotateScreen(scenario: ActivityScenario) { + val initialOrientation = + currentOrientation(scenario).takeIf { + it == Configuration.ORIENTATION_LANDSCAPE || it == Configuration.ORIENTATION_PORTRAIT + } ?: Configuration.ORIENTATION_PORTRAIT + val rotatedRequestedOrientation = + if (initialOrientation == Configuration.ORIENTATION_LANDSCAPE) { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + + setRequestedOrientation(scenario, rotatedRequestedOrientation) + awaitOrientation(scenario, orientationForRequest(rotatedRequestedOrientation)) + + setRequestedOrientation(scenario, orientationRequestFor(initialOrientation)) + awaitOrientation(scenario, initialOrientation) + + awaitPager(scenario) + awaitTabFragment(scenario) + } + + private fun setRequestedOrientation( + scenario: ActivityScenario, + requestedOrientation: Int, + ) { + scenario.onActivity { activity -> + activity.requestedOrientation = requestedOrientation + } + } + + private fun currentOrientation(scenario: ActivityScenario): Int { + var orientation = Configuration.ORIENTATION_UNDEFINED + + scenario.onActivity { activity -> + orientation = activity.resources.configuration.orientation + } + + return orientation + } + + private fun orientationForRequest(requestedOrientation: Int): Int = + when (requestedOrientation) { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE -> Configuration.ORIENTATION_LANDSCAPE + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT -> Configuration.ORIENTATION_PORTRAIT + else -> Configuration.ORIENTATION_UNDEFINED + } + + private fun orientationRequestFor(orientation: Int): Int = + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + + private fun awaitOrientation( + scenario: ActivityScenario, + expectedOrientation: Int, + ) { + await().atMost(10, TimeUnit.SECONDS).until { + currentOrientation(scenario) == expectedOrientation + } + } + + private fun awaitCurrentItem( + scenario: ActivityScenario, + index: Int, + ) { + await().pollDelay(50, TimeUnit.MILLISECONDS).atMost(100, TimeUnit.MILLISECONDS).until { + var currentItem = -1 + + runCatching { + scenario.onActivity { activity -> + currentItem = activity.findViewById(R.id.pager).currentItem + } + } + + currentItem == index + } } -} +} diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt index 45790663fb..027849707b 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallable.kt @@ -58,7 +58,7 @@ class SevenZipHelperCallable( entries.addAll( consolidate( entriesMap.keys.filter { - it.startsWith(relativePath) + CompressedHelper.isEntryPathValid(it) && it.startsWith(relativePath) }, if (relativePath == "") { 0 @@ -101,7 +101,7 @@ class SevenZipHelperCallable( } catch (e: PasswordRequiredException) { // this is so that the caller can use onError to ask the user for the password throw e - } catch (e: IOException) { + } catch (_: IOException) { throw ArchiveException(String.format("7zip archive %s is corrupt", filePath)) } } diff --git a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java index 409600c6d8..12b64b9953 100644 --- a/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java +++ b/app/src/main/java/com/amaze/filemanager/asynchronous/services/ExtractService.java @@ -373,8 +373,7 @@ public boolean isCancelled() { } else { LOG.error("Error while extracting file " + compressedPath, e); AppConfig.toast(getApplicationContext(), extractService.getString(R.string.error)); - paused = true; - publishProgress(e); + return false; } } catch (Throwable unhandledException) { LOG.error("Unhandled exception thrown", unhandledException); diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java index 5d36ad0875..a214e76143 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/CompressedHelper.java @@ -164,10 +164,6 @@ public static Decompressor getCompressorInstance(@NonNull Context context, @NonN // without the compression extension decompressor = new UnknownCompressedFileDecompressor(context); } else { - if (BuildConfig.DEBUG) { - throw new IllegalArgumentException("The compressed file has no way of opening it: " + file); - } - LOG.error("The compressed file has no way of opening it: " + file); decompressor = null; } @@ -228,8 +224,27 @@ public static String getFileName(String compressedName) { } } - public static final boolean isEntryPathValid(String entryPath) { - return !entryPath.startsWith("..\\") && !entryPath.startsWith("../") && !entryPath.equals(".."); + public static boolean isEntryPathValid(String entryPath) { + if (entryPath == null || entryPath.isEmpty()) { + return false; + } + // Normalize path separators to handle both Unix and Windows-style paths. + String normalized = entryPath.replace('\\', '/'); + // Walk the path segments, tracking depth to detect escaping the archive root. + // A path like "dir/sub/.." is valid (it resolves to "dir"), but "../../evil" is not. + int depth = 0; + for (String segment : normalized.split("/")) { + if ("..".equals(segment)) { + depth--; + if (depth < 0) { + // Path would escape the archive root. + return false; + } + } else if (!segment.isEmpty() && !".".equals(segment)) { + depth++; + } + } + return true; } private static boolean isZip(String type) { diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java index 3e1a5097a4..3e98b0acf1 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/Extractor.java @@ -23,6 +23,7 @@ import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.SEPARATOR; import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.SEPARATOR_CHAR; +import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -113,6 +114,27 @@ protected String fixEntryName(String entryName) { } } + /** + * Verifies that {@code outputFile} is contained within {@code outputDir}, guarding against + * zip-slip / path-traversal attacks. + * + * @throws IOException if the resolved canonical path of {@code outputFile} would land outside + * {@code outputDir}. + */ + protected static void checkEntryPath(File outputFile, String outputDir) throws IOException { + String canonicalOutput = outputFile.getCanonicalPath(); + String canonicalDir = new File(outputDir).getCanonicalPath() + File.separator; + if (!canonicalOutput.startsWith(canonicalDir) + && !canonicalOutput.equals(new File(outputDir).getCanonicalPath())) { + throw new IOException( + "Refusing to extract entry '" + + outputFile.getName() + + "' outside target directory '" + + canonicalDir + + "'"); + } + } + public static class EmptyArchiveNotice extends IOException {} public static class BadArchiveNotice extends IOException { diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt index d9e8e63899..c030961fac 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/AbstractCommonsArchiveExtractor.kt @@ -67,7 +67,7 @@ abstract class AbstractCommonsArchiveExtractor( } } } - if (archiveEntries.size > 0) { + if (archiveEntries.isNotEmpty()) { listener.onStart(totalBytes, archiveEntries[0].name) inputStream.close() inputStream = createFrom(FileInputStream(filePath)) @@ -96,11 +96,12 @@ abstract class AbstractCommonsArchiveExtractor( entry: ArchiveEntry, outputDir: String, ) { + val outputFile = File(outputDir, entry.name) + checkEntryPath(outputFile, outputDir) if (entry.isDirectory) { MakeDirectoryOperation.mkdir(File(outputDir, entry.name), context) return } - val outputFile = File(outputDir, entry.name) if (false == outputFile.parentFile?.exists()) { MakeDirectoryOperation.mkdir(outputFile.parentFile, context) } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt index b39c9db054..83e7a7b042 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/SevenZipExtractor.kt @@ -105,12 +105,12 @@ class SevenZipExtractor( entry: SevenZArchiveEntry, outputDir: String, ) { - val name = entry.name + val outputFile = File(outputDir, entry.name) + checkEntryPath(outputFile, outputDir) if (entry.isDirectory) { - MakeDirectoryOperation.mkdir(File(outputDir, name), context) + MakeDirectoryOperation.mkdir(File(outputDir, entry.name), context) return } - val outputFile = File(outputDir, name) if (!outputFile.parentFile.exists()) { MakeDirectoryOperation.mkdir(outputFile.parentFile, context) } diff --git a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt index 368bbbf2b3..159cf3d772 100644 --- a/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt +++ b/app/src/main/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/ZipExtractor.kt @@ -21,7 +21,6 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers import android.content.Context -import android.os.Build import com.amaze.filemanager.R import com.amaze.filemanager.application.AppConfig import com.amaze.filemanager.fileoperations.filesystem.compressed.ArchivePasswordCache @@ -47,8 +46,6 @@ class ZipExtractor( listener: OnUpdate, updatePosition: UpdatePosition, ) : Extractor(context, filePath, outputPath, listener, updatePosition) { - private val isRobolectricTest = Build.HARDWARE == "robolectric" - @Throws(IOException::class) override fun extractWithFilter(filter: Filter) { var totalBytes: Long = 0 @@ -110,11 +107,7 @@ class ZipExtractor( outputDir: String, ) { val outputFile = File(outputDir, fixEntryName(entry.fileName)) - if (!outputFile.canonicalPath.startsWith(outputDir) && - (isRobolectricTest && !outputFile.canonicalPath.startsWith("/private$outputDir")) - ) { - throw IOException("Incorrect ZipEntry path!") - } + checkEntryPath(outputFile, outputDir) if (entry.isDirectory) { // zip entry is a directory, return after creating new directory MakeDirectoryOperation.mkdir(outputFile, context) diff --git a/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java b/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java index a1f4dcad60..818058a145 100644 --- a/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java +++ b/app/src/main/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialog.java @@ -32,7 +32,6 @@ import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; -import java.net.URL; import java.security.GeneralSecurityException; import org.slf4j.Logger; @@ -55,6 +54,7 @@ import android.app.Dialog; import android.content.Context; +import android.net.Uri; import android.net.UrlQuerySanitizer; import android.os.Bundle; import android.text.Editable; @@ -258,7 +258,7 @@ public void afterTextChanged(@NonNull Editable s) { conName.setText(name); try { - URL a = new URL(path); + Uri a = Uri.parse(path); String userinfo = a.getUserInfo(); if (userinfo != null) { String inf = decode(userinfo, Charsets.UTF_8.name()); @@ -296,8 +296,6 @@ public void afterTextChanged(@NonNull Editable s) { } } catch (UnsupportedEncodingException | IllegalArgumentException e) { LOG.warn("failed to load smb dialog info for path {}", path, e); - } catch (MalformedURLException e) { - LOG.warn("failed to load smb dialog info", e); } } else if (path != null && path.length() > 0) { diff --git a/app/src/main/res/values/translators.xml b/app/src/main/res/values/translators.xml index cc95411197..410070b3bb 100644 --- a/app/src/main/res/values/translators.xml +++ b/app/src/main/res/values/translators.xml @@ -43,7 +43,7 @@ ngoisaosang Naofumi Fukue Kuralarasi for StarsSoft - v3.11.2 + v3.11.3 Arpit Khurana Vishal Nehra Emmanuel Messulam diff --git a/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt b/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt index 672298019f..3cb5ebe485 100644 --- a/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt +++ b/app/src/play/java/com/amaze/filemanager/filesystem/compressed/extractcontents/helpers/RarExtractor.kt @@ -77,9 +77,11 @@ class RarExtractor( MainHeaderNullException::class.java.isAssignableFrom(it::class.java) -> { throw BadArchiveNotice(it) } + UnsupportedRarV5Exception::class.java.isAssignableFrom(it::class.java) -> { throw it } + else -> { throw PasswordRequiredException(filePath) } @@ -144,11 +146,7 @@ class RarExtractor( CompressedHelper.SEPARATOR, ) val outputFile = File(outputDir, name) - if (!outputFile.canonicalPath.startsWith(outputDir) && - (isRobolectricTest && !outputFile.canonicalPath.startsWith("/private$outputDir")) - ) { - throw IOException("Incorrect RAR FileHeader path!") - } + checkEntryPath(outputFile, outputDir) if (entry.isDirectory) { MakeDirectoryOperation.mkdir(outputFile, context) outputFile.setLastModified(entry.mTime.time) @@ -232,7 +230,12 @@ class RarExtractor( "\\\\".toRegex(), CompressedHelper.SEPARATOR, ) - extractEntry(context, archive, header, context.externalCacheDir!!.absolutePath) + extractEntry( + context, + archive, + header, + context.externalCacheDir!!.absolutePath, + ) return "${context.externalCacheDir!!.absolutePath}/$filename" } } diff --git a/app/src/play/java/com/amaze/filemanager/utils/Billing.kt b/app/src/play/java/com/amaze/filemanager/utils/Billing.kt index 091a88c5c1..8ca3393fc1 100644 --- a/app/src/play/java/com/amaze/filemanager/utils/Billing.kt +++ b/app/src/play/java/com/amaze/filemanager/utils/Billing.kt @@ -44,6 +44,7 @@ import com.android.billingclient.api.Purchase import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsParams.Product +import com.android.billingclient.api.QueryProductDetailsResult import org.slf4j.LoggerFactory import java.util.concurrent.Callable @@ -59,13 +60,28 @@ class Billing(private val activity: BasicActivity) : private lateinit var productDetails: List // create new donations client - private lateinit var billingClient: BillingClient + private var billingClient: BillingClient // True if billing service is connected private var isServiceConnected = false private lateinit var donationDialog: MaterialDialog + init { + productList = + listOf( + createProductWith("donations"), + createProductWith("donations_2"), + createProductWith("donations_3"), + createProductWith("donations_4"), + ) + billingClient = + BillingClient.newBuilder(activity).setListener(this).enablePendingPurchases( + PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(), + ).build() + initiatePurchaseFlow() + } + override fun onPurchasesUpdated( response: BillingResult, purchases: List?, @@ -93,11 +109,11 @@ class Billing(private val activity: BasicActivity) : billingClient.queryProductDetailsAsync( params.build(), - ) { responseCode: BillingResult, queryResult: List -> - if (queryResult.isNotEmpty()) { + ) { responseCode: BillingResult, queryResult: QueryProductDetailsResult -> + if (queryResult.productDetailsList.isNotEmpty()) { // Successfully fetched product details - productDetails = queryResult - popProductsList(responseCode, queryResult) + productDetails = queryResult.productDetailsList + popProductsList(responseCode, queryResult.productDetailsList) } else { AppConfig.toast(activity, R.string.error_fetching_google_play_product_list) @Suppress("ktlint:standard:max-line-length") @@ -198,21 +214,6 @@ class Billing(private val activity: BasicActivity) : } } - init { - productList = - listOf( - createProductWith("donations"), - createProductWith("donations_2"), - createProductWith("donations_3"), - createProductWith("donations_4"), - ) - billingClient = - BillingClient.newBuilder(activity).setListener(this).enablePendingPurchases( - PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(), - ).build() - initiatePurchaseFlow() - } - /** * We executes a connection request to Google Play * diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt new file mode 100644 index 0000000000..2f2926762c --- /dev/null +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/asynctasks/compress/SevenZipHelperCallableMaliciousTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2014-2021 Arpit Khurana , Vishal Nehra , + * Emmanuel Messulam, Raymond Lai and Contributors. + * + * This file is part of Amaze File Manager. + * + * Amaze File Manager 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 . + */ + +package com.amaze.filemanager.asynchronous.asynctasks.compress + +import android.os.Environment +import org.junit.Assert.assertFalse +import org.junit.Test +import java.io.File + +/** + * Tests for [SevenZipHelperCallable]. + */ +class SevenZipHelperCallableMaliciousTest : AbstractCompressedHelperCallableTest() { + /** + * Test to ensure that the root of the archive does not expose parent folder entries + * (e.g., ".." or "../"). + */ + @Test + fun testRootDoesNotExposeParentFolderEntries() { + val archive = File(Environment.getExternalStorageDirectory(), "malicious.7z") + val result = SevenZipHelperCallable(archive.absolutePath, "", false).call() + + assertFalse(result.any { it.name == ".." || it.name.startsWith("../") }) + } +} diff --git a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt index 10b2b9d8d7..7bdbbf94dc 100644 --- a/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt +++ b/app/src/test/java/com/amaze/filemanager/asynchronous/services/ExtractServiceTest.kt @@ -49,6 +49,7 @@ import org.awaitility.Awaitility.await import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Before import org.junit.Ignore @@ -95,6 +96,11 @@ class ExtractServiceTest { private val tarXzfile: File private val tarBz2file: File private val sevenZipfile: File + private val maliciousTarGzFile: File + private val maliciousTarBz2File: File + private val maliciousTarXzFile: File + private val maliciousTarLzmaFile: File + private val maliciousSevenZipFile: File private val passwordProtectedZipfile: File private val passwordProtected7Zipfile: File private val listPasswordProtected7Zipfile: File @@ -126,6 +132,11 @@ class ExtractServiceTest { tarXzfile = File(this, "test-archive.tar.xz") tarBz2file = File(this, "test-archive.tar.bz2") sevenZipfile = File(this, "test-archive.7z") + maliciousTarGzFile = File(this, "malicious.tar.gz") + maliciousTarBz2File = File(this, "malicious.tar.bz2") + maliciousTarXzFile = File(this, "malicious.tar.xz") + maliciousTarLzmaFile = File(this, "malicious.tar.lzma") + maliciousSevenZipFile = File(this, "malicious.7z") passwordProtectedZipfile = File(this, "test-archive-encrypted.zip") passwordProtected7Zipfile = File(this, "test-archive-encrypted.7z") listPasswordProtected7Zipfile = File(this, "test-archive-encrypted-list.7z") @@ -163,6 +174,11 @@ class ExtractServiceTest { getResourceAsStream("test-archive.tar.xz").copyTo(FileOutputStream(tarXzfile)) getResourceAsStream("test-archive.tar.bz2").copyTo(FileOutputStream(tarBz2file)) getResourceAsStream("test-archive.7z").copyTo(FileOutputStream(sevenZipfile)) + getResourceAsStream("malicious.tar.gz").copyTo(FileOutputStream(maliciousTarGzFile)) + getResourceAsStream("malicious.tar.bz2").copyTo(FileOutputStream(maliciousTarBz2File)) + getResourceAsStream("malicious.tar.xz").copyTo(FileOutputStream(maliciousTarXzFile)) + getResourceAsStream("malicious.tar.lzma").copyTo(FileOutputStream(maliciousTarLzmaFile)) + getResourceAsStream("malicious.7z").copyTo(FileOutputStream(maliciousSevenZipFile)) getResourceAsStream("test-archive-encrypted.zip") .copyTo(FileOutputStream(passwordProtectedZipfile)) getResourceAsStream("test-archive-encrypted.7z") @@ -326,6 +342,72 @@ class ExtractServiceTest { assertNull(ShadowToast.getTextOfLatestToast()) } + /** + * Test malicious 7z extraction exits with a regular error toast. + */ + @Test + fun testExtractMalicious7z() { + performTest(maliciousSevenZipFile) + ShadowLooper.idleMainLooper() + await().atMost(10, TimeUnit.SECONDS).until { ShadowToast.getLatestToast() != null } + assertErrorOrInvalidEntriesToast() + } + + /** + * Test malicious tar.gz extraction exits with a regular error toast. + */ + @Test + fun testExtractMaliciousTarGz() { + performTest(maliciousTarGzFile) + ShadowLooper.idleMainLooper() + await().atMost(10, TimeUnit.SECONDS).until { ShadowToast.getLatestToast() != null } + assertErrorOrInvalidEntriesToast() + } + + /** + * Test malicious tar.bz2 extraction exits with a regular error toast. + */ + @Test + fun testExtractMaliciousTarBz2() { + performTest(maliciousTarBz2File) + ShadowLooper.idleMainLooper() + await().atMost(10, TimeUnit.SECONDS).until { ShadowToast.getLatestToast() != null } + assertErrorOrInvalidEntriesToast() + } + + /** + * Test malicious tar.xz extraction exits with a regular error toast. + */ + @Test + fun testExtractMaliciousTarXz() { + performTest(maliciousTarXzFile) + ShadowLooper.idleMainLooper() + await().atMost(10, TimeUnit.SECONDS).until { ShadowToast.getLatestToast() != null } + assertErrorOrInvalidEntriesToast() + } + + /** + * Test malicious tar.lzma extraction exits with a regular error toast. + */ + @Test + fun testExtractMaliciousTarLzma() { + performTest(maliciousTarLzmaFile) + ShadowLooper.idleMainLooper() + await().atMost(10, TimeUnit.SECONDS).until { ShadowToast.getLatestToast() != null } + assertErrorOrInvalidEntriesToast() + } + + private fun assertErrorOrInvalidEntriesToast() { + val context = ApplicationProvider.getApplicationContext() + val latestToastText = ShadowToast.getTextOfLatestToast() + assertTrue( + setOf( + context.getString(R.string.error), + context.getString(R.string.multiple_invalid_archive_entries), + ).contains(latestToastText), + ) + } + /** * Test password-protected zip. */ diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/CompressedHelperTest.java b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/CompressedHelperTest.java index 8208508b35..0573930e05 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/CompressedHelperTest.java +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/CompressedHelperTest.java @@ -279,4 +279,46 @@ public void getFileNameTest() throws Exception { // no path assertEquals("", CompressedHelper.getFileName("")); } + + /** + * isEntryPathValid() tests. + * + *

Validates that paths which resolve within the archive root are accepted, and that paths + * which would escape the archive root are rejected — including cases where {@code ..} components + * appear in the middle of an otherwise valid path. + */ + @Test + public void isEntryPathValidTest() { + // null / empty are always invalid + assertFalse(CompressedHelper.isEntryPathValid(null)); + assertFalse(CompressedHelper.isEntryPathValid("")); + + // simple valid paths + assertTrue(CompressedHelper.isEntryPathValid("test.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir/file.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir/sub/file.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir/")); + + // ".." that resolves back inside the root is VALID + // e.g. "dir/sub/.." resolves to "dir" — still inside the archive + assertTrue(CompressedHelper.isEntryPathValid("dir/sub/..")); + assertTrue(CompressedHelper.isEntryPathValid("dir/sub/../file.txt")); + assertTrue(CompressedHelper.isEntryPathValid("a/b/c/../../file.txt")); + + // paths that escape the archive root are INVALID + assertFalse(CompressedHelper.isEntryPathValid("../evil.txt")); + assertFalse(CompressedHelper.isEntryPathValid("../../evil.txt")); + assertFalse(CompressedHelper.isEntryPathValid("foo/../../evil.txt")); + assertFalse(CompressedHelper.isEntryPathValid("foo/../../../evil.txt")); + + // Windows-style separators are normalised first + assertFalse(CompressedHelper.isEntryPathValid("..\\evil.txt")); + assertFalse(CompressedHelper.isEntryPathValid("foo\\..\\..\\evil.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir\\sub\\file.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir\\sub\\..\\file.txt")); + + // "." segments are ignored (current directory) + assertTrue(CompressedHelper.isEntryPathValid("./test.txt")); + assertTrue(CompressedHelper.isEntryPathValid("dir/./file.txt")); + } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/SevenZipExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/SevenZipExtractorTest.kt index d730b39644..2b6c7b7703 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/SevenZipExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/SevenZipExtractorTest.kt @@ -20,10 +20,66 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.SevenZipExtractor +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException open class SevenZipExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "7z" override fun extractorClass(): Class = SevenZipExtractor::class.java + + /** + * Verify that a 7-Zip archive carrying a path-traversal entry + * (../POC_7Z_PROOF.txt) is blocked by the canonical-path guard: + * - extractEverything() must throw IOException + * - no file is written outside the designated output directory + */ + @Test + fun testExtractMalicious7z() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.7z") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + SevenZipExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + try { + extractor.extractEverything() + fail("Expected IOException: canonical-path guard must reject the traversal entry") + } catch (e: IOException) { + // Confirm the guard fired (not a generic bad-archive error) + assertFalse( + "Exception must not be a BadArchiveNotice", + e is Extractor.BadArchiveNotice, + ) + } + + // The malicious file must NOT have been written outside the output directory + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_7Z_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest.kt index 53761d1387..6f134d98d4 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest.kt @@ -20,10 +20,64 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarBzip2Extractor +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException +/** + * Tests for [TarBzip2Extractor]. + */ open class TarBzip2ExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "tar.bz2" override fun extractorClass(): Class = TarBzip2Extractor::class.java + + /** + * Test extracting a malicious tar.bz2 archive does not allow path traversal. + */ + @Test + fun testExtractMaliciousTarBzip2() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.tar.bz2") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + TarBzip2Extractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + try { + extractor.extractEverything() + fail("Expected IOException: canonical-path guard must reject the traversal entry") + } catch (e: IOException) { + assertFalse( + "Exception must not be a BadArchiveNotice", + e is Extractor.BadArchiveNotice, + ) + } + + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_ZIPSLIP_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest2.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest2.kt index ae8ada8978..9b3eafffb7 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest2.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarBzip2ExtractorTest2.kt @@ -20,6 +20,9 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +/** + * Tests for [TarBzip2Extractor], but with .tbz extension instead of .tar.bz2. + */ class TarBzip2ExtractorTest2 : TarBzip2ExtractorTest() { override val archiveType: String = "tbz" } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarGzExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarGzExtractorTest.kt index 0f537554b4..bcf9805724 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarGzExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarGzExtractorTest.kt @@ -20,10 +20,67 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarGzExtractor +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException open class TarGzExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "tar.gz" override fun extractorClass(): Class = TarGzExtractor::class.java + + /** + * Verify that a tar.gz archive carrying a path-traversal entry + * (../POC_ZIPSLIP_PROOF.txt) is blocked by the canonical-path guard + * in AbstractCommonsArchiveExtractor: + * - extractEverything() must throw IOException + * - no file is written outside the designated output directory + */ + @Test + fun testExtractMaliciousTarGz() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.tar.gz") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + TarGzExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + try { + extractor.extractEverything() + fail("Expected IOException: canonical-path guard must reject the traversal entry") + } catch (e: IOException) { + // Confirm the guard fired (not a generic bad-archive error) + assertFalse( + "Exception must not be a BadArchiveNotice", + e is Extractor.BadArchiveNotice, + ) + } + + // The malicious file must NOT have been written outside the output directory + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_ZIPSLIP_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarLzmaExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarLzmaExtractorTest.kt index e4d0707408..784a81e81f 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarLzmaExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarLzmaExtractorTest.kt @@ -20,10 +20,64 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarLzmaExtractor +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException +/** + * Tests for [TarLzmaExtractor]. + */ class TarLzmaExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "tar.lzma" override fun extractorClass(): Class = TarLzmaExtractor::class.java + + /** + * Test extracting a malicious tar.lzma archive does not allow path traversal. + */ + @Test + fun testExtractMaliciousTarLzma() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.tar.lzma") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + TarLzmaExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + try { + extractor.extractEverything() + fail("Expected IOException: canonical-path guard must reject the traversal entry") + } catch (e: IOException) { + assertFalse( + "Exception must not be a BadArchiveNotice", + e is Extractor.BadArchiveNotice, + ) + } + + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_ZIPSLIP_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarXzExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarXzExtractorTest.kt index 6125b05b7f..359af1c1fd 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarXzExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/TarXzExtractorTest.kt @@ -20,10 +20,64 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarXzExtractor +import org.junit.Assert.assertFalse +import org.junit.Assert.fail +import org.junit.Test +import java.io.File +import java.io.IOException +/** + * Tests for [TarXzExtractor]. + */ class TarXzExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "tar.xz" override fun extractorClass(): Class = TarXzExtractor::class.java + + /** + * Test extracting a malicious tar.xz archive does not allow path traversal. + */ + @Test + fun testExtractMaliciousTarXz() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.tar.xz") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + TarXzExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + try { + extractor.extractEverything() + fail("Expected IOException: canonical-path guard must reject the traversal entry") + } catch (e: IOException) { + assertFalse( + "Exception must not be a BadArchiveNotice", + e is Extractor.BadArchiveNotice, + ) + } + + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_ZIPSLIP_PROOF.txt").exists(), + ) + } } diff --git a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt index 418868998e..89f2ea2237 100644 --- a/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt +++ b/app/src/test/java/com/amaze/filemanager/filesystem/compressed/extractcontents/ZipExtractorTest.kt @@ -20,10 +20,72 @@ package com.amaze.filemanager.filesystem.compressed.extractcontents +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.ZipExtractor +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File class ZipExtractorTest : AbstractArchiveExtractorTest() { override val archiveType: String = "zip" override fun extractorClass(): Class = ZipExtractor::class.java + + /** + * Verify that a ZIP archive carrying a path-traversal entry + * (foo/../../POC_ZIP_PROOF.txt) is handled safely: + * - extraction completes without an exception + * - the offending entry is recorded in invalidArchiveEntries + * - no file is written outside the designated output directory + */ + @Test + fun testExtractMaliciousZip() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.zip") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + ZipExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + // Extraction must succeed — path-traversal entries are quarantined, not thrown + extractor.extractEverything() + + // The traversal entry must be recorded as invalid … + assertTrue( + "Malicious path-traversal entry must be captured in invalidArchiveEntries", + extractor.invalidArchiveEntries.isNotEmpty(), + ) + assertTrue( + "invalidArchiveEntries must contain the POC entry", + extractor.invalidArchiveEntries.any { "POC_ZIP_PROOF" in it }, + ) + // … and must NOT have been written outside the output directory + val escapedFile = File(outputDir, "foo/../../POC_ZIP_PROOF.txt").canonicalFile + assertFalse( + "Malicious file must not escape the output directory", + escapedFile.exists(), + ) + assertFalse( + "Escaped file canonical path must not reside under output directory", + escapedFile.canonicalPath.startsWith(outputDir.canonicalPath), + ) + } } diff --git a/app/src/test/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialogTest.kt b/app/src/test/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialogTest.kt index 72305b064a..1bf2f87bd4 100644 --- a/app/src/test/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialogTest.kt +++ b/app/src/test/java/com/amaze/filemanager/ui/dialogs/SmbConnectDialogTest.kt @@ -36,6 +36,7 @@ import com.amaze.filemanager.utils.smb.SmbUtil import io.mockk.confirmVerified import io.mockk.spyk import io.mockk.verify +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.robolectric.shadows.ShadowDialog @@ -45,6 +46,64 @@ import org.robolectric.shadows.ShadowLooper * Tests [SmbConnectDialog]. */ class SmbConnectDialogTest : AbstractMainActivityTestBase() { + /** + * Test editing an existing connection pre-fills all dialog fields. + * Regression test for https://github.com/TeamAmaze/AmazeFileManager/issues/4543 + */ + @Test + fun testEditConnectionPreFillsAllFields() { + val listener = spyk() + val encryptedPath = + SmbUtil.getSmbEncryptedPath( + AppConfig.getInstance(), + "smb://user:password@192.168.1.100/share", + ) + doTestWithDialog( + listener = listener, + arguments = + Bundle().also { + it.putString(ARG_NAME, "My SMB Connection") + it.putString(ARG_PATH, encryptedPath) + it.putBoolean(ARG_EDIT, true) + }, + withDialog = { dialog, _ -> + dialog.binding.run { + assertEquals("My SMB Connection", this.connectionET.text.toString()) + assertEquals("192.168.1.100", this.ipET.text.toString()) + assertEquals("share", this.shareET.text.toString()) + assertEquals("user", this.usernameET.text.toString()) + assertEquals("password", this.passwordET.text.toString()) + } + }, + ) + } + + /** + * Test editing an anonymous connection checks the anonymous checkbox. + * Regression test for https://github.com/TeamAmaze/AmazeFileManager/issues/4543 + */ + @Test + fun testEditAnonymousConnectionSetsAnonymousCheckbox() { + val listener = spyk() + doTestWithDialog( + listener = listener, + arguments = + Bundle().also { + it.putString(ARG_NAME, "Anonymous SMB") + it.putString(ARG_PATH, "smb://192.168.1.100/share") + it.putBoolean(ARG_EDIT, true) + }, + withDialog = { dialog, _ -> + dialog.binding.run { + assertEquals("Anonymous SMB", this.connectionET.text.toString()) + assertEquals("192.168.1.100", this.ipET.text.toString()) + assertEquals("share", this.shareET.text.toString()) + assertTrue(this.chkSmbAnonymous.isChecked) + } + }, + ) + } + /** * Test call to [SmbConnectionListener.addConnection] is encrypted path. */ diff --git a/app/src/test/resources/malicious.7z b/app/src/test/resources/malicious.7z new file mode 100644 index 0000000000..5576b92418 Binary files /dev/null and b/app/src/test/resources/malicious.7z differ diff --git a/app/src/test/resources/malicious.rar b/app/src/test/resources/malicious.rar new file mode 100644 index 0000000000..a1ac941229 Binary files /dev/null and b/app/src/test/resources/malicious.rar differ diff --git a/app/src/test/resources/malicious.tar.bz2 b/app/src/test/resources/malicious.tar.bz2 new file mode 100644 index 0000000000..eeba086779 Binary files /dev/null and b/app/src/test/resources/malicious.tar.bz2 differ diff --git a/app/src/test/resources/malicious.tar.gz b/app/src/test/resources/malicious.tar.gz new file mode 100644 index 0000000000..0139101ea6 Binary files /dev/null and b/app/src/test/resources/malicious.tar.gz differ diff --git a/app/src/test/resources/malicious.tar.lzma b/app/src/test/resources/malicious.tar.lzma new file mode 100644 index 0000000000..4b2303f611 Binary files /dev/null and b/app/src/test/resources/malicious.tar.lzma differ diff --git a/app/src/test/resources/malicious.tar.xz b/app/src/test/resources/malicious.tar.xz new file mode 100644 index 0000000000..200e5eee58 Binary files /dev/null and b/app/src/test/resources/malicious.tar.xz differ diff --git a/app/src/test/resources/malicious.zip b/app/src/test/resources/malicious.zip new file mode 100644 index 0000000000..fe64c1a685 Binary files /dev/null and b/app/src/test/resources/malicious.zip differ diff --git a/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/RarExtractorTest.kt b/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/RarExtractorTest.kt index 81b4d7c32a..c9dc1376bf 100644 --- a/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/RarExtractorTest.kt +++ b/app/src/testPlay/java/com/amaze/filemanager/filesystem/compressed/extractcontents/RarExtractorTest.kt @@ -27,6 +27,8 @@ import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.RarExtractor import com.github.junrar.Archive import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Ignore import org.junit.Test import java.io.File @@ -89,6 +91,56 @@ class RarExtractorTest : AbstractArchiveExtractorTest() { } } + /** + * Verify that a RAR4 archive carrying a path-traversal entry + * (foo//../../POC_RAR_PROOF.txt/POC_RAR_PROOF.txt) is handled safely: + * - extraction completes without an exception + * - the offending entry is recorded in invalidArchiveEntries + * - no file is written outside the designated output directory + */ + @Test + fun testExtractMaliciousRar() { + val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.rar") + val outputDir = Environment.getExternalStorageDirectory() + val extractor = + RarExtractor( + ApplicationProvider.getApplicationContext(), + maliciousArchive.absolutePath, + outputDir.absolutePath, + object : Extractor.OnUpdate { + override fun onStart( + totalBytes: Long, + firstEntryName: String, + ) = Unit + + override fun onUpdate(entryPath: String) = Unit + + override fun isCancelled(): Boolean = false + + override fun onFinish() = Unit + }, + ServiceWatcherUtil.UPDATE_POSITION, + ) + + // Extraction must succeed — path-traversal entries are quarantined, not thrown + extractor.extractEverything() + + // The traversal entry must be recorded as invalid … + assertTrue( + "Malicious path-traversal entry must be captured in invalidArchiveEntries", + extractor.invalidArchiveEntries.isNotEmpty(), + ) + assertTrue( + "invalidArchiveEntries must contain the POC entry", + extractor.invalidArchiveEntries.any { "POC_RAR_PROOF" in it }, + ) + // … and must NOT have been written outside the output directory + assertFalse( + "Malicious file must not escape the output directory", + File(outputDir.parentFile, "POC_RAR_PROOF.txt").exists(), + ) + } + @Test @Ignore override fun testExtractBadArchive() = Unit } diff --git a/fastlane/metadata/android/en-US/changelogs/125.txt b/fastlane/metadata/android/en-US/changelogs/125.txt new file mode 100644 index 0000000000..ed04ddd0a4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/125.txt @@ -0,0 +1 @@ +* Fixes for path traversal vulnerabilities \ No newline at end of file diff --git a/file_operations/.gitignore b/file_operations/.gitignore index f2869aa55f..a930b72f50 100644 --- a/file_operations/.gitignore +++ b/file_operations/.gitignore @@ -1,11 +1,10 @@ # Rust build artifacts target/ **/*.rs.bk -Cargo.lock # Auto-generated configuration (generated from template) .cargo/config.toml # Android build artifacts .cxx/ -build/ \ No newline at end of file +build/ diff --git a/file_operations/Cargo.lock b/file_operations/Cargo.lock new file mode 100644 index 0000000000..62ac5f7e67 --- /dev/null +++ b/file_operations/Cargo.lock @@ -0,0 +1,339 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_log-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e" + +[[package]] +name = "android_logger" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8619b80c242aa7bd638b5c7ddd952addeecb71f69c75e33f1d47b2804f8f883a" +dependencies = [ + "android_log-sys", + "env_logger", + "log", + "once_cell", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rootoperations" +version = "0.1.0" +dependencies = [ + "android_logger", + "jni", + "libc", + "log", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" diff --git a/gradle.properties b/gradle.properties index e9994ab628..a7ce00a43f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,3 +28,6 @@ org.gradle.parallel=true android.disableResourceValidation=true # for macs, omit for other operating systems # org.gradle.java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e475d17e45..8968d58db7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ apacheMina = "2.0.16" mockito = "4.11.0" mockitoInline = "4.11.0" mockitoKotlin = "4.1.0" -androidBilling = "7.1.1" +androidBilling = "8.0.0" cloudrailSiAndroid = "2.22.4" junrar = "7.4.0" vectordrawableAnimated = "1.2.0"