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