Skip to main content

stack_profile/
device_identity.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use crate::{ProfileData, ProfileError, ProfileStore};
5
6/// Persistent identity for a CLI installation.
7///
8/// Each device gets a unique `device_instance_id` (UUIDv4) and a human-readable
9/// `device_name` (defaults to the hostname). The identity is stored in
10/// `~/.cipherstash/device.json` and reused across sessions so the server can
11/// track device lifecycle.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct DeviceIdentity {
14    /// A UUIDv4 that uniquely identifies this CLI installation.
15    pub device_instance_id: Uuid,
16    /// A human-readable name for this device (defaults to the hostname).
17    pub device_name: String,
18}
19
20impl ProfileData for DeviceIdentity {
21    const FILENAME: &'static str = "device.json";
22    const MODE: Option<u32> = Some(0o600);
23}
24
25impl DeviceIdentity {
26    /// Load an existing device identity from the given store, or create a new
27    /// one if none exists.
28    ///
29    /// When creating, generates a UUIDv4 and uses the system hostname as the
30    /// default device name. The file is written with mode 0600 on Unix.
31    pub fn load_or_create(store: &ProfileStore) -> Result<Self, ProfileError> {
32        match store.load_profile::<Self>() {
33            Ok(identity) => Ok(identity),
34            Err(ProfileError::NotFound { .. }) => {
35                let identity = Self {
36                    device_instance_id: Uuid::new_v4(),
37                    device_name: gethostname::gethostname().to_string_lossy().into_owned(),
38                };
39                store.save_profile(&identity)?;
40                Ok(identity)
41            }
42            Err(e) => Err(e),
43        }
44    }
45
46    /// Load a device identity from the given store.
47    ///
48    /// Returns [`ProfileError::NotFound`] if the file does not exist.
49    pub fn load(store: &ProfileStore) -> Result<Self, ProfileError> {
50        store.load_profile()
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn load_or_create_generates_new_identity() {
60        let dir = tempfile::tempdir().unwrap();
61        let store = ProfileStore::new(dir.path());
62
63        let identity = DeviceIdentity::load_or_create(&store).unwrap();
64        assert!(!identity.device_instance_id.is_nil());
65        assert!(!identity.device_name.is_empty());
66    }
67
68    #[test]
69    fn load_or_create_reuses_existing() {
70        let dir = tempfile::tempdir().unwrap();
71        let store = ProfileStore::new(dir.path());
72
73        let first = DeviceIdentity::load_or_create(&store).unwrap();
74        let second = DeviceIdentity::load_or_create(&store).unwrap();
75        assert_eq!(first.device_instance_id, second.device_instance_id);
76        assert_eq!(first.device_name, second.device_name);
77    }
78
79    #[test]
80    fn load_returns_not_found_for_missing_file() {
81        let dir = tempfile::tempdir().unwrap();
82        let store = ProfileStore::new(dir.path());
83        let err = DeviceIdentity::load(&store).unwrap_err();
84        assert!(matches!(err, ProfileError::NotFound { .. }));
85    }
86
87    #[test]
88    fn round_trip_serialization() {
89        let dir = tempfile::tempdir().unwrap();
90        let store = ProfileStore::new(dir.path());
91
92        let original = DeviceIdentity {
93            device_instance_id: Uuid::new_v4(),
94            device_name: "test-host".to_string(),
95        };
96        store.save("device.json", &original).unwrap();
97
98        let loaded = DeviceIdentity::load(&store).unwrap();
99        assert_eq!(original.device_instance_id, loaded.device_instance_id);
100        assert_eq!(original.device_name, loaded.device_name);
101    }
102}