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