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}
37
38pub fn default_relay_url() -> String {
39    "http://localhost:8080".to_string()
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct RelayProfile {
44    pub id: String,
45    pub label: String,
46    pub relay_url: String,
47    pub user_id: String,
48    pub device_id: String,
49    pub hostname: String,
50    #[serde(default)]
51    pub encryption_key: String,
52    #[serde(default)]
53    pub device_private_key: String,
54    #[serde(default)]
55    pub credential_version: u64,
56    #[serde(default)]
57    pub token: String,
58    /// Stable per-machine identifier (opaque hash). Used by the relay to
59    /// recognize repeat sign-ins from the same machine and reuse a single
60    /// device row instead of creating duplicates. Empty for legacy configs;
61    /// backfilled on next save via `client_core::machine::stable_machine_id`.
62    #[serde(default)]
63    pub machine_id: String,
64    /// Verified email address returned by the OAuth provider at login time.
65    /// Empty for legacy configs or when the provider did not return a verified email.
66    #[serde(default)]
67    pub email: String,
68    /// OAuth identity provider used for the most recent login ("google" or "github").
69    /// Empty for legacy configs.
70    #[serde(default)]
71    pub identity_provider: String,
72}
73
74impl RelayProfile {
75    pub fn from_config(cfg: &Config, label: Option<String>) -> Self {
76        use ulid::Ulid;
77        let id = Ulid::new().to_string();
78        let label = label.unwrap_or_else(|| {
79            url::Url::parse(&cfg.relay_url)
80                .ok()
81                .and_then(|u| u.host_str().map(|h| h.to_string()))
82                .unwrap_or_else(|| cfg.relay_url.clone())
83        });
84        Self {
85            id,
86            label,
87            relay_url: cfg.relay_url.clone(),
88            user_id: cfg.user_id.clone(),
89            device_id: cfg.active_device_id.clone(),
90            hostname: cfg.hostname.clone(),
91            encryption_key: cfg.encryption_key.clone(),
92            device_private_key: cfg.device_private_key.clone(),
93            credential_version: cfg.credential_version,
94            token: cfg.token.clone(),
95            machine_id: crate::machine::stable_machine_id(),
96            email: cfg.email.clone(),
97            identity_provider: cfg.identity_provider.clone(),
98        }
99    }
100
101    pub fn to_config(&self) -> Config {
102        Config {
103            token: self.token.clone(),
104            user_id: self.user_id.clone(),
105            relay_url: self.relay_url.clone(),
106            hostname: self.hostname.clone(),
107            active_device_id: self.device_id.clone(),
108            credential_version: self.credential_version,
109            encryption_key: self.encryption_key.clone(),
110            device_private_key: self.device_private_key.clone(),
111            email: self.email.clone(),
112            identity_provider: self.identity_provider.clone(),
113        }
114    }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct MultiConfig {
119    #[serde(default)]
120    pub active_relay_id: Option<String>,
121    #[serde(default)]
122    pub relays: Vec<RelayProfile>,
123}
124
125pub type MultiConfigHandle = Arc<Mutex<MultiConfig>>;
126
127impl MultiConfig {
128    pub fn load() -> Self {
129        let Some(home) = dirs::home_dir() else {
130            return Self::default();
131        };
132        let path = home.join(".cinch").join("config.json");
133        if !path.exists() {
134            return Self::default();
135        }
136        let Ok(data) = std::fs::read_to_string(&path) else {
137            return Self::default();
138        };
139        let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) else {
140            return Self::default();
141        };
142        if v.get("relays").is_some() {
143            serde_json::from_value(v).unwrap_or_default()
144        } else {
145            let old: Config = match serde_json::from_value(v) {
146                Ok(c) => c,
147                Err(_) => return Self::default(),
148            };
149            Self::from_legacy(old)
150        }
151    }
152
153    pub fn save(&self) -> Result<(), String> {
154        let home = dirs::home_dir().ok_or("cannot determine home directory")?;
155        let dir = home.join(".cinch");
156        std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir: {}", e))?;
157        let path = dir.join("config.json");
158        let data = serde_json::to_string_pretty(self).map_err(|e| format!("marshal: {}", e))?;
159        std::fs::write(&path, &data).map_err(|e| format!("write: {}", e))?;
160        #[cfg(unix)]
161        {
162            use std::os::unix::fs::PermissionsExt;
163            if let Ok(meta) = std::fs::metadata(&path) {
164                let mut perms = meta.permissions();
165                perms.set_mode(0o600);
166                let _ = std::fs::set_permissions(&path, perms);
167            }
168        }
169        Ok(())
170    }
171
172    pub fn active_profile(&self) -> Option<&RelayProfile> {
173        let id = self.active_relay_id.as_deref()?;
174        self.relays.iter().find(|r| r.id == id)
175    }
176
177    pub fn active_profile_mut(&mut self) -> Option<&mut RelayProfile> {
178        let id = self.active_relay_id.clone()?;
179        self.relays.iter_mut().find(|r| r.id == id)
180    }
181
182    pub fn to_active_config(&self) -> Config {
183        self.active_profile()
184            .map(|p| p.to_config())
185            .unwrap_or_default()
186    }
187
188    pub fn from_legacy_pub(old: Config) -> Self {
189        Self::from_legacy(old)
190    }
191
192    fn from_legacy(old: Config) -> Self {
193        if old.user_id.is_empty() && old.token.is_empty() {
194            return Self::default();
195        }
196        let profile = RelayProfile::from_config(&old, None);
197        let id = profile.id.clone();
198        Self {
199            active_relay_id: Some(id),
200            relays: vec![profile],
201        }
202    }
203}
204
205impl Default for Config {
206    fn default() -> Self {
207        Self {
208            token: String::new(),
209            user_id: String::new(),
210            relay_url: default_relay_url(),
211            hostname: String::new(),
212            active_device_id: String::new(),
213            credential_version: 0,
214            encryption_key: String::new(),
215            device_private_key: String::new(),
216            email: String::new(),
217            identity_provider: String::new(),
218        }
219    }
220}
221
222impl Config {
223    pub fn is_configured(&self) -> bool {
224        !self.user_id.is_empty() && !self.active_device_id.is_empty()
225    }
226
227    pub fn load() -> Result<Self, String> {
228        let mc = MultiConfig::load();
229        let cfg = mc.to_active_config();
230        if cfg.user_id.is_empty() && cfg.token.is_empty() {
231            return Err("no active relay configured — run: cinch auth login".to_string());
232        }
233        Ok(cfg)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_is_configured_accepts_keyring_backed_config() {
243        let config = Config {
244            token: String::new(),
245            user_id: "u1".into(),
246            relay_url: "https://api.cinchcli.com".into(),
247            hostname: "macbook".into(),
248            active_device_id: "d1".into(),
249            credential_version: 1,
250            encryption_key: String::new(),
251            device_private_key: String::new(),
252            email: String::new(),
253            identity_provider: String::new(),
254        };
255        assert!(config.is_configured());
256    }
257}