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";
10const WORKSPACES_DIR: &str = "workspaces";
11const CURRENT_WORKSPACE_FILE: &str = "current_workspace";
12
13#[derive(Debug, Clone)]
38pub struct ProfileStore {
39 dir: PathBuf,
40}
41
42#[must_use = "the lock is released as soon as this guard is dropped"]
49#[derive(Debug)]
50pub struct FileLockGuard {
51 file: std::fs::File,
52}
53
54impl Drop for FileLockGuard {
55 fn drop(&mut self) {
56 let _ = self.file.unlock();
60 }
61}
62
63impl ProfileStore {
64 pub fn new(dir: impl Into<PathBuf>) -> Self {
66 Self { dir: dir.into() }
67 }
68
69 pub fn resolve(explicit: Option<PathBuf>) -> Result<Self, ProfileError> {
76 if let Some(path) = explicit {
77 return Ok(Self::new(path));
78 }
79 if let Ok(path) = std::env::var(CS_CONFIG_PATH_ENV) {
80 if !path.trim().is_empty() {
81 return Ok(Self::new(path));
82 }
83 }
84 let home = dirs::home_dir().ok_or(ProfileError::HomeDirNotFound)?;
85 Ok(Self::new(home.join(DEFAULT_DIR_NAME)))
86 }
87
88 pub fn dir(&self) -> &Path {
90 &self.dir
91 }
92
93 pub fn save<T: Serialize>(&self, filename: &str, value: &T) -> Result<(), ProfileError> {
97 self.write(filename, value, None)
98 }
99
100 pub fn save_with_mode<T: Serialize>(
104 &self,
105 filename: &str,
106 value: &T,
107 _mode: u32,
108 ) -> Result<(), ProfileError> {
109 #[cfg(unix)]
110 return self.write(filename, value, Some(_mode));
111 #[cfg(not(unix))]
112 self.write(filename, value, None)
113 }
114
115 fn validate_filename(filename: &str) -> Result<(), ProfileError> {
117 let path = Path::new(filename);
118 if filename.is_empty()
119 || path.is_absolute()
120 || filename.contains(std::path::MAIN_SEPARATOR)
121 || filename.contains('/')
122 || path
123 .components()
124 .any(|c| matches!(c, std::path::Component::ParentDir))
125 {
126 return Err(ProfileError::InvalidFilename(filename.to_string()));
127 }
128 Ok(())
129 }
130
131 fn validate_workspace_id(id: &str) -> Result<(), ProfileError> {
135 let valid = id.len() == 16
136 && id
137 .bytes()
138 .all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b));
139 if valid {
140 Ok(())
141 } else {
142 Err(ProfileError::InvalidWorkspaceId(id.to_string()))
143 }
144 }
145
146 pub fn set_current_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> {
158 Self::validate_workspace_id(workspace_id)?;
159 let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id);
160 if !ws_dir.is_dir() {
161 return Err(ProfileError::WorkspaceNotFound(workspace_id.to_string()));
162 }
163 std::fs::create_dir_all(&self.dir)?;
164 let path = self.dir.join(CURRENT_WORKSPACE_FILE);
165 std::fs::write(&path, workspace_id)?;
166 Ok(())
167 }
168
169 pub fn init_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> {
175 Self::validate_workspace_id(workspace_id)?;
176 let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id);
178 std::fs::create_dir_all(&ws_dir)?;
179 let path = self.dir.join(CURRENT_WORKSPACE_FILE);
180 std::fs::write(&path, workspace_id)?;
181 Ok(())
182 }
183
184 pub fn current_workspace(&self) -> Result<String, ProfileError> {
188 let path = self.dir.join(CURRENT_WORKSPACE_FILE);
189 match std::fs::read_to_string(&path) {
190 Ok(contents) => {
191 let id = contents.trim().to_string();
192 Self::validate_workspace_id(&id)?;
193 Ok(id)
194 }
195 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
196 Err(ProfileError::NoCurrentWorkspace)
197 }
198 Err(e) => Err(ProfileError::Io(e)),
199 }
200 }
201
202 pub fn clear_current_workspace(&self) -> Result<(), ProfileError> {
204 let path = self.dir.join(CURRENT_WORKSPACE_FILE);
205 match std::fs::remove_file(&path) {
206 Ok(()) => Ok(()),
207 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
208 Err(e) => Err(ProfileError::Io(e)),
209 }
210 }
211
212 pub fn list_workspaces(&self) -> Result<Vec<String>, ProfileError> {
217 let ws_dir = self.dir.join(WORKSPACES_DIR);
218 match std::fs::read_dir(&ws_dir) {
219 Ok(entries) => {
220 let mut ids = Vec::new();
221 for entry in entries {
222 let entry = entry?;
223 if entry.file_type()?.is_dir() {
224 if let Some(name) = entry.file_name().to_str() {
225 if Self::validate_workspace_id(name).is_ok() {
226 ids.push(name.to_string());
227 }
228 }
229 }
230 }
231 ids.sort();
232 Ok(ids)
233 }
234 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
235 Err(e) => Err(ProfileError::Io(e)),
236 }
237 }
238
239 pub fn workspace_store(&self, workspace_id: &str) -> Result<ProfileStore, ProfileError> {
245 Self::validate_workspace_id(workspace_id)?;
246 Ok(ProfileStore::new(
247 self.dir.join(WORKSPACES_DIR).join(workspace_id),
248 ))
249 }
250
251 pub fn current_workspace_store(&self) -> Result<ProfileStore, ProfileError> {
256 let id = self.current_workspace()?;
257 self.workspace_store(&id)
258 }
259
260 pub fn migrate_to_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> {
267 Self::validate_workspace_id(workspace_id)?;
268 let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id);
269 std::fs::create_dir_all(&ws_dir)?;
270
271 for filename in &["auth.json", "secretkey.json"] {
272 let src = self.dir.join(filename);
273 let dst = ws_dir.join(filename);
274 if src.exists() && !dst.exists() {
275 std::fs::rename(&src, &dst)?;
276 }
277 }
278
279 self.set_current_workspace(workspace_id)?;
280 Ok(())
281 }
282
283 fn write<T: Serialize>(
286 &self,
287 filename: &str,
288 value: &T,
289 _mode: Option<u32>,
290 ) -> Result<(), ProfileError> {
291 Self::validate_filename(filename)?;
292 std::fs::create_dir_all(&self.dir)?;
293 let path = self.dir.join(filename);
294 let json = serde_json::to_string_pretty(value)?;
295 Self::write_to_path(&path, &json, _mode)
296 }
297
298 fn write_to_path(path: &Path, json: &str, _mode: Option<u32>) -> Result<(), ProfileError> {
328 use std::io::Write;
329
330 let parent = path.parent().ok_or_else(|| {
331 ProfileError::Io(std::io::Error::new(
332 std::io::ErrorKind::InvalidInput,
333 "target path has no parent directory",
334 ))
335 })?;
336 let file_name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
337 ProfileError::Io(std::io::Error::new(
338 std::io::ErrorKind::InvalidInput,
339 "target path has no file name",
340 ))
341 })?;
342 let tmp_path = parent.join(format!(
343 ".{file_name}.tmp.{}.{}",
344 std::process::id(),
345 uuid::Uuid::new_v4().simple()
346 ));
347
348 let result = (|| -> Result<(), ProfileError> {
349 let mut file = {
350 let mut opts = std::fs::OpenOptions::new();
351 let _ = opts.write(true).create_new(true);
352 #[cfg(unix)]
353 if let Some(mode) = _mode {
354 use std::os::unix::fs::OpenOptionsExt;
355 let _ = opts.mode(mode);
356 }
357 opts.open(&tmp_path)?
358 };
359 file.write_all(json.as_bytes())?;
360 file.sync_all()?;
361 drop(file);
362
363 #[cfg(unix)]
367 if let Some(mode) = _mode {
368 use std::os::unix::fs::PermissionsExt;
369 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(mode))?;
370 }
371
372 Self::rename_with_retry(&tmp_path, path)?;
373
374 #[cfg(unix)]
380 {
381 let dir = std::fs::File::open(parent)?;
382 dir.sync_all()?;
383 }
384
385 Ok(())
386 })();
387
388 if result.is_err() {
393 let _ = std::fs::remove_file(&tmp_path);
394 }
395
396 result
397 }
398
399 fn rename_with_retry(from: &Path, to: &Path) -> std::io::Result<()> {
405 const MAX_ATTEMPTS: u32 = 5;
409 const BACKOFF: std::time::Duration = std::time::Duration::from_millis(20);
410
411 for attempt in 1..=MAX_ATTEMPTS {
412 match std::fs::rename(from, to) {
413 Ok(()) => return Ok(()),
414 Err(e) if attempt < MAX_ATTEMPTS && Self::is_transient_rename_error(&e) => {
415 std::thread::sleep(BACKOFF);
416 }
417 Err(e) => return Err(e),
418 }
419 }
420 unreachable!()
423 }
424
425 #[cfg(windows)]
430 fn is_transient_rename_error(e: &std::io::Error) -> bool {
431 matches!(e.raw_os_error(), Some(32) | Some(5))
434 }
435
436 #[cfg(not(windows))]
437 fn is_transient_rename_error(_e: &std::io::Error) -> bool {
438 false
439 }
440
441 pub fn load<T: DeserializeOwned>(&self, filename: &str) -> Result<T, ProfileError> {
445 Self::validate_filename(filename)?;
446 let path = self.dir.join(filename);
447 match std::fs::read_to_string(&path) {
448 Ok(contents) => {
449 let value: T = serde_json::from_str(&contents)?;
450 Ok(value)
451 }
452 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
453 Err(ProfileError::NotFound { path })
454 }
455 Err(e) => Err(ProfileError::Io(e)),
456 }
457 }
458
459 pub fn clear(&self, filename: &str) -> Result<(), ProfileError> {
463 Self::validate_filename(filename)?;
464 let path = self.dir.join(filename);
465 match std::fs::remove_file(&path) {
466 Ok(()) => Ok(()),
467 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
468 Err(e) => Err(ProfileError::Io(e)),
469 }
470 }
471
472 pub fn exists(&self, filename: &str) -> bool {
474 Self::validate_filename(filename).is_ok() && self.dir.join(filename).exists()
475 }
476
477 pub fn lock_exclusive(&self, filename: &str) -> Result<FileLockGuard, ProfileError> {
490 Self::validate_filename(filename)?;
491 std::fs::create_dir_all(&self.dir)?;
492 let lock_path = self.dir.join(format!(".{filename}.lock"));
493 let file = std::fs::OpenOptions::new()
494 .write(true)
495 .create(true)
496 .truncate(false)
497 .open(&lock_path)?;
498 file.lock()?;
499 Ok(FileLockGuard { file })
500 }
501
502 pub fn save_profile<T: ProfileData>(&self, value: &T) -> Result<(), ProfileError> {
504 self.write(T::FILENAME, value, T::MODE)
505 }
506
507 pub fn load_profile<T: ProfileData>(&self) -> Result<T, ProfileError> {
509 self.load(T::FILENAME)
510 }
511
512 pub fn clear_profile<T: ProfileData>(&self) -> Result<(), ProfileError> {
514 self.clear(T::FILENAME)
515 }
516
517 pub fn exists_profile<T: ProfileData>(&self) -> bool {
519 self.exists(T::FILENAME)
520 }
521}
522
523impl Default for ProfileStore {
529 #[allow(clippy::expect_used)]
530 fn default() -> Self {
531 let home = dirs::home_dir().expect("could not determine home directory");
532 Self::new(home.join(DEFAULT_DIR_NAME))
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use serde::{Deserialize, Serialize};
540
541 #[derive(Debug, PartialEq, Serialize, Deserialize)]
542 struct TestData {
543 name: String,
544 value: u32,
545 }
546
547 #[test]
548 fn round_trip_save_and_load() {
549 let dir = tempfile::tempdir().unwrap();
550 let store = ProfileStore::new(dir.path());
551
552 let data = TestData {
553 name: "hello".into(),
554 value: 42,
555 };
556 store.save("data.json", &data).unwrap();
557
558 let loaded: TestData = store.load("data.json").unwrap();
559 assert_eq!(loaded, data);
560 }
561
562 #[test]
563 fn load_returns_not_found_for_missing_file() {
564 let dir = tempfile::tempdir().unwrap();
565 let store = ProfileStore::new(dir.path());
566
567 let err = store.load::<TestData>("missing.json").unwrap_err();
568 assert!(matches!(err, ProfileError::NotFound { .. }));
569 }
570
571 #[test]
572 fn clear_removes_existing_file() {
573 let dir = tempfile::tempdir().unwrap();
574 let store = ProfileStore::new(dir.path());
575
576 store
577 .save(
578 "data.json",
579 &TestData {
580 name: "x".into(),
581 value: 1,
582 },
583 )
584 .unwrap();
585 assert!(store.exists("data.json"));
586
587 store.clear("data.json").unwrap();
588 assert!(!store.exists("data.json"));
589 }
590
591 #[test]
592 fn clear_succeeds_for_missing_file() {
593 let dir = tempfile::tempdir().unwrap();
594 let store = ProfileStore::new(dir.path());
595 store.clear("missing.json").unwrap();
596 }
597
598 #[test]
599 fn save_creates_directory() {
600 let dir = tempfile::tempdir().unwrap();
601 let store = ProfileStore::new(dir.path().join("nested").join("dir"));
602
603 store
604 .save(
605 "data.json",
606 &TestData {
607 name: "nested".into(),
608 value: 99,
609 },
610 )
611 .unwrap();
612
613 let loaded: TestData = store.load("data.json").unwrap();
614 assert_eq!(loaded.name, "nested");
615 }
616
617 #[test]
618 fn exists_returns_false_for_missing_file() {
619 let dir = tempfile::tempdir().unwrap();
620 let store = ProfileStore::new(dir.path());
621 assert!(!store.exists("missing.json"));
622 }
623
624 #[test]
625 fn default_is_home_dot_cipherstash() {
626 let store = ProfileStore::default();
627 let home = dirs::home_dir().unwrap();
628 assert_eq!(store.dir(), home.join(".cipherstash"));
629 }
630
631 #[test]
632 fn resolve_explicit_overrides_all() {
633 let store = ProfileStore::resolve(Some("/tmp/custom".into())).unwrap();
634 assert_eq!(store.dir(), std::path::Path::new("/tmp/custom"));
635 }
636
637 mod filename_validation {
638 use super::*;
639
640 #[test]
641 fn rejects_empty_string() {
642 let dir = tempfile::tempdir().unwrap();
643 let store = ProfileStore::new(dir.path());
644
645 let err = store
646 .save(
647 "",
648 &TestData {
649 name: "x".into(),
650 value: 1,
651 },
652 )
653 .unwrap_err();
654 assert!(matches!(err, ProfileError::InvalidFilename(_)));
655 }
656
657 #[test]
658 fn rejects_absolute_path() {
659 let dir = tempfile::tempdir().unwrap();
660 let store = ProfileStore::new(dir.path());
661
662 let err = store
663 .save(
664 "/etc/passwd",
665 &TestData {
666 name: "x".into(),
667 value: 1,
668 },
669 )
670 .unwrap_err();
671 assert!(matches!(err, ProfileError::InvalidFilename(_)));
672 }
673
674 #[test]
675 fn rejects_parent_traversal() {
676 let dir = tempfile::tempdir().unwrap();
677 let store = ProfileStore::new(dir.path());
678
679 let err = store
680 .save(
681 "../escape.json",
682 &TestData {
683 name: "x".into(),
684 value: 1,
685 },
686 )
687 .unwrap_err();
688 assert!(matches!(err, ProfileError::InvalidFilename(_)));
689 }
690
691 #[test]
692 fn rejects_path_with_separator() {
693 let dir = tempfile::tempdir().unwrap();
694 let store = ProfileStore::new(dir.path());
695
696 let err = store
697 .save(
698 "sub/file.json",
699 &TestData {
700 name: "x".into(),
701 value: 1,
702 },
703 )
704 .unwrap_err();
705 assert!(matches!(err, ProfileError::InvalidFilename(_)));
706 }
707
708 #[test]
709 fn rejects_on_load() {
710 let dir = tempfile::tempdir().unwrap();
711 let store = ProfileStore::new(dir.path());
712
713 let err = store.load::<TestData>("../escape.json").unwrap_err();
714 assert!(matches!(err, ProfileError::InvalidFilename(_)));
715 }
716
717 #[test]
718 fn rejects_on_clear() {
719 let dir = tempfile::tempdir().unwrap();
720 let store = ProfileStore::new(dir.path());
721
722 let err = store.clear("../escape.json").unwrap_err();
723 assert!(matches!(err, ProfileError::InvalidFilename(_)));
724 }
725
726 #[test]
727 fn exists_returns_false_for_invalid_filename() {
728 let dir = tempfile::tempdir().unwrap();
729 let store = ProfileStore::new(dir.path());
730
731 assert!(!store.exists("../escape.json"));
732 }
733
734 #[test]
735 fn accepts_plain_filename() {
736 let dir = tempfile::tempdir().unwrap();
737 let store = ProfileStore::new(dir.path());
738
739 store
740 .save(
741 "valid.json",
742 &TestData {
743 name: "ok".into(),
744 value: 1,
745 },
746 )
747 .unwrap();
748 let loaded: TestData = store.load("valid.json").unwrap();
749 assert_eq!(loaded.name, "ok");
750 }
751 }
752
753 #[cfg(unix)]
754 #[test]
755 fn save_with_mode_sets_permissions() {
756 use std::os::unix::fs::PermissionsExt;
757
758 let dir = tempfile::tempdir().unwrap();
759 let store = ProfileStore::new(dir.path());
760
761 store
762 .save_with_mode(
763 "secret.json",
764 &TestData {
765 name: "secret".into(),
766 value: 1,
767 },
768 0o600,
769 )
770 .unwrap();
771
772 let meta = std::fs::metadata(dir.path().join("secret.json")).unwrap();
773 let mode = meta.permissions().mode() & 0o777;
774 assert_eq!(mode, 0o600);
775 }
776
777 #[cfg(unix)]
778 #[test]
779 fn save_with_mode_tightens_existing_permissions() {
780 use std::os::unix::fs::PermissionsExt;
781
782 let dir = tempfile::tempdir().unwrap();
783 let store = ProfileStore::new(dir.path());
784 let path = dir.path().join("secret.json");
785
786 store
788 .save(
789 "secret.json",
790 &TestData {
791 name: "v1".into(),
792 value: 1,
793 },
794 )
795 .unwrap();
796 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
797
798 store
800 .save_with_mode(
801 "secret.json",
802 &TestData {
803 name: "v2".into(),
804 value: 2,
805 },
806 0o600,
807 )
808 .unwrap();
809
810 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
811 assert_eq!(
812 mode, 0o600,
813 "permissions should be tightened on existing file"
814 );
815 }
816
817 #[test]
822 fn concurrent_writes_never_expose_torn_content() {
823 use std::sync::atomic::{AtomicBool, Ordering};
824 use std::sync::Arc;
825 use std::thread;
826
827 #[derive(serde::Serialize, serde::Deserialize)]
828 struct Big {
829 payload: String,
832 writer: usize,
833 }
834
835 fn make_value(writer: usize, payload_size: usize) -> Big {
836 Big {
837 payload: char::from_digit(writer as u32, 16)
841 .unwrap()
842 .to_string()
843 .repeat(payload_size),
844 writer,
845 }
846 }
847
848 let dir = tempfile::tempdir().unwrap();
849 let store = Arc::new(ProfileStore::new(dir.path()));
850 let writers = 8;
851 let iterations = 50;
852 let payload_size = 64 * 1024;
854
855 store
858 .save("contended.json", &make_value(0, payload_size))
859 .unwrap();
860
861 let done = Arc::new(AtomicBool::new(false));
862
863 let mut handles = Vec::with_capacity(writers);
864 for writer in 0..writers {
865 let store = Arc::clone(&store);
866 handles.push(thread::spawn(move || {
867 for _ in 0..iterations {
868 store
869 .save("contended.json", &make_value(writer, payload_size))
870 .unwrap();
871 }
872 }));
873 }
874
875 let reader_store = Arc::clone(&store);
879 let reader_done = Arc::clone(&done);
880 let reader = thread::spawn(move || {
881 let mut reads = 0;
882 while !reader_done.load(Ordering::Relaxed) {
883 match reader_store.load::<Big>("contended.json") {
884 Ok(value) => {
885 let expected_char = char::from_digit(value.writer as u32, 16)
886 .unwrap()
887 .to_string();
888 assert_eq!(
889 value.payload.len(),
890 payload_size,
891 "torn write — payload truncated"
892 );
893 assert!(
894 value
895 .payload
896 .chars()
897 .all(|c| c.to_string() == expected_char),
898 "torn write — writer {} payload contained foreign content",
899 value.writer
900 );
901 reads += 1;
902 }
903 Err(e) => panic!("reader saw IO/parse error: {e}"),
904 }
905 }
906 reads
907 });
908
909 for h in handles {
910 h.join().unwrap();
911 }
912 done.store(true, Ordering::Relaxed);
913 let reads = reader.join().unwrap();
914 assert!(reads > 0, "reader never observed any successful load");
915
916 let final_value: Big = store.load("contended.json").unwrap();
918 assert_eq!(final_value.payload.len(), payload_size);
919
920 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
922 .unwrap()
923 .filter_map(|e| e.ok())
924 .filter(|e| {
925 let name = e.file_name();
926 let s = name.to_string_lossy();
927 s.starts_with(".contended.json.tmp.")
928 })
929 .collect();
930 assert!(
931 leftovers.is_empty(),
932 "tmp staging files leaked: {leftovers:?}"
933 );
934 }
935
936 mod workspace {
937 use super::*;
938 use crate::ProfileData;
939
940 const WS_A: &str = "AAAAAAAAAAAAAAAA";
941 const WS_B: &str = "BBBBBBBBBBBBBBBB";
942
943 #[derive(Debug, PartialEq, Serialize, Deserialize)]
944 struct WsData {
945 name: String,
946 }
947
948 impl ProfileData for WsData {
949 const FILENAME: &'static str = "ws-data.json";
950 }
951
952 mod given_no_workspace_set {
953 use super::*;
954
955 #[test]
956 fn current_workspace_returns_no_current_workspace() {
957 let dir = tempfile::tempdir().unwrap();
958 let store = ProfileStore::new(dir.path());
959
960 let err = store.current_workspace().unwrap_err();
961 assert!(
962 matches!(err, ProfileError::NoCurrentWorkspace),
963 "expected NoCurrentWorkspace, got: {err:?}"
964 );
965 }
966
967 #[test]
968 fn current_workspace_store_returns_no_current_workspace() {
969 let dir = tempfile::tempdir().unwrap();
970 let store = ProfileStore::new(dir.path());
971
972 let err = store.current_workspace_store().unwrap_err();
973 assert!(
974 matches!(err, ProfileError::NoCurrentWorkspace),
975 "expected NoCurrentWorkspace, got: {err:?}"
976 );
977 }
978
979 #[test]
980 fn clear_current_workspace_succeeds() {
981 let dir = tempfile::tempdir().unwrap();
982 let store = ProfileStore::new(dir.path());
983 store.clear_current_workspace().unwrap();
984 }
985
986 #[test]
987 fn set_current_workspace_returns_workspace_not_found() {
988 let dir = tempfile::tempdir().unwrap();
989 let store = ProfileStore::new(dir.path());
990
991 let err = store.set_current_workspace(WS_A).unwrap_err();
992 assert!(
993 matches!(err, ProfileError::WorkspaceNotFound(_)),
994 "expected WorkspaceNotFound, got: {err:?}"
995 );
996 }
997
998 #[test]
999 fn init_workspace_creates_dir_and_sets_current() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let store = ProfileStore::new(dir.path());
1002
1003 store.init_workspace(WS_A).unwrap();
1004 assert_eq!(
1005 store.current_workspace().unwrap(),
1006 WS_A,
1007 "init_workspace should set the current workspace"
1008 );
1009 assert!(
1010 dir.path().join("workspaces").join(WS_A).is_dir(),
1011 "init_workspace should create the workspace directory"
1012 );
1013 }
1014 }
1015
1016 mod given_workspace_set {
1017 use super::*;
1018
1019 fn scenario() -> (tempfile::TempDir, ProfileStore) {
1020 let dir = tempfile::tempdir().unwrap();
1021 let store = ProfileStore::new(dir.path());
1022 store.init_workspace(WS_A).unwrap();
1023 (dir, store)
1024 }
1025
1026 #[test]
1027 fn returns_workspace_id() {
1028 let (_dir, store) = scenario();
1029 assert_eq!(
1030 store.current_workspace().unwrap(),
1031 WS_A,
1032 "should return the workspace that was set"
1033 );
1034 }
1035
1036 #[test]
1037 fn current_workspace_store_returns_scoped_store() {
1038 let (dir, store) = scenario();
1039 let ws_store = store.current_workspace_store().unwrap();
1040 assert_eq!(
1041 ws_store.dir(),
1042 dir.path().join("workspaces").join(WS_A),
1043 "workspace store should be rooted in workspaces/<id>"
1044 );
1045 }
1046
1047 #[test]
1048 fn clear_removes_selection() {
1049 let (_dir, store) = scenario();
1050 store.clear_current_workspace().unwrap();
1051
1052 let err = store.current_workspace().unwrap_err();
1053 assert!(
1054 matches!(err, ProfileError::NoCurrentWorkspace),
1055 "expected NoCurrentWorkspace after clear, got: {err:?}"
1056 );
1057 }
1058
1059 #[test]
1060 fn save_and_load_round_trips_through_workspace_store() {
1061 let (dir, store) = scenario();
1062 let ws_store = store.current_workspace_store().unwrap();
1063
1064 let data = WsData {
1065 name: "hello".into(),
1066 };
1067 ws_store.save_profile(&data).unwrap();
1068
1069 let loaded: WsData = ws_store.load_profile().unwrap();
1070 assert_eq!(loaded, data, "workspace store should round-trip data");
1071
1072 assert!(
1073 dir.path()
1074 .join("workspaces")
1075 .join(WS_A)
1076 .join("ws-data.json")
1077 .exists(),
1078 "file should be in the workspace directory"
1079 );
1080 assert!(
1081 !store.exists_profile::<WsData>(),
1082 "root store should not see workspace-scoped file"
1083 );
1084 }
1085 }
1086
1087 mod given_multiple_workspaces {
1088 use super::*;
1089
1090 fn scenario() -> (tempfile::TempDir, ProfileStore) {
1091 let dir = tempfile::tempdir().unwrap();
1092 let store = ProfileStore::new(dir.path());
1093
1094 store
1095 .workspace_store(WS_A)
1096 .unwrap()
1097 .save_profile(&WsData {
1098 name: "alpha".into(),
1099 })
1100 .unwrap();
1101 store
1102 .workspace_store(WS_B)
1103 .unwrap()
1104 .save_profile(&WsData {
1105 name: "bravo".into(),
1106 })
1107 .unwrap();
1108
1109 (dir, store)
1110 }
1111
1112 #[test]
1113 fn switching_changes_current_workspace_store_data() {
1114 let (_dir, store) = scenario();
1115
1116 store.set_current_workspace(WS_A).unwrap();
1117 let loaded: WsData = store
1118 .current_workspace_store()
1119 .unwrap()
1120 .load_profile()
1121 .unwrap();
1122 assert_eq!(
1123 loaded.name, "alpha",
1124 "should load workspace A data after switching to A"
1125 );
1126
1127 store.set_current_workspace(WS_B).unwrap();
1128 let loaded: WsData = store
1129 .current_workspace_store()
1130 .unwrap()
1131 .load_profile()
1132 .unwrap();
1133 assert_eq!(
1134 loaded.name, "bravo",
1135 "should load workspace B data after switching to B"
1136 );
1137 }
1138
1139 #[test]
1140 fn list_workspaces_returns_sorted_ids() {
1141 let (_dir, store) = scenario();
1142
1143 let workspaces = store.list_workspaces().unwrap();
1144 assert_eq!(
1145 workspaces,
1146 vec![WS_A, WS_B],
1147 "should list both workspaces in sorted order"
1148 );
1149 }
1150 }
1151
1152 mod list_workspaces {
1153 use super::*;
1154
1155 #[test]
1156 fn returns_empty_when_no_workspaces_dir() {
1157 let dir = tempfile::tempdir().unwrap();
1158 let store = ProfileStore::new(dir.path());
1159 assert_eq!(
1160 store.list_workspaces().unwrap(),
1161 Vec::<String>::new(),
1162 "should return empty list when workspaces/ does not exist"
1163 );
1164 }
1165
1166 #[test]
1167 fn ignores_files_and_invalid_dirs() {
1168 let dir = tempfile::tempdir().unwrap();
1169 let store = ProfileStore::new(dir.path());
1170
1171 let ws_dir = dir.path().join("workspaces");
1172 std::fs::create_dir_all(&ws_dir).unwrap();
1173 std::fs::create_dir(ws_dir.join(WS_A)).unwrap();
1174 std::fs::write(ws_dir.join("not-a-dir.txt"), "").unwrap();
1175 std::fs::create_dir(ws_dir.join("invalid-name")).unwrap();
1176
1177 let workspaces = store.list_workspaces().unwrap();
1178 assert_eq!(
1179 workspaces,
1180 vec![WS_A],
1181 "should only include valid workspace directories"
1182 );
1183 }
1184 }
1185
1186 mod workspace_store {
1187 use super::*;
1188
1189 #[test]
1190 fn returns_scoped_store() {
1191 let dir = tempfile::tempdir().unwrap();
1192 let store = ProfileStore::new(dir.path());
1193
1194 let ws_store = store.workspace_store(WS_A).unwrap();
1195 assert_eq!(
1196 ws_store.dir(),
1197 dir.path().join("workspaces").join(WS_A),
1198 "workspace store should be rooted in workspaces/<id>"
1199 );
1200 }
1201
1202 #[test]
1203 fn rejects_invalid_id() {
1204 let dir = tempfile::tempdir().unwrap();
1205 let store = ProfileStore::new(dir.path());
1206
1207 let err = store.workspace_store("../escape").unwrap_err();
1208 assert!(
1209 matches!(err, ProfileError::InvalidWorkspaceId(_)),
1210 "expected InvalidWorkspaceId for path traversal, got: {err:?}"
1211 );
1212 }
1213 }
1214
1215 mod validate_workspace_id {
1216 use super::*;
1217
1218 #[test]
1219 fn accepts_valid_base32() {
1220 ProfileStore::validate_workspace_id("ABCDEFGH234567AB").unwrap();
1221 ProfileStore::validate_workspace_id(WS_A).unwrap();
1222 }
1223
1224 #[test]
1225 fn rejects_lowercase() {
1226 let err = ProfileStore::validate_workspace_id("abcdefgh234567ab").unwrap_err();
1227 assert!(
1228 matches!(err, ProfileError::InvalidWorkspaceId(_)),
1229 "expected InvalidWorkspaceId for lowercase, got: {err:?}"
1230 );
1231 }
1232
1233 #[test]
1234 fn rejects_wrong_length() {
1235 let err = ProfileStore::validate_workspace_id("SHORT").unwrap_err();
1236 assert!(
1237 matches!(err, ProfileError::InvalidWorkspaceId(_)),
1238 "expected InvalidWorkspaceId for short string, got: {err:?}"
1239 );
1240 }
1241
1242 #[test]
1243 fn rejects_empty() {
1244 let err = ProfileStore::validate_workspace_id("").unwrap_err();
1245 assert!(
1246 matches!(err, ProfileError::InvalidWorkspaceId(_)),
1247 "expected InvalidWorkspaceId for empty string, got: {err:?}"
1248 );
1249 }
1250
1251 #[test]
1252 fn rejects_path_traversal() {
1253 let err = ProfileStore::validate_workspace_id("../escape.json..").unwrap_err();
1254 assert!(
1255 matches!(err, ProfileError::InvalidWorkspaceId(_)),
1256 "expected InvalidWorkspaceId for path traversal, got: {err:?}"
1257 );
1258 }
1259
1260 #[test]
1261 fn rejects_non_base32_digits() {
1262 let err = ProfileStore::validate_workspace_id("0000000000000000").unwrap_err();
1263 assert!(
1264 matches!(err, ProfileError::InvalidWorkspaceId(_)),
1265 "expected InvalidWorkspaceId for digits outside base32 alphabet, got: {err:?}"
1266 );
1267 }
1268 }
1269
1270 mod migrate_to_workspace {
1271 use super::*;
1272
1273 mod given_legacy_flat_files {
1274 use super::*;
1275
1276 fn scenario() -> (tempfile::TempDir, ProfileStore) {
1277 let dir = tempfile::tempdir().unwrap();
1278 let store = ProfileStore::new(dir.path());
1279 std::fs::create_dir_all(dir.path()).unwrap();
1280 std::fs::write(dir.path().join("auth.json"), r#"{"token":"old"}"#).unwrap();
1281 std::fs::write(dir.path().join("secretkey.json"), r#"{"key":"old"}"#).unwrap();
1282 (dir, store)
1283 }
1284
1285 #[test]
1286 fn moves_files_to_workspace_dir() {
1287 let (dir, store) = scenario();
1288 store.migrate_to_workspace(WS_A).unwrap();
1289
1290 assert!(
1291 !dir.path().join("auth.json").exists(),
1292 "legacy auth.json should be removed from root"
1293 );
1294 assert!(
1295 !dir.path().join("secretkey.json").exists(),
1296 "legacy secretkey.json should be removed from root"
1297 );
1298
1299 let ws_dir = dir.path().join("workspaces").join(WS_A);
1300 assert!(
1301 ws_dir.join("auth.json").exists(),
1302 "auth.json should be in workspace dir"
1303 );
1304 assert!(
1305 ws_dir.join("secretkey.json").exists(),
1306 "secretkey.json should be in workspace dir"
1307 );
1308 }
1309
1310 #[test]
1311 fn sets_current_workspace() {
1312 let (_dir, store) = scenario();
1313 store.migrate_to_workspace(WS_A).unwrap();
1314 assert_eq!(
1315 store.current_workspace().unwrap(),
1316 WS_A,
1317 "current workspace should be set after migration"
1318 );
1319 }
1320 }
1321
1322 mod given_existing_files_in_target {
1323 use super::*;
1324
1325 #[test]
1326 fn does_not_overwrite() {
1327 let dir = tempfile::tempdir().unwrap();
1328 let store = ProfileStore::new(dir.path());
1329
1330 std::fs::create_dir_all(dir.path()).unwrap();
1331 std::fs::write(dir.path().join("auth.json"), r#"{"token":"legacy"}"#).unwrap();
1332
1333 let ws_dir = dir.path().join("workspaces").join(WS_A);
1334 std::fs::create_dir_all(&ws_dir).unwrap();
1335 std::fs::write(ws_dir.join("auth.json"), r#"{"token":"existing"}"#).unwrap();
1336
1337 store.migrate_to_workspace(WS_A).unwrap();
1338
1339 let contents = std::fs::read_to_string(ws_dir.join("auth.json")).unwrap();
1340 assert!(
1341 contents.contains("existing"),
1342 "workspace file should be unchanged, got: {contents}"
1343 );
1344 assert!(
1345 dir.path().join("auth.json").exists(),
1346 "legacy file should remain when target exists"
1347 );
1348 }
1349 }
1350
1351 mod given_no_legacy_files {
1352 use super::*;
1353
1354 #[test]
1355 fn sets_current_workspace() {
1356 let dir = tempfile::tempdir().unwrap();
1357 let store = ProfileStore::new(dir.path());
1358
1359 store.migrate_to_workspace(WS_A).unwrap();
1360 assert_eq!(
1361 store.current_workspace().unwrap(),
1362 WS_A,
1363 "should set current workspace even without legacy files"
1364 );
1365 }
1366 }
1367 }
1368 }
1369}