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";
10const WORKSPACES_DIR: &str = "workspaces";
11const CURRENT_WORKSPACE_FILE: &str = "current_workspace";
12
13/// A directory-scoped JSON file store for profile data.
14///
15/// `ProfileStore` represents a profile directory (typically `~/.cipherstash/`).
16/// Individual files are addressed by name when calling [`save`](Self::save),
17/// [`load`](Self::load), and other operations.
18///
19/// # Example
20///
21/// ```no_run
22/// use stack_profile::ProfileStore;
23/// use serde::{Serialize, Deserialize};
24///
25/// #[derive(Serialize, Deserialize)]
26/// struct MyConfig {
27///     name: String,
28/// }
29///
30/// # fn main() -> Result<(), stack_profile::ProfileError> {
31/// let store = ProfileStore::resolve(None)?;
32/// store.save("my-config.json", &MyConfig { name: "example".into() })?;
33/// let config: MyConfig = store.load("my-config.json")?;
34/// # Ok(())
35/// # }
36/// ```
37#[derive(Debug, Clone)]
38pub struct ProfileStore {
39    dir: PathBuf,
40}
41
42/// RAII guard for an advisory file lock acquired via
43/// [`ProfileStore::lock_exclusive`]. Releases on drop.
44///
45/// The guard owns the lock file handle; dropping it calls `unlock` and
46/// closes the descriptor. The lock file itself is left on disk — it's reused
47/// across acquisitions and carries no useful content.
48#[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        // Best-effort — the kernel releases on close regardless, so a failure
57        // here only matters for diagnostics. Don't log: this runs during
58        // teardown and the file may already be invalid (e.g. on process exit).
59        let _ = self.file.unlock();
60    }
61}
62
63impl ProfileStore {
64    /// Create a profile store rooted at the given directory.
65    pub fn new(dir: impl Into<PathBuf>) -> Self {
66        Self { dir: dir.into() }
67    }
68
69    /// Resolve the profile directory.
70    ///
71    /// Resolution order:
72    /// 1. `explicit` path, if provided
73    /// 2. `CS_CONFIG_PATH` environment variable, if set
74    /// 3. `~/.cipherstash` (the default)
75    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    /// Return the directory path.
89    pub fn dir(&self) -> &Path {
90        &self.dir
91    }
92
93    /// Save a value as pretty-printed JSON to a file in the store directory.
94    ///
95    /// Creates the directory and any parents if they don't exist.
96    pub fn save<T: Serialize>(&self, filename: &str, value: &T) -> Result<(), ProfileError> {
97        self.write(filename, value, None)
98    }
99
100    /// Save a value as pretty-printed JSON with restricted Unix file permissions.
101    ///
102    /// On non-Unix platforms the mode is ignored and this behaves like [`save`](Self::save).
103    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    /// Validate that a filename is a plain, non-empty filename (no path separators or `..`).
116    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    /// Validate that a workspace ID is a 16-character base32 string (A-Z, 2-7).
132    ///
133    /// This prevents path traversal without depending on `cts_common::WorkspaceId`.
134    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    // ---- Workspace management ----
147
148    /// Set the current workspace.
149    ///
150    /// Writes the workspace ID to the `current_workspace` file in the profile
151    /// directory. The workspace must already have a directory under `workspaces/`
152    /// (created during login). Use [`init_workspace`](Self::init_workspace) to
153    /// create a new workspace directory.
154    ///
155    /// Returns [`ProfileError::WorkspaceNotFound`] if the workspace directory
156    /// does not exist.
157    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    /// Create a workspace directory and set it as the current workspace.
170    ///
171    /// Unlike [`set_current_workspace`](Self::set_current_workspace), this
172    /// creates the workspace directory if it does not exist. Used during login
173    /// to initialize a new workspace.
174    pub fn init_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> {
175        Self::validate_workspace_id(workspace_id)?;
176        // create_dir_all creates self.dir and workspaces/ as ancestors.
177        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    /// Return the current workspace ID.
185    ///
186    /// Returns [`ProfileError::NoCurrentWorkspace`] if no workspace has been set.
187    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    /// Remove the current workspace selection.
203    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    /// List workspace IDs that have profile data on disk.
213    ///
214    /// Returns a sorted list of workspace IDs that have subdirectories in
215    /// the `workspaces/` directory.
216    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    /// Return a [`ProfileStore`] scoped to a specific workspace directory.
240    ///
241    /// The returned store is rooted at `workspaces/<workspace_id>/` within this
242    /// store's directory. All `save`/`load`/`save_profile`/`load_profile` calls
243    /// on the returned store operate inside that workspace directory.
244    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    /// Return a [`ProfileStore`] scoped to the current workspace.
252    ///
253    /// Shortcut for `store.workspace_store(&store.current_workspace()?)`.
254    /// Returns [`ProfileError::NoCurrentWorkspace`] if no workspace has been set.
255    pub fn current_workspace_store(&self) -> Result<ProfileStore, ProfileError> {
256        let id = self.current_workspace()?;
257        self.workspace_store(&id)
258    }
259
260    /// Move legacy flat-file profiles into a workspace directory.
261    ///
262    /// Moves `auth.json` and `secretkey.json` from the profile root into
263    /// `workspaces/<workspace_id>/` and sets `workspace_id` as the current
264    /// workspace. Files that already exist in the target are not overwritten.
265    /// Missing source files are silently skipped.
266    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    // ---- Internal write helpers ----
284
285    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    /// Atomically write JSON content to an absolute path, optionally setting
299    /// Unix file permissions.
300    ///
301    /// Sequence:
302    ///   1. Open a uniquely-named sibling tmp file in the same directory.
303    ///   2. Write the bytes and `sync_all` (fsync the data + metadata).
304    ///   3. Apply the requested mode with `set_permissions` (overrides
305    ///      umask).
306    ///   4. Rename the tmp file over the target. `std::fs::rename` uses
307    ///      `MOVEFILE_REPLACE_EXISTING` on Windows and the POSIX `rename`
308    ///      on Unix, so the replacement is atomic on both platforms.
309    ///   5. On Unix, fsync the parent directory so the rename is durable
310    ///      across power loss. Windows doesn't expose directory fsync, so
311    ///      this step is Unix-only — Windows callers get atomicity but
312    ///      slightly weaker crash-durability guarantees.
313    ///
314    /// Two concurrent writers cannot produce torn reads, and a crash
315    /// mid-write leaves either the prior file intact or no destination
316    /// file at all. The tmp file name embeds the process ID + a UUID so
317    /// concurrent writers (across processes or threads) don't collide on
318    /// the staging path.
319    ///
320    /// On Windows the rename can transiently fail with
321    /// `ERROR_SHARING_VIOLATION` when an external (non-Rust) process holds
322    /// the target open without `FILE_SHARE_DELETE`. Rust's own
323    /// `File::open` sets that share flag, so contention between two Rust
324    /// processes won't trip this — but to defend against third-party
325    /// readers we retry the rename a handful of times with brief backoff
326    /// before giving up.
327    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            // `OpenOptions::mode()` is masked by the process umask, so an
364            // explicit `set_permissions` is required to guarantee the exact
365            // mode the caller asked for.
366            #[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            // Durability: fsync the parent directory so the rename itself
375            // survives a power loss, not just the file contents written
376            // above. Unix-only because Windows has no directory fsync
377            // primitive (and its filesystem metadata journaling makes
378            // this less necessary in practice).
379            #[cfg(unix)]
380            {
381                let dir = std::fs::File::open(parent)?;
382                dir.sync_all()?;
383            }
384
385            Ok(())
386        })();
387
388        // On failure, the rename never happened (or was rolled back), so
389        // clean up the staging file. Best-effort — if cleanup itself
390        // fails there's nothing useful we can do beyond the original
391        // error.
392        if result.is_err() {
393            let _ = std::fs::remove_file(&tmp_path);
394        }
395
396        result
397    }
398
399    /// Rename `from` to `to`, retrying briefly on Windows
400    /// `ERROR_SHARING_VIOLATION` (a transient failure when an external
401    /// process holds the target open without `FILE_SHARE_DELETE`). On
402    /// Unix the first attempt always succeeds or fails for a permanent
403    /// reason, so the retry loop is a no-op there.
404    fn rename_with_retry(from: &Path, to: &Path) -> std::io::Result<()> {
405        // 5 attempts * 20ms = up to 100ms — long enough to ride out a
406        // typical short-lived external read, short enough not to feel
407        // hung if the contention is real.
408        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 — the loop either returns or breaks via the last
421        // attempt's `Err` arm above.
422        unreachable!()
423    }
424
425    /// True if the error is a sharing/access conflict that's worth
426    /// retrying. On Unix `rename` doesn't produce these (the equivalent
427    /// would be `EBUSY` on overlay/network filesystems, but it's rare and
428    /// usually non-transient), so this is effectively a Windows guard.
429    #[cfg(windows)]
430    fn is_transient_rename_error(e: &std::io::Error) -> bool {
431        // ERROR_SHARING_VIOLATION = 32, ERROR_ACCESS_DENIED = 5. Both can
432        // appear transiently when MoveFileEx hits a target that's open.
433        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    /// Load a value from a JSON file in the store directory.
442    ///
443    /// Returns [`ProfileError::NotFound`] if the file does not exist.
444    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    /// Remove a file from the store directory.
460    ///
461    /// Does nothing if the file does not already exist.
462    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    /// Check whether a file exists in the store directory.
473    pub fn exists(&self, filename: &str) -> bool {
474        Self::validate_filename(filename).is_ok() && self.dir.join(filename).exists()
475    }
476
477    /// Acquire an exclusive advisory lock that serialises critical sections
478    /// against other processes sharing this profile directory.
479    ///
480    /// The lock is held on a sibling file (`.<filename>.lock`) so it survives
481    /// atomic rewrites of the target. This is **blocking** — call it from a
482    /// `spawn_blocking` task when invoked from async code. Released when the
483    /// returned [`FileLockGuard`] is dropped.
484    ///
485    /// Intended use is around the read-modify-write window for files like
486    /// `auth.json` where a non-atomic critical section across processes
487    /// causes silent state corruption (in the auth case: refresh-token
488    /// rotation replay).
489    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    /// Save a [`ProfileData`] value using its declared filename and mode.
503    pub fn save_profile<T: ProfileData>(&self, value: &T) -> Result<(), ProfileError> {
504        self.write(T::FILENAME, value, T::MODE)
505    }
506
507    /// Load a [`ProfileData`] value from its declared filename.
508    pub fn load_profile<T: ProfileData>(&self) -> Result<T, ProfileError> {
509        self.load(T::FILENAME)
510    }
511
512    /// Remove the file for a [`ProfileData`] type.
513    pub fn clear_profile<T: ProfileData>(&self) -> Result<(), ProfileError> {
514        self.clear(T::FILENAME)
515    }
516
517    /// Check whether the file for a [`ProfileData`] type exists.
518    pub fn exists_profile<T: ProfileData>(&self) -> bool {
519        self.exists(T::FILENAME)
520    }
521}
522
523/// Returns a profile store at `~/.cipherstash`.
524///
525/// # Panics
526///
527/// Panics if the home directory cannot be determined.
528impl 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        // Create file with broad permissions first
787        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        // Overwrite with restricted mode
799        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    /// Concurrent writers must never expose torn content to a reader. Each
818    /// write goes through a sibling tmp file + rename, so an interleaved
819    /// reader sees either the prior complete file or a complete new file —
820    /// never a half-written one.
821    #[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            // Large payload so any non-atomic write would leave an
830            // observably-incomplete file mid-flight.
831            payload: String,
832            writer: usize,
833        }
834
835        fn make_value(writer: usize, payload_size: usize) -> Big {
836            Big {
837                // Encode the writer ID into the payload so any torn
838                // mix-and-match between writers would show up as an
839                // unparseable / inconsistent file.
840                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        // 64 KiB per write — well above any sane page/buffer size.
853        let payload_size = 64 * 1024;
854
855        // Pre-seed so the reader always has a file to observe, even before
856        // any concurrent writer completes its first save.
857        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        // Race reads against the writers. Every successful read must yield a
876        // well-formed JSON whose payload matches the declared writer — proving
877        // we never observed a partial overwrite.
878        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        // Final state should be a clean, complete JSON from one of the writers.
917        let final_value: Big = store.load("contended.json").unwrap();
918        assert_eq!(final_value.payload.len(), payload_size);
919
920        // No staging files should be left behind after all writers finished.
921        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}