From 9f5a048dd8a3e936d0e10fdcf73dca61749c5e5f Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 17 Jun 2026 17:27:47 +0200 Subject: [PATCH 1/2] Add Rust directory filesystem facade --- .../VnidropFsPlugin/VnidropFsPlugin.swift | 28 + src/fs.rs | 854 +++++++++++++++++- src/lib.rs | 5 + tests/rust_backend_streams.rs | 185 +++- 4 files changed, 1010 insertions(+), 62 deletions(-) diff --git a/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift b/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift index 2d52675..8ab3261 100644 --- a/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift +++ b/ios/Sources/VnidropFsPlugin/VnidropFsPlugin.swift @@ -327,6 +327,34 @@ final class VnidropFsPlugin: Plugin { } } + @objc public func resolveFile(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(BaseDirRelativePathArgs.self) + run(invoke) { + let url = try self.childUrl(base: args.baseDirUri, relativePath: args.relativePath) + return try self.withAccess(try self.resolve(.uri(args.baseDirUri)).url) { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + throw FsError("file does not exist") + } + return self.persist(url: url) + } + } + } + + @objc public func resolveDir(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(BaseDirRelativePathArgs.self) + run(invoke) { + let url = try self.childUrl(base: args.baseDirUri, relativePath: args.relativePath) + return try self.withAccess(try self.resolve(.uri(args.baseDirUri)).url) { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue else { + throw FsError("directory does not exist") + } + return self.persist(url: url) + } + } + } + @objc public func createNewFile(_ invoke: Invoke) throws { let args = try invoke.parseArgs(CreateNewFileArgs.self) run(invoke) { diff --git a/src/fs.rs b/src/fs.rs index 410d1e2..03ebdfe 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -2,11 +2,13 @@ use std::{ io::{Read, Seek, SeekFrom, Write}, marker::PhantomData, path::{Path, PathBuf}, + time::SystemTime, }; -#[cfg(target_os = "ios")] -use serde::Serialize; +use serde::{Deserialize, Serialize}; +#[cfg(target_os = "android")] +use crate::Entry; use crate::{Error, FileUri, IosFsUri, Result}; const DEFAULT_COPY_BUFFER_LEN: usize = 1024 * 1024; @@ -16,7 +18,8 @@ const DEFAULT_COPY_BUFFER_LEN: usize = 1024 * 1024; /// Use filesystem paths for desktop and app-container files. Use [`FileUri`] /// for Android SAF/content targets. Use [`IosFsUri`] for external iOS files /// selected through Vnidrop FS pickers or bookmark helpers. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] pub enum VnidropFsTarget { /// Local filesystem path. Path(PathBuf), @@ -28,6 +31,67 @@ pub enum VnidropFsTarget { IosUri(IosFsUri), } +/// Directory target accepted by the Rust backend API. +/// +/// Directory targets are intentionally separate from file targets. Mobile +/// providers often give different capabilities to directory and file URIs, and +/// keeping them separate prevents callers from accidentally treating a folder as +/// an openable file stream. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum VnidropDirTarget { + /// Local filesystem directory path. + Path(PathBuf), + + /// Android directory/content URI. + AndroidUri(FileUri), + + /// iOS directory URL with optional security-scoped bookmark metadata. + IosUri(IosFsUri), +} + +/// A filesystem entry target returned by Rust-side directory APIs. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum VnidropEntryTarget { + File(VnidropFsTarget), + Dir(VnidropDirTarget), +} + +/// Portable entry kind used by the Rust backend API. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum VnidropEntryKind { + File, + Dir, +} + +/// Metadata for a file or directory target. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VnidropEntryInfo { + pub target: VnidropEntryTarget, + pub name: String, + pub kind: VnidropEntryKind, + pub len: Option, + pub mime_type: Option, + pub last_modified: Option, +} + +/// Directory entry returned by [`VnidropFs::read_dir`] and +/// [`VnidropFs::walk_dir_recursive`]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VnidropDirEntry { + pub target: VnidropEntryTarget, + pub relative_path: PathBuf, + pub name: String, + pub kind: VnidropEntryKind, + pub len: Option, + pub mime_type: Option, + pub last_modified: Option, +} + impl From for VnidropFsTarget { fn from(value: PathBuf) -> Self { Self::Path(value) @@ -70,8 +134,86 @@ impl From<&IosFsUri> for VnidropFsTarget { } } +impl From for VnidropDirTarget { + fn from(value: PathBuf) -> Self { + Self::Path(value) + } +} + +impl From<&Path> for VnidropDirTarget { + fn from(value: &Path) -> Self { + Self::Path(value.to_path_buf()) + } +} + +impl From<&PathBuf> for VnidropDirTarget { + fn from(value: &PathBuf) -> Self { + Self::Path(value.clone()) + } +} + +impl From for VnidropDirTarget { + fn from(value: FileUri) -> Self { + Self::AndroidUri(value) + } +} + +impl From<&FileUri> for VnidropDirTarget { + fn from(value: &FileUri) -> Self { + Self::AndroidUri(value.clone()) + } +} + +impl From for VnidropDirTarget { + fn from(value: IosFsUri) -> Self { + Self::IosUri(value) + } +} + +impl From<&IosFsUri> for VnidropDirTarget { + fn from(value: &IosFsUri) -> Self { + Self::IosUri(value.clone()) + } +} + +impl From for VnidropEntryTarget { + fn from(value: VnidropFsTarget) -> Self { + Self::File(value) + } +} + +impl From for VnidropEntryTarget { + fn from(value: VnidropDirTarget) -> Self { + Self::Dir(value) + } +} + +impl VnidropEntryTarget { + pub fn kind(&self) -> VnidropEntryKind { + match self { + Self::File(_) => VnidropEntryKind::File, + Self::Dir(_) => VnidropEntryKind::Dir, + } + } + + pub fn file_target(&self) -> Option<&VnidropFsTarget> { + match self { + Self::File(target) => Some(target), + Self::Dir(_) => None, + } + } + + pub fn dir_target(&self) -> Option<&VnidropDirTarget> { + match self { + Self::File(_) => None, + Self::Dir(target) => Some(target), + } + } +} + /// Controls how the Rust backend API opens a file for writing. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct VnidropOpenWriteOptions { /// Creates the file when it does not already exist. pub create: bool, @@ -200,12 +342,9 @@ impl VnidropFs { /// Replaces a target with the provided bytes. /// /// Prefer [`VnidropFs::open_write`] for large files. - pub fn write( - &self, - target: impl Into, - data: impl AsRef<[u8]>, - ) -> Result<()> { - let mut writer = self.open_write(target, VnidropOpenWriteOptions::default().create(true))?; + pub fn write(&self, target: impl Into, data: impl AsRef<[u8]>) -> Result<()> { + let mut writer = + self.open_write(target, VnidropOpenWriteOptions::default().create(true))?; writer.write_all(data.as_ref())?; writer.flush()?; Ok(()) @@ -218,15 +357,89 @@ impl VnidropFs { destination: impl Into, ) -> Result { let mut reader = self.open_read(source)?; - let mut writer = self.open_write( - destination, - VnidropOpenWriteOptions::default().create(true), - )?; + let mut writer = + self.open_write(destination, VnidropOpenWriteOptions::default().create(true))?; let copied = copy_with_buffer(&mut reader, &mut writer, DEFAULT_COPY_BUFFER_LEN)?; writer.flush()?; Ok(copied) } + /// Returns metadata for a file or directory target. + pub fn entry_info(&self, target: impl Into) -> Result { + match target.into() { + VnidropEntryTarget::File(target) => self.file_entry_info(target), + VnidropEntryTarget::Dir(target) => self.dir_entry_info(target), + } + } + + /// Lists immediate children of a directory target. + pub fn read_dir(&self, target: impl Into) -> Result> { + self.read_dir_with_prefix(target.into(), Path::new("")) + } + + /// Recursively visits all descendants of a directory target. + /// + /// This uses a visitor instead of returning a prebuilt recursive vector so + /// large directory trees can be streamed into application logic without + /// retaining every entry in memory. + pub fn walk_dir_recursive( + &self, + target: impl Into, + mut visitor: F, + ) -> Result<()> + where + F: FnMut(VnidropDirEntry) -> Result<()>, + { + self.walk_dir_recursive_inner(target.into(), PathBuf::new(), &mut visitor) + } + + /// Creates a directory and any missing parents under a directory target. + pub fn create_dir_all( + &self, + base: impl Into, + relative_path: impl AsRef, + ) -> Result { + let relative_path = validate_backend_relative_path(relative_path.as_ref())?; + match base.into() { + VnidropDirTarget::Path(path) => { + let target = path.join(relative_path); + std::fs::create_dir_all(&target)?; + Ok(VnidropDirTarget::Path(target)) + } + VnidropDirTarget::AndroidUri(uri) => self.create_android_dir_all(uri, relative_path), + VnidropDirTarget::IosUri(uri) => self.create_ios_dir_all(uri, relative_path), + } + } + + /// Opens a file under a directory target for writing. + /// + /// Parent directories are created before the file is opened. On providers + /// that sanitize names or avoid collisions, the returned writer points to + /// the provider-created target. + pub fn open_write_relative( + &self, + base: impl Into, + relative_path: impl AsRef, + options: VnidropOpenWriteOptions, + ) -> Result> { + let relative_path = validate_backend_relative_path(relative_path.as_ref())?; + match base.into() { + VnidropDirTarget::Path(path) => { + let target = path.join(relative_path); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + self.open_path_write(target, options) + } + VnidropDirTarget::AndroidUri(uri) => { + self.open_android_write_relative(uri, relative_path, options) + } + VnidropDirTarget::IosUri(uri) => { + self.open_ios_write_relative(uri, relative_path, options) + } + } + } + fn open_path_read(&self, path: PathBuf) -> Result> { let file = std::fs::File::open(path)?; Ok(VnidropFileReader::from_std(file)) @@ -252,7 +465,10 @@ impl VnidropFs { fn open_android_read(&self, uri: FileUri) -> Result> { #[cfg(target_os = "android")] { - return self.android.open_file_readable(&uri).map(VnidropFileReader::from_std); + return self + .android + .open_file_readable(&uri) + .map(VnidropFileReader::from_std); } #[cfg(not(target_os = "android"))] @@ -300,12 +516,13 @@ impl VnidropFs { fn open_ios_read(&self, uri: IosFsUri) -> Result> { #[cfg(target_os = "ios")] { - let id = self - .handle - .run_mobile_plugin::("openReadFileStream", IosOpenReadFileStreamArgs { + let id = self.handle.run_mobile_plugin::( + "openReadFileStream", + IosOpenReadFileStreamArgs { uri: IosStreamTarget::Uri(uri), offset: None, - })?; + }, + )?; return Ok(VnidropFileReader::from_ios(self.handle.clone(), id)); } @@ -326,15 +543,16 @@ impl VnidropFs { ) -> Result> { #[cfg(target_os = "ios")] { - let id = self - .handle - .run_mobile_plugin::("openWriteFileStream", IosOpenWriteFileStreamArgs { + let id = self.handle.run_mobile_plugin::( + "openWriteFileStream", + IosOpenWriteFileStreamArgs { uri: IosStreamTarget::Uri(uri), create: options.create, append: options.append, truncate: options.truncate, offset: options.offset, - })?; + }, + )?; return Ok(VnidropFileWriter::from_ios(self.handle.clone(), id)); } @@ -347,6 +565,326 @@ impl VnidropFs { } } } + + fn file_entry_info(&self, target: VnidropFsTarget) -> Result { + match target { + VnidropFsTarget::Path(path) => self.path_entry_info(path, VnidropEntryKind::File), + VnidropFsTarget::AndroidUri(uri) => { + self.android_entry_info(uri, VnidropEntryKind::File) + } + VnidropFsTarget::IosUri(uri) => self.ios_entry_info(uri, VnidropEntryKind::File), + } + } + + fn dir_entry_info(&self, target: VnidropDirTarget) -> Result { + match target { + VnidropDirTarget::Path(path) => self.path_entry_info(path, VnidropEntryKind::Dir), + VnidropDirTarget::AndroidUri(uri) => { + self.android_entry_info(uri, VnidropEntryKind::Dir) + } + VnidropDirTarget::IosUri(uri) => self.ios_entry_info(uri, VnidropEntryKind::Dir), + } + } + + fn path_entry_info( + &self, + path: PathBuf, + expected: VnidropEntryKind, + ) -> Result { + let metadata = std::fs::metadata(&path)?; + let kind = metadata_to_kind(&metadata); + ensure_expected_kind(kind, expected, &path)?; + Ok(VnidropEntryInfo { + target: entry_target_for_path(path.clone(), kind), + name: path_file_name(&path), + kind, + len: metadata.is_file().then_some(metadata.len()), + mime_type: None, + last_modified: metadata.modified().ok(), + }) + } + + fn android_entry_info( + &self, + uri: FileUri, + expected: VnidropEntryKind, + ) -> Result { + #[cfg(target_os = "android")] + { + let entry = self.android.get_info(&uri)?; + return entry_to_info_android(entry, Some(expected)); + } + + #[cfg(not(target_os = "android"))] + { + if let Some(path) = uri.to_path() { + return self.path_entry_info(path, expected); + } + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + + fn ios_entry_info( + &self, + uri: IosFsUri, + expected: VnidropEntryKind, + ) -> Result { + #[cfg(target_os = "ios")] + { + let entry = self.handle.run_mobile_plugin::( + "getMetadata", + IosUriArg { + uri: IosFsUriOrString::Uri(uri), + }, + )?; + return ios_entry_to_info(entry, Some(expected)); + } + + #[cfg(not(target_os = "ios"))] + { + if let Some(path) = ios_file_url_to_path(&uri.uri) { + return self.path_entry_info(path, expected); + } + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + + fn read_dir_with_prefix( + &self, + target: VnidropDirTarget, + prefix: &Path, + ) -> Result> { + match target { + VnidropDirTarget::Path(path) => self.read_path_dir(path, prefix), + VnidropDirTarget::AndroidUri(uri) => self.read_android_dir(uri, prefix), + VnidropDirTarget::IosUri(uri) => self.read_ios_dir(uri, prefix), + } + } + + fn walk_dir_recursive_inner( + &self, + target: VnidropDirTarget, + prefix: PathBuf, + visitor: &mut F, + ) -> Result<()> + where + F: FnMut(VnidropDirEntry) -> Result<()>, + { + for entry in self.read_dir_with_prefix(target, &prefix)? { + let next_dir = match &entry.target { + VnidropEntryTarget::Dir(dir) => Some((dir.clone(), entry.relative_path.clone())), + VnidropEntryTarget::File(_) => None, + }; + visitor(entry)?; + if let Some((dir, relative_path)) = next_dir { + self.walk_dir_recursive_inner(dir, relative_path, visitor)?; + } + } + Ok(()) + } + + fn read_path_dir(&self, path: PathBuf, prefix: &Path) -> Result> { + let mut entries = Vec::new(); + for child in std::fs::read_dir(path)? { + let child = child?; + let metadata = child.metadata()?; + let kind = metadata_to_kind(&metadata); + let name = child.file_name().to_string_lossy().into_owned(); + let relative_path = prefix.join(&name); + entries.push(VnidropDirEntry { + target: entry_target_for_path(child.path(), kind), + relative_path, + name, + kind, + len: metadata.is_file().then_some(metadata.len()), + mime_type: None, + last_modified: metadata.modified().ok(), + }); + } + Ok(entries) + } + + fn read_android_dir(&self, uri: FileUri, prefix: &Path) -> Result> { + #[cfg(target_os = "android")] + { + return self + .android + .read_dir(&uri)? + .into_iter() + .map(|entry| entry_to_dir_entry_android(entry, prefix)) + .collect(); + } + + #[cfg(not(target_os = "android"))] + { + if let Some(path) = uri.to_path() { + return self.read_path_dir(path, prefix); + } + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + + fn read_ios_dir(&self, uri: IosFsUri, prefix: &Path) -> Result> { + #[cfg(target_os = "ios")] + { + return self + .handle + .run_mobile_plugin::>( + "readDir", + IosReadDirArgs { + uri, + offset: None, + limit: None, + }, + )? + .into_iter() + .map(|entry| ios_entry_to_dir_entry(entry, prefix)) + .collect(); + } + + #[cfg(not(target_os = "ios"))] + { + if let Some(path) = ios_file_url_to_path(&uri.uri) { + return self.read_path_dir(path, prefix); + } + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + + fn create_android_dir_all( + &self, + uri: FileUri, + relative_path: &Path, + ) -> Result { + #[cfg(target_os = "android")] + { + return self + .android + .create_dir_all(&uri, relative_path) + .map(VnidropDirTarget::AndroidUri); + } + + #[cfg(not(target_os = "android"))] + { + if let Some(path) = uri.to_path() { + let target = path.join(relative_path); + std::fs::create_dir_all(&target)?; + return Ok(VnidropDirTarget::Path(target)); + } + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + + fn create_ios_dir_all(&self, uri: IosFsUri, relative_path: &Path) -> Result { + #[cfg(target_os = "ios")] + { + return self + .handle + .run_mobile_plugin::( + "createDir", + IosBaseDirRelativePathArgs { + base_dir_uri: uri, + relative_path: relative_path_to_string(relative_path), + }, + ) + .map(VnidropDirTarget::IosUri) + .map_err(Into::into); + } + + #[cfg(not(target_os = "ios"))] + { + if let Some(path) = ios_file_url_to_path(&uri.uri) { + let target = path.join(relative_path); + std::fs::create_dir_all(&target)?; + return Ok(VnidropDirTarget::Path(target)); + } + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + + fn open_android_write_relative( + &self, + uri: FileUri, + relative_path: &Path, + options: VnidropOpenWriteOptions, + ) -> Result> { + #[cfg(target_os = "android")] + { + let file_uri = match self.android.resolve_file_uri(&uri, relative_path) { + Ok(existing) => existing, + Err(err) if options.create => { + let _ = err; + self.android.create_new_file(&uri, relative_path, None)? + } + Err(err) => return Err(err), + }; + return self.open_android_write(file_uri, options); + } + + #[cfg(not(target_os = "android"))] + { + if let Some(path) = uri.to_path() { + let target = path.join(relative_path); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + return self.open_path_write(target, options); + } + Err(Error::invalid_uri_scheme(uri.uri)) + } + } + + fn open_ios_write_relative( + &self, + uri: IosFsUri, + relative_path: &Path, + options: VnidropOpenWriteOptions, + ) -> Result> { + #[cfg(target_os = "ios")] + { + if let Some(parent) = relative_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + { + let _ = self.create_ios_dir_all(uri.clone(), parent)?; + } + let relative_path = relative_path_to_string(relative_path); + let file_uri = match self.handle.run_mobile_plugin::( + "resolveFile", + IosBaseDirRelativePathArgs { + base_dir_uri: uri.clone(), + relative_path: relative_path.clone(), + }, + ) { + Ok(existing) => existing, + Err(err) if options.create => { + let _ = err; + self.handle.run_mobile_plugin::( + "createNewFile", + IosCreateNewFileArgs { + base_dir_uri: uri, + relative_path, + mime_type: None, + }, + )? + } + Err(err) => return Err(err.into()), + }; + return self.open_ios_write(file_uri, options); + } + + #[cfg(not(target_os = "ios"))] + { + if let Some(path) = ios_file_url_to_path(&uri.uri) { + let target = path.join(relative_path); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + return self.open_path_write(target, options); + } + Err(Error::invalid_uri_scheme(uri.uri)) + } + } } /// Reader returned by [`VnidropFs::open_read`]. @@ -482,12 +1020,13 @@ impl IosReadStream { return Ok(0); } - let bytes = self - .handle - .run_mobile_plugin::>("readFileStreamChunk", IosReadChunkArgs { + let bytes = self.handle.run_mobile_plugin::>( + "readFileStreamChunk", + IosReadChunkArgs { id: self.id, length: buf.len(), - })?; + }, + )?; let len = bytes.len(); buf[..len].copy_from_slice(&bytes); Ok(len) @@ -531,10 +1070,7 @@ impl IosWriteStream { fn write_all(&self, data: &[u8]) -> Result<()> { self.handle.run_mobile_plugin::<()>( "writeFileStreamChunk", - IosWriteChunkArgs { - id: self.id, - data, - }, + IosWriteChunkArgs { id: self.id, data }, )?; Ok(()) } @@ -568,6 +1104,15 @@ impl Drop for IosWriteStream { #[serde(untagged)] enum IosStreamTarget { Uri(IosFsUri), + Path(String), +} + +#[cfg(target_os = "ios")] +#[derive(Serialize)] +#[serde(untagged)] +enum IosFsUriOrString { + Uri(IosFsUri), + Path(String), } #[cfg(target_os = "ios")] @@ -609,6 +1154,57 @@ struct IosWriteChunkArgs<'a> { data: &'a [u8], } +#[cfg(target_os = "ios")] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct IosUriArg { + uri: IosFsUriOrString, +} + +#[cfg(target_os = "ios")] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct IosReadDirArgs { + uri: IosFsUri, + offset: Option, + limit: Option, +} + +#[cfg(target_os = "ios")] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct IosBaseDirRelativePathArgs { + base_dir_uri: IosFsUri, + relative_path: String, +} + +#[cfg(target_os = "ios")] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct IosCreateNewFileArgs { + base_dir_uri: IosFsUri, + relative_path: String, + mime_type: Option, +} + +#[cfg(target_os = "ios")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +enum IosEntry { + File { + uri: IosFsUri, + name: String, + last_modified: f64, + byte_length: u64, + mime_type: String, + }, + Dir { + uri: IosFsUri, + name: String, + last_modified: f64, + }, +} + fn copy_with_buffer( reader: &mut R, writer: &mut W, @@ -627,6 +1223,202 @@ fn copy_with_buffer( } } +fn validate_backend_relative_path(path: &Path) -> Result<&Path> { + if path.as_os_str().is_empty() { + return Err(Error::with("relative path must not be empty")); + } + + for component in path.components() { + use std::path::Component::*; + + match component { + Prefix(_) | RootDir => return Err(Error::with("relative path must not be absolute")), + ParentDir => return Err(Error::with("relative path must not contain '..'")), + CurDir => return Err(Error::with("relative path must not contain '.'")), + Normal(part) => { + let part = part.to_string_lossy(); + if part.contains('\\') || part.chars().any(char::is_control) { + return Err(Error::with("relative path contains unsupported characters")); + } + } + } + } + + Ok(path) +} + +fn metadata_to_kind(metadata: &std::fs::Metadata) -> VnidropEntryKind { + if metadata.is_dir() { + VnidropEntryKind::Dir + } else { + VnidropEntryKind::File + } +} + +fn ensure_expected_kind( + actual: VnidropEntryKind, + expected: VnidropEntryKind, + target: impl std::fmt::Debug, +) -> Result<()> { + if actual == expected { + Ok(()) + } else { + Err(Error::with(format!( + "expected {expected:?}, found {actual:?}: {target:?}" + ))) + } +} + +fn entry_target_for_path(path: PathBuf, kind: VnidropEntryKind) -> VnidropEntryTarget { + match kind { + VnidropEntryKind::File => VnidropEntryTarget::File(VnidropFsTarget::Path(path)), + VnidropEntryKind::Dir => VnidropEntryTarget::Dir(VnidropDirTarget::Path(path)), + } +} + +fn path_file_name(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()) +} + +#[cfg(target_os = "ios")] +fn relative_path_to_string(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +fn entry_name_relative(prefix: &Path, name: &str) -> PathBuf { + prefix.join(name) +} + +#[cfg(target_os = "android")] +fn entry_to_info_android( + entry: Entry, + expected: Option, +) -> Result { + match entry { + Entry::File { + uri, + name, + last_modified, + len, + mime_type, + } => { + if let Some(expected) = expected { + ensure_expected_kind(VnidropEntryKind::File, expected, &uri)?; + } + Ok(VnidropEntryInfo { + target: VnidropEntryTarget::File(VnidropFsTarget::AndroidUri(uri)), + name, + kind: VnidropEntryKind::File, + len: Some(len), + mime_type: Some(mime_type), + last_modified: Some(last_modified), + }) + } + Entry::Dir { + uri, + name, + last_modified, + } => { + if let Some(expected) = expected { + ensure_expected_kind(VnidropEntryKind::Dir, expected, &uri)?; + } + Ok(VnidropEntryInfo { + target: VnidropEntryTarget::Dir(VnidropDirTarget::AndroidUri(uri)), + name, + kind: VnidropEntryKind::Dir, + len: None, + mime_type: None, + last_modified: Some(last_modified), + }) + } + } +} + +#[cfg(target_os = "android")] +fn entry_to_dir_entry_android(entry: Entry, prefix: &Path) -> Result { + let info = entry_to_info_android(entry, None)?; + Ok(VnidropDirEntry { + relative_path: entry_name_relative(prefix, &info.name), + target: info.target, + name: info.name, + kind: info.kind, + len: info.len, + mime_type: info.mime_type, + last_modified: info.last_modified, + }) +} + +#[cfg(target_os = "ios")] +fn ios_entry_to_info( + entry: IosEntry, + expected: Option, +) -> Result { + match entry { + IosEntry::File { + uri, + name, + last_modified, + byte_length, + mime_type, + } => { + if let Some(expected) = expected { + ensure_expected_kind(VnidropEntryKind::File, expected, &uri)?; + } + Ok(VnidropEntryInfo { + target: VnidropEntryTarget::File(VnidropFsTarget::IosUri(uri)), + name, + kind: VnidropEntryKind::File, + len: Some(byte_length), + mime_type: Some(mime_type), + last_modified: Some(ios_millis_to_system_time(last_modified)), + }) + } + IosEntry::Dir { + uri, + name, + last_modified, + } => { + if let Some(expected) = expected { + ensure_expected_kind(VnidropEntryKind::Dir, expected, &uri)?; + } + Ok(VnidropEntryInfo { + target: VnidropEntryTarget::Dir(VnidropDirTarget::IosUri(uri)), + name, + kind: VnidropEntryKind::Dir, + len: None, + mime_type: None, + last_modified: Some(ios_millis_to_system_time(last_modified)), + }) + } + } +} + +#[cfg(target_os = "ios")] +fn ios_entry_to_dir_entry(entry: IosEntry, prefix: &Path) -> Result { + let info = ios_entry_to_info(entry, None)?; + Ok(VnidropDirEntry { + relative_path: entry_name_relative(prefix, &info.name), + target: info.target, + name: info.name, + kind: info.kind, + len: info.len, + mime_type: info.mime_type, + last_modified: info.last_modified, + }) +} + +#[cfg(target_os = "ios")] +fn ios_millis_to_system_time(value: f64) -> SystemTime { + let millis = value.max(0.0) as u64; + SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(millis) +} + #[cfg(not(target_os = "ios"))] fn ios_file_url_to_path(uri: &str) -> Option { uri.strip_prefix("file://") diff --git a/src/lib.rs b/src/lib.rs index fc5e400..9571406 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,11 @@ use utils::*; pub use api::models::*; pub use api::consts::*; pub use fs::{ + VnidropDirEntry, + VnidropDirTarget, + VnidropEntryInfo, + VnidropEntryKind, + VnidropEntryTarget, VnidropFileReader, VnidropFileWriter, VnidropFs, diff --git a/tests/rust_backend_streams.rs b/tests/rust_backend_streams.rs index 8cf74b5..7b078c1 100644 --- a/tests/rust_backend_streams.rs +++ b/tests/rust_backend_streams.rs @@ -1,37 +1,160 @@ use std::io::{Read, Write}; -use tauri_plugin_vnidrop_fs::{VnidropFsExt as _, VnidropOpenWriteOptions}; +use tauri_plugin_vnidrop_fs::{ + VnidropEntryKind, VnidropEntryTarget, VnidropFsExt as _, VnidropOpenWriteOptions, +}; #[test] fn backend_api_streams_local_files_without_frontend_ipc() { - let temp = tempfile::tempdir().expect("tempdir should be created"); - let source = temp.path().join("source.bin"); - let destination = temp.path().join("destination.bin"); - std::fs::write(&source, b"large-ish fixture").expect("source should be written"); - - let app = tauri::test::mock_builder() - .plugin(tauri_plugin_vnidrop_fs::init()) - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("mock app should build with vnidrop fs plugin"); - - let fs = app.vnidrop_fs(); - let mut reader = fs.open_read(&source).expect("reader should open"); - let mut writer = fs - .open_write(&destination, VnidropOpenWriteOptions::default().create(true)) - .expect("writer should open"); - - let mut buffer = [0; 4]; - loop { - let read = reader.read(&mut buffer).expect("read should succeed"); - if read == 0 { - break; - } - writer.write_all(&buffer[..read]).expect("write should succeed"); - } - writer.flush().expect("flush should succeed"); - - assert_eq!( - std::fs::read(destination).expect("destination should be readable"), - b"large-ish fixture" - ); + let temp = tempfile::tempdir().expect("tempdir should be created"); + let source = temp.path().join("source.bin"); + let destination = temp.path().join("destination.bin"); + std::fs::write(&source, b"large-ish fixture").expect("source should be written"); + + let app = tauri::test::mock_builder() + .plugin(tauri_plugin_vnidrop_fs::init()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app should build with vnidrop fs plugin"); + + let fs = app.vnidrop_fs(); + let mut reader = fs.open_read(&source).expect("reader should open"); + let mut writer = fs + .open_write( + &destination, + VnidropOpenWriteOptions::default().create(true), + ) + .expect("writer should open"); + + let mut buffer = [0; 4]; + loop { + let read = reader.read(&mut buffer).expect("read should succeed"); + if read == 0 { + break; + } + writer + .write_all(&buffer[..read]) + .expect("write should succeed"); + } + writer.flush().expect("flush should succeed"); + + assert_eq!( + std::fs::read(destination).expect("destination should be readable"), + b"large-ish fixture" + ); +} + +#[test] +fn backend_api_lists_and_walks_local_directories() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let root = temp.path().join("source"); + std::fs::create_dir_all(root.join("nested")).expect("nested dir should be created"); + std::fs::write(root.join("alpha.txt"), b"alpha").expect("alpha should be written"); + std::fs::write(root.join("nested").join("beta.txt"), b"beta").expect("beta should be written"); + + let app = tauri::test::mock_builder() + .plugin(tauri_plugin_vnidrop_fs::init()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app should build with vnidrop fs plugin"); + + let fs = app.vnidrop_fs(); + let mut immediate = fs.read_dir(&root).expect("directory should be listed"); + immediate.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); + + assert_eq!(immediate.len(), 2); + assert_eq!( + immediate[0].relative_path, + std::path::PathBuf::from("alpha.txt") + ); + assert_eq!(immediate[0].kind, VnidropEntryKind::File); + assert_eq!(immediate[0].len, Some(5)); + assert_eq!( + immediate[1].relative_path, + std::path::PathBuf::from("nested") + ); + assert_eq!(immediate[1].kind, VnidropEntryKind::Dir); + + let mut walked = Vec::new(); + fs.walk_dir_recursive(&root, |entry| { + walked.push((entry.relative_path, entry.kind)); + Ok(()) + }) + .expect("directory tree should be walked"); + walked.sort_by(|a, b| a.0.cmp(&b.0)); + + assert_eq!( + walked, + vec![ + ( + std::path::PathBuf::from("alpha.txt"), + VnidropEntryKind::File + ), + (std::path::PathBuf::from("nested"), VnidropEntryKind::Dir), + ( + std::path::PathBuf::from("nested").join("beta.txt"), + VnidropEntryKind::File + ), + ] + ); +} + +#[test] +fn backend_api_creates_dirs_and_opens_relative_writers() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + + let app = tauri::test::mock_builder() + .plugin(tauri_plugin_vnidrop_fs::init()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app should build with vnidrop fs plugin"); + + let fs = app.vnidrop_fs(); + let created_dir = fs + .create_dir_all(temp.path(), std::path::Path::new("out/nested")) + .expect("nested directory should be created"); + let VnidropEntryTarget::Dir(dir_target) = VnidropEntryTarget::from(created_dir) else { + panic!("created target should be a directory"); + }; + + let mut writer = fs + .open_write_relative( + temp.path(), + std::path::Path::new("out/nested/file.txt"), + VnidropOpenWriteOptions::default().create(true), + ) + .expect("relative writer should open"); + writer + .write_all(b"relative output") + .expect("relative writer should write"); + writer.flush().expect("relative writer should flush"); + + let info = fs + .entry_info(dir_target) + .expect("created directory metadata should be readable"); + assert_eq!(info.kind, VnidropEntryKind::Dir); + assert_eq!( + std::fs::read(temp.path().join("out/nested/file.txt")).expect("file should be readable"), + b"relative output" + ); +} + +#[test] +fn backend_api_rejects_unsafe_relative_paths() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + + let app = tauri::test::mock_builder() + .plugin(tauri_plugin_vnidrop_fs::init()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app should build with vnidrop fs plugin"); + + let fs = app.vnidrop_fs(); + + assert!(fs + .create_dir_all(temp.path(), std::path::Path::new("../escape")) + .is_err()); + assert!(fs + .open_write_relative( + temp.path(), + std::path::Path::new("bad\\name.txt"), + VnidropOpenWriteOptions::default().create(true), + ) + .is_err()); } From eff35fa4f9a3bb2c1aabc0d950a01b087918eb89 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 17 Jun 2026 17:29:12 +0200 Subject: [PATCH 2/2] Bump package version to rc.2 --- Cargo.toml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3a08b07..2f22a74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-plugin-vnidrop-fs" -version = "1.0.0-rc.1" +version = "1.0.0-rc.2" authors = [ "AbassHammed" ] description = "Cross-platform filesystem manager for Tauri with Android SAF and iOS document picker support." edition = "2021" diff --git a/package.json b/package.json index 897ef65..4be7410 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vnidrop/tauri-plugin-fs", - "version": "1.0.0-rc.1", + "version": "1.0.0-rc.2", "author": "AbassHammed", "description": "Cross-platform filesystem manager for Tauri with Android SAF and iOS document picker support.", "keywords": [