Skip to content
Merged
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
22 changes: 11 additions & 11 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tauri-plugin-vnidrop-fs"
version = "0.1.0"
version = "1.0.0-rc.1"
authors = [ "AbassHammed" ]
description = "Cross-platform filesystem manager for Tauri with Android SAF and iOS document picker support."
edition = "2021"
Expand All @@ -25,26 +25,26 @@ legacy-storage-permission = ["legacy_storage_permission"]
legacy-storage-permission-include-android-10 = ["legacy_storage_permission_include_android_10"]

[dependencies]
tauri = { version = "2.8.5", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tauri-plugin-fs = "2"
tauri = { version = "2.11.2", default-features = false }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
thiserror = "2.0.18"
tauri-plugin-fs = "2.5.1"
sync_async = "0.1.0"
schemars = "0.8"
glob = "0.3"
percent-encoding = "2"
getrandom = { version = "0.4", optional = true }
getrandom = { version = "0.4.2", optional = true }
http-range = { version = "0.1.5", optional = true }

[target.'cfg(target_os = "android")'.dependencies]
base64 = "0.22.1"

[build-dependencies]
tauri-plugin = { version = "2.4.0", features = ["build"] }
tauri-plugin = { version = "2.6.2", features = ["build"] }
schemars = "0.8"
serde = "1"
serde = "1.0.228"

[dev-dependencies]
tauri = { version = "2.8.5", default-features = false, features = ["test"] }
tempfile = "3"
tauri = { version = "2.11.2", default-features = false, features = ["test"] }
tempfile = "3.27.0"
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,30 @@ Android file paths are checked against the Vnidrop scope. Android picker
`content://` URIs use Android URI permissions. iOS external files use
security-scoped bookmarks.

## Security Model

Treat filesystem access as an explicit capability:

- Prefer picker-returned mobile URI objects over raw paths.
- Keep production capability files narrow. Do not ship `vnidrop-fs:all`,
`fs:read-all`, `fs:write-all`, or `"allow": ["**"]` unless the whole app is
intended to manage every reachable file.
- Android `content://` operations are authorized by Android URI permissions and
document providers. Destructive operations such as rename and delete should
only be exposed in your UI for URIs the user selected or the app created.
- Android relative paths are validated before native create/find operations:
absolute paths, `.`/`..`, backslashes, and control characters are rejected.
- iOS raw string paths are limited to the app container. External iOS files and
folders must use picker/bookmark-backed `IosFsUri` objects.
- iOS bookmark data is stored in app `UserDefaults`. Bookmark IDs are random,
but IDs and bookmark data are app-local access state, not secret material.

If you enable the Android content or thumbnail protocols, protocol URLs should
be treated like bearer references inside your webview. Only generate them for
files your UI is allowed to show, and keep the protocol scopes as narrow as
possible. Invalid protocol scope configuration fails plugin startup so release
builds do not silently run with an unexpected protocol policy.

## Root API

Import portable functions from the package root:
Expand Down Expand Up @@ -334,7 +358,8 @@ if (bookmarkId) {

iOS picker results are opened in place. External document-provider files are
persisted as security-scoped bookmarks when possible. App-local `file://` URLs
may have `bookmarkId: null`.
may have `bookmarkId: null`; external URLs without a bookmark are rejected by
native operations.

iOS supports the shared root API for:

Expand Down
21 changes: 12 additions & 9 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,25 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "1.8"
}

kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
}

dependencies {

implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0")
implementation("androidx.core:core-ktx:1.19.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("com.google.android.material:material:1.14.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20240303")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
implementation(project(":tauri-android"))
}
2 changes: 1 addition & 1 deletion android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
Expand Down
4 changes: 2 additions & 2 deletions android/settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ pluginManagement {
eachPlugin {
switch (requested.id.id) {
case "com.android.library":
useVersion("8.0.2")
useVersion("8.13.2")
break
case "org.jetbrains.kotlin.android":
useVersion("1.8.20")
useVersion("2.2.21")
break
}
}
Expand Down
21 changes: 10 additions & 11 deletions android/src/main/java/AFMediaStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ class AFMediaStore private constructor() { companion object {
isPending: Boolean,
ctx: Context
): JSObject {
val safeRelativePath = AFUtils.validateRelativePath(relativePath)

val uri = when {
// Q は Android 10
Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT -> {
_createNewFile(
volumeName ?: MediaStore.VOLUME_EXTERNAL_PRIMARY,
relativePath,
safeRelativePath,
mimeType,
isPending,
ctx
Expand All @@ -43,7 +44,7 @@ class AFMediaStore private constructor() { companion object {
throw Exception("volume name is available for Android 10 or higher")
}

_createNewFileLegacy(relativePath, mimeType, ctx)
_createNewFileLegacy(safeRelativePath, mimeType, ctx)
}
}

Expand Down Expand Up @@ -73,14 +74,15 @@ class AFMediaStore private constructor() { companion object {
newName: String,
ctx: Context
) {
val safeNewName = AFUtils.validateFileName(newName)

when {
// Q は Android 10
Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT -> {
_rename(uri, newName, ctx)
_rename(uri, safeNewName, ctx)
}
else -> {
_renameLegacy(uri, newName, ctx)
_renameLegacy(uri, safeNewName, ctx)
}
}
}
Expand Down Expand Up @@ -411,10 +413,7 @@ private fun _createNewFile(
ctx: Context
): Uri {

val entry = File(relativePath)
if (entry.isAbsolute) {
throw IllegalArgumentException("absolute path is not supported")
}
val entry = File(AFUtils.validateRelativePath(relativePath))

val displayName = entry.name
val parentRelativePath = entry.parent
Expand Down Expand Up @@ -452,8 +451,8 @@ private fun _createNewFileLegacy(
ctx: Context
): Uri {

val relativePath = relativePath.trimStart('/')
val path = Environment.getExternalStorageDirectory().absolutePath + "/" + relativePath
val relativePath = AFUtils.validateRelativePath(relativePath)
val path = AFUtils.resolveChildFile(Environment.getExternalStorageDirectory(), relativePath).path
val mimeType = mimeType ?: AFUtils.guessFileMimeTypeFromExtension(File(path))
val baseContentUri = getBaseContentUriLegacy(relativePath, mimeType)

Expand Down Expand Up @@ -587,4 +586,4 @@ private fun <T> useNewFilePathWithNameSuffixFallback(
i++
pathToCreate = buildPath(i)
}
}
}
50 changes: 49 additions & 1 deletion android/src/main/java/AFUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,52 @@ class AFUtils private constructor() { companion object {

throw Exception("Failed to find entry: $uri")
}
}}

fun validateFileName(name: String): String {
if (
name.isEmpty() ||
name == "." ||
name == ".." ||
name.contains('/') ||
name.contains('\\') ||
name.any { it.code < 0x20 }
) {
throw Exception("Illegal file name: $name")
}

return name
}

fun validateRelativePath(relativePath: String, allowEmpty: Boolean = false): String {
if (relativePath.isEmpty()) {
if (allowEmpty) return ""
throw Exception("Relative path is empty.")
}
if (relativePath.startsWith('/')) {
throw Exception("Illegal relative path format, starts with '/'.")
}
if (relativePath.contains('\\')) {
throw Exception("Illegal relative path format, contains '\\'.")
}

val parts = relativePath.split('/').filter { it.isNotEmpty() }
if (parts.any { it == "." || it == ".." || it.any { ch -> ch.code < 0x20 } }) {
throw Exception("Illegal relative path segment: $relativePath")
}

return parts.joinToString("/")
}

fun resolveChildFile(parent: File, relativePath: String, allowEmpty: Boolean = false): File {
val safeRelativePath = validateRelativePath(relativePath, allowEmpty)
val base = parent.canonicalFile
val child = if (safeRelativePath.isEmpty()) base else File(base, safeRelativePath).canonicalFile
val basePath = base.path

if (child.path != basePath && !child.path.startsWith(basePath.trimEnd(File.separatorChar) + File.separator)) {
throw Exception("Relative path escapes base directory: $relativePath")
}

return child
}
}}
52 changes: 12 additions & 40 deletions android/src/main/java/DocumentFileController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,8 @@ class DocumentFileController(private val activity: Activity): FileController {
if (relativePath.endsWith('/')) {
throw Exception("Illegal file path format, ends with '/'. $relativePath")
}
if (relativePath.isEmpty()) {
throw Exception("Relative path is empty.")
}

val _relativePath = relativePath.trimStart('/')
val _relativePath = AFUtils.validateRelativePath(relativePath)
val relativeDirPath = _relativePath.substringBeforeLast("/", "")
val fileName = _relativePath.substringAfterLast("/", _relativePath)

Expand All @@ -242,11 +239,8 @@ class DocumentFileController(private val activity: Activity): FileController {
if (relativePath.endsWith('/')) {
throw Exception("Illegal file path format, ends with '/'. $relativePath")
}
if (relativePath.isEmpty()) {
throw Exception("Relative path is empty.")
}

val _relativePath = relativePath.trimStart('/')
val _relativePath = AFUtils.validateRelativePath(relativePath)
val relativeDirPath = _relativePath.substringBeforeLast("/", "")
val fileName = _relativePath.substringAfterLast("/", _relativePath)

Expand Down Expand Up @@ -277,11 +271,8 @@ class DocumentFileController(private val activity: Activity): FileController {
if (relativePath.endsWith('/')) {
throw Exception("Illegal file path format, ends with '/'. $relativePath")
}
if (relativePath.isEmpty()) {
throw Exception("Relative path is empty.")
}

val _relativePath = relativePath.trimStart('/')
val _relativePath = AFUtils.validateRelativePath(relativePath)
val relativeDirPath = _relativePath.substringBeforeLast("/", "")
val fileName = _relativePath.substringAfterLast("/", _relativePath)

Expand All @@ -305,11 +296,8 @@ class DocumentFileController(private val activity: Activity): FileController {
if (relativePath.endsWith('/')) {
throw Exception("Illegal file path format, ends with '/'. $relativePath")
}
if (relativePath.isEmpty()) {
throw Exception("Relative path is empty.")
}

val _relativePath = relativePath.trimStart('/')
val _relativePath = AFUtils.validateRelativePath(relativePath)
val relativeDirPath = _relativePath.substringBeforeLast("/", "")
val fileName = _relativePath.substringAfterLast("/", _relativePath)

Expand Down Expand Up @@ -340,11 +328,8 @@ class DocumentFileController(private val activity: Activity): FileController {
if (relativePath.endsWith('/')) {
throw Exception("Illegal file path format, ends with '/'. $relativePath")
}
if (relativePath.isEmpty()) {
throw Exception("Relative path is empty.")
}

val uri = createOrGetDir(dirUri, relativePath)
val uri = createOrGetDir(dirUri, AFUtils.validateRelativePath(relativePath))

val res = JSObject()
res.put("uri", uri)
Expand All @@ -357,11 +342,8 @@ class DocumentFileController(private val activity: Activity): FileController {
if (relativePath.endsWith('/')) {
throw Exception("Illegal file path format, ends with '/'. $relativePath")
}
if (relativePath.isEmpty()) {
throw Exception("Relative path is empty.")
}

val entry = createOrGetDirAndReturnRelativePath(dirUri, relativePath)
val entry = createOrGetDirAndReturnRelativePath(dirUri, AFUtils.validateRelativePath(relativePath))
val uri = entry.first
val actualRelativePath = entry.second

Expand Down Expand Up @@ -425,7 +407,7 @@ class DocumentFileController(private val activity: Activity): FileController {
val updatedUri = DocumentsContract.renameDocument(
activity.contentResolver,
documentUri,
newName
AFUtils.validateFileName(newName)
)

val res = JSObject()
Expand Down Expand Up @@ -598,14 +580,9 @@ class DocumentFileController(private val activity: Activity): FileController {
}

fun findFileUri(dirUri: AFUri, relativePath: String): JSObject {
if (relativePath.startsWith('/')) {
throw Exception("Illegal file path format, starts with '/'.")
}
if (relativePath.isEmpty()) {
throw Exception("Relative path is empty.")
}
val safeRelativePath = AFUtils.validateRelativePath(relativePath)

val uri = findUri(dirUri, relativePath)
val uri = findUri(dirUri, safeRelativePath)
if (isDir(uri)) {
throw Exception("This is a directory: $uri")
}
Expand All @@ -617,14 +594,9 @@ class DocumentFileController(private val activity: Activity): FileController {
}

fun findDirUri(dirUri: AFUri, relativePath: String): JSObject {
if (relativePath.startsWith('/')) {
throw Exception("Illegal file path format, starts with '/'.")
}
if (relativePath.isEmpty()) {
throw Exception("Relative path is empty.")
}
val safeRelativePath = AFUtils.validateRelativePath(relativePath)

val uri = findUri(dirUri, relativePath)
val uri = findUri(dirUri, safeRelativePath)
if (!isDir(uri)) {
throw Exception("This is a file: $uri")
}
Expand All @@ -651,4 +623,4 @@ class DocumentFileController(private val activity: Activity): FileController {

throw Exception("Failed to get name from $uri")
}
}
}
Loading
Loading