Skip to main content

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}