rustic_rs/commands/
mount.rs

1//! `mount` subcommand
2
3// ignore markdown clippy lints as we use doc-comments to generate clap help texts
4#![allow(clippy::doc_markdown)]
5
6mod fusefs;
7use fusefs::FuseFS;
8
9use abscissa_core::{
10    Command, FrameworkError, FrameworkErrorKind::ParseError, Runnable, Shutdown, config::Override,
11};
12use anyhow::{Result, bail};
13use clap::Parser;
14use conflate::{Merge, MergeFrom};
15use fuse_mt::{FuseMT, mount};
16use rustic_core::vfs::{FilePolicy, IdenticalSnapshot, Latest, Vfs};
17use std::{ffi::OsStr, path::PathBuf};
18
19use crate::{
20    Application, RUSTIC_APP, RusticConfig,
21    repository::{CliIndexedRepo, get_filtered_snapshots},
22    status_err,
23};
24
25#[derive(Clone, Debug, Default, Command, Parser, Merge, serde::Serialize, serde::Deserialize)]
26#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
27pub struct MountCmd {
28    /// The path template to use for snapshots. {id}, {id_long}, {time}, {username}, {hostname}, {label}, {tags}, {backup_start}, {backup_end} are replaced. [default: "[{hostname}]/[{label}]/{time}"]
29    #[clap(long)]
30    #[merge(strategy=conflate::option::overwrite_none)]
31    path_template: Option<String>,
32
33    /// The time template to use to display times in the path template. See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for format options. [default: "%Y-%m-%d_%H-%M-%S"]
34    #[clap(long)]
35    #[merge(strategy=conflate::option::overwrite_none)]
36    time_template: Option<String>,
37
38    /// Don't allow other users to access the mount point
39    #[clap(short, long)]
40    #[merge(strategy=conflate::bool::overwrite_false)]
41    exclusive: bool,
42
43    /// How to handle access to files. [default: "forbidden" for hot/cold repositories, else "read"]
44    #[clap(long)]
45    #[merge(strategy=conflate::option::overwrite_none)]
46    file_access: Option<FilePolicy>,
47
48    /// The mount point to use
49    #[clap(value_name = "PATH")]
50    #[merge(strategy=conflate::option::overwrite_none)]
51    mount_point: Option<PathBuf>,
52
53    /// Specify directly which snapshot/path to mount
54    #[clap(value_name = "SNAPSHOT[:PATH]")]
55    #[merge(strategy=conflate::option::overwrite_none)]
56    snapshot_path: Option<String>,
57
58    /// Other options to use for mount
59    #[clap(short, long = "option", value_name = "OPTION")]
60    #[merge(strategy = conflate::vec::overwrite_empty)]
61    options: Vec<String>,
62}
63
64impl Override<RusticConfig> for MountCmd {
65    // Process the given command line options, overriding settings from
66    // a configuration file using explicit flags taken from command-line
67    // arguments.
68    fn override_config(&self, mut config: RusticConfig) -> Result<RusticConfig, FrameworkError> {
69        // Merge by precedence, cli <- config <- default
70        let self_config = self
71            .clone()
72            .merge_from(config.mount)
73            .merge_from(Self::with_default_config());
74
75        // Other values
76        if self_config.mount_point.is_none() {
77            return Err(ParseError
78                .context("Please specify a valid mount point!")
79                .into());
80        }
81
82        // rewrite the "mount" section in the config file
83        config.mount = self_config;
84
85        Ok(config)
86    }
87}
88
89impl Runnable for MountCmd {
90    fn run(&self) {
91        if let Err(err) = RUSTIC_APP
92            .config()
93            .repository
94            .run_indexed(|repo| self.inner_run(repo))
95        {
96            status_err!("{}", err);
97            RUSTIC_APP.shutdown(Shutdown::Crash);
98        };
99    }
100}
101
102impl MountCmd {
103    fn with_default_config() -> Self {
104        Self {
105            path_template: Some(String::from("[{hostname}]/[{label}]/{time}")),
106            time_template: Some(String::from("%Y-%m-%d_%H-%M-%S")),
107            options: vec![String::from("kernel_cache")],
108            ..Default::default()
109        }
110    }
111
112    fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
113        let config = RUSTIC_APP.config();
114
115        // We have merged the config file, the command line options, and the
116        // default values into a single struct. Now we can use the values.
117        // If a value is missing, we can return an error.
118        let Some(path_template) = config.mount.path_template.clone() else {
119            bail!("Please specify a path template!");
120        };
121
122        let Some(time_template) = config.mount.time_template.clone() else {
123            bail!("Please specify a time template!");
124        };
125
126        let Some(mount_point) = config.mount.mount_point.clone() else {
127            bail!("Please specify a mount point!");
128        };
129
130        let vfs = if let Some(snap) = &config.mount.snapshot_path {
131            let node =
132                repo.node_from_snapshot_path(snap, |sn| config.snapshot_filter.matches(sn))?;
133            Vfs::from_dir_node(&node)
134        } else {
135            let snapshots = get_filtered_snapshots(&repo)?;
136            Vfs::from_snapshots(
137                snapshots,
138                &path_template,
139                &time_template,
140                Latest::AsLink,
141                IdenticalSnapshot::AsLink,
142            )?
143        };
144
145        // Prepare the mount options
146        let mut mount_options = config.mount.options.clone();
147
148        mount_options.push(format!("fsname=rusticfs:{}", repo.config().id));
149
150        if !config.mount.exclusive {
151            mount_options
152                .extend_from_slice(&["allow_other".to_string(), "default_permissions".to_string()]);
153        }
154
155        let file_access = config.mount.file_access.as_ref().map_or_else(
156            || {
157                if repo.config().is_hot == Some(true) {
158                    FilePolicy::Forbidden
159                } else {
160                    FilePolicy::Read
161                }
162            },
163            |s| *s,
164        );
165
166        let fs = FuseMT::new(FuseFS::new(repo, vfs, file_access), 1);
167
168        // Sort and deduplicate options
169        mount_options.sort_unstable();
170        mount_options.dedup();
171
172        // join options into a single comma-delimited string and prepent "-o "
173        // this should be parsed just fine by fuser, here
174        // https://github.com/cberner/fuser/blob/9f6ced73a36f1d99846e28be9c5e4903939ee9d5/src/mnt/mount_options.rs#L157
175        let opt_string = format!("-o {}", mount_options.join(","));
176
177        mount(fs, mount_point, &[OsStr::new(&opt_string)])?;
178
179        Ok(())
180    }
181}