Skip to main content

client_core/
config.rs

1//! On-disk client config: `~/.cinch/config.json`.
2//!
3//! Wire-compatible with the Go CLI's `cinch/internal/config/config.go` schema.
4//! `MultiConfig` is the canonical disk format; `Config` is the legacy single-relay
5//! shape kept for backwards compatibility (and as the in-memory shape consumers
6//! handle most often via `MultiConfig::to_active_config`).
7//!
8//! Permissions: 0700 dir, 0600 file on Unix.
9
10use std::sync::{Arc, Mutex};
11
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Config {
16    #[serde(default)]
17    pub token: String,
18    #[serde(default)]
19    pub user_id: String,
20    #[serde(default = "default_relay_url")]
21    pub relay_url: String,
22    #[serde(default)]
23    pub hostname: String,
24    #[serde(default)]
25    pub active_device_id: String,
26    #[serde(default)]
27    pub credential_version: u64,
28    #[serde(default)]
29    pub encryption_key: String,
30    #[serde(default)]
31    pub device_private_key: String,
32    #[serde(default)]
33    pub email: String,
34    #[serde(default)]
35    pub identity_provider: String,
36    #[serde(default)]
37    pub display_name: String,
38    /// True when this device registered its X25519 public key with the relay
39    /// but has not yet received the user's master AES key via the
40    /// `key_exchange_requested` ECDH flow. While true, `encryption_key` is
41    /// empty and every push/pull must first attempt an auto-retry of
42    /// `retry_key_bundle` + `poll_key_bundle`.
43    ///
44    /// Set by `auth_session::install_credentials` on a fresh sign-in, cleared
45    /// by `auth::poll_key_bundle` the moment a master-key bundle arrives and
46    /// decrypts.
47    #[serde(default)]
48    pub key_pending: bool,
49}
50
51pub fn default_relay_url() -> String {
52    "http://localhost:8080".to_string()
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct RelayProfile {
57    pub id: String,
58    pub label: String,
59    pub relay_url: String,
60    pub user_id: String,
61    pub device_id: String,
62    pub hostname: String,
63    #[serde(default)]
64    pub encryption_key: String,
65    #[serde(default)]
66    pub device_private_key: String,
67    #[serde(default)]
68    pub credential_version: u64,
69    #[serde(default)]
70    pub token: String,
71    /// Stable per-machine identifier (opaque hash). Used by the relay to
72    /// recognize repeat sign-ins from the same machine and reuse a single
73    /// device row instead of creating duplicates. Empty for legacy configs;
74    /// backfilled on next save via `client_core::machine::stable_machine_id`.
75    #[serde(default)]
76    pub machine_id: String,
77    /// Verified email address returned by the OAuth provider at login time.
78    /// Empty for legacy configs or when the provider did not return a verified email.
79    #[serde(default)]
80    pub email: String,
81    /// OAuth identity provider used for the most recent login ("google" or "github").
82    /// Empty for legacy configs.
83    #[serde(default)]
84    pub identity_provider: String,
85    /// Effective display name. Set by the user via `cinch auth set-name` or
86    /// the desktop settings UI; falls back to the OAuth-fetched name on
87    /// login if the user hasn't overridden it. Empty for legacy configs.
88    #[serde(default)]
89    pub display_name: String,
90    /// Mirrors `Config::key_pending`. See that field's doc comment for the
91    /// full semantics. Defaults to false so configs written by pre-fix
92    /// builds — where a fresh AES key was always generated locally —
93    /// continue to look "ready" on disk, and only newly-installed devices
94    /// (after the fix lands) opt into the pending-key flow.
95    #[serde(default)]
96    pub key_pending: bool,
97}
98
99impl RelayProfile {
100    pub fn from_config(cfg: &Config, label: Option<String>) -> Self {
101        use ulid::Ulid;
102        let id = Ulid::new().to_string();
103        let label = label.unwrap_or_else(|| {
104            url::Url::parse(&cfg.relay_url)
105                .ok()
106                .and_then(|u| u.host_str().map(|h| h.to_string()))
107                .unwrap_or_else(|| cfg.relay_url.clone())
108        });
109        Self {
110            id,
111            label,
112            relay_url: cfg.relay_url.clone(),
113            user_id: cfg.user_id.clone(),
114            device_id: cfg.active_device_id.clone(),
115            hostname: cfg.hostname.clone(),
116            encryption_key: cfg.encryption_key.clone(),
117            device_private_key: cfg.device_private_key.clone(),
118            credential_version: cfg.credential_version,
119            token: cfg.token.clone(),
120            machine_id: crate::machine::stable_machine_id(),
121            email: cfg.email.clone(),
122            identity_provider: cfg.identity_provider.clone(),
123            display_name: cfg.display_name.clone(),
124            key_pending: cfg.key_pending,
125        }
126    }
127
128    pub fn to_config(&self) -> Config {
129        Config {
130            token: self.token.clone(),
131            user_id: self.user_id.clone(),
132            relay_url: self.relay_url.clone(),
133            hostname: self.hostname.clone(),
134            active_device_id: self.device_id.clone(),
135            credential_version: self.credential_version,
136            encryption_key: self.encryption_key.clone(),
137            device_private_key: self.device_private_key.clone(),
138            email: self.email.clone(),
139            identity_provider: self.identity_provider.clone(),
140            display_name: self.display_name.clone(),
141            key_pending: self.key_pending,
142        }
143    }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, Default)]
147pub struct MultiConfig {
148    #[serde(default)]
149    pub active_relay_id: Option<String>,
150    #[serde(default)]
151    pub relays: Vec<RelayProfile>,
152}
153
154pub type MultiConfigHandle = Arc<Mutex<MultiConfig>>;
155
156impl MultiConfig {
157    pub fn load() -> Self {
158        let Some(home) = dirs::home_dir() else {
159            return Self::default();
160        };
161        let path = home.join(".cinch").join("config.json");
162        if !path.exists() {
163            return Self::default();
164        }
165        let Ok(data) = std::fs::read_to_string(&path) else {
166            return Self::default();
167        };
168        let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) else {
169            return Self::default();
170        };
171        if v.get("relays").is_some() {
172            serde_json::from_value(v).unwrap_or_default()
173        } else {
174            let old: Config = match serde_json::from_value(v) {
175                Ok(c) => c,
176                Err(_) => return Self::default(),
177            };
178            Self::from_legacy(old)
179        }
180    }
181
182    pub fn save(&self) -> Result<(), String> {
183        let home = dirs::home_dir().ok_or("cannot determine home directory")?;
184        let dir = home.join(".cinch");
185        std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir: {}", e))?;
186        let path = dir.join("config.json");
187        let data = serde_json::to_string_pretty(self).map_err(|e| format!("marshal: {}", e))?;
188        std::fs::write(&path, &data).map_err(|e| format!("write: {}", e))?;
189        #[cfg(unix)]
190        {
191            use std::os::unix::fs::PermissionsExt;
192            if let Ok(meta) = std::fs::metadata(&path) {
193                let mut perms = meta.permissions();
194                perms.set_mode(0o600);
195                let _ = std::fs::set_permissions(&path, perms);
196            }
197        }
198        Ok(())
199    }
200
201    pub fn active_profile(&self) -> Option<&RelayProfile> {
202        let id = self.active_relay_id.as_deref()?;
203        self.relays.iter().find(|r| r.id == id)
204    }
205
206    pub fn active_profile_mut(&mut self) -> Option<&mut RelayProfile> {
207        let id = self.active_relay_id.clone()?;
208        self.relays.iter_mut().find(|r| r.id == id)
209    }
210
211    pub fn to_active_config(&self) -> Config {
212        self.active_profile()
213            .map(|p| p.to_config())
214            .unwrap_or_default()
215    }
216
217    pub fn from_legacy_pub(old: Config) -> Self {
218        Self::from_legacy(old)
219    }
220
221    fn from_legacy(old: Config) -> Self {
222        if old.user_id.is_empty() && old.token.is_empty() {
223            return Self::default();
224        }
225        let profile = RelayProfile::from_config(&old, None);
226        let id = profile.id.clone();
227        Self {
228            active_relay_id: Some(id),
229            relays: vec![profile],
230        }
231    }
232}
233
234impl Default for Config {
235    fn default() -> Self {
236        Self {
237            token: String::new(),
238            user_id: String::new(),
239            relay_url: default_relay_url(),
240            hostname: String::new(),
241            active_device_id: String::new(),
242            credential_version: 0,
243            encryption_key: String::new(),
244            device_private_key: String::new(),
245            email: String::new(),
246            identity_provider: String::new(),
247            display_name: String::new(),
248            key_pending: false,
249        }
250    }
251}
252
253impl Config {
254    pub fn is_configured(&self) -> bool {
255        !self.user_id.is_empty() && !self.active_device_id.is_empty()
256    }
257
258    pub fn load() -> Result<Self, String> {
259        let mc = MultiConfig::load();
260        let cfg = mc.to_active_config();
261        if cfg.user_id.is_empty() && cfg.token.is_empty() {
262            return Err("no active relay configured — run: cinch auth login".to_string());
263        }
264        Ok(cfg)
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_is_configured_accepts_keyring_backed_config() {
274        let config = Config {
275            token: String::new(),
276            user_id: "u1".into(),
277            relay_url: "https://api.cinchcli.com".into(),
278            hostname: "macbook".into(),
279            active_device_id: "d1".into(),
280            credential_version: 1,
281            encryption_key: String::new(),
282            device_private_key: String::new(),
283            email: String::new(),
284            identity_provider: String::new(),
285            display_name: String::new(),
286            key_pending: false,
287        };
288        assert!(config.is_configured());
289    }
290
291    #[test]
292    fn relay_profile_roundtrips_display_name_through_config() {
293        let cfg = Config {
294            token: "t".into(),
295            user_id: "u".into(),
296            relay_url: "https://r".into(),
297            hostname: "h".into(),
298            active_device_id: "d".into(),
299            credential_version: 1,
300            encryption_key: String::new(),
301            device_private_key: String::new(),
302            email: "alice@example.com".into(),
303            identity_provider: "github".into(),
304            display_name: "Alice Example".into(),
305            key_pending: false,
306        };
307        let prof = RelayProfile::from_config(&cfg, Some("test".into()));
308        assert_eq!(prof.display_name, "Alice Example");
309        let back = prof.to_config();
310        assert_eq!(back.display_name, "Alice Example");
311    }
312
313    #[test]
314    fn relay_profile_defaults_display_name_when_legacy_json_missing_field() {
315        let json = r#"{
316            "id": "01HZ",
317            "label": "main",
318            "relay_url": "https://r",
319            "user_id": "u",
320            "device_id": "d",
321            "hostname": "h"
322        }"#;
323        let p: RelayProfile = serde_json::from_str(json).expect("decode");
324        assert_eq!(p.display_name, "");
325    }
326}