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
11pub struct ProfileStore {
36 dir: PathBuf,
37}
38
39impl ProfileStore {
40 pub fn new(dir: impl Into<PathBuf>) -> Self {
42 Self { dir: dir.into() }
43 }
44
45 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 pub fn dir(&self) -> &Path {
66 &self.dir
67 }
68
69 pub fn save<T: Serialize>(&self, filename: &str, value: &T) -> Result<(), ProfileError> {
73 self.write(filename, value, None)
74 }
75
76 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 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 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 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 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 pub fn exists(&self, filename: &str) -> bool {
177 Self::validate_filename(filename).is_ok() && self.dir.join(filename).exists()
178 }
179
180 pub fn save_profile<T: ProfileData>(&self, value: &T) -> Result<(), ProfileError> {
182 self.write(T::FILENAME, value, T::MODE)
183 }
184
185 pub fn load_profile<T: ProfileData>(&self) -> Result<T, ProfileError> {
187 self.load(T::FILENAME)
188 }
189
190 pub fn clear_profile<T: ProfileData>(&self) -> Result<(), ProfileError> {
192 self.clear(T::FILENAME)
193 }
194
195 pub fn exists_profile<T: ProfileData>(&self) -> bool {
197 self.exists(T::FILENAME)
198 }
199}
200
201impl 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 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 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}