Skip to main content

stack_profile/
profile_store.rs

1use std::path::{Path, PathBuf};
2
3use serde::de::DeserializeOwned;
4use serde::Serialize;
5
6use crate::{ProfileData, ProfileError};
7
8const CS_CONFIG_PATH_ENV: &str = "CS_CONFIG_PATH";
9const DEFAULT_DIR_NAME: &str = ".cipherstash";
10
11/// A directory-scoped JSON file store for profile data.
12///
13/// `ProfileStore` represents a profile directory (typically `~/.cipherstash/`).
14/// Individual files are addressed by name when calling [`save`](Self::save),
15/// [`load`](Self::load), and other operations.
16///
17/// # Example
18///
19/// ```no_run
20/// use stack_profile::ProfileStore;
21/// use serde::{Serialize, Deserialize};
22///
23/// #[derive(Serialize, Deserialize)]
24/// struct MyConfig {
25///     name: String,
26/// }
27///
28/// # fn main() -> Result<(), stack_profile::ProfileError> {
29/// let store = ProfileStore::resolve(None)?;
30/// store.save("my-config.json", &MyConfig { name: "example".into() })?;
31/// let config: MyConfig = store.load("my-config.json")?;
32/// # Ok(())
33/// # }
34/// ```
35pub struct ProfileStore {
36    dir: PathBuf,
37}
38
39impl ProfileStore {
40    /// Create a profile store rooted at the given directory.
41    pub fn new(dir: impl Into<PathBuf>) -> Self {
42        Self { dir: dir.into() }
43    }
44
45    /// Resolve the profile directory.
46    ///
47    /// Resolution order:
48    /// 1. `explicit` path, if provided
49    /// 2. `CS_CONFIG_PATH` environment variable, if set
50    /// 3. `~/.cipherstash` (the default)
51    pub fn resolve(explicit: Option<PathBuf>) -> Result<Self, ProfileError> {
52        if let Some(path) = explicit {
53            return Ok(Self::new(path));
54        }
55        if let Ok(path) = std::env::var(CS_CONFIG_PATH_ENV) {
56            if !path.trim().is_empty() {
57                return Ok(Self::new(path));
58            }
59        }
60        let home = dirs::home_dir().ok_or(ProfileError::HomeDirNotFound)?;
61        Ok(Self::new(home.join(DEFAULT_DIR_NAME)))
62    }
63
64    /// Return the directory path.
65    pub fn dir(&self) -> &Path {
66        &self.dir
67    }
68
69    /// Save a value as pretty-printed JSON to a file in the store directory.
70    ///
71    /// Creates the directory and any parents if they don't exist.
72    pub fn save<T: Serialize>(&self, filename: &str, value: &T) -> Result<(), ProfileError> {
73        self.write(filename, value, None)
74    }
75
76    /// Save a value as pretty-printed JSON with restricted Unix file permissions.
77    ///
78    /// On non-Unix platforms the mode is ignored and this behaves like [`save`](Self::save).
79    pub fn save_with_mode<T: Serialize>(
80        &self,
81        filename: &str,
82        value: &T,
83        _mode: u32,
84    ) -> Result<(), ProfileError> {
85        #[cfg(unix)]
86        return self.write(filename, value, Some(_mode));
87        #[cfg(not(unix))]
88        self.write(filename, value, None)
89    }
90
91    /// Validate that a filename is a plain, non-empty filename (no path separators or `..`).
92    fn validate_filename(filename: &str) -> Result<(), ProfileError> {
93        let path = Path::new(filename);
94        if filename.is_empty()
95            || path.is_absolute()
96            || filename.contains(std::path::MAIN_SEPARATOR)
97            || filename.contains('/')
98            || path
99                .components()
100                .any(|c| matches!(c, std::path::Component::ParentDir))
101        {
102            return Err(ProfileError::InvalidFilename(filename.to_string()));
103        }
104        Ok(())
105    }
106
107    fn write<T: Serialize>(
108        &self,
109        filename: &str,
110        value: &T,
111        _mode: Option<u32>,
112    ) -> Result<(), ProfileError> {
113        Self::validate_filename(filename)?;
114        std::fs::create_dir_all(&self.dir)?;
115        let path = self.dir.join(filename);
116        let json = serde_json::to_string_pretty(value)?;
117
118        #[cfg(unix)]
119        if let Some(mode) = _mode {
120            use std::fs::OpenOptions;
121            use std::io::Write;
122            use std::os::unix::fs::OpenOptionsExt;
123
124            let mut file = OpenOptions::new()
125                .write(true)
126                .create(true)
127                .truncate(true)
128                .mode(mode)
129                .open(&path)?;
130            file.write_all(json.as_bytes())?;
131
132            // Ensure permissions are set even if the file already existed,
133            // since OpenOptions::mode() only applies on creation.
134            use std::os::unix::fs::PermissionsExt;
135            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode))?;
136
137            return Ok(());
138        }
139
140        std::fs::write(&path, json)?;
141        Ok(())
142    }
143
144    /// Load a value from a JSON file in the store directory.
145    ///
146    /// Returns [`ProfileError::NotFound`] if the file does not exist.
147    pub fn load<T: DeserializeOwned>(&self, filename: &str) -> Result<T, ProfileError> {
148        Self::validate_filename(filename)?;
149        let path = self.dir.join(filename);
150        match std::fs::read_to_string(&path) {
151            Ok(contents) => {
152                let value: T = serde_json::from_str(&contents)?;
153                Ok(value)
154            }
155            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
156                Err(ProfileError::NotFound { path })
157            }
158            Err(e) => Err(ProfileError::Io(e)),
159        }
160    }
161
162    /// Remove a file from the store directory.
163    ///
164    /// Does nothing if the file does not already exist.
165    pub fn clear(&self, filename: &str) -> Result<(), ProfileError> {
166        Self::validate_filename(filename)?;
167        let path = self.dir.join(filename);
168        match std::fs::remove_file(&path) {
169            Ok(()) => Ok(()),
170            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
171            Err(e) => Err(ProfileError::Io(e)),
172        }
173    }
174
175    /// Check whether a file exists in the store directory.
176    pub fn exists(&self, filename: &str) -> bool {
177        Self::validate_filename(filename).is_ok() && self.dir.join(filename).exists()
178    }
179
180    /// Save a [`ProfileData`] value using its declared filename and mode.
181    pub fn save_profile<T: ProfileData>(&self, value: &T) -> Result<(), ProfileError> {
182        self.write(T::FILENAME, value, T::MODE)
183    }
184
185    /// Load a [`ProfileData`] value from its declared filename.
186    pub fn load_profile<T: ProfileData>(&self) -> Result<T, ProfileError> {
187        self.load(T::FILENAME)
188    }
189
190    /// Remove the file for a [`ProfileData`] type.
191    pub fn clear_profile<T: ProfileData>(&self) -> Result<(), ProfileError> {
192        self.clear(T::FILENAME)
193    }
194
195    /// Check whether the file for a [`ProfileData`] type exists.
196    pub fn exists_profile<T: ProfileData>(&self) -> bool {
197        self.exists(T::FILENAME)
198    }
199}
200
201/// Returns a profile store at `~/.cipherstash`.
202///
203/// # Panics
204///
205/// Panics if the home directory cannot be determined.
206impl Default for ProfileStore {
207    #[allow(clippy::expect_used)]
208    fn default() -> Self {
209        let home = dirs::home_dir().expect("could not determine home directory");
210        Self::new(home.join(DEFAULT_DIR_NAME))
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use serde::{Deserialize, Serialize};
218
219    #[derive(Debug, PartialEq, Serialize, Deserialize)]
220    struct TestData {
221        name: String,
222        value: u32,
223    }
224
225    #[test]
226    fn round_trip_save_and_load() {
227        let dir = tempfile::tempdir().unwrap();
228        let store = ProfileStore::new(dir.path());
229
230        let data = TestData {
231            name: "hello".into(),
232            value: 42,
233        };
234        store.save("data.json", &data).unwrap();
235
236        let loaded: TestData = store.load("data.json").unwrap();
237        assert_eq!(loaded, data);
238    }
239
240    #[test]
241    fn load_returns_not_found_for_missing_file() {
242        let dir = tempfile::tempdir().unwrap();
243        let store = ProfileStore::new(dir.path());
244
245        let err = store.load::<TestData>("missing.json").unwrap_err();
246        assert!(matches!(err, ProfileError::NotFound { .. }));
247    }
248
249    #[test]
250    fn clear_removes_existing_file() {
251        let dir = tempfile::tempdir().unwrap();
252        let store = ProfileStore::new(dir.path());
253
254        store
255            .save(
256                "data.json",
257                &TestData {
258                    name: "x".into(),
259                    value: 1,
260                },
261            )
262            .unwrap();
263        assert!(store.exists("data.json"));
264
265        store.clear("data.json").unwrap();
266        assert!(!store.exists("data.json"));
267    }
268
269    #[test]
270    fn clear_succeeds_for_missing_file() {
271        let dir = tempfile::tempdir().unwrap();
272        let store = ProfileStore::new(dir.path());
273        store.clear("missing.json").unwrap();
274    }
275
276    #[test]
277    fn save_creates_directory() {
278        let dir = tempfile::tempdir().unwrap();
279        let store = ProfileStore::new(dir.path().join("nested").join("dir"));
280
281        store
282            .save(
283                "data.json",
284                &TestData {
285                    name: "nested".into(),
286                    value: 99,
287                },
288            )
289            .unwrap();
290
291        let loaded: TestData = store.load("data.json").unwrap();
292        assert_eq!(loaded.name, "nested");
293    }
294
295    #[test]
296    fn exists_returns_false_for_missing_file() {
297        let dir = tempfile::tempdir().unwrap();
298        let store = ProfileStore::new(dir.path());
299        assert!(!store.exists("missing.json"));
300    }
301
302    #[test]
303    fn default_is_home_dot_cipherstash() {
304        let store = ProfileStore::default();
305        let home = dirs::home_dir().unwrap();
306        assert_eq!(store.dir(), home.join(".cipherstash"));
307    }
308
309    #[test]
310    fn resolve_explicit_overrides_all() {
311        let store = ProfileStore::resolve(Some("/tmp/custom".into())).unwrap();
312        assert_eq!(store.dir(), std::path::Path::new("/tmp/custom"));
313    }
314
315    mod filename_validation {
316        use super::*;
317
318        #[test]
319        fn rejects_empty_string() {
320            let dir = tempfile::tempdir().unwrap();
321            let store = ProfileStore::new(dir.path());
322
323            let err = store
324                .save(
325                    "",
326                    &TestData {
327                        name: "x".into(),
328                        value: 1,
329                    },
330                )
331                .unwrap_err();
332            assert!(matches!(err, ProfileError::InvalidFilename(_)));
333        }
334
335        #[test]
336        fn rejects_absolute_path() {
337            let dir = tempfile::tempdir().unwrap();
338            let store = ProfileStore::new(dir.path());
339
340            let err = store
341                .save(
342                    "/etc/passwd",
343                    &TestData {
344                        name: "x".into(),
345                        value: 1,
346                    },
347                )
348                .unwrap_err();
349            assert!(matches!(err, ProfileError::InvalidFilename(_)));
350        }
351
352        #[test]
353        fn rejects_parent_traversal() {
354            let dir = tempfile::tempdir().unwrap();
355            let store = ProfileStore::new(dir.path());
356
357            let err = store
358                .save(
359                    "../escape.json",
360                    &TestData {
361                        name: "x".into(),
362                        value: 1,
363                    },
364                )
365                .unwrap_err();
366            assert!(matches!(err, ProfileError::InvalidFilename(_)));
367        }
368
369        #[test]
370        fn rejects_path_with_separator() {
371            let dir = tempfile::tempdir().unwrap();
372            let store = ProfileStore::new(dir.path());
373
374            let err = store
375                .save(
376                    "sub/file.json",
377                    &TestData {
378                        name: "x".into(),
379                        value: 1,
380                    },
381                )
382                .unwrap_err();
383            assert!(matches!(err, ProfileError::InvalidFilename(_)));
384        }
385
386        #[test]
387        fn rejects_on_load() {
388            let dir = tempfile::tempdir().unwrap();
389            let store = ProfileStore::new(dir.path());
390
391            let err = store.load::<TestData>("../escape.json").unwrap_err();
392            assert!(matches!(err, ProfileError::InvalidFilename(_)));
393        }
394
395        #[test]
396        fn rejects_on_clear() {
397            let dir = tempfile::tempdir().unwrap();
398            let store = ProfileStore::new(dir.path());
399
400            let err = store.clear("../escape.json").unwrap_err();
401            assert!(matches!(err, ProfileError::InvalidFilename(_)));
402        }
403
404        #[test]
405        fn exists_returns_false_for_invalid_filename() {
406            let dir = tempfile::tempdir().unwrap();
407            let store = ProfileStore::new(dir.path());
408
409            assert!(!store.exists("../escape.json"));
410        }
411
412        #[test]
413        fn accepts_plain_filename() {
414            let dir = tempfile::tempdir().unwrap();
415            let store = ProfileStore::new(dir.path());
416
417            store
418                .save(
419                    "valid.json",
420                    &TestData {
421                        name: "ok".into(),
422                        value: 1,
423                    },
424                )
425                .unwrap();
426            let loaded: TestData = store.load("valid.json").unwrap();
427            assert_eq!(loaded.name, "ok");
428        }
429    }
430
431    #[cfg(unix)]
432    #[test]
433    fn save_with_mode_sets_permissions() {
434        use std::os::unix::fs::PermissionsExt;
435
436        let dir = tempfile::tempdir().unwrap();
437        let store = ProfileStore::new(dir.path());
438
439        store
440            .save_with_mode(
441                "secret.json",
442                &TestData {
443                    name: "secret".into(),
444                    value: 1,
445                },
446                0o600,
447            )
448            .unwrap();
449
450        let meta = std::fs::metadata(dir.path().join("secret.json")).unwrap();
451        let mode = meta.permissions().mode() & 0o777;
452        assert_eq!(mode, 0o600);
453    }
454
455    #[cfg(unix)]
456    #[test]
457    fn save_with_mode_tightens_existing_permissions() {
458        use std::os::unix::fs::PermissionsExt;
459
460        let dir = tempfile::tempdir().unwrap();
461        let store = ProfileStore::new(dir.path());
462        let path = dir.path().join("secret.json");
463
464        // Create file with broad permissions first
465        store
466            .save(
467                "secret.json",
468                &TestData {
469                    name: "v1".into(),
470                    value: 1,
471                },
472            )
473            .unwrap();
474        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
475
476        // Overwrite with restricted mode
477        store
478            .save_with_mode(
479                "secret.json",
480                &TestData {
481                    name: "v2".into(),
482                    value: 2,
483                },
484                0o600,
485            )
486            .unwrap();
487
488        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
489        assert_eq!(
490            mode, 0o600,
491            "permissions should be tightened on existing file"
492        );
493    }
494}