libsftpman/
manager.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4use std::process::Command;
5use std::time::Duration;
6
7use crate::model::DEFAULT_MOUNT_PATH_PREFIX;
8
9use super::errors::{ManagerInitError, PreflightCheckError, SftpManError};
10use super::model::{FilesystemMountDefinition, MountState};
11
12use super::utils::command::{run_command, run_command_background};
13use super::utils::fs::{
14    ensure_directory_recursively_created, get_mounts_under_path_prefix, remove_empty_directory,
15};
16use super::utils::fusermount::{create_fusermount_check_command, create_fusermount3_check_command};
17use super::utils::process::{ensure_process_killed, sshfs_pid_by_definition};
18
19const VFS_TYPE_SSHFS: &str = "fuse.sshfs";
20
21#[derive(Default, Clone)]
22pub struct Manager {
23    config_path: PathBuf,
24}
25
26impl Manager {
27    pub fn new() -> Result<Self, ManagerInitError> {
28        let d = directories::ProjectDirs::from("sftpman", "Devture Ltd", "sftpman")
29            .ok_or(ManagerInitError::NoConfigDirectory)?;
30
31        Ok(Self {
32            config_path: d.config_dir().to_path_buf().to_owned(),
33        })
34    }
35
36    /// Returns the list of all known (stored in the config directory) filesystem definitions.
37    pub fn definitions(&self) -> Result<Vec<FilesystemMountDefinition>, SftpManError> {
38        let dir_path = self.config_path_mounts();
39
40        if !dir_path.is_dir() {
41            log::debug!(
42                "Mount config directory {0} doesn't exist. Returning an empty definitions list ...",
43                dir_path.display()
44            );
45            return Ok(vec![]);
46        }
47
48        let mut list: Vec<FilesystemMountDefinition> = Vec::new();
49
50        let directory_entries =
51            fs::read_dir(dir_path).map_err(|err| SftpManError::Generic(err.to_string()))?;
52
53        for entry in directory_entries {
54            let entry = entry.map_err(|err| SftpManError::Generic(err.to_string()))?;
55
56            let path = entry.path();
57            if !path.is_file() {
58                continue;
59            }
60
61            let name = path.file_name();
62            if name.is_none() {
63                continue;
64            }
65            if !name.unwrap().to_string_lossy().ends_with(".json") {
66                continue;
67            }
68
69            match Self::definition_from_config_path(&path) {
70                Ok(cfg) => list.push(cfg),
71                Err(err) => return Err(err),
72            }
73        }
74
75        list.sort_by_key(|item| item.id.clone());
76
77        Ok(list)
78    }
79
80    /// Returns the filesystem definition (as stored in the config directory) for the given ID.
81    pub fn definition(&self, id: &str) -> Result<FilesystemMountDefinition, SftpManError> {
82        Self::definition_from_config_path(&self.config_path_for_definition_id(id))
83    }
84
85    /// Returns the full state (configuration and mount status) of all known (stored in the config directory) filesystem definitions.
86    pub fn full_state(&self) -> Result<Vec<MountState>, SftpManError> {
87        let mut mounted_sshfs_paths_map: HashMap<String, bool> = HashMap::new();
88
89        for mount in get_mounts_under_path_prefix("/")? {
90            if mount.vfstype != VFS_TYPE_SSHFS {
91                continue;
92            }
93
94            mounted_sshfs_paths_map
95                .insert(mount.file.as_os_str().to_str().unwrap().to_owned(), true);
96        }
97
98        let mut list: Vec<MountState> = Vec::new();
99
100        for definition in self.definitions()? {
101            let mounted = mounted_sshfs_paths_map.contains_key(&definition.local_mount_path());
102            list.push(MountState::new(definition, mounted));
103        }
104
105        Ok(list)
106    }
107
108    /// Tells if the given filesystem definition is currently mounted.
109    pub fn is_definition_mounted(
110        &self,
111        definition: &FilesystemMountDefinition,
112    ) -> Result<bool, SftpManError> {
113        let local_mount_path = definition.local_mount_path();
114
115        for mount in get_mounts_under_path_prefix(local_mount_path.as_str())? {
116            if *mount.file.as_os_str().to_str().unwrap() != local_mount_path {
117                continue;
118            }
119
120            if mount.vfstype != VFS_TYPE_SSHFS {
121                return Err(SftpManError::MountVfsTypeMismatch {
122                    path: std::path::Path::new(&local_mount_path).to_path_buf(),
123                    found_vfs_type: mount.vfstype.to_string(),
124                    expected_vfs_type: VFS_TYPE_SSHFS.to_string(),
125                });
126            }
127
128            return Ok(true);
129        }
130
131        Ok(false)
132    }
133
134    /// Mounts a filesystem definition unless already mounted.
135    pub fn mount(&self, definition: &FilesystemMountDefinition) -> Result<(), SftpManError> {
136        if self.is_definition_mounted(definition)? {
137            log::info!("{0}: already mounted, nothing to do..", definition.id);
138            return Ok(());
139        }
140
141        log::info!("{0}: mounting..", definition.id);
142
143        ensure_directory_recursively_created(&definition.local_mount_path())?;
144
145        let cmds = definition.mount_commands().unwrap();
146
147        for cmd in cmds {
148            log::debug!("{0}: executing mount command: {1:?}", definition.id, cmd);
149
150            if let Err(err) = run_command(cmd) {
151                log::error!(
152                    "{0}: failed to run mount command: {1:?}",
153                    definition.id,
154                    err
155                );
156
157                log::debug!("{0}: performing umount to clean up", definition.id);
158
159                // This will most likely fail, but we should try to do it anyway.
160                if let Err(err) = self.umount(definition) {
161                    log::debug!(
162                        "{0}: failed to perform cleanup-umount: {1:?}",
163                        definition.id,
164                        err
165                    );
166                }
167
168                self.clean_up_after_unmount(definition);
169
170                return Err(err);
171            }
172        }
173
174        Ok(())
175    }
176
177    /// Unmounts a filesystem definition (unless already unmounted) and removes its mount path from the filesystem hierarchy.
178    ///
179    /// Unmounting is performed via a command call to `fusermount3 -u ..` (preferred) or `fusermount -u ..` (fallback),
180    /// which may fail on filesystems that are currently busy.
181    /// In such cases, a fallback is performed - the `sshfs` process responsible for the mount gets terminated.
182    pub fn umount(&self, definition: &FilesystemMountDefinition) -> Result<(), SftpManError> {
183        if !self.is_definition_mounted(definition)? {
184            log::info!("{0}: not mounted, nothing to do..", definition.id);
185            return Ok(());
186        }
187
188        log::info!("{0}: unmounting..", definition.id);
189
190        match self.do_umount(definition) {
191            Ok(_) => Ok(()),
192
193            Err(err) => {
194                // It's likely that this is a "Device is busy" error.
195
196                log::warn!("{0} failed to get unmounted: {1:?}", definition.id, err);
197
198                self.kill_sshfs_for_definition(definition)?;
199
200                // Killing successfully is good enough to unmount.
201                // We don't need to call do_umount() again, as calling `fusermount -u ..` (etc), may fail with:
202                // > CommandUnsuccessfulError("fusermount" "-u" "/home/user/mounts/storage", Output { status: ExitStatus(unix_wait_status(256)), stdout: "", stderr: "fusermount: entry for /path not found in /etc/mtab\n" })
203                // We only need to clean up now.
204
205                self.clean_up_after_unmount(definition);
206
207                Ok(())
208            }
209        }
210    }
211
212    fn do_umount(&self, definition: &FilesystemMountDefinition) -> Result<(), SftpManError> {
213        let cmds = definition.umount_commands().unwrap();
214
215        for cmd in cmds {
216            log::debug!("{0}: executing unmount command: {1:?}", definition.id, cmd);
217
218            if let Err(err) = run_command(cmd) {
219                log::error!(
220                    "{0}: failed to run unmount command: {1:?}",
221                    definition.id,
222                    err
223                );
224
225                // We weren't successful to unmount, but it may be because the mount point already got unmounted.
226                // It doesn't hurt to try and clean up.
227                self.clean_up_after_unmount(definition);
228
229                return Err(err);
230            }
231        }
232
233        self.clean_up_after_unmount(definition);
234
235        Ok(())
236    }
237
238    /// Unmounts the given filesystem (if mounted) and removes the configuration file for it.
239    pub fn remove(&self, definition: &FilesystemMountDefinition) -> Result<(), SftpManError> {
240        log::info!("{0}: removing..", definition.id);
241
242        self.umount(definition)?;
243
244        let definition_config_path = self.config_path_for_definition_id(&definition.id);
245
246        log::debug!(
247            "{0}: deleting file {1}",
248            definition.id,
249            definition_config_path.display()
250        );
251
252        fs::remove_file(&definition_config_path).map_err(|err| {
253            SftpManError::FilesystemMountDefinitionRemove(definition_config_path, err)
254        })?;
255
256        Ok(())
257    }
258
259    /// Checks if we have everything needed to mount/unmount sshfs/SFTP filesystems.
260    pub fn preflight_check(&self) -> Result<(), Vec<PreflightCheckError>> {
261        let mut cmd_alternative_groups: Vec<Vec<Command>> = Vec::new();
262
263        let mut cmd_sshfs = Command::new("sshfs");
264        cmd_sshfs.arg("-h");
265        cmd_alternative_groups.push(vec![cmd_sshfs]);
266
267        let mut cmd_ssh = Command::new("ssh");
268        cmd_ssh.arg("-V");
269        cmd_alternative_groups.push(vec![cmd_ssh]);
270
271        // We favor `fusermount3`, but will also make do with `fusermount` if `fusermount3` is not available.
272        // See: https://github.com/spantaleev/sftpman-rs/issues/3
273        cmd_alternative_groups.push(vec![
274            create_fusermount3_check_command(),
275            create_fusermount_check_command(),
276        ]);
277
278        let mut errors: Vec<PreflightCheckError> = Vec::new();
279
280        for cmd_group in cmd_alternative_groups {
281            let mut cmd_group_successful = false;
282            let mut cmd_group_errors: Vec<PreflightCheckError> = Vec::new();
283
284            for cmd in cmd_group {
285                log::debug!("Executing preflight-check command: {0:?}", cmd);
286
287                if let Err(err) = run_command(cmd) {
288                    log::warn!("Failed to run preflight-check command: {0:?}", err);
289
290                    let preflight_check_error = match err {
291                        SftpManError::CommandExecution(cmd, err) => {
292                            Some(PreflightCheckError::CommandExecution(cmd, err))
293                        }
294                        SftpManError::CommandUnsuccessful(cmd, output) => {
295                            Some(PreflightCheckError::CommandUnsuccessful(cmd, output))
296                        }
297                        _ => {
298                            // This should never happen since run_command() only returns these two error variants
299                            log::error!("Unexpected error type: {0:?}", err);
300                            None
301                        }
302                    };
303
304                    if let Some(preflight_check_error) = preflight_check_error {
305                        cmd_group_errors.push(preflight_check_error);
306                    }
307                } else {
308                    log::debug!("Preflight-check command succeeded");
309                    cmd_group_successful = true;
310                    break;
311                }
312            }
313
314            if !cmd_group_successful {
315                errors.extend(cmd_group_errors);
316            }
317        }
318
319        let default_mount_path = PathBuf::from(DEFAULT_MOUNT_PATH_PREFIX);
320        let mut default_mount_path_ok = false;
321        let random_test_path = default_mount_path.join(format!(
322            "_{}_test_{}",
323            env!("CARGO_PKG_NAME"),
324            rand::random::<u32>()
325        ));
326
327        if default_mount_path.exists() {
328            log::debug!(
329                "Default mount path {} already exists",
330                DEFAULT_MOUNT_PATH_PREFIX
331            );
332            default_mount_path_ok = true;
333        } else {
334            log::warn!(
335                "Default mount path {} does not exist, attempting to create it",
336                DEFAULT_MOUNT_PATH_PREFIX
337            );
338
339            if let Err(err) = fs::create_dir_all(&default_mount_path) {
340                log::error!(
341                    "Failed to create mount path {}: {}",
342                    DEFAULT_MOUNT_PATH_PREFIX,
343                    err
344                );
345
346                errors.push(PreflightCheckError::DefaultBasePathIO(
347                    default_mount_path,
348                    err,
349                ));
350            } else {
351                default_mount_path_ok = true;
352            }
353        }
354
355        if default_mount_path_ok {
356            log::debug!(
357                "Testing if we can create and remove directory: {}",
358                random_test_path.display()
359            );
360
361            if let Err(err) = fs::create_dir_all(&random_test_path) {
362                log::error!(
363                    "Failed to create test directory {}: {}",
364                    random_test_path.display(),
365                    err
366                );
367                errors.push(PreflightCheckError::TestUnderBasePathIO(
368                    random_test_path,
369                    err,
370                ));
371            } else if let Err(err) = fs::remove_dir(&random_test_path) {
372                log::error!(
373                    "Failed to remove test directory {}: {}",
374                    random_test_path.display(),
375                    err
376                );
377                errors.push(PreflightCheckError::TestUnderBasePathIO(
378                    random_test_path,
379                    err,
380                ));
381            }
382        }
383
384        if errors.is_empty() {
385            Ok(())
386        } else {
387            Err(errors)
388        }
389    }
390
391    /// Persists (creates or updates) a filesystem definition.
392    ///
393    /// If the definition already exists, it will be unmounted before persisting and will be remounted after.
394    pub fn persist(&self, definition: &FilesystemMountDefinition) -> Result<(), SftpManError> {
395        let mut is_existing_and_mounted = false;
396        if let Ok(old) = self.definition(&definition.id) {
397            is_existing_and_mounted = self.is_definition_mounted(&old)?;
398
399            if is_existing_and_mounted {
400                log::debug!(
401                    "{0} was found to be an existing and currently mounted definition. Unmounting..",
402                    definition.id
403                );
404
405                if let Err(err) = self.umount(&old) {
406                    log::error!("{0} failed to be unmounted: {1:?}", definition.id, err);
407                }
408            }
409        }
410
411        let path = self.config_path_for_definition_id(&definition.id);
412
413        let config_dir_path = path
414            .parent()
415            .expect("Config directory path should have a parent");
416
417        if !config_dir_path.exists() {
418            log::info!(
419                "Config directory {} does not exist, attempting to create it",
420                config_dir_path.display()
421            );
422
423            if let Err(err) = fs::create_dir_all(config_dir_path) {
424                log::error!(
425                    "Failed to create config directory {}: {}",
426                    config_dir_path.display(),
427                    err
428                );
429                return Err(SftpManError::IO(path.clone(), err));
430            }
431        }
432
433        let serialized = definition
434            .to_json_string()
435            .map_err(|err| SftpManError::JSON(path.clone(), err))?;
436
437        fs::write(&path, serialized).map_err(|err| SftpManError::IO(path.clone(), err))?;
438
439        if is_existing_and_mounted {
440            log::debug!(
441                "{0} is being mounted, because it was before updating..",
442                definition.id
443            );
444
445            if let Err(err) = self.mount(definition) {
446                log::error!(
447                    "{0} failed get re-mounted afte rupdating: {1:?}",
448                    definition.id,
449                    err
450                );
451            }
452        }
453
454        Ok(())
455    }
456
457    /// Opens the directory where the given filesystem definition is mounted.
458    pub fn open(&self, definition: &FilesystemMountDefinition) -> Result<(), SftpManError> {
459        if let Err(err) = run_command_background(definition.open_command()) {
460            log::error!("{0}: failed to run open command: {1:?}", definition.id, err);
461        }
462        Ok(())
463    }
464
465    fn kill_sshfs_for_definition(
466        &self,
467        definition: &FilesystemMountDefinition,
468    ) -> Result<(), SftpManError> {
469        log::debug!(
470            "Trying to determine the sshfs process for {0}",
471            definition.id
472        );
473
474        let pid = sshfs_pid_by_definition(definition)?;
475
476        match pid {
477            Some(pid) => {
478                log::debug!(
479                    "Process id for {0} determined to be: {1}. Killing..",
480                    definition.id,
481                    pid
482                );
483
484                ensure_process_killed(pid, Duration::from_millis(500), Duration::from_millis(2000))
485            }
486
487            None => Err(SftpManError::Generic(format!(
488                "Failed to determine pid for: {0}",
489                definition.id
490            ))),
491        }
492    }
493
494    fn clean_up_after_unmount(&self, definition: &FilesystemMountDefinition) {
495        log::debug!("{0}: cleaning up after unmounting", definition.id);
496
497        if let Err(err) = remove_empty_directory(&definition.local_mount_path()) {
498            log::debug!(
499                "{0}: failed to remove local mount point: {1:?}",
500                definition.id,
501                err
502            );
503        }
504    }
505
506    fn config_path_mounts(&self) -> PathBuf {
507        self.config_path.join("mounts")
508    }
509
510    fn config_path_for_definition_id(&self, id: &str) -> PathBuf {
511        self.config_path_mounts().join(format!("{0}.json", id))
512    }
513
514    fn definition_from_config_path(
515        path: &PathBuf,
516    ) -> Result<FilesystemMountDefinition, SftpManError> {
517        let contents = fs::read_to_string(path)
518            .map_err(|err| SftpManError::FilesystemMountDefinitionRead(path.clone(), err))?;
519
520        let mount_config_result = FilesystemMountDefinition::from_json_string(&contents);
521
522        match mount_config_result {
523            Ok(cfg) => Ok(cfg),
524            Err(err) => Err(SftpManError::JSON(path.clone(), err)),
525        }
526    }
527}