libsftpman/model/
filesystem_mount_definition.rs

1use std::process::Command;
2
3use serde::{Deserialize, Serialize};
4
5use validator::{Validate, ValidationError};
6
7use crate::utils::command::command_to_string;
8use crate::utils::fusermount::get_fusermount_command;
9
10use crate::auth_type::{
11    AuthType, deserialize_auth_type_from_string, serialize_auth_type_to_string,
12};
13
14use crate::errors::SftpManError;
15
16pub const DEFAULT_MOUNT_PATH_PREFIX: &str = "/mnt/sshfs";
17
18#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
19#[validate(schema(
20    function = "validate_ssh_key_for_publickey_auth",
21    skip_on_field_errors = false
22))]
23pub struct FilesystemMountDefinition {
24    /// Unique identifier for this definition.
25    /// If `mount_dest_path` is `None`, this will also influence where the filesystem gets mounted locally (see `local_mount_path()`).
26    #[validate(
27        length(min = 1, message = "An ID must be provided."),
28        custom(
29            function = "validate_id",
30            message = "The ID must be a valid identifier (alphanumeric characters, underscores, dashes, or dots)."
31        )
32    )]
33    pub id: String,
34
35    /// Hostname or IP address of the remote machine
36    #[validate(length(min = 1, message = "A host must be provided."))]
37    pub host: String,
38
39    /// Port number of the remote machine (e.g. `22`).
40    pub port: u16,
41
42    /// Username to SSH in on the remote machine (e.g. `user`).
43    #[validate(length(min = 1, message = "A user must be provided."))]
44    pub user: String,
45
46    /// Mount options to pass to sshfs (-o).
47    /// Example: [`follow_symlinks`, `rename`]
48    #[serde(rename = "mountOptions")]
49    pub mount_options: Vec<String>,
50
51    /// Path on the remote server that will be mounted locally (e.g. `/storage`).
52    #[serde(rename = "mountPoint")]
53    #[validate(
54        length(min = 1, message = "A remote path must be provided."),
55        custom(
56            function = "validate_absolute_path",
57            message = "The remote path must be absolute."
58        )
59    )]
60    pub remote_path: String,
61
62    /// Path where the filesystem will be mounted locally (e.g. `/home/user/storage`).
63    /// If not provided, it defaults to `{DEFAULT_MOUNT_PATH_PREFIX}/{id}`.
64    #[serde(rename = "mountDestPath")]
65    #[validate(
66        length(min = 1, message = "A local mount destination path must be provided."),
67        custom(
68            function = "validate_absolute_path",
69            message = "The local mount destination path must be absolute."
70        )
71    )]
72    pub mount_dest_path: Option<String>,
73
74    /// Command to run before mounting (e.g. `/bin/true`)
75    #[serde(rename = "beforeMount")]
76    #[serde(default)]
77    pub cmd_before_mount: String,
78
79    /// Authentication method.
80    /// Most of the potential values match SSH's `PreferredAuthentications` list, but some are special values that we recognize & handle here.
81    #[serde(rename = "authType")]
82    #[serde(
83        serialize_with = "serialize_auth_type_to_string",
84        deserialize_with = "deserialize_auth_type_from_string"
85    )]
86    pub auth_type: AuthType,
87
88    /// Path to an SSH private key (e.g. `/home/user/.ssh/id_ed25519`) for authentication types (like `AuthType::PublicKey`) that use a key.
89    #[serde(rename = "sshKey")]
90    pub ssh_key: String,
91}
92
93const SSH_DEFAULT_TIMEOUT: u32 = 10;
94
95impl Default for FilesystemMountDefinition {
96    fn default() -> Self {
97        FilesystemMountDefinition {
98            id: String::new(),
99            host: String::new(),
100            port: 22,
101            user: String::new(),
102            mount_options: Vec::new(),
103            remote_path: String::new(),
104            mount_dest_path: None,
105            cmd_before_mount: String::new(),
106            auth_type: AuthType::PublicKey,
107            ssh_key: String::new(),
108        }
109    }
110}
111
112impl FilesystemMountDefinition {
113    pub fn from_json_string(contents: &str) -> Result<Self, serde_json::Error> {
114        let deserialized: Self = serde_json::from_str(contents)?;
115        Ok(deserialized)
116    }
117
118    pub fn to_json_string(&self) -> serde_json::Result<String> {
119        serde_json::to_string_pretty(self)
120    }
121
122    /// Returns the local mount path for this definition.
123    /// If `mount_dest_path` is not `None` for this definition, it will be used.
124    /// Otherwise, the default mount path (`DEFAULT_MOUNT_PATH_PREFIX`) will be used (e.g. `/mnt/sshfs/{id}`).
125    pub fn local_mount_path(&self) -> String {
126        match &self.mount_dest_path {
127            Some(path) => path.clone(),
128            None => format!("{0}/{1}", DEFAULT_MOUNT_PATH_PREFIX, self.id),
129        }
130    }
131
132    /// Returns a list of commands for mounting the filesystem definition.
133    /// Mounting is performed via `sshfs` and `ssh` commands.
134    pub fn mount_commands(&self) -> Result<Vec<Command>, SftpManError> {
135        log::debug!("{0}: building list of mount commands", self.id);
136
137        let mut list: Vec<Command> = Vec::new();
138
139        if !self.cmd_before_mount.is_empty() {
140            if self.cmd_before_mount == "/bin/true" || self.cmd_before_mount == "true" {
141                // sftpman-gtk used to hardcode `/bin/true` or `true` as a before-mount command.
142                // We don't really need to run this.
143                log::debug!(
144                    "{0}: ignoring no-op before-mount command {1}",
145                    self.id,
146                    self.cmd_before_mount
147                );
148            } else {
149                let mut program_name = "";
150                let mut args: Vec<&str> = Vec::new();
151
152                for (idx, arg) in self.cmd_before_mount.split(' ').enumerate() {
153                    match idx {
154                        0 => {
155                            program_name = arg;
156                        }
157                        _ => {
158                            args.push(arg);
159                        }
160                    }
161                }
162
163                if program_name.is_empty() {
164                    return Err(SftpManError::MountCommandBuilding(format!(
165                        "could not extract program name from {0}",
166                        self.cmd_before_mount
167                    )));
168                }
169
170                let mut cmd_before = Command::new(program_name);
171                for arg in args {
172                    cmd_before.arg(arg);
173                }
174
175                list.push(cmd_before);
176            }
177        }
178
179        let mut cmd_ssh = Command::new("ssh");
180        cmd_ssh
181            .arg("-p")
182            .arg(self.port.to_string())
183            .arg("-o")
184            .arg(format!("ConnectTimeout={0}", SSH_DEFAULT_TIMEOUT));
185
186        match &self.auth_type {
187            AuthType::PublicKey => {
188                cmd_ssh.arg(format!(
189                    "-o PreferredAuthentications={0}",
190                    AuthType::PublicKey.to_static_str()
191                ));
192                cmd_ssh.arg(format!("-i {0}", self.ssh_key));
193            }
194            AuthType::AuthenticationAgent => {
195                // By not specifying a key and preferred authentication type,
196                // we're hoping to delegate all this to an already running SSH agent, if available.
197            }
198            any_other => {
199                cmd_ssh.arg(format!(
200                    "-o PreferredAuthentications={0}",
201                    any_other.to_static_str()
202                ));
203            }
204        };
205
206        let mut cmd_sshfs = Command::new("sshfs");
207        cmd_sshfs
208            // Add mount options prefixed with "-o" (ignored if empty).
209            .args(self.mount_options.iter().flat_map(|opt| ["-o", opt]))
210            // Add the formatted SSH command as an sshfs option.
211            .arg("-o")
212            .arg(format!("ssh_command={0}", command_to_string(&cmd_ssh)))
213            // We use `[]` around the host to avoid issues with hostnames (IPv6 addresses) containing `:`.
214            // This also works well for IPv4 addresses and name-based hostnames.
215            .arg(format!(
216                "{0}@[{1}]:{2}",
217                self.user, self.host, self.remote_path
218            ))
219            // Set the local mount point for the remote directory.
220            .arg(self.local_mount_path());
221
222        list.push(cmd_sshfs);
223
224        Ok(list)
225    }
226
227    /// Returns a list of commands for unmounting the filesystem definition.
228    ///
229    /// Unmounting with this command may fail if the filesystem is busy and a fallback mechanism may be necessary
230    /// (killing the `sshfs` process responsible for the mount).
231    pub fn umount_commands(&self) -> Result<Vec<Command>, SftpManError> {
232        log::debug!("{0}: building list of unmount commands", self.id);
233
234        // Using `nix::mount::umount` or `nix::mount::umount2` sounds like a good idea,
235        // but those require special privileges (`CAP_SYS_ADMIN``) and return `EPERM` to regular users.
236        let mut cmd = Command::new(get_fusermount_command());
237        cmd.arg("-u").arg(self.local_mount_path());
238
239        Ok(vec![cmd])
240    }
241
242    /// Returns a command that opens a file manager (via `xdg-open`) at the local mount path (see `local_mount_path()`).
243    ///
244    /// Opening requires that the filesystem is already mounted.
245    pub fn open_command(&self) -> Command {
246        let mut cmd = Command::new("xdg-open");
247        cmd.arg(self.local_mount_path());
248
249        cmd
250    }
251}
252
253fn validate_id(id: &str) -> Result<(), ValidationError> {
254    if !id
255        .chars()
256        .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
257    {
258        return Err(ValidationError::new("invalid_id").with_message(
259            "The ID must contain only alphanumeric characters, underscores, dashes, or dots."
260                .into(),
261        ));
262    }
263    Ok(())
264}
265
266fn validate_absolute_path(path: &str) -> Result<(), ValidationError> {
267    if !path.starts_with('/') {
268        return Err(ValidationError::new("not_absolute_path")
269            .with_message(format!("The path {0} is not absolute.", path).into()));
270    }
271
272    Ok(())
273}
274
275fn validate_ssh_key_for_publickey_auth(
276    entity: &&FilesystemMountDefinition,
277) -> Result<(), ValidationError> {
278    match entity.auth_type {
279        AuthType::PublicKey => {
280            if entity.ssh_key.is_empty() {
281                Err(
282                    ValidationError::new("no_ssh_key_for_publickey_auth").with_message(
283                        format!(
284                            "The {0} authentication type requires an SSH key to be provided.",
285                            AuthType::PublicKey,
286                        )
287                        .into(),
288                    ),
289                )
290            } else {
291                Ok(())
292            }
293        }
294        _ => Ok(()),
295    }
296}