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