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}