1use std::collections::HashMap;
9use std::path::Path;
10use std::sync::Mutex;
11
12use chrono::{DateTime, Utc};
13use hmac::{Hmac, Mac};
14use sha2::Sha256;
15
16type HmacSha256 = Hmac<Sha256>;
17
18#[derive(Debug, Clone)]
22pub struct TuiEntry {
23 pub tui_id: String,
24 pub connected_at: DateTime<Utc>,
25 pub peer_label: String,
26 pub transport: String,
28 pub env: HashMap<String, String>,
32}
33
34pub struct TuiRegistry {
41 signing_key: Option<Vec<u8>>,
45 connected: Mutex<HashMap<String, TuiEntry>>,
47}
48
49impl TuiRegistry {
50 pub fn new(config_dir: &Path) -> Self {
54 let key_path = config_dir.join(".secret_key");
55 let signing_key = std::fs::read_to_string(&key_path)
56 .ok()
57 .and_then(|hex_str| hex::decode(hex_str.trim()).ok())
58 .filter(|key| !key.is_empty());
59
60 Self {
61 signing_key,
62 connected: Mutex::new(HashMap::new()),
63 }
64 }
65
66 #[cfg(test)]
68 pub fn new_unsigned() -> Self {
69 Self {
70 signing_key: None,
71 connected: Mutex::new(HashMap::new()),
72 }
73 }
74
75 pub fn signing_is_enabled(&self) -> bool {
77 self.signing_key.is_some()
78 }
79
80 pub fn generate_tui_id() -> String {
84 let bytes: [u8; 4] = rand::random();
85 format!("tui_{}", hex::encode(bytes))
86 }
87
88 pub fn generate_unique_tui_id(&self) -> String {
90 let connected = self.connected.lock().unwrap_or_else(|e| e.into_inner());
91 loop {
92 let id = Self::generate_tui_id();
93 if !connected.contains_key(&id) {
94 return id;
95 }
96 }
97 }
98
99 pub fn sign(&self, tui_id: &str) -> Option<String> {
104 let key = self.signing_key.as_ref()?;
105 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
106 mac.update(tui_id.as_bytes());
107 Some(hex::encode(mac.finalize().into_bytes()))
108 }
109
110 pub fn verify(&self, tui_id: &str, sig: &str) -> bool {
114 let Some(ref key) = self.signing_key else {
115 return true;
116 };
117 let Ok(sig_bytes) = hex::decode(sig) else {
118 return false;
119 };
120 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
121 mac.update(tui_id.as_bytes());
122 mac.verify_slice(&sig_bytes).is_ok()
123 }
124
125 pub fn register(&self, entry: TuiEntry) {
129 self.connected
130 .lock()
131 .unwrap_or_else(|e| e.into_inner())
132 .insert(entry.tui_id.clone(), entry);
133 }
134
135 pub fn unregister(&self, tui_id: &str) {
137 self.connected
138 .lock()
139 .unwrap_or_else(|e| e.into_inner())
140 .remove(tui_id);
141 }
142
143 pub fn list(&self) -> Vec<TuiEntry> {
145 self.connected
146 .lock()
147 .unwrap_or_else(|e| e.into_inner())
148 .values()
149 .cloned()
150 .collect()
151 }
152
153 pub fn get_env(&self, tui_id: &str) -> Option<HashMap<String, String>> {
156 self.connected
157 .lock()
158 .unwrap_or_else(|e| e.into_inner())
159 .get(tui_id)
160 .map(|e| e.env.clone())
161 }
162}
163
164#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn generate_tui_id_format() {
172 let id = TuiRegistry::generate_tui_id();
173 assert!(id.starts_with("tui_"), "expected tui_ prefix, got {id}");
174 assert_eq!(id.len(), 12, "tui_ + 8 hex chars = 12, got {}", id.len());
175 assert!(
177 id[4..].chars().all(|c| c.is_ascii_hexdigit()),
178 "non-hex chars in {id}"
179 );
180 }
181
182 #[test]
183 fn sign_verify_roundtrip() {
184 let registry = TuiRegistry {
185 signing_key: Some(vec![0xAB; 32]),
186 connected: Mutex::new(HashMap::new()),
187 };
188 let id = "tui_deadbeef";
189 let sig = registry.sign(id).expect("signing should succeed");
190 assert!(registry.verify(id, &sig), "roundtrip verify failed");
191 }
192
193 #[test]
194 fn verify_rejects_tampered_sig() {
195 let registry = TuiRegistry {
196 signing_key: Some(vec![0xAB; 32]),
197 connected: Mutex::new(HashMap::new()),
198 };
199 let id = "tui_deadbeef";
200 let sig = registry.sign(id).unwrap();
201 let mut tampered = sig.clone();
203 let replacement = if tampered.ends_with('0') { 'f' } else { '0' };
204 tampered.pop();
205 tampered.push(replacement);
206 assert!(!registry.verify(id, &tampered), "tampered sig should fail");
207 }
208
209 #[test]
210 fn verify_rejects_wrong_id() {
211 let registry = TuiRegistry {
212 signing_key: Some(vec![0xAB; 32]),
213 connected: Mutex::new(HashMap::new()),
214 };
215 let sig = registry.sign("tui_aaaaaaaa").unwrap();
216 assert!(
217 !registry.verify("tui_bbbbbbbb", &sig),
218 "wrong ID should fail"
219 );
220 }
221
222 #[test]
223 fn verify_without_key_trusts_all() {
224 let registry = TuiRegistry::new_unsigned();
225 assert!(registry.verify("tui_anything", "bogus_sig"));
226 }
227
228 #[test]
229 fn signing_disabled_returns_none() {
230 let registry = TuiRegistry::new_unsigned();
231 assert!(registry.sign("tui_test").is_none());
232 assert!(!registry.signing_is_enabled());
233 }
234
235 #[test]
236 fn register_unregister_lifecycle() {
237 let registry = TuiRegistry::new_unsigned();
238 assert!(registry.list().is_empty());
239
240 registry.register(TuiEntry {
241 tui_id: "tui_aabb0011".to_string(),
242 connected_at: Utc::now(),
243 peer_label: "test".to_string(),
244 transport: "unix".to_string(),
245 env: HashMap::new(),
246 });
247 assert_eq!(registry.list().len(), 1);
248 assert_eq!(registry.list()[0].tui_id, "tui_aabb0011");
249
250 registry.unregister("tui_aabb0011");
251 assert!(registry.list().is_empty());
252 }
253
254 #[test]
255 fn unregister_unknown_is_noop() {
256 let registry = TuiRegistry::new_unsigned();
257 registry.unregister("tui_nonexistent"); }
259
260 #[test]
261 fn generate_unique_avoids_existing() {
262 let registry = TuiRegistry::new_unsigned();
263 registry.register(TuiEntry {
265 tui_id: "tui_00000000".to_string(),
266 connected_at: Utc::now(),
267 peer_label: "test".to_string(),
268 transport: "unix".to_string(),
269 env: HashMap::new(),
270 });
271 let id = registry.generate_unique_tui_id();
273 assert_ne!(id, "tui_00000000");
274 }
275
276 #[test]
279 fn tui_entry_stores_env() {
280 let registry = TuiRegistry::new_unsigned();
281 let mut env = HashMap::new();
282 env.insert("MY_VAR".to_string(), "my_value".to_string());
283 env.insert("ANTHROPIC_API_KEY".to_string(), "sk-secret".to_string());
284
285 registry.register(TuiEntry {
286 tui_id: "tui_aabbccdd".to_string(),
287 connected_at: Utc::now(),
288 peer_label: "test".to_string(),
289 transport: "unix".to_string(),
290 env,
291 });
292
293 let entries = registry.list();
294 assert_eq!(entries.len(), 1);
295 assert_eq!(
296 entries[0].env.get("MY_VAR").map(|s| s.as_str()),
297 Some("my_value")
298 );
299 assert_eq!(
300 entries[0].env.get("ANTHROPIC_API_KEY").map(|s| s.as_str()),
301 Some("sk-secret"),
302 "full env should be stored without filtering"
303 );
304 }
305
306 #[test]
307 fn tui_entry_env_defaults_to_empty() {
308 let registry = TuiRegistry::new_unsigned();
310 registry.register(TuiEntry {
311 tui_id: "tui_11223344".to_string(),
312 connected_at: Utc::now(),
313 peer_label: "test".to_string(),
314 transport: "unix".to_string(),
315 env: HashMap::new(),
316 });
317
318 let entries = registry.list();
319 assert!(entries[0].env.is_empty());
320 }
321
322 #[test]
323 fn tui_entry_env_dropped_on_unregister() {
324 let registry = TuiRegistry::new_unsigned();
325 let mut env = HashMap::new();
326 env.insert("SOME_VAR".to_string(), "some_value".to_string());
327
328 registry.register(TuiEntry {
329 tui_id: "tui_deadbeef".to_string(),
330 connected_at: Utc::now(),
331 peer_label: "test".to_string(),
332 transport: "unix".to_string(),
333 env,
334 });
335 assert_eq!(registry.list().len(), 1);
336
337 registry.unregister("tui_deadbeef");
338 assert!(
339 registry.list().is_empty(),
340 "env should be dropped with entry"
341 );
342 }
343
344 #[test]
345 fn tui_entry_env_survives_clone() {
346 let mut env = HashMap::new();
348 env.insert("CLONED_VAR".to_string(), "cloned_value".to_string());
349
350 let entry = TuiEntry {
351 tui_id: "tui_cafebabe".to_string(),
352 connected_at: Utc::now(),
353 peer_label: "test".to_string(),
354 transport: "unix".to_string(),
355 env,
356 };
357 let cloned = entry.clone();
358 assert_eq!(
359 cloned.env.get("CLONED_VAR").map(|s| s.as_str()),
360 Some("cloned_value")
361 );
362 }
363
364 #[test]
365 fn get_env_returns_env_for_connected_tui() {
366 let registry = TuiRegistry::new_unsigned();
367 let mut env = HashMap::new();
368 env.insert("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string());
369 env.insert("SSH_AUTH_SOCK".to_string(), "/tmp/ssh.sock".to_string());
370
371 registry.register(TuiEntry {
372 tui_id: "tui_getenv01".to_string(),
373 connected_at: Utc::now(),
374 peer_label: "test".to_string(),
375 transport: "unix".to_string(),
376 env,
377 });
378
379 let got = registry.get_env("tui_getenv01").expect("should find env");
380 assert_eq!(
381 got.get("PATH").map(|s| s.as_str()),
382 Some("/usr/bin:/usr/local/bin")
383 );
384 assert_eq!(
385 got.get("SSH_AUTH_SOCK").map(|s| s.as_str()),
386 Some("/tmp/ssh.sock")
387 );
388 }
389
390 #[test]
391 fn get_env_returns_none_for_unknown_tui() {
392 let registry = TuiRegistry::new_unsigned();
393 assert!(registry.get_env("tui_nothere").is_none());
394 }
395
396 #[test]
397 fn get_env_returns_none_after_unregister() {
398 let registry = TuiRegistry::new_unsigned();
399 let mut env = HashMap::new();
400 env.insert("SOME_VAR".to_string(), "val".to_string());
401 registry.register(TuiEntry {
402 tui_id: "tui_gone0001".to_string(),
403 connected_at: Utc::now(),
404 peer_label: "test".to_string(),
405 transport: "unix".to_string(),
406 env,
407 });
408 assert!(registry.get_env("tui_gone0001").is_some());
409 registry.unregister("tui_gone0001");
410 assert!(registry.get_env("tui_gone0001").is_none());
411 }
412}