From f7253a31bc18584d07ea9f6e3e22d0dc49d2bfac Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 16 Jun 2026 12:21:29 +0200 Subject: [PATCH 1/4] Harden mobile filesystem access checks --- android/src/main/java/AFMediaStore.kt | 21 ++++---- android/src/main/java/AFUtils.kt | 50 +++++++++++++++++- .../src/main/java/DocumentFileController.kt | 52 +++++-------------- android/src/main/java/RawFileController.kt | 20 +++---- android/src/test/java/AFUtilsUnitTest.kt | 26 ++++++++++ ios/Sources/VnidropFsCore/VnidropFsCore.swift | 26 +++++----- .../VnidropFsPlugin/VnidropFsPlugin.swift | 32 ++++++++++-- .../VnidropFsCoreTests.swift | 30 ++++++++--- src/lib.rs | 2 +- src/protocols/state.rs | 12 ++--- 10 files changed, 179 insertions(+), 92 deletions(-) diff --git a/android/src/main/java/AFMediaStore.kt b/android/src/main/java/AFMediaStore.kt index 6c4194c..6c0021a 100644 --- a/android/src/main/java/AFMediaStore.kt +++ b/android/src/main/java/AFMediaStore.kt @@ -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 @@ -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) } } @@ -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) } } } @@ -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 @@ -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) @@ -587,4 +586,4 @@ private fun useNewFilePathWithNameSuffixFallback( i++ pathToCreate = buildPath(i) } -} \ No newline at end of file +} diff --git a/android/src/main/java/AFUtils.kt b/android/src/main/java/AFUtils.kt index 6ff7764..3aaba6b 100644 --- a/android/src/main/java/AFUtils.kt +++ b/android/src/main/java/AFUtils.kt @@ -181,4 +181,52 @@ class AFUtils private constructor() { companion object { throw Exception("Failed to find entry: $uri") } -}} \ No newline at end of file + + 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 + } +}} diff --git a/android/src/main/java/DocumentFileController.kt b/android/src/main/java/DocumentFileController.kt index fccc0ff..7038115 100644 --- a/android/src/main/java/DocumentFileController.kt +++ b/android/src/main/java/DocumentFileController.kt @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 @@ -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() @@ -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") } @@ -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") } @@ -651,4 +623,4 @@ class DocumentFileController(private val activity: Activity): FileController { throw Exception("Failed to get name from $uri") } -} \ No newline at end of file +} diff --git a/android/src/main/java/RawFileController.kt b/android/src/main/java/RawFileController.kt index 0e4ad25..842ae1b 100644 --- a/android/src/main/java/RawFileController.kt +++ b/android/src/main/java/RawFileController.kt @@ -95,7 +95,7 @@ class RawFileController: FileController { @Synchronized override fun createNewFile(dirUri: AFUri, relativePath: String, mimeType: String): JSObject { val dir = File(Uri.parse(dirUri.uri).path!!) - val baseFile = File(dir.path + "/" + relativePath.trimStart('/')) + val baseFile = AFUtils.resolveChildFile(dir, relativePath) val fileName = baseFile.nameWithoutExtension val fileExtension = baseFile.extension @@ -130,7 +130,7 @@ class RawFileController: FileController { ): JSObject { val dir = File(Uri.parse(dirUri.uri).path!!) - val baseFile = File(dir.path + "/" + relativePath.trimStart('/')) + val baseFile = AFUtils.resolveChildFile(dir, relativePath) val fileName = baseFile.nameWithoutExtension val fileExtension = baseFile.extension @@ -165,7 +165,7 @@ class RawFileController: FileController { @Synchronized override fun createNewDir(dirUri: AFUri, relativePath: String): JSObject { val parentDir = File(Uri.parse(dirUri.uri).path!!) - val baseDir = File(parentDir.path + "/" + relativePath.trimStart('/')) + val baseDir = AFUtils.resolveChildFile(parentDir, relativePath) val dirName = baseDir.name var dir = baseDir @@ -193,7 +193,7 @@ class RawFileController: FileController { ): JSObject { val dir = File(Uri.parse(dirUri.uri).path!!) - val baseFile = File(dir.path + "/" + relativePath.trimStart('/')) + val baseFile = AFUtils.resolveChildFile(dir, relativePath) val fileName = baseFile.name var file = baseFile @@ -221,8 +221,8 @@ class RawFileController: FileController { @Synchronized override fun createDirAll(dirUri: AFUri, relativePath: String): JSObject { - val parentPath = Uri.parse(dirUri.uri).path!!.trimEnd('/') - val dir = File(parentPath + "/" + relativePath.trimStart('/')) + val parent = File(Uri.parse(dirUri.uri).path!!) + val dir = AFUtils.resolveChildFile(parent, relativePath) dir.mkdirs() val res = JSObject() @@ -233,8 +233,8 @@ class RawFileController: FileController { @Synchronized override fun createDirAllAndReturnRelativePath(dirUri: AFUri, relativePath: String): JSObject { - val parentPath = Uri.parse(dirUri.uri).path!!.trimEnd('/') - val dir = File(parentPath + "/" + relativePath.trimStart('/')) + val parent = File(Uri.parse(dirUri.uri).path!!) + val dir = AFUtils.resolveChildFile(parent, relativePath) dir.mkdirs() return JSObject().apply { @@ -279,7 +279,7 @@ class RawFileController: FileController { override fun rename(uri: AFUri, newName: String): JSObject { val file = File(Uri.parse(uri.uri).path!!) - val newFile = File(file.parentFile, newName) + val newFile = File(file.parentFile, AFUtils.validateFileName(newName)) if (newFile.exists()) { throw Exception("File already exists: ${newFile.path}") @@ -318,4 +318,4 @@ class RawFileController: FileController { .getMimeTypeFromExtension(file.extension) ?: "application/octet-stream" } -} \ No newline at end of file +} diff --git a/android/src/test/java/AFUtilsUnitTest.kt b/android/src/test/java/AFUtilsUnitTest.kt index adbd294..0edeb51 100644 --- a/android/src/test/java/AFUtilsUnitTest.kt +++ b/android/src/test/java/AFUtilsUnitTest.kt @@ -29,4 +29,30 @@ class AFUtilsUnitTest { assertEquals("file:///data/user/0/app/files/local.txt", obj.getString("uri")) assertTrue(obj.isNull("documentTopTreeUri")) } + + @Test + fun relativePathValidationRejectsTraversalAndSeparators() { + assertEquals("safe/report.txt", AFUtils.validateRelativePath("safe/report.txt")) + + listOf("../secret.txt", "safe/../secret.txt", "/secret.txt", "safe\\secret.txt", "./secret.txt").forEach { + try { + AFUtils.validateRelativePath(it) + throw AssertionError("expected invalid relative path: $it") + } catch (_: Exception) { + } + } + } + + @Test + fun fileNameValidationRejectsPathComponents() { + assertEquals("report.txt", AFUtils.validateFileName("report.txt")) + + listOf("", ".", "..", "../report.txt", "nested/report.txt", "nested\\report.txt").forEach { + try { + AFUtils.validateFileName(it) + throw AssertionError("expected invalid file name: $it") + } catch (_: Exception) { + } + } + } } diff --git a/ios/Sources/VnidropFsCore/VnidropFsCore.swift b/ios/Sources/VnidropFsCore/VnidropFsCore.swift index 4b66096..9f77c7a 100644 --- a/ios/Sources/VnidropFsCore/VnidropFsCore.swift +++ b/ios/Sources/VnidropFsCore/VnidropFsCore.swift @@ -29,8 +29,7 @@ public final class SecurityScopedBookmarkStore { } @discardableResult - public func save(url: URL, bookmarkData: Data) -> IosFsUri { - let id = stableBookmarkId(for: url) + public func save(url: URL, bookmarkData: Data, id: String = UUID().uuidString) -> IosFsUri { var values = bookmarks() values[id] = bookmarkData defaults.set(values.mapValues { $0.base64EncodedString() }, forKey: key) @@ -51,15 +50,6 @@ public final class SecurityScopedBookmarkStore { } } -public func stableBookmarkId(for url: URL) -> String { - var hash: UInt64 = 0xcbf29ce484222325 - for byte in Data(url.absoluteString.utf8) { - hash ^= UInt64(byte) - hash &*= 0x100000001b3 - } - return String(format: "%016llx", hash) -} - public func uniqueCandidateURL(baseURL: URL, exists: (URL) -> Bool) -> URL { if !exists(baseURL) { return baseURL @@ -80,12 +70,24 @@ public func uniqueCandidateURL(baseURL: URL, exists: (URL) -> Bool) -> URL { return baseURL } +public func validateFileName(_ name: String) throws -> String { + if name.isEmpty || name == "." || name == ".." { + throw IosFsCoreError.invalidRelativePath + } + if name.contains("/") || name.contains("\\") || name.unicodeScalars.contains(where: { $0.value < 0x20 }) { + throw IosFsCoreError.invalidRelativePath + } + return name +} + public func childURL(baseURL: URL, relativePath: String) throws -> URL { let parts = relativePath .split(separator: "/", omittingEmptySubsequences: true) .map(String.init) - if parts.contains("..") || relativePath.hasPrefix("/") { + if relativePath.hasPrefix("/") || relativePath.contains("\\") || parts.contains(where: { part in + part == "." || part == ".." || part.unicodeScalars.contains(where: { $0.value < 0x20 }) + }) { throw IosFsCoreError.invalidRelativePath } diff --git a/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift b/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift index 7e81eef..6cdb6f1 100644 --- a/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift +++ b/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift @@ -311,7 +311,7 @@ final class VnidropFsPlugin: Plugin { @objc public func showSaveFilePicker(_ invoke: Invoke) throws { let args = try invoke.parseArgs(SaveFilePickerArgs.self) - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(args.defaultFileName) + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(try validateFileName(args.defaultFileName)) _ = FileManager.default.createFile(atPath: tmp.path, contents: Data()) presentExportPicker(invoke: invoke, url: tmp, cancelValue: Optional.none) { urls in urls.first.map { self.persist(url: $0) } @@ -322,7 +322,7 @@ final class VnidropFsPlugin: Plugin { let args = try invoke.parseArgs(RenameArgs.self) run(invoke) { let resolved = try self.resolve(.uri(args.uri)) - let dest = resolved.url.deletingLastPathComponent().appendingPathComponent(args.newName) + let dest = resolved.url.deletingLastPathComponent().appendingPathComponent(try validateFileName(args.newName)) try self.withAccess(resolved.url) { try FileManager.default.moveItem(at: resolved.url, to: dest) } @@ -355,19 +355,25 @@ final class VnidropFsPlugin: Plugin { switch input { case .string(let value): let url = URL(string: value) ?? URL(fileURLWithPath: value) + guard isAppLocalFileURL(url) else { + throw FsError("raw iOS file paths are limited to the app container; use a picker IosFsUri or security-scoped bookmark for external files") + } return (url, nil) case .uri(let uri): if let id = uri.bookmarkId, let data = store.data(for: id) { var stale = false let url = try URL(resolvingBookmarkData: data, options: [], relativeTo: nil, bookmarkDataIsStale: &stale) if stale { - _ = persist(url: url) + _ = persist(url: url, bookmarkId: id) } return (url, uri) } guard let url = URL(string: uri.uri) else { throw FsError("invalid URL") } + guard isAppLocalFileURL(url) else { + throw FsError("external iOS URLs require a security-scoped bookmark") + } return (url, uri) } } @@ -378,15 +384,18 @@ final class VnidropFsPlugin: Plugin { } var stale = false let url = try URL(resolvingBookmarkData: data, options: [], relativeTo: nil, bookmarkDataIsStale: &stale) - let uri = stale ? persist(url: url) : IosFsUri(uri: url.absoluteString, bookmarkId: id, isDirectory: url.hasDirectoryPath) + let uri = stale ? persist(url: url, bookmarkId: id) : IosFsUri(uri: url.absoluteString, bookmarkId: id, isDirectory: url.hasDirectoryPath) return (url, uri) } - private func persist(url: URL) -> IosFsUri { + private func persist(url: URL, bookmarkId: String? = nil) -> IosFsUri { do { let accessed = url.startAccessingSecurityScopedResource() defer { if accessed { url.stopAccessingSecurityScopedResource() } } let data = try url.bookmarkData(options: [], includingResourceValuesForKeys: nil, relativeTo: nil) + if let bookmarkId { + return store.save(url: url, bookmarkData: data, id: bookmarkId) + } return store.save(url: url, bookmarkData: data) } catch { return IosFsUri(uri: url.absoluteString, bookmarkId: nil, isDirectory: url.hasDirectoryPath) @@ -399,6 +408,19 @@ final class VnidropFsPlugin: Plugin { return try body() } + private func isAppLocalFileURL(_ url: URL) -> Bool { + guard url.isFileURL else { return false } + let candidate = url.standardizedFileURL.path + let roots = [ + NSHomeDirectory(), + FileManager.default.temporaryDirectory.path + ].map { URL(fileURLWithPath: $0).standardizedFileURL.path } + + return roots.contains { root in + candidate == root || candidate.hasPrefix(root.hasSuffix("/") ? root : "\(root)/") + } + } + private func childUrl(base: IosFsUri, relativePath: String) throws -> URL { let resolved = try resolve(.uri(base)) return try childURL(baseURL: resolved.url, relativePath: relativePath) diff --git a/ios/Tests/VnidropFsCoreTests/VnidropFsCoreTests.swift b/ios/Tests/VnidropFsCoreTests/VnidropFsCoreTests.swift index 5009ff3..c604848 100644 --- a/ios/Tests/VnidropFsCoreTests/VnidropFsCoreTests.swift +++ b/ios/Tests/VnidropFsCoreTests/VnidropFsCoreTests.swift @@ -2,12 +2,6 @@ import XCTest @testable import VnidropFsCore final class VnidropFsCoreTests: XCTestCase { - func testStableBookmarkIdIsDeterministic() { - let url = URL(fileURLWithPath: "/tmp/Vnidrop Test/report.txt") - - XCTAssertEqual(stableBookmarkId(for: url), stableBookmarkId(for: url)) - } - func testUniqueCandidateAddsSuffixBeforeExtension() { let base = URL(fileURLWithPath: "/tmp/report.txt") let taken = Set([ @@ -23,6 +17,17 @@ final class VnidropFsCoreTests: XCTestCase { func testChildURLRejectsUnsafeRelativePath() { XCTAssertThrowsError(try childURL(baseURL: URL(fileURLWithPath: "/tmp"), relativePath: "../secret.txt")) XCTAssertThrowsError(try childURL(baseURL: URL(fileURLWithPath: "/tmp"), relativePath: "/secret.txt")) + XCTAssertThrowsError(try childURL(baseURL: URL(fileURLWithPath: "/tmp"), relativePath: "safe/../secret.txt")) + XCTAssertThrowsError(try childURL(baseURL: URL(fileURLWithPath: "/tmp"), relativePath: "safe\\secret.txt")) + } + + func testValidateFileNameRejectsPathComponents() { + XCTAssertEqual(try validateFileName("report.txt"), "report.txt") + XCTAssertThrowsError(try validateFileName("")) + XCTAssertThrowsError(try validateFileName(".")) + XCTAssertThrowsError(try validateFileName("..")) + XCTAssertThrowsError(try validateFileName("../report.txt")) + XCTAssertThrowsError(try validateFileName("nested/report.txt")) } func testBookmarkStorePersistsBase64Data() { @@ -40,6 +45,19 @@ final class VnidropFsCoreTests: XCTestCase { XCTAssertEqual(store.bookmarkIds(), []) } + func testBookmarkStoreUsesRandomIdsByDefault() { + let suite = "plugin.vnidrop.fs.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = SecurityScopedBookmarkStore(defaults: defaults) + let url = URL(fileURLWithPath: "/tmp/report.txt") + + let first = store.save(url: url, bookmarkData: Data([1])) + let second = store.save(url: url, bookmarkData: Data([2])) + + XCTAssertNotEqual(first.bookmarkId, second.bookmarkId) + } + func testMimeTypeMappingFallsBackToOctetStream() { XCTAssertEqual(mimeType(for: URL(fileURLWithPath: "/tmp/photo.JPG")), "image/jpeg") XCTAssertEqual(mimeType(for: URL(fileURLWithPath: "/tmp/report.pdf")), "application/pdf") diff --git a/src/lib.rs b/src/lib.rs index 42b7148..7b6dfc9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,7 +55,7 @@ pub fn init() -> tauri::plugin::TauriPlugin; pub fn new_config_state>( config: Option<&config::Config>, manager: &M, -) -> ProtocolConfigStateInner { +) -> crate::Result { - std::sync::Arc::new(ProtocolsConfig { + Ok(std::sync::Arc::new(ProtocolsConfig { #[cfg(feature = "protocol_thumbnail")] thumbnail: ThumbnailProtocolConfig { scope: config.as_ref().and_then(|c| tauri::scope::fs::Scope::new( manager, &c.thumbnail_protocol.scope, - ).ok()), + ).transpose()?), enable: config.as_ref().map(|c| c.thumbnail_protocol.enable).unwrap_or(false), }, #[cfg(feature = "protocol_content")] @@ -23,10 +23,10 @@ pub fn new_config_state>( scope: config.as_ref().and_then(|c| tauri::scope::fs::Scope::new( manager, &c.content_protocol.scope, - ).ok()), + ).transpose()?), enable: config.as_ref().map(|c| c.content_protocol.enable).unwrap_or(false), }, - }) + })) } pub struct ProtocolsConfig { @@ -47,4 +47,4 @@ pub struct ContentProtocolConfig { pub struct ThumbnailProtocolConfig { pub scope: Option, pub enable: bool, -} \ No newline at end of file +} From 854502cd919f4a98605829a8043bbf77d1280b8b Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 16 Jun 2026 12:21:33 +0200 Subject: [PATCH 2/4] Document filesystem security model --- README.md | 27 ++++++++++++++++++++++++++- guest-js/ios.ts | 17 +++++++++-------- ios/README.md | 10 ++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e18545e..24207f3 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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: diff --git a/guest-js/ios.ts b/guest-js/ios.ts index d447bab..198efa8 100644 --- a/guest-js/ios.ts +++ b/guest-js/ios.ts @@ -146,14 +146,15 @@ export async function releaseSecurityScopedBookmark(bookmarkId: string): Promise * Persists a security-scoped bookmark for an iOS URL. * * Picker results are already persisted. Call this for URLs obtained through - * other trusted flows when you need future access. + * other trusted flows when you need future access. Raw string paths are limited + * to app-container file URLs; external documents should come from a picker. */ export async function persistSecurityScopedBookmark(uri: IosFsUri | FsPath): Promise { return invoke('plugin:vnidrop-fs|persistSecurityScopedBookmark', { uri: mapFsPathForInput(uri) }) } /** - * Reads an iOS file as bytes. + * Reads an iOS app-local file URL or picker URI as bytes. */ export async function readFile(uri: IosFsUri | FsPath): Promise> { const bytes = await invoke('plugin:vnidrop-fs|readFile', { uri: mapFsPathForInput(uri) }) @@ -161,14 +162,14 @@ export async function readFile(uri: IosFsUri | FsPath): Promise { return invoke('plugin:vnidrop-fs|readTextFile', { uri: mapFsPathForInput(uri), encoding: options?.encoding ?? null }) } /** - * Writes bytes to an iOS file URL or picker URI. + * Writes bytes to an iOS app-local file URL or picker URI. */ export async function writeFile( uri: IosFsUri | FsPath, @@ -183,7 +184,7 @@ export async function writeFile( } /** - * Writes text to an iOS file URL or picker URI. + * Writes text to an iOS app-local file URL or picker URI. */ export async function writeTextFile( uri: IosFsUri | FsPath, @@ -239,7 +240,7 @@ export async function createNewDir(baseDirUri: IosFsUri, relativePath: string): } /** - * Copies one iOS file URL or picker URI to another file URL or picker URI. + * Copies between iOS app-local file URLs and/or picker URIs. */ export async function copyFile(srcPath: IosFsUri | FsPath, destPath: IosFsUri | FsPath): Promise { return invoke('plugin:vnidrop-fs|copyFile', { @@ -285,14 +286,14 @@ export async function removeDirAll(uri: IosFsUri): Promise { } /** - * Checks whether an iOS file URL or picker URI exists. + * Checks whether an iOS app-local file URL or picker URI exists. */ export async function exists(uri: IosFsUri | FsPath): Promise { return invoke('plugin:vnidrop-fs|exists', { uri: mapFsPathForInput(uri) }) } /** - * Reads metadata for an iOS file URL or picker URI. + * Reads metadata for an iOS app-local file URL or picker URI. */ export async function getMetadata(uri: IosFsUri | FsPath): Promise { const entry = await invoke('plugin:vnidrop-fs|getMetadata', { uri: mapFsPathForInput(uri) }) diff --git a/ios/README.md b/ios/README.md index deecbef..a95b147 100644 --- a/ios/README.md +++ b/ios/README.md @@ -7,6 +7,16 @@ It implements the shared filesystem API for files and folders returned by `UIDocumentPickerViewController`, opens external documents in place, and stores security-scoped bookmarks in a namespaced `UserDefaults` store. +Security notes: + +- Raw string paths are accepted only for app-container `file://` locations. +- External document-provider files and folders must use picker/bookmark-backed + `IosFsUri` values. +- Bookmark IDs are random, and bookmark data in `UserDefaults` is app-local + access state, not a secret store. +- Save-picker default names and rename targets are validated as single filename + components. + Run the pure Swift core tests with: ```sh From e04ee100733929131fa7d6eee0b65155d1bae406 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 16 Jun 2026 12:21:37 +0200 Subject: [PATCH 3/4] Refresh dependency and build tooling baselines --- Cargo.toml | 20 +++++++++--------- android/build.gradle.kts | 21 +++++++++++-------- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle | 4 ++-- ios/Package.swift | 3 ++- package-lock.json | 20 +++++++++--------- package.json | 16 +++++++------- tsconfig.json | 1 + 8 files changed, 46 insertions(+), 41 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 65a3fae..4bcdd32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 6da19c8..4d728a9 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -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")) } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 1ef00a1..df6a6ad 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle b/android/settings.gradle index 1a56032..a9f43dc 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -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 } } diff --git a/ios/Package.swift b/ios/Package.swift index 7b133f3..c596d6e 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -2,7 +2,8 @@ import Foundation import PackageDescription -let tauriApiPath = FileManager.default.fileExists(atPath: "../.tauri/tauri-api/Package.swift") +let useTauriStub = ProcessInfo.processInfo.environment["VNIDROP_FS_USE_TAURI_STUB"] == "1" +let tauriApiPath = !useTauriStub && FileManager.default.fileExists(atPath: "../.tauri/tauri-api/Package.swift") ? "../.tauri/tauri-api" : "test-support/tauri-api" diff --git a/package-lock.json b/package-lock.json index 6bf55e9..549b171 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,16 @@ "version": "0.1.0", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.0.0", + "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.1", - "@tauri-apps/plugin-fs": "^2.0.0", - "create-web-stream": "^1.1.1" + "@tauri-apps/plugin-fs": "^2.5.1", + "create-web-stream": "^1.1.3" }, "devDependencies": { - "@rollup/plugin-typescript": "^12.0.0", - "rollup": "^4.9.6", - "tslib": "^2.6.2", - "typescript": "^5.3.3", + "@rollup/plugin-typescript": "^12.3.0", + "rollup": "^4.62.0", + "tslib": "^2.8.1", + "typescript": "^6.0.3", "vitest": "^4.1.9" } }, @@ -1666,9 +1666,9 @@ "license": "0BSD" }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 00d0196..a7b2979 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "test": "vitest run", "test:android:jvm": "cd android && ./gradlew testDebugUnitTest", "test:android:connected": "cd android && ./gradlew connectedDebugAndroidTest", - "test:ios": "cd ios && swift test", + "test:ios": "cd ios && VNIDROP_FS_USE_TAURI_STUB=1 swift test", "test:types": "tsc -p tsconfig.test.json --noEmit", "example:install": "cd examples/tauri-app && npm ci", "example:build": "cd examples/tauri-app && npm run build", @@ -54,16 +54,16 @@ "check": "npm run build && npm run test && npm run test:types && cargo check --all-features && cargo test" }, "dependencies": { - "@tauri-apps/api": "^2.0.0", + "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.1", - "@tauri-apps/plugin-fs": "^2.0.0", - "create-web-stream": "^1.1.1" + "@tauri-apps/plugin-fs": "^2.5.1", + "create-web-stream": "^1.1.3" }, "devDependencies": { - "@rollup/plugin-typescript": "^12.0.0", - "rollup": "^4.9.6", - "tslib": "^2.6.2", - "typescript": "^5.3.3", + "@rollup/plugin-typescript": "^12.3.0", + "rollup": "^4.62.0", + "tslib": "^2.8.1", + "typescript": "^6.0.3", "vitest": "^4.1.9" } } diff --git a/tsconfig.json b/tsconfig.json index 0591122..a4862a1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "strict": true, "noUnusedLocals": true, "noImplicitAny": true, + "rootDir": ".", "noEmit": true }, "include": ["guest-js/*.ts"], From c427cece69f81c3fe4c825fe2b26be743b723e97 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Tue, 16 Jun 2026 12:27:24 +0200 Subject: [PATCH 4/4] Prepare release candidate package metadata --- Cargo.toml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- rollup.config.js | 1 + tsconfig.build.json | 11 +++++++++++ 5 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 tsconfig.build.json diff --git a/Cargo.toml b/Cargo.toml index 4bcdd32..3a08b07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/package-lock.json b/package-lock.json index 549b171..6d4aa89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vnidrop/tauri-plugin-fs", - "version": "0.1.0", + "version": "1.0.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vnidrop/tauri-plugin-fs", - "version": "0.1.0", + "version": "1.0.0-rc.1", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.11.0", diff --git a/package.json b/package.json index a7b2979..897ef65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vnidrop/tauri-plugin-fs", - "version": "0.1.0", + "version": "1.0.0-rc.1", "author": "AbassHammed", "description": "Cross-platform filesystem manager for Tauri with Android SAF and iOS document picker support.", "keywords": [ diff --git a/rollup.config.js b/rollup.config.js index a83b24f..4baed28 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -25,6 +25,7 @@ export default { ], plugins: [ typescript({ + tsconfig: './tsconfig.build.json', declaration: true, declarationDir: dirname(pkg.exports['.'].import) }) diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..5b87bd1 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "guest-js", + "noEmit": false, + "declaration": true, + "declarationDir": "dist-js" + }, + "include": ["guest-js/*.ts"], + "exclude": ["dist-js", "node_modules"] +}