Skip to main content

mountpoint_s3_fs/data_cache/
cache_directory.rs

1//! Provides functionality related to the inner cache directory Mountpoint creates or uses.
2//! Mountpoint attempts to cleanup the contents during mount and exit.
3//!
4//! Mountpoint uses a directory inside the user-provided cache directory
5//! to mitigate any impact from the user providing a directory that already contains data.
6//! Using a new sub-directory minimizes the interference with the existing directory structure,
7//! and limits the risk from deleting or overwriting data to files written within this sub-directory.
8
9use sha2::{Digest, Sha256};
10use std::ffi::OsStr;
11use std::fs;
12use std::io;
13use std::os::unix::ffi::OsStrExt as _;
14use std::os::unix::fs::DirBuilderExt;
15use std::path::{Path, PathBuf};
16
17use thiserror::Error;
18
19/// Cache directory that will be created with appropriate permissions if it doesn't exist,
20/// and - where configured - emptied at creation and when dropped.
21///
22/// When using a `cache_key`, the key is hashed and added as a subdirectory of `mountpoint-cache`.
23#[derive(Debug)]
24pub struct ManagedCacheDir {
25    /// `<parent_path>/mountpoint-cache`
26    mountpoint_cache_path: PathBuf,
27    /// `<parent_path>/mountpoint-cache` or `<parent_path>/mountpoint-cache/<hashed_cache_key>`
28    managed_cache_path: PathBuf,
29    /// Indicates if directory should be removed before construction and when dropped.
30    should_cleanup: bool,
31}
32
33#[derive(Debug, Error)]
34pub enum ManagedCacheDirError {
35    #[error("creation of cache sub-directory failed due to IO error: {0}")]
36    CreationFailure(#[source] io::Error),
37    #[error("cleanup of cache sub-directory failed due to IO error: {0}")]
38    CleanupFailure(#[source] io::Error),
39}
40
41impl ManagedCacheDir {
42    /// Create a new directory inside the provided parent path.
43    ///
44    /// If `should_cleanup` is `true` and `<parent_path>/mountpoint-cache` already exists,
45    /// it will be deleted before being recreated with the correct permissions.
46    /// If `should_cleanup` is `false`, the directory will only be created if it doesn't exist.
47    /// Any existing directory will be used 'as is', and will not have its permissions updated.
48    ///
49    /// By cleaning up the directory, we ensure caches are cleaned up where Mountpoint may have exited uncleanly
50    /// and also ensure that the correct permissions are configured on the cache directory.
51    pub fn new_from_parent_with_cache_key(
52        parent_path: impl AsRef<Path>,
53        cache_key: Option<&OsStr>,
54        should_cleanup: bool,
55    ) -> Result<Self, ManagedCacheDirError> {
56        let mountpoint_cache_path = parent_path.as_ref().join("mountpoint-cache");
57        let managed_cache_path = match cache_key {
58            None => mountpoint_cache_path.clone(),
59            Some(cache_key) => mountpoint_cache_path.join(hash_cache_key(cache_key.as_bytes())),
60        };
61        let managed_cache_dir = Self {
62            mountpoint_cache_path,
63            managed_cache_path,
64            should_cleanup,
65        };
66
67        if should_cleanup {
68            managed_cache_dir.remove()?;
69        }
70        Self::create_dir(&managed_cache_dir.mountpoint_cache_path)?;
71        if cache_key.is_some() {
72            Self::create_dir(&managed_cache_dir.managed_cache_path)?;
73        }
74        Ok(managed_cache_dir)
75    }
76
77    /// Remove the cache sub-directory, along with its contents if any
78    fn remove(&self) -> Result<(), ManagedCacheDirError> {
79        tracing::debug!(cache_subdirectory = ?self.mountpoint_cache_path, "removing the cache sub-directory and any contents");
80        if let Err(remove_dir_err) = fs::remove_dir_all(&self.mountpoint_cache_path) {
81            match remove_dir_err.kind() {
82                io::ErrorKind::NotFound => (),
83                _kind => return Err(ManagedCacheDirError::CleanupFailure(remove_dir_err)),
84            }
85        }
86        tracing::trace!(cache_subdirectory = ?self.mountpoint_cache_path, "cache sub-directory removal complete");
87        Ok(())
88    }
89
90    /// Create a directory, assuming the parent path exists.
91    fn create_dir(path: &Path) -> Result<(), ManagedCacheDirError> {
92        let mkdir_result = fs::DirBuilder::new().mode(0o700).create(path);
93        if let Err(mkdir_err) = mkdir_result {
94            match mkdir_err.kind() {
95                io::ErrorKind::AlreadyExists => tracing::debug!(
96                    cache_dir = ?path,
97                    "cache sub-directory already existed",
98                ),
99                _kind => return Err(ManagedCacheDirError::CreationFailure(mkdir_err)),
100            }
101        }
102
103        Ok(())
104    }
105
106    /// Retrieve a reference to the managed path
107    pub fn as_path(&self) -> &Path {
108        self.managed_cache_path.as_path()
109    }
110
111    /// Create an owned copy of the managed path
112    pub fn as_path_buf(&self) -> PathBuf {
113        self.managed_cache_path.clone()
114    }
115}
116
117impl Drop for ManagedCacheDir {
118    fn drop(&mut self) {
119        if self.should_cleanup
120            && let Err(err) = self.remove()
121        {
122            tracing::error!(cache_subdirectory = ?self.mountpoint_cache_path, "failed to remove cache sub-directory: {err}");
123        }
124    }
125}
126
127/// Hash the cache_key to avoid path traversal attacks.
128fn hash_cache_key(cache_key: &[u8]) -> String {
129    let hashed_key = Sha256::digest(cache_key);
130    hex::encode(hashed_key)
131}
132
133#[cfg(test)]
134mod tests {
135    use test_case::test_matrix;
136
137    use super::{ManagedCacheDir, hash_cache_key};
138
139    use std::ffi::OsStr;
140    use std::fs;
141    use std::os::unix::ffi::OsStrExt as _;
142    use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
143
144    const EXPECTED_DIR_MODE: u32 = 0o700;
145
146    const SHOULD_CLEANUP: bool = true;
147    const SHOULD_NOT_CLEANUP: bool = !SHOULD_CLEANUP;
148
149    macro_rules! assert_dir_does_not_exist {
150        ($path:expr, $($arg:tt)+) => {
151            let err = fs::metadata($path).expect_err("path should not exist");
152            assert!(
153                matches!(err.kind(), std::io::ErrorKind::NotFound),
154                $($arg)+
155            );
156        };
157    }
158
159    macro_rules! assert_cache_dir_existence_after_cleanup {
160        ($expected:expr, $path:expr) => {
161            let exists = $path.try_exists().unwrap();
162            if $expected {
163                assert_eq!($expected, exists, "expected cache sub-directory to exist");
164            } else {
165                assert_eq!($expected, exists, "expected cache sub-directory to not exist");
166            }
167        };
168    }
169
170    macro_rules! assert_dir_exists_with_permissions {
171        ($expected_path:expr) => {
172            let dir_mode = fs::metadata($expected_path)
173                .expect("path should exist")
174                .permissions()
175                .mode();
176            let dir_mode = dir_mode & 0o777;
177            assert_eq!(
178                dir_mode, EXPECTED_DIR_MODE,
179                "path should have {EXPECTED_DIR_MODE:#o} permission mode but had {dir_mode:#o}",
180            );
181        };
182    }
183
184    #[test_matrix([SHOULD_CLEANUP, SHOULD_NOT_CLEANUP])]
185    fn test_unused(should_cleanup: bool) {
186        let temp_dir = tempfile::tempdir().unwrap();
187        let expected_path = temp_dir.path().join("mountpoint-cache");
188
189        let managed_dir = ManagedCacheDir::new_from_parent_with_cache_key(temp_dir.path(), None, should_cleanup)
190            .expect("creating managed dir should succeed");
191        assert_dir_exists_with_permissions!(&expected_path);
192
193        drop(managed_dir);
194        assert_cache_dir_existence_after_cleanup!(!should_cleanup, &expected_path);
195
196        temp_dir.close().unwrap();
197    }
198
199    #[test_matrix([SHOULD_CLEANUP, SHOULD_NOT_CLEANUP])]
200    fn test_cache_key_unused(should_cleanup: bool) {
201        let temp_dir = tempfile::tempdir().unwrap();
202        let cache_key = OsStr::new("cache_key");
203        let mp_cache_path = temp_dir.path().join("mountpoint-cache");
204        let expected_path = mp_cache_path.join(hash_cache_key(cache_key.as_bytes()));
205
206        let managed_dir =
207            ManagedCacheDir::new_from_parent_with_cache_key(temp_dir.path(), Some(cache_key), should_cleanup)
208                .expect("creating managed dir should succeed");
209        assert_dir_does_not_exist!(
210            &mp_cache_path.join("cache_key"),
211            "raw cache key should not be used in cache dir name",
212        );
213        assert_dir_exists_with_permissions!(&expected_path);
214
215        drop(managed_dir);
216        assert_cache_dir_existence_after_cleanup!(!should_cleanup, &mp_cache_path);
217
218        temp_dir.close().unwrap();
219    }
220
221    #[test_matrix([SHOULD_CLEANUP, SHOULD_NOT_CLEANUP])]
222    fn test_used(should_cleanup: bool) {
223        let temp_dir = tempfile::tempdir().unwrap();
224        let expected_path = temp_dir.path().join("mountpoint-cache");
225
226        let managed_dir = ManagedCacheDir::new_from_parent_with_cache_key(temp_dir.path(), None, should_cleanup)
227            .expect("creating managed dir should succeed");
228        assert_dir_exists_with_permissions!(&expected_path);
229
230        fs::File::create(expected_path.join("file.txt"))
231            .expect("should be able to create file within managed directory");
232        fs::create_dir(expected_path.join("dir")).expect("should be able to create dir within managed directory");
233        fs::File::create(expected_path.join("dir/file.txt"))
234            .expect("should be able to create file within subdirectory");
235
236        drop(managed_dir);
237        assert_cache_dir_existence_after_cleanup!(!should_cleanup, &expected_path);
238
239        temp_dir.close().unwrap();
240    }
241
242    #[test_matrix([SHOULD_CLEANUP, SHOULD_NOT_CLEANUP])]
243    fn test_cache_key_used(should_cleanup: bool) {
244        let temp_dir = tempfile::tempdir().unwrap();
245        let cache_key = OsStr::new("cache_key");
246        let mp_cache_path = temp_dir.path().join("mountpoint-cache");
247        let expected_path = mp_cache_path.join(hash_cache_key(cache_key.as_bytes()));
248
249        let managed_dir =
250            ManagedCacheDir::new_from_parent_with_cache_key(temp_dir.path(), Some(cache_key), should_cleanup)
251                .expect("creating managed dir should succeed");
252        assert_dir_does_not_exist!(
253            &mp_cache_path.join("cache_key"),
254            "raw cache key should not be used in cache dir name",
255        );
256        assert_dir_exists_with_permissions!(&expected_path);
257
258        fs::File::create(expected_path.join("file.txt"))
259            .expect("should be able to create file within managed directory");
260        fs::create_dir(expected_path.join("dir")).expect("should be able to create dir within managed directory");
261        fs::File::create(expected_path.join("dir/file.txt"))
262            .expect("should be able to create file within subdirectory");
263
264        drop(managed_dir);
265        assert_cache_dir_existence_after_cleanup!(!should_cleanup, &expected_path);
266
267        temp_dir.close().unwrap();
268    }
269
270    #[test_matrix([SHOULD_CLEANUP, SHOULD_NOT_CLEANUP])]
271    fn test_already_exists(should_cleanup: bool) {
272        let temp_dir = tempfile::tempdir().unwrap();
273        let expected_path = temp_dir.path().join("mountpoint-cache");
274
275        fs::DirBuilder::new()
276            .recursive(true)
277            .mode(0o775) // something that isn't the expected `0o700`
278            .create(expected_path.join("dir"))
279            .unwrap();
280        fs::File::create(expected_path.join("dir/file.txt")).unwrap();
281
282        let managed_dir = ManagedCacheDir::new_from_parent_with_cache_key(temp_dir.path(), None, should_cleanup)
283            .expect("creating managed dir should succeed");
284
285        fs::metadata(&expected_path).expect("path should exist");
286
287        let dir_entries = fs::read_dir(&expected_path).unwrap().count();
288        if should_cleanup {
289            assert_eq!(dir_entries, 0, "directory should be empty");
290            // Also check permissions! If we created it, then it should be the right permissions.
291            assert_dir_exists_with_permissions!(&expected_path);
292        } else {
293            assert_eq!(dir_entries, 1, "directory should have one entry");
294        }
295
296        drop(managed_dir);
297        assert_cache_dir_existence_after_cleanup!(!should_cleanup, &expected_path);
298
299        temp_dir.close().unwrap();
300    }
301}