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)]
38pub struct ProfileStore {
39 dir: PathBuf,
40}
41
42impl ProfileStore {
43 pub fn new(dir: impl Into<PathBuf>) -> Self {
45 Self { dir: dir.into() }
46 }
47
48 pub fn resolve(explicit: Option<PathBuf>) -> Result<Self, ProfileError> {
55 if let Some(path) = explicit {
56 return Ok(Self::new(path));
57 }
58 if let Ok(path) = std::env::var(CS_CONFIG_PATH_ENV) {
59 if !path.trim().is_empty() {
60 return Ok(Self::new(path));
61 }
62 }
63 let home = dirs::home_dir().ok_or(ProfileError::HomeDirNotFound)?;
64 Ok(Self::new(home.join(DEFAULT_DIR_NAME)))
65 }
66
67 pub fn dir(&self) -> &Path {
69 &self.dir
70 }
71
72 pub fn save<T: Serialize>(&self, filename: &str, value: &T) -> Result<(), ProfileError> {
76 self.write(filename, value, None)
77 }
78
79 pub fn save_with_mode<T: Serialize>(
83 &self,
84 filename: &str,
85 value: &T,
86 _mode: u32,
87 ) -> Result<(), ProfileError> {
88 #[cfg(unix)]
89 return self.write(filename, value, Some(_mode));
90 #[cfg(not(unix))]
91 self.write(filename, value, None)
92 }
93
94 fn validate_filename(filename: &str) -> Result<(), ProfileError> {
96 let path = Path::new(filename);
97 if filename.is_empty()
98 || path.is_absolute()
99 || filename.contains(std::path::MAIN_SEPARATOR)
100 || filename.contains('/')
101 || path
102 .components()
103 .any(|c| matches!(c, std::path::Component::ParentDir))
104 {
105 return Err(ProfileError::InvalidFilename(filename.to_string()));
106 }
107 Ok(())
108 }
109
110 fn validate_workspace_id(id: &str) -> Result<(), ProfileError> {
114 let valid = id.len() == 16
115 && id
116 .bytes()
117 .all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b));
118 if valid {
119 Ok(())
120 } else {
121 Err(ProfileError::InvalidWorkspaceId(id.to_string()))
122 }
123 }
124
125 pub fn set_current_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> {
137 Self::validate_workspace_id(workspace_id)?;
138 let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id);
139 if !ws_dir.is_dir() {
140 return Err(ProfileError::WorkspaceNotFound(workspace_id.to_string()));
141 }
142 std::fs::create_dir_all(&self.dir)?;
143 let path = self.dir.join(CURRENT_WORKSPACE_FILE);
144 std::fs::write(&path, workspace_id)?;
145 Ok(())
146 }
147
148 pub fn init_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> {
154 Self::validate_workspace_id(workspace_id)?;
155 let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id);
157 std::fs::create_dir_all(&ws_dir)?;
158 let path = self.dir.join(CURRENT_WORKSPACE_FILE);
159 std::fs::write(&path, workspace_id)?;
160 Ok(())
161 }
162
163 pub fn current_workspace(&self) -> Result<String, ProfileError> {
167 let path = self.dir.join(CURRENT_WORKSPACE_FILE);
168 match std::fs::read_to_string(&path) {
169 Ok(contents) => {
170 let id = contents.trim().to_string();
171 Self::validate_workspace_id(&id)?;
172 Ok(id)
173 }
174 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
175 Err(ProfileError::NoCurrentWorkspace)
176 }
177 Err(e) => Err(ProfileError::Io(e)),
178 }
179 }
180
181 pub fn clear_current_workspace(&self) -> Result<(), ProfileError> {
183 let path = self.dir.join(CURRENT_WORKSPACE_FILE);
184 match std::fs::remove_file(&path) {
185 Ok(()) => Ok(()),
186 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
187 Err(e) => Err(ProfileError::Io(e)),
188 }
189 }
190
191 pub fn list_workspaces(&self) -> Result<Vec<String>, ProfileError> {
196 let ws_dir = self.dir.join(WORKSPACES_DIR);
197 match std::fs::read_dir(&ws_dir) {
198 Ok(entries) => {
199 let mut ids = Vec::new();
200 for entry in entries {
201 let entry = entry?;
202 if entry.file_type()?.is_dir() {
203 if let Some(name) = entry.file_name().to_str() {
204 if Self::validate_workspace_id(name).is_ok() {
205 ids.push(name.to_string());
206 }
207 }
208 }
209 }
210 ids.sort();
211 Ok(ids)
212 }
213 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
214 Err(e) => Err(ProfileError::Io(e)),
215 }
216 }
217
218 pub fn workspace_store(&self, workspace_id: &str) -> Result<ProfileStore, ProfileError> {
224 Self::validate_workspace_id(workspace_id)?;
225 Ok(ProfileStore::new(
226 self.dir.join(WORKSPACES_DIR).join(workspace_id),
227 ))
228 }
229
230 pub fn current_workspace_store(&self) -> Result<ProfileStore, ProfileError> {
235 let id = self.current_workspace()?;
236 self.workspace_store(&id)
237 }
238
239 pub fn migrate_to_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> {
246 Self::validate_workspace_id(workspace_id)?;
247 let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id);
248 std::fs::create_dir_all(&ws_dir)?;
249
250 for filename in &["auth.json", "secretkey.json"] {
251 let src = self.dir.join(filename);
252 let dst = ws_dir.join(filename);
253 if src.exists() && !dst.exists() {
254 std::fs::rename(&src, &dst)?;
255 }
256 }
257
258 self.set_current_workspace(workspace_id)?;
259 Ok(())
260 }
261
262 fn write<T: Serialize>(
265 &self,
266 filename: &str,
267 value: &T,
268 _mode: Option<u32>,
269 ) -> Result<(), ProfileError> {
270 Self::validate_filename(filename)?;
271 std::fs::create_dir_all(&self.dir)?;
272 let path = self.dir.join(filename);
273 let json = serde_json::to_string_pretty(value)?;
274 Self::write_to_path(&path, &json, _mode)
275 }
276
277 fn write_to_path(path: &Path, json: &str, _mode: Option<u32>) -> Result<(), ProfileError> {
279 #[cfg(unix)]
280 if let Some(mode) = _mode {
281 use std::fs::OpenOptions;
282 use std::io::Write;
283 use std::os::unix::fs::OpenOptionsExt;
284
285 let mut file = OpenOptions::new()
286 .write(true)
287 .create(true)
288 .truncate(true)
289 .mode(mode)
290 .open(path)?;
291 file.write_all(json.as_bytes())?;
292
293 use std::os::unix::fs::PermissionsExt;
296 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
297
298 return Ok(());
299 }
300
301 std::fs::write(path, json)?;
302 Ok(())
303 }
304
305 pub fn load<T: DeserializeOwned>(&self, filename: &str) -> Result<T, ProfileError> {
309 Self::validate_filename(filename)?;
310 let path = self.dir.join(filename);
311 match std::fs::read_to_string(&path) {
312 Ok(contents) => {
313 let value: T = serde_json::from_str(&contents)?;
314 Ok(value)
315 }
316 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
317 Err(ProfileError::NotFound { path })
318 }
319 Err(e) => Err(ProfileError::Io(e)),
320 }
321 }
322
323 pub fn clear(&self, filename: &str) -> Result<(), ProfileError> {
327 Self::validate_filename(filename)?;
328 let path = self.dir.join(filename);
329 match std::fs::remove_file(&path) {
330 Ok(()) => Ok(()),
331 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
332 Err(e) => Err(ProfileError::Io(e)),
333 }
334 }
335
336 pub fn exists(&self, filename: &str) -> bool {
338 Self::validate_filename(filename).is_ok() && self.dir.join(filename).exists()
339 }
340
341 pub fn save_profile<T: ProfileData>(&self, value: &T) -> Result<(), ProfileError> {
343 self.write(T::FILENAME, value, T::MODE)
344 }
345
346 pub fn load_profile<T: ProfileData>(&self) -> Result<T, ProfileError> {
348 self.load(T::FILENAME)
349 }
350
351 pub fn clear_profile<T: ProfileData>(&self) -> Result<(), ProfileError> {
353 self.clear(T::FILENAME)
354 }
355
356 pub fn exists_profile<T: ProfileData>(&self) -> bool {
358 self.exists(T::FILENAME)
359 }
360}
361
362impl Default for ProfileStore {
368 #[allow(clippy::expect_used)]
369 fn default() -> Self {
370 let home = dirs::home_dir().expect("could not determine home directory");
371 Self::new(home.join(DEFAULT_DIR_NAME))
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use serde::{Deserialize, Serialize};
379
380 #[derive(Debug, PartialEq, Serialize, Deserialize)]
381 struct TestData {
382 name: String,
383 value: u32,
384 }
385
386 #[test]
387 fn round_trip_save_and_load() {
388 let dir = tempfile::tempdir().unwrap();
389 let store = ProfileStore::new(dir.path());
390
391 let data = TestData {
392 name: "hello".into(),
393 value: 42,
394 };
395 store.save("data.json", &data).unwrap();
396
397 let loaded: TestData = store.load("data.json").unwrap();
398 assert_eq!(loaded, data);
399 }
400
401 #[test]
402 fn load_returns_not_found_for_missing_file() {
403 let dir = tempfile::tempdir().unwrap();
404 let store = ProfileStore::new(dir.path());
405
406 let err = store.load::<TestData>("missing.json").unwrap_err();
407 assert!(matches!(err, ProfileError::NotFound { .. }));
408 }
409
410 #[test]
411 fn clear_removes_existing_file() {
412 let dir = tempfile::tempdir().unwrap();
413 let store = ProfileStore::new(dir.path());
414
415 store
416 .save(
417 "data.json",
418 &TestData {
419 name: "x".into(),
420 value: 1,
421 },
422 )
423 .unwrap();
424 assert!(store.exists("data.json"));
425
426 store.clear("data.json").unwrap();
427 assert!(!store.exists("data.json"));
428 }
429
430 #[test]
431 fn clear_succeeds_for_missing_file() {
432 let dir = tempfile::tempdir().unwrap();
433 let store = ProfileStore::new(dir.path());
434 store.clear("missing.json").unwrap();
435 }
436
437 #[test]
438 fn save_creates_directory() {
439 let dir = tempfile::tempdir().unwrap();
440 let store = ProfileStore::new(dir.path().join("nested").join("dir"));
441
442 store
443 .save(
444 "data.json",
445 &TestData {
446 name: "nested".into(),
447 value: 99,
448 },
449 )
450 .unwrap();
451
452 let loaded: TestData = store.load("data.json").unwrap();
453 assert_eq!(loaded.name, "nested");
454 }
455
456 #[test]
457 fn exists_returns_false_for_missing_file() {
458 let dir = tempfile::tempdir().unwrap();
459 let store = ProfileStore::new(dir.path());
460 assert!(!store.exists("missing.json"));
461 }
462
463 #[test]
464 fn default_is_home_dot_cipherstash() {
465 let store = ProfileStore::default();
466 let home = dirs::home_dir().unwrap();
467 assert_eq!(store.dir(), home.join(".cipherstash"));
468 }
469
470 #[test]
471 fn resolve_explicit_overrides_all() {
472 let store = ProfileStore::resolve(Some("/tmp/custom".into())).unwrap();
473 assert_eq!(store.dir(), std::path::Path::new("/tmp/custom"));
474 }
475
476 mod filename_validation {
477 use super::*;
478
479 #[test]
480 fn rejects_empty_string() {
481 let dir = tempfile::tempdir().unwrap();
482 let store = ProfileStore::new(dir.path());
483
484 let err = store
485 .save(
486 "",
487 &TestData {
488 name: "x".into(),
489 value: 1,
490 },
491 )
492 .unwrap_err();
493 assert!(matches!(err, ProfileError::InvalidFilename(_)));
494 }
495
496 #[test]
497 fn rejects_absolute_path() {
498 let dir = tempfile::tempdir().unwrap();
499 let store = ProfileStore::new(dir.path());
500
501 let err = store
502 .save(
503 "/etc/passwd",
504 &TestData {
505 name: "x".into(),
506 value: 1,
507 },
508 )
509 .unwrap_err();
510 assert!(matches!(err, ProfileError::InvalidFilename(_)));
511 }
512
513 #[test]
514 fn rejects_parent_traversal() {
515 let dir = tempfile::tempdir().unwrap();
516 let store = ProfileStore::new(dir.path());
517
518 let err = store
519 .save(
520 "../escape.json",
521 &TestData {
522 name: "x".into(),
523 value: 1,
524 },
525 )
526 .unwrap_err();
527 assert!(matches!(err, ProfileError::InvalidFilename(_)));
528 }
529
530 #[test]
531 fn rejects_path_with_separator() {
532 let dir = tempfile::tempdir().unwrap();
533 let store = ProfileStore::new(dir.path());
534
535 let err = store
536 .save(
537 "sub/file.json",
538 &TestData {
539 name: "x".into(),
540 value: 1,
541 },
542 )
543 .unwrap_err();
544 assert!(matches!(err, ProfileError::InvalidFilename(_)));
545 }
546
547 #[test]
548 fn rejects_on_load() {
549 let dir = tempfile::tempdir().unwrap();
550 let store = ProfileStore::new(dir.path());
551
552 let err = store.load::<TestData>("../escape.json").unwrap_err();
553 assert!(matches!(err, ProfileError::InvalidFilename(_)));
554 }
555
556 #[test]
557 fn rejects_on_clear() {
558 let dir = tempfile::tempdir().unwrap();
559 let store = ProfileStore::new(dir.path());
560
561 let err = store.clear("../escape.json").unwrap_err();
562 assert!(matches!(err, ProfileError::InvalidFilename(_)));
563 }
564
565 #[test]
566 fn exists_returns_false_for_invalid_filename() {
567 let dir = tempfile::tempdir().unwrap();
568 let store = ProfileStore::new(dir.path());
569
570 assert!(!store.exists("../escape.json"));
571 }
572
573 #[test]
574 fn accepts_plain_filename() {
575 let dir = tempfile::tempdir().unwrap();
576 let store = ProfileStore::new(dir.path());
577
578 store
579 .save(
580 "valid.json",
581 &TestData {
582 name: "ok".into(),
583 value: 1,
584 },
585 )
586 .unwrap();
587 let loaded: TestData = store.load("valid.json").unwrap();
588 assert_eq!(loaded.name, "ok");
589 }
590 }
591
592 #[cfg(unix)]
593 #[test]
594 fn save_with_mode_sets_permissions() {
595 use std::os::unix::fs::PermissionsExt;
596
597 let dir = tempfile::tempdir().unwrap();
598 let store = ProfileStore::new(dir.path());
599
600 store
601 .save_with_mode(
602 "secret.json",
603 &TestData {
604 name: "secret".into(),
605 value: 1,
606 },
607 0o600,
608 )
609 .unwrap();
610
611 let meta = std::fs::metadata(dir.path().join("secret.json")).unwrap();
612 let mode = meta.permissions().mode() & 0o777;
613 assert_eq!(mode, 0o600);
614 }
615
616 #[cfg(unix)]
617 #[test]
618 fn save_with_mode_tightens_existing_permissions() {
619 use std::os::unix::fs::PermissionsExt;
620
621 let dir = tempfile::tempdir().unwrap();
622 let store = ProfileStore::new(dir.path());
623 let path = dir.path().join("secret.json");
624
625 store
627 .save(
628 "secret.json",
629 &TestData {
630 name: "v1".into(),
631 value: 1,
632 },
633 )
634 .unwrap();
635 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
636
637 store
639 .save_with_mode(
640 "secret.json",
641 &TestData {
642 name: "v2".into(),
643 value: 2,
644 },
645 0o600,
646 )
647 .unwrap();
648
649 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
650 assert_eq!(
651 mode, 0o600,
652 "permissions should be tightened on existing file"
653 );
654 }
655
656 mod workspace {
657 use super::*;
658 use crate::ProfileData;
659
660 const WS_A: &str = "AAAAAAAAAAAAAAAA";
661 const WS_B: &str = "BBBBBBBBBBBBBBBB";
662
663 #[derive(Debug, PartialEq, Serialize, Deserialize)]
664 struct WsData {
665 name: String,
666 }
667
668 impl ProfileData for WsData {
669 const FILENAME: &'static str = "ws-data.json";
670 }
671
672 mod given_no_workspace_set {
673 use super::*;
674
675 #[test]
676 fn current_workspace_returns_no_current_workspace() {
677 let dir = tempfile::tempdir().unwrap();
678 let store = ProfileStore::new(dir.path());
679
680 let err = store.current_workspace().unwrap_err();
681 assert!(
682 matches!(err, ProfileError::NoCurrentWorkspace),
683 "expected NoCurrentWorkspace, got: {err:?}"
684 );
685 }
686
687 #[test]
688 fn current_workspace_store_returns_no_current_workspace() {
689 let dir = tempfile::tempdir().unwrap();
690 let store = ProfileStore::new(dir.path());
691
692 let err = store.current_workspace_store().unwrap_err();
693 assert!(
694 matches!(err, ProfileError::NoCurrentWorkspace),
695 "expected NoCurrentWorkspace, got: {err:?}"
696 );
697 }
698
699 #[test]
700 fn clear_current_workspace_succeeds() {
701 let dir = tempfile::tempdir().unwrap();
702 let store = ProfileStore::new(dir.path());
703 store.clear_current_workspace().unwrap();
704 }
705
706 #[test]
707 fn set_current_workspace_returns_workspace_not_found() {
708 let dir = tempfile::tempdir().unwrap();
709 let store = ProfileStore::new(dir.path());
710
711 let err = store.set_current_workspace(WS_A).unwrap_err();
712 assert!(
713 matches!(err, ProfileError::WorkspaceNotFound(_)),
714 "expected WorkspaceNotFound, got: {err:?}"
715 );
716 }
717
718 #[test]
719 fn init_workspace_creates_dir_and_sets_current() {
720 let dir = tempfile::tempdir().unwrap();
721 let store = ProfileStore::new(dir.path());
722
723 store.init_workspace(WS_A).unwrap();
724 assert_eq!(
725 store.current_workspace().unwrap(),
726 WS_A,
727 "init_workspace should set the current workspace"
728 );
729 assert!(
730 dir.path().join("workspaces").join(WS_A).is_dir(),
731 "init_workspace should create the workspace directory"
732 );
733 }
734 }
735
736 mod given_workspace_set {
737 use super::*;
738
739 fn scenario() -> (tempfile::TempDir, ProfileStore) {
740 let dir = tempfile::tempdir().unwrap();
741 let store = ProfileStore::new(dir.path());
742 store.init_workspace(WS_A).unwrap();
743 (dir, store)
744 }
745
746 #[test]
747 fn returns_workspace_id() {
748 let (_dir, store) = scenario();
749 assert_eq!(
750 store.current_workspace().unwrap(),
751 WS_A,
752 "should return the workspace that was set"
753 );
754 }
755
756 #[test]
757 fn current_workspace_store_returns_scoped_store() {
758 let (dir, store) = scenario();
759 let ws_store = store.current_workspace_store().unwrap();
760 assert_eq!(
761 ws_store.dir(),
762 dir.path().join("workspaces").join(WS_A),
763 "workspace store should be rooted in workspaces/<id>"
764 );
765 }
766
767 #[test]
768 fn clear_removes_selection() {
769 let (_dir, store) = scenario();
770 store.clear_current_workspace().unwrap();
771
772 let err = store.current_workspace().unwrap_err();
773 assert!(
774 matches!(err, ProfileError::NoCurrentWorkspace),
775 "expected NoCurrentWorkspace after clear, got: {err:?}"
776 );
777 }
778
779 #[test]
780 fn save_and_load_round_trips_through_workspace_store() {
781 let (dir, store) = scenario();
782 let ws_store = store.current_workspace_store().unwrap();
783
784 let data = WsData {
785 name: "hello".into(),
786 };
787 ws_store.save_profile(&data).unwrap();
788
789 let loaded: WsData = ws_store.load_profile().unwrap();
790 assert_eq!(loaded, data, "workspace store should round-trip data");
791
792 assert!(
793 dir.path()
794 .join("workspaces")
795 .join(WS_A)
796 .join("ws-data.json")
797 .exists(),
798 "file should be in the workspace directory"
799 );
800 assert!(
801 !store.exists_profile::<WsData>(),
802 "root store should not see workspace-scoped file"
803 );
804 }
805 }
806
807 mod given_multiple_workspaces {
808 use super::*;
809
810 fn scenario() -> (tempfile::TempDir, ProfileStore) {
811 let dir = tempfile::tempdir().unwrap();
812 let store = ProfileStore::new(dir.path());
813
814 store
815 .workspace_store(WS_A)
816 .unwrap()
817 .save_profile(&WsData {
818 name: "alpha".into(),
819 })
820 .unwrap();
821 store
822 .workspace_store(WS_B)
823 .unwrap()
824 .save_profile(&WsData {
825 name: "bravo".into(),
826 })
827 .unwrap();
828
829 (dir, store)
830 }
831
832 #[test]
833 fn switching_changes_current_workspace_store_data() {
834 let (_dir, store) = scenario();
835
836 store.set_current_workspace(WS_A).unwrap();
837 let loaded: WsData = store
838 .current_workspace_store()
839 .unwrap()
840 .load_profile()
841 .unwrap();
842 assert_eq!(
843 loaded.name, "alpha",
844 "should load workspace A data after switching to A"
845 );
846
847 store.set_current_workspace(WS_B).unwrap();
848 let loaded: WsData = store
849 .current_workspace_store()
850 .unwrap()
851 .load_profile()
852 .unwrap();
853 assert_eq!(
854 loaded.name, "bravo",
855 "should load workspace B data after switching to B"
856 );
857 }
858
859 #[test]
860 fn list_workspaces_returns_sorted_ids() {
861 let (_dir, store) = scenario();
862
863 let workspaces = store.list_workspaces().unwrap();
864 assert_eq!(
865 workspaces,
866 vec![WS_A, WS_B],
867 "should list both workspaces in sorted order"
868 );
869 }
870 }
871
872 mod list_workspaces {
873 use super::*;
874
875 #[test]
876 fn returns_empty_when_no_workspaces_dir() {
877 let dir = tempfile::tempdir().unwrap();
878 let store = ProfileStore::new(dir.path());
879 assert_eq!(
880 store.list_workspaces().unwrap(),
881 Vec::<String>::new(),
882 "should return empty list when workspaces/ does not exist"
883 );
884 }
885
886 #[test]
887 fn ignores_files_and_invalid_dirs() {
888 let dir = tempfile::tempdir().unwrap();
889 let store = ProfileStore::new(dir.path());
890
891 let ws_dir = dir.path().join("workspaces");
892 std::fs::create_dir_all(&ws_dir).unwrap();
893 std::fs::create_dir(ws_dir.join(WS_A)).unwrap();
894 std::fs::write(ws_dir.join("not-a-dir.txt"), "").unwrap();
895 std::fs::create_dir(ws_dir.join("invalid-name")).unwrap();
896
897 let workspaces = store.list_workspaces().unwrap();
898 assert_eq!(
899 workspaces,
900 vec![WS_A],
901 "should only include valid workspace directories"
902 );
903 }
904 }
905
906 mod workspace_store {
907 use super::*;
908
909 #[test]
910 fn returns_scoped_store() {
911 let dir = tempfile::tempdir().unwrap();
912 let store = ProfileStore::new(dir.path());
913
914 let ws_store = store.workspace_store(WS_A).unwrap();
915 assert_eq!(
916 ws_store.dir(),
917 dir.path().join("workspaces").join(WS_A),
918 "workspace store should be rooted in workspaces/<id>"
919 );
920 }
921
922 #[test]
923 fn rejects_invalid_id() {
924 let dir = tempfile::tempdir().unwrap();
925 let store = ProfileStore::new(dir.path());
926
927 let err = store.workspace_store("../escape").unwrap_err();
928 assert!(
929 matches!(err, ProfileError::InvalidWorkspaceId(_)),
930 "expected InvalidWorkspaceId for path traversal, got: {err:?}"
931 );
932 }
933 }
934
935 mod validate_workspace_id {
936 use super::*;
937
938 #[test]
939 fn accepts_valid_base32() {
940 ProfileStore::validate_workspace_id("ABCDEFGH234567AB").unwrap();
941 ProfileStore::validate_workspace_id(WS_A).unwrap();
942 }
943
944 #[test]
945 fn rejects_lowercase() {
946 let err = ProfileStore::validate_workspace_id("abcdefgh234567ab").unwrap_err();
947 assert!(
948 matches!(err, ProfileError::InvalidWorkspaceId(_)),
949 "expected InvalidWorkspaceId for lowercase, got: {err:?}"
950 );
951 }
952
953 #[test]
954 fn rejects_wrong_length() {
955 let err = ProfileStore::validate_workspace_id("SHORT").unwrap_err();
956 assert!(
957 matches!(err, ProfileError::InvalidWorkspaceId(_)),
958 "expected InvalidWorkspaceId for short string, got: {err:?}"
959 );
960 }
961
962 #[test]
963 fn rejects_empty() {
964 let err = ProfileStore::validate_workspace_id("").unwrap_err();
965 assert!(
966 matches!(err, ProfileError::InvalidWorkspaceId(_)),
967 "expected InvalidWorkspaceId for empty string, got: {err:?}"
968 );
969 }
970
971 #[test]
972 fn rejects_path_traversal() {
973 let err = ProfileStore::validate_workspace_id("../escape.json..").unwrap_err();
974 assert!(
975 matches!(err, ProfileError::InvalidWorkspaceId(_)),
976 "expected InvalidWorkspaceId for path traversal, got: {err:?}"
977 );
978 }
979
980 #[test]
981 fn rejects_non_base32_digits() {
982 let err = ProfileStore::validate_workspace_id("0000000000000000").unwrap_err();
983 assert!(
984 matches!(err, ProfileError::InvalidWorkspaceId(_)),
985 "expected InvalidWorkspaceId for digits outside base32 alphabet, got: {err:?}"
986 );
987 }
988 }
989
990 mod migrate_to_workspace {
991 use super::*;
992
993 mod given_legacy_flat_files {
994 use super::*;
995
996 fn scenario() -> (tempfile::TempDir, ProfileStore) {
997 let dir = tempfile::tempdir().unwrap();
998 let store = ProfileStore::new(dir.path());
999 std::fs::create_dir_all(dir.path()).unwrap();
1000 std::fs::write(dir.path().join("auth.json"), r#"{"token":"old"}"#).unwrap();
1001 std::fs::write(dir.path().join("secretkey.json"), r#"{"key":"old"}"#).unwrap();
1002 (dir, store)
1003 }
1004
1005 #[test]
1006 fn moves_files_to_workspace_dir() {
1007 let (dir, store) = scenario();
1008 store.migrate_to_workspace(WS_A).unwrap();
1009
1010 assert!(
1011 !dir.path().join("auth.json").exists(),
1012 "legacy auth.json should be removed from root"
1013 );
1014 assert!(
1015 !dir.path().join("secretkey.json").exists(),
1016 "legacy secretkey.json should be removed from root"
1017 );
1018
1019 let ws_dir = dir.path().join("workspaces").join(WS_A);
1020 assert!(
1021 ws_dir.join("auth.json").exists(),
1022 "auth.json should be in workspace dir"
1023 );
1024 assert!(
1025 ws_dir.join("secretkey.json").exists(),
1026 "secretkey.json should be in workspace dir"
1027 );
1028 }
1029
1030 #[test]
1031 fn sets_current_workspace() {
1032 let (_dir, store) = scenario();
1033 store.migrate_to_workspace(WS_A).unwrap();
1034 assert_eq!(
1035 store.current_workspace().unwrap(),
1036 WS_A,
1037 "current workspace should be set after migration"
1038 );
1039 }
1040 }
1041
1042 mod given_existing_files_in_target {
1043 use super::*;
1044
1045 #[test]
1046 fn does_not_overwrite() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let store = ProfileStore::new(dir.path());
1049
1050 std::fs::create_dir_all(dir.path()).unwrap();
1051 std::fs::write(dir.path().join("auth.json"), r#"{"token":"legacy"}"#).unwrap();
1052
1053 let ws_dir = dir.path().join("workspaces").join(WS_A);
1054 std::fs::create_dir_all(&ws_dir).unwrap();
1055 std::fs::write(ws_dir.join("auth.json"), r#"{"token":"existing"}"#).unwrap();
1056
1057 store.migrate_to_workspace(WS_A).unwrap();
1058
1059 let contents = std::fs::read_to_string(ws_dir.join("auth.json")).unwrap();
1060 assert!(
1061 contents.contains("existing"),
1062 "workspace file should be unchanged, got: {contents}"
1063 );
1064 assert!(
1065 dir.path().join("auth.json").exists(),
1066 "legacy file should remain when target exists"
1067 );
1068 }
1069 }
1070
1071 mod given_no_legacy_files {
1072 use super::*;
1073
1074 #[test]
1075 fn sets_current_workspace() {
1076 let dir = tempfile::tempdir().unwrap();
1077 let store = ProfileStore::new(dir.path());
1078
1079 store.migrate_to_workspace(WS_A).unwrap();
1080 assert_eq!(
1081 store.current_workspace().unwrap(),
1082 WS_A,
1083 "should set current workspace even without legacy files"
1084 );
1085 }
1086 }
1087 }
1088 }
1089}