tauri_plugin_fs/lib.rs
1// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5//! Access the file system.
6//!
7//! ## Cargo features
8//!
9//! - **watch**: Enables the `watch` command backed by [`notify`](http://crates.io/crates/notify).
10
11// TODO(v3): consider redesign the API to implement automatic stopAccessingSecurityScopedResource on iOS
12// this likely requires returning a handle to a resource so we can impl Drop for it
13
14#![doc(
15 html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
16 html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
17)]
18
19use std::io::Read;
20#[cfg(target_os = "ios")]
21use std::sync::Mutex;
22
23use serde::Deserialize;
24use tauri::{
25 AppHandle, DragDropEvent, Manager, RunEvent, Runtime, WindowEvent,
26 ipc::ScopeObject,
27 plugin::{Builder as PluginBuilder, TauriPlugin},
28 utils::{acl::Value, config::FsScope},
29};
30
31#[cfg(target_os = "android")]
32mod android;
33mod commands;
34mod config;
35#[cfg(desktop)]
36mod desktop;
37mod error;
38mod file_path;
39#[cfg(target_os = "ios")]
40mod ios;
41#[cfg(target_os = "android")]
42mod models;
43mod scope;
44#[cfg(feature = "watch")]
45mod watcher;
46
47#[cfg(target_os = "android")]
48pub use android::Fs;
49#[cfg(desktop)]
50pub use desktop::Fs;
51#[cfg(target_os = "ios")]
52pub use ios::Fs;
53
54pub use error::Error;
55
56pub use file_path::FilePath;
57pub use file_path::SafeFilePath;
58
59type Result<T> = std::result::Result<T, Error>;
60
61/// Options and flags which can be used to configure how a file is opened.
62///
63/// This builder exposes the ability to configure how a [`std::fs::File`] is opened and
64/// what operations are permitted on the open file. Build it with [`OpenOptions::new`],
65/// chain calls to the setter methods and pass it to [`Fs::open`].
66///
67/// The `read` option defaults to `true`, every other option defaults to `false`.
68#[derive(Debug, Default, Clone, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct OpenOptions {
71 #[serde(default = "default_true")]
72 read: bool,
73 #[serde(default)]
74 write: bool,
75 #[serde(default)]
76 append: bool,
77 #[serde(default)]
78 truncate: bool,
79 #[serde(default)]
80 create: bool,
81 #[serde(default)]
82 create_new: bool,
83 #[serde(default)]
84 #[allow(unused)]
85 mode: Option<u32>,
86 // Never deserialized: the webview must not be able to pass arbitrary `open(2)` flags
87 // (e.g. `O_TRUNC`), it can only be set from Rust with `OpenOptionsExt::custom_flags`.
88 #[serde(skip)]
89 #[allow(unused)]
90 custom_flags: Option<i32>,
91}
92
93fn default_true() -> bool {
94 true
95}
96
97impl From<OpenOptions> for std::fs::OpenOptions {
98 fn from(open_options: OpenOptions) -> Self {
99 let mut opts = std::fs::OpenOptions::new();
100
101 #[cfg(unix)]
102 {
103 use std::os::unix::fs::OpenOptionsExt;
104 if let Some(mode) = open_options.mode {
105 opts.mode(mode);
106 }
107 if let Some(flags) = open_options.custom_flags {
108 opts.custom_flags(flags);
109 }
110 }
111
112 opts.read(open_options.read)
113 .write(open_options.write)
114 .create(open_options.create)
115 .append(open_options.append)
116 .truncate(open_options.truncate)
117 .create_new(open_options.create_new);
118
119 opts
120 }
121}
122
123impl OpenOptions {
124 /// Creates a blank new set of options ready for configuration.
125 ///
126 /// All options are initially set to `false`.
127 ///
128 /// # Examples
129 ///
130 /// ```rust,no_run
131 /// use std::path::Path;
132 /// use tauri_plugin_fs::{FsExt, OpenOptions};
133 ///
134 /// tauri::Builder::default()
135 /// .setup(|app| {
136 /// let mut options = OpenOptions::new();
137 /// options.read(true);
138 /// let file = app.fs().open(Path::new("foo.txt"), options)?;
139 /// Ok(())
140 /// });
141 /// ```
142 #[must_use]
143 pub fn new() -> Self {
144 Self::default()
145 }
146
147 /// Sets the option for read access.
148 ///
149 /// This option, when true, will indicate that the file should be
150 /// `read`-able if opened.
151 ///
152 /// # Examples
153 ///
154 /// ```rust,no_run
155 /// use std::path::Path;
156 /// use tauri_plugin_fs::{FsExt, OpenOptions};
157 ///
158 /// tauri::Builder::default()
159 /// .setup(|app| {
160 /// let mut options = OpenOptions::new();
161 /// options.read(true);
162 /// let file = app.fs().open(Path::new("foo.txt"), options)?;
163 /// Ok(())
164 /// });
165 /// ```
166 pub fn read(&mut self, read: bool) -> &mut Self {
167 self.read = read;
168 self
169 }
170
171 /// Sets the option for write access.
172 ///
173 /// This option, when true, will indicate that the file should be
174 /// `write`-able if opened.
175 ///
176 /// If the file already exists, any write calls on it will overwrite its
177 /// contents, without truncating it.
178 ///
179 /// # Examples
180 ///
181 /// ```rust,no_run
182 /// use std::path::Path;
183 /// use tauri_plugin_fs::{FsExt, OpenOptions};
184 ///
185 /// tauri::Builder::default()
186 /// .setup(|app| {
187 /// let mut options = OpenOptions::new();
188 /// options.write(true);
189 /// let file = app.fs().open(Path::new("foo.txt"), options)?;
190 /// Ok(())
191 /// });
192 /// ```
193 pub fn write(&mut self, write: bool) -> &mut Self {
194 self.write = write;
195 self
196 }
197
198 /// Sets the option for the append mode.
199 ///
200 /// This option, when true, means that writes will append to a file instead
201 /// of overwriting previous contents.
202 /// Note that setting `.write(true).append(true)` has the same effect as
203 /// setting only `.append(true)`.
204 ///
205 /// Append mode guarantees that writes will be positioned at the current end of file,
206 /// even when there are other processes or threads appending to the same file. This is
207 /// unlike <code>[seek]\([SeekFrom]::[End]\(0))</code> followed by `write()`, which
208 /// has a race between seeking and writing during which another writer can write, with
209 /// our `write()` overwriting their data.
210 ///
211 /// Keep in mind that this does not necessarily guarantee that data appended by
212 /// different processes or threads does not interleave. The amount of data accepted a
213 /// single `write()` call depends on the operating system and file system. A
214 /// successful `write()` is allowed to write only part of the given data, so even if
215 /// you're careful to provide the whole message in a single call to `write()`, there
216 /// is no guarantee that it will be written out in full. If you rely on the filesystem
217 /// accepting the message in a single write, make sure that all data that belongs
218 /// together is written in one operation. This can be done by concatenating strings
219 /// before passing them to [`write()`].
220 ///
221 /// If a file is opened with both read and append access, beware that after
222 /// opening, and after every write, the position for reading may be set at the
223 /// end of the file. So, before writing, save the current position (using
224 /// <code>[Seek]::[stream_position]</code>), and restore it before the next read.
225 ///
226 /// ## Note
227 ///
228 /// This function doesn't create the file if it doesn't exist. Use the
229 /// [`OpenOptions::create`] method to do so.
230 ///
231 /// [`write()`]: std::io::Write::write "io::Write::write"
232 /// [`flush()`]: std::io::Write::flush "io::Write::flush"
233 /// [Seek]: std::io::Seek "io::Seek"
234 /// [stream_position]: std::io::Seek::stream_position "io::Seek::stream_position"
235 /// [seek]: std::io::Seek::seek "io::Seek::seek"
236 /// [SeekFrom]: std::io::SeekFrom "io::SeekFrom"
237 /// [Current]: std::io::SeekFrom::Current "io::SeekFrom::Current"
238 /// [End]: std::io::SeekFrom::End "io::SeekFrom::End"
239 ///
240 /// # Examples
241 ///
242 /// ```rust,no_run
243 /// use std::path::Path;
244 /// use tauri_plugin_fs::{FsExt, OpenOptions};
245 ///
246 /// tauri::Builder::default()
247 /// .setup(|app| {
248 /// let mut options = OpenOptions::new();
249 /// options.append(true);
250 /// let file = app.fs().open(Path::new("foo.txt"), options)?;
251 /// Ok(())
252 /// });
253 /// ```
254 pub fn append(&mut self, append: bool) -> &mut Self {
255 self.append = append;
256 self
257 }
258
259 /// Sets the option for truncating a previous file.
260 ///
261 /// If a file is successfully opened with this option set it will truncate
262 /// the file to 0 length if it already exists.
263 ///
264 /// The file must be opened with write access for truncate to work.
265 ///
266 /// # Examples
267 ///
268 /// ```rust,no_run
269 /// use std::path::Path;
270 /// use tauri_plugin_fs::{FsExt, OpenOptions};
271 ///
272 /// tauri::Builder::default()
273 /// .setup(|app| {
274 /// let mut options = OpenOptions::new();
275 /// options.write(true).truncate(true);
276 /// let file = app.fs().open(Path::new("foo.txt"), options)?;
277 /// Ok(())
278 /// });
279 /// ```
280 pub fn truncate(&mut self, truncate: bool) -> &mut Self {
281 self.truncate = truncate;
282 self
283 }
284
285 /// Sets the option to create a new file, or open it if it already exists.
286 ///
287 /// In order for the file to be created, [`OpenOptions::write`] or
288 /// [`OpenOptions::append`] access must be used.
289 ///
290 ///
291 /// # Examples
292 ///
293 /// ```rust,no_run
294 /// use std::path::Path;
295 /// use tauri_plugin_fs::{FsExt, OpenOptions};
296 ///
297 /// tauri::Builder::default()
298 /// .setup(|app| {
299 /// let mut options = OpenOptions::new();
300 /// options.write(true).create(true);
301 /// let file = app.fs().open(Path::new("foo.txt"), options)?;
302 /// Ok(())
303 /// });
304 /// ```
305 pub fn create(&mut self, create: bool) -> &mut Self {
306 self.create = create;
307 self
308 }
309
310 /// Sets the option to create a new file, failing if it already exists.
311 ///
312 /// No file is allowed to exist at the target location, also no (dangling) symlink. In this
313 /// way, if the call succeeds, the file returned is guaranteed to be new.
314 /// If a file exists at the target location, creating a new file will fail with [`AlreadyExists`]
315 /// or another error based on the situation. See [`std::fs::OpenOptions::open`] for a
316 /// non-exhaustive list of likely errors.
317 ///
318 /// This option is useful because it is atomic. Otherwise between checking
319 /// whether a file exists and creating a new one, the file may have been
320 /// created by another process (a TOCTOU race condition / attack).
321 ///
322 /// If `.create_new(true)` is set, [`.create()`] and [`.truncate()`] are
323 /// ignored.
324 ///
325 /// The file must be opened with write or append access in order to create
326 /// a new file.
327 ///
328 /// [`.create()`]: OpenOptions::create
329 /// [`.truncate()`]: OpenOptions::truncate
330 /// [`AlreadyExists`]: std::io::ErrorKind::AlreadyExists
331 ///
332 /// # Examples
333 ///
334 /// ```rust,no_run
335 /// use std::path::Path;
336 /// use tauri_plugin_fs::{FsExt, OpenOptions};
337 ///
338 /// tauri::Builder::default()
339 /// .setup(|app| {
340 /// let mut options = OpenOptions::new();
341 /// options.write(true).create_new(true);
342 /// let file = app.fs().open(Path::new("foo.txt"), options)?;
343 /// Ok(())
344 /// });
345 /// ```
346 pub fn create_new(&mut self, create_new: bool) -> &mut Self {
347 self.create_new = create_new;
348 self
349 }
350}
351
352#[cfg(unix)]
353impl std::os::unix::fs::OpenOptionsExt for OpenOptions {
354 fn custom_flags(&mut self, flags: i32) -> &mut Self {
355 self.custom_flags.replace(flags);
356 self
357 }
358
359 fn mode(&mut self, mode: u32) -> &mut Self {
360 self.mode.replace(mode);
361 self
362 }
363}
364
365impl OpenOptions {
366 /// The mode passed to `ContentResolver.openAssetFileDescriptor` / `ParcelFileDescriptor.parseMode`,
367 /// which only accept `r`, `w`, `wt`, `wa`, `rw` and `rwt`.
368 ///
369 /// `create` and `create_new` have no equivalent: whether a missing file is created
370 /// depends on the content provider.
371 #[cfg(any(target_os = "android", test))]
372 fn android_mode(&self) -> &'static str {
373 match (self.read, self.write || self.append) {
374 (_, false) => "r",
375 // there is no read + append mode, and `read` defaults to `true` from JavaScript:
376 // honor the explicit append
377 (_, true) if self.append => "wa",
378 (true, true) if self.truncate => "rwt",
379 (true, true) => "rw",
380 (false, true) if self.truncate => "wt",
381 (false, true) => "w",
382 }
383 }
384}
385
386impl<R: Runtime> Fs<R> {
387 /// Reads the entire contents of a file into a string.
388 ///
389 /// The file is opened in read-only mode with [`Fs::open`].
390 ///
391 /// # Errors
392 ///
393 /// Returns an error if `path` cannot be opened for reading or if its
394 /// contents are not valid UTF-8.
395 pub fn read_to_string<P: Into<FilePath>>(&self, path: P) -> std::io::Result<String> {
396 let mut s = String::new();
397 self.open(
398 path,
399 OpenOptions {
400 read: true,
401 ..Default::default()
402 },
403 )?
404 .read_to_string(&mut s)?;
405 Ok(s)
406 }
407
408 /// Reads the entire contents of a file into a bytes vector.
409 ///
410 /// The file is opened in read-only mode with [`Fs::open`].
411 ///
412 /// # Errors
413 ///
414 /// Returns an error if `path` cannot be opened for reading.
415 pub fn read<P: Into<FilePath>>(&self, path: P) -> std::io::Result<Vec<u8>> {
416 let mut buf = Vec::new();
417 self.open(
418 path,
419 OpenOptions {
420 read: true,
421 ..Default::default()
422 },
423 )?
424 .read_to_end(&mut buf)?;
425 Ok(buf)
426 }
427}
428
429// implement ScopeObject here instead of in the scope module because it is also used on the build script
430// and we don't want to add tauri as a build dependency
431impl ScopeObject for scope::Entry {
432 type Error = Error;
433 fn deserialize<R: Runtime>(
434 app: &AppHandle<R>,
435 raw: Value,
436 ) -> std::result::Result<Self, Self::Error> {
437 let path = serde_json::from_value(raw.into()).map(|raw| match raw {
438 scope::EntryRaw::Value(path) => path,
439 scope::EntryRaw::Object { path } => path,
440 })?;
441
442 match app.path().parse(path) {
443 Ok(path) => Ok(Self { path: Some(path) }),
444 #[cfg(not(target_os = "android"))]
445 Err(tauri::Error::UnknownPath) => Ok(Self { path: None }),
446 Err(err) => Err(err.into()),
447 }
448 }
449}
450
451pub(crate) struct Scope {
452 pub(crate) scope: tauri::fs::Scope,
453 pub(crate) require_literal_leading_dot: Option<bool>,
454 pub(crate) scope_dropped_paths: bool,
455}
456
457/// Tracks which paths have active security-scoped resource access on iOS.
458#[cfg(target_os = "ios")]
459pub(crate) struct SecurityScopedResources {
460 /// Set of file URLs that are currently accessing security-scoped resources.
461 /// The key is the URL string representation.
462 pub(crate) active_urls: Mutex<std::collections::HashSet<String>>,
463}
464
465#[cfg(target_os = "ios")]
466impl SecurityScopedResources {
467 pub(crate) fn new() -> Self {
468 Self {
469 active_urls: Mutex::new(std::collections::HashSet::new()),
470 }
471 }
472
473 pub(crate) fn is_tracked_manually(&self, url: &str) -> bool {
474 self.active_urls.lock().unwrap().contains(url)
475 }
476
477 pub(crate) fn track_manually(&self, url: String) {
478 self.active_urls.lock().unwrap().insert(url);
479 }
480
481 pub(crate) fn remove(&self, url: &str) {
482 self.active_urls.lock().unwrap().remove(url);
483 }
484}
485
486#[cfg(not(target_os = "ios"))]
487pub(crate) struct SecurityScopedResources;
488
489#[cfg(not(target_os = "ios"))]
490impl SecurityScopedResources {
491 pub(crate) fn new() -> Self {
492 Self
493 }
494
495 #[allow(dead_code)] // Used on iOS, but not on other platforms
496 pub(crate) fn is_tracked_manually(&self, _url: &str) -> bool {
497 false
498 }
499
500 #[allow(dead_code)] // Used on iOS, but not on other platforms
501 pub(crate) fn track_manually(&self, _url: String) {}
502
503 #[allow(dead_code)] // Used on iOS, but not on other platforms
504 pub(crate) fn remove(&self, _url: &str) {}
505}
506
507/// Extension trait implemented by every [`Manager`] (the app handle, windows, webviews, ...)
508/// to access the file system plugin APIs.
509///
510/// # Examples
511///
512/// ```rust,no_run
513/// use std::path::Path;
514/// use tauri::Runtime;
515/// use tauri_plugin_fs::FsExt;
516///
517/// fn setup<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn std::error::Error>> {
518/// // allow the app to access a directory that is not part of the static scope
519/// app.fs_scope().allow_directory(Path::new("/path/to/directory"), true)?;
520///
521/// let contents = app.fs().read_to_string(Path::new("/path/to/directory/file.txt"))?;
522/// println!("{contents}");
523///
524/// Ok(())
525/// }
526/// ```
527pub trait FsExt<R: Runtime> {
528 /// Returns the file system scope, which can be used to dynamically
529 /// allow or deny paths at runtime.
530 ///
531 /// # Panics
532 ///
533 /// Panics if the plugin is not registered in the app.
534 /// Use [`FsExt::try_fs_scope`] if the plugin might not be registered.
535 fn fs_scope(&self) -> tauri::fs::Scope;
536
537 /// Returns the file system scope, or `None` if the plugin is not registered in the app.
538 fn try_fs_scope(&self) -> Option<tauri::fs::Scope>;
539
540 /// Cross platform file system APIs that also support manipulating Android files.
541 fn fs(&self) -> &Fs<R>;
542}
543
544impl<R: Runtime, T: Manager<R>> FsExt<R> for T {
545 fn fs_scope(&self) -> tauri::fs::Scope {
546 self.state::<Scope>().scope.clone()
547 }
548
549 fn try_fs_scope(&self) -> Option<tauri::fs::Scope> {
550 self.try_state::<Scope>().map(|s| s.scope.clone())
551 }
552
553 fn fs(&self) -> &Fs<R> {
554 self.state::<Fs<R>>().inner()
555 }
556}
557
558/// Initializes the plugin.
559pub fn init<R: Runtime>() -> TauriPlugin<R, Option<config::Config>> {
560 PluginBuilder::<R, Option<config::Config>>::new("fs")
561 .invoke_handler(tauri::generate_handler![
562 commands::create,
563 commands::open,
564 commands::copy_file,
565 commands::mkdir,
566 commands::read_dir,
567 commands::read,
568 commands::read_file,
569 commands::read_text_file,
570 commands::read_text_file_lines,
571 commands::read_text_file_lines_next,
572 commands::remove,
573 commands::rename,
574 commands::seek,
575 commands::stat,
576 commands::lstat,
577 commands::fstat,
578 commands::truncate,
579 commands::ftruncate,
580 commands::write,
581 commands::write_file,
582 commands::write_text_file,
583 commands::exists,
584 commands::size,
585 commands::start_accessing_security_scoped_resource,
586 commands::stop_accessing_security_scoped_resource,
587 #[cfg(feature = "watch")]
588 watcher::watch,
589 ])
590 .setup(|app, api| {
591 let scope = Scope {
592 require_literal_leading_dot: api
593 .config()
594 .as_ref()
595 .and_then(|c| c.require_literal_leading_dot),
596 scope_dropped_paths: api
597 .config()
598 .as_ref()
599 .and_then(|c| c.scope_dropped_paths)
600 .unwrap_or(true),
601 scope: tauri::fs::Scope::new(app, &FsScope::default())?,
602 };
603
604 #[cfg(target_os = "android")]
605 {
606 let fs = android::init(app, api)?;
607 app.manage(fs);
608 }
609 #[cfg(target_os = "ios")]
610 {
611 let fs = ios::init(app, api)?;
612 app.manage(fs);
613 }
614 #[cfg(desktop)]
615 app.manage(Fs(app.clone()));
616
617 app.manage(scope);
618 app.manage(SecurityScopedResources::new());
619 Ok(())
620 })
621 .on_event(|app, event| {
622 if let RunEvent::WindowEvent {
623 label: _,
624 event: WindowEvent::DragDrop(DragDropEvent::Drop { paths, position: _ }),
625 ..
626 } = event
627 {
628 let Some(scope) = app.try_state::<Scope>() else {
629 return;
630 };
631 if !scope.scope_dropped_paths {
632 return;
633 }
634 let scope = &scope.scope;
635 for path in paths {
636 if path.is_file() {
637 let _ = scope.allow_file(path);
638 } else {
639 let _ = scope.allow_directory(path, true);
640 }
641 }
642 }
643 })
644 .build()
645}
646
647#[cfg(test)]
648mod tests {
649 use super::OpenOptions;
650
651 #[test]
652 fn android_modes_are_valid() {
653 let mode = |json: &str| {
654 serde_json::from_str::<OpenOptions>(json)
655 .unwrap()
656 .android_mode()
657 };
658
659 // `read` defaults to true when deserialized
660 assert_eq!(mode(r#"{}"#), "r");
661 assert_eq!(mode(r#"{ "read": false }"#), "r");
662 assert_eq!(mode(r#"{ "write": true }"#), "rw");
663 assert_eq!(mode(r#"{ "write": true, "truncate": true }"#), "rwt");
664 assert_eq!(mode(r#"{ "append": true }"#), "wa");
665 assert_eq!(mode(r#"{ "read": false, "write": true }"#), "w");
666 assert_eq!(
667 mode(r#"{ "read": false, "write": true, "truncate": true }"#),
668 "wt"
669 );
670 assert_eq!(mode(r#"{ "read": false, "append": true }"#), "wa");
671 assert_eq!(
672 mode(r#"{ "read": false, "write": true, "truncate": true, "append": true }"#),
673 "wa"
674 );
675 assert_eq!(
676 mode(r#"{ "read": false, "write": true, "create": true }"#),
677 "w"
678 );
679 }
680
681 #[test]
682 fn open_options_ignore_custom_flags_from_ipc() {
683 let options: OpenOptions =
684 serde_json::from_str(r#"{ "read": true, "customFlags": 512 }"#).unwrap();
685 assert!(options.read);
686 assert_eq!(options.custom_flags, None);
687 }
688}