//! Server profiles and trusted host keys stored in the vault //! (port of `profiles/profileService.js`; identical vault JSON layout). use crate::core::error::{CoreError, CoreResult}; use crate::core::vault::VaultStore; use once_cell::sync::Lazy; use regex::Regex; use serde_json::{json, Map, Value}; use std::net::IpAddr; use std::sync::Arc; static HOSTNAME: Lazy = Lazy::new(|| { Regex::new(r"^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*$").unwrap() }); static FINGERPRINT: Lazy = Lazy::new(|| Regex::new(r"^[a-fA-F0-9]{64}$").unwrap()); pub fn is_ip(value: &str) -> Option { match value.parse::() { Ok(IpAddr::V4(_)) => Some(4), Ok(IpAddr::V6(_)) => Some(6), Err(_) => None, } } pub fn normalize_host(value: &str) -> CoreResult { let host = value.trim(); if host.is_empty() || (is_ip(host).is_none() && (host.len() > 253 || !HOSTNAME.is_match(host))) { return Err(CoreError::plain("Invalid server host name or IP address.")); } Ok(host.to_lowercase()) } /// JS `Number(value || 22)` semantics for numbers and numeric strings. pub fn normalize_port(value: Option<&Value>) -> CoreResult { let number = match value { None | Some(Value::Null) | Some(Value::Bool(false)) => 22.0, Some(Value::Number(n)) => { let n = n.as_f64().unwrap_or(f64::NAN); if n == 0.0 { 22.0 } else { n } } Some(Value::String(s)) if s.is_empty() => 22.0, Some(Value::String(s)) => s.trim().parse::().unwrap_or(f64::NAN), _ => f64::NAN, }; if number.fract() != 0.0 || !(1.0..=65535.0).contains(&number) { return Err(CoreError::plain("SSH port must be between 1 and 65535.")); } Ok(number as u16) } fn has_forbidden(value: &str) -> bool { value.contains('\0') || value.contains('\r') || value.contains('\n') } fn text(input: &Value, key: &str) -> String { match input.get(key) { Some(Value::String(s)) => s.clone(), Some(Value::Number(n)) => n.to_string(), Some(Value::Bool(b)) => b.to_string(), _ => String::new(), } } fn now_iso() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) } pub fn normalize_profile(input: &Value, existing: Option<&Value>) -> CoreResult { normalize_host(&text(input, "host"))?; let host = text(input, "host").trim().to_string(); let username = text(input, "username").trim().to_string(); if username.is_empty() || username.encode_utf16().count() > 128 || has_forbidden(&username) { return Err(CoreError::plain("Invalid SSH username.")); } let auth_method = if input.get("authMethod").and_then(Value::as_str) == Some("privateKey") { "privateKey" } else { "password" }; let private_key_path = text(input, "privateKeyPath").trim().to_string(); if auth_method == "privateKey" && (private_key_path.is_empty() || has_forbidden(&private_key_path)) { return Err(CoreError::plain("Select a private key file.")); } let now = now_iso(); let label_source = { let l = text(input, "label"); if l.is_empty() { host.clone() } else { l } }; let mut units = 0; let mut label: String = label_source.trim().chars().take_while(|c| { units += c.len_utf16(); units <= 100 }).collect(); if label.is_empty() { label = host.clone(); } let mut tags: Vec = Vec::new(); if let Some(Value::Array(items)) = input.get("tags") { for item in items { let tag = match item { Value::String(s) => s.trim().to_string(), Value::Null => "null".into(), other => other.to_string() }; if !tag.is_empty() && !tags.iter().any(|t| t.as_str() == Some(tag.as_str())) { tags.push(Value::String(tag)); } } tags.truncate(20); } let existing_field = |key: &str| existing.and_then(|e| e.get(key)).cloned(); Ok(json!({ "id": existing_field("id").unwrap_or_else(|| Value::String(uuid::Uuid::new_v4().to_string())), "label": label, "host": host, "port": normalize_port(input.get("port"))?, "username": username, "authMethod": auth_method, "privateKeyPath": if auth_method == "privateKey" { private_key_path } else { String::new() }, "tags": tags, "jumpProfileId": input.get("jumpProfileId").or_else(|| existing.and_then(|p| p.get("jumpProfileId"))).and_then(Value::as_str).unwrap_or("").trim(), "createdAt": existing_field("createdAt").unwrap_or_else(|| Value::String(now.clone())), "updatedAt": now, "lastUsedAt": existing_field("lastUsedAt").unwrap_or(Value::Null) })) } pub fn host_key_id(host: &str, port: Option<&Value>) -> CoreResult { Ok(format!("{}:{}", normalize_host(host)?, normalize_port(port)?)) } fn validate_stored_password(value: &str) -> CoreResult { if value.is_empty() || value.encode_utf16().count() > 1024 || value.contains('\0') { return Err(CoreError::plain("Stored SSH password is invalid.")); } Ok(value.to_string()) } fn password_store(data: &mut Value) -> &mut Map { if !data.get("secrets").map(Value::is_object).unwrap_or(false) { data["secrets"] = json!({}); } if !data["secrets"].get("profilePasswords").map(Value::is_object).unwrap_or(false) { data["secrets"]["profilePasswords"] = json!({}); } data["secrets"]["profilePasswords"].as_object_mut().expect("object") } fn has_password(data: &Value, id: &str) -> bool { data.pointer("/secrets/profilePasswords").and_then(|p| p.get(id)).map(Value::is_string).unwrap_or(false) } fn sort_key(profile: &Value) -> String { profile .get("lastUsedAt") .and_then(Value::as_str) .filter(|s| !s.is_empty()) .or_else(|| profile.get("updatedAt").and_then(Value::as_str)) .unwrap_or("") .to_string() } pub struct ProfileService { pub vault: Arc, } impl ProfileService { pub fn new(vault: Arc) -> Self { Self { vault } } pub fn list(&self) -> CoreResult> { let data = self.vault.get_data()?; let mut profiles: Vec = data["profiles"] .as_array() .cloned() .unwrap_or_default() .into_iter() .map(|mut profile| { let id = profile.get("id").and_then(Value::as_str).unwrap_or("").to_string(); profile["hasStoredPassword"] = Value::Bool(has_password(&data, &id)); profile }) .collect(); profiles.sort_by(|a, b| sort_key(b).cmp(&sort_key(a))); Ok(profiles) } pub fn get(&self, id: &str) -> CoreResult { let data = self.vault.get_data()?; let mut profile = data["profiles"] .as_array() .and_then(|items| items.iter().find(|p| p.get("id").and_then(Value::as_str) == Some(id)).cloned()) .ok_or_else(|| CoreError::plain("Server profile was not found."))?; profile["hasStoredPassword"] = Value::Bool(has_password(&data, id)); Ok(profile) } pub fn save(&self, input: &Value) -> CoreResult { let id = self.vault.update(|data| { let profiles = data["profiles"].as_array_mut().ok_or_else(|| CoreError::plain("Vault profiles are invalid."))?; let input_id = input.get("id").and_then(Value::as_str); let index = input_id.and_then(|id| profiles.iter().position(|p| p.get("id").and_then(Value::as_str) == Some(id))); let saved = normalize_profile(input, index.map(|i| &profiles[i]))?; let saved_id = saved["id"].as_str().unwrap_or("").to_string(); let mut seen = std::collections::HashSet::from([saved_id.clone()]); let mut next = saved["jumpProfileId"].as_str().unwrap_or(""); while !next.is_empty() { if !seen.insert(next.to_string()) { return Err(CoreError::new("JUMP_LOOP", "The jump route contains a cycle.")); } let hop = profiles.iter().find(|p| p["id"].as_str() == Some(next)) .ok_or_else(|| CoreError::new("JUMP_PROFILE_NOT_FOUND", "Jump profile was not found."))?; next = hop["jumpProfileId"].as_str().unwrap_or(""); } let auth_password = saved["authMethod"] == "password"; match index { Some(i) => profiles[i] = saved, None => profiles.push(saved), } let store_password = input.get("storePassword").and_then(Value::as_bool); let passwords = password_store(data); if !auth_password || store_password == Some(false) { passwords.remove(&saved_id); } else if store_password == Some(true) { let password = text(input, "password"); if !password.is_empty() { passwords.insert(saved_id.clone(), Value::String(validate_stored_password(&password)?)); } else if !passwords.get(&saved_id).map(Value::is_string).unwrap_or(false) { return Err(CoreError::plain("Enter the SSH password to store it in the vault.")); } } Ok(saved_id) })?; self.get(&id) } pub fn delete(&self, id: &str) -> CoreResult { let removed = self.vault.update(|data| { let profiles = data["profiles"].as_array_mut().ok_or_else(|| CoreError::plain("Vault profiles are invalid."))?; let before = profiles.len(); profiles.retain(|p| p.get("id").and_then(Value::as_str) != Some(id)); let removed = before != profiles.len(); for profile in profiles.iter_mut() { if profile["jumpProfileId"].as_str() == Some(id) { profile["jumpProfileId"] = json!(""); } } password_store(data).remove(id); Ok(removed) })?; Ok(json!({ "removed": removed })) } pub fn mark_used(&self, id: &str) -> CoreResult<()> { self.vault.update(|data| { if let Some(profile) = data["profiles"].as_array_mut().and_then(|p| p.iter_mut().find(|p| p.get("id").and_then(Value::as_str) == Some(id))) { profile["lastUsedAt"] = Value::String(now_iso()); } Ok(()) }) } pub fn get_host_key(&self, host: &str, port: u16) -> CoreResult> { let id = host_key_id(host, Some(&json!(port)))?; Ok(self.vault.get_data()?["hostKeys"].get(&id).and_then(Value::as_str).map(str::to_string)) } pub fn get_stored_password(&self, profile_id: &str) -> CoreResult> { let data = self.vault.get_data()?; let exists = data["profiles"].as_array().map(|p| p.iter().any(|p| p.get("id").and_then(Value::as_str) == Some(profile_id))).unwrap_or(false); if !exists { return Err(CoreError::plain("Server profile was not found.")); } Ok(data.pointer("/secrets/profilePasswords").and_then(|p| p.get(profile_id)).and_then(Value::as_str).map(str::to_string)) } pub fn trust_host_key(&self, host: &str, port: u16, fingerprint: &str) -> CoreResult { let normalized = fingerprint.trim(); if !FINGERPRINT.is_match(normalized) { return Err(CoreError::plain("Invalid SHA-256 host fingerprint.")); } let normalized = normalized.to_lowercase(); let id = host_key_id(host, Some(&json!(port)))?; self.vault.update(|data| { data["hostKeys"][&id] = Value::String(normalized.clone()); Ok(()) })?; Ok(json!({ "host": normalize_host(host)?, "port": port, "fingerprint": normalized })) } } #[cfg(test)] mod tests { use super::*; async fn service() -> (tempfile::TempDir, ProfileService) { let dir = tempfile::tempdir().unwrap(); let vault = Arc::new(VaultStore::new(dir.path())); vault.create("heslo123").await.unwrap(); (dir, ProfileService::new(vault)) } #[tokio::test] async fn save_list_password_and_host_keys() { let (_dir, service) = service().await; let saved = service .save(&json!({"label": "Web", "host": "Example.COM", "port": "2222", "username": "root", "authMethod": "password", "storePassword": true, "password": "pw"})) .unwrap(); assert_eq!(saved["port"], 2222); assert_eq!(saved["host"], "Example.COM"); assert_eq!(saved["hasStoredPassword"], true); let id = saved["id"].as_str().unwrap(); assert_eq!(service.get_stored_password(id).unwrap().as_deref(), Some("pw")); service.trust_host_key("example.com", 2222, &"AB".repeat(32)).unwrap(); assert_eq!(service.get_host_key("EXAMPLE.com", 2222).unwrap().unwrap(), "ab".repeat(32)); let updated = service.save(&json!({"id": id, "host": "example.com", "username": "root", "storePassword": false})).unwrap(); assert_eq!(updated["hasStoredPassword"], false); assert_eq!(updated["createdAt"], saved["createdAt"]); assert_eq!(service.delete(id).unwrap()["removed"], true); assert!(service.list().unwrap().is_empty()); } #[test] fn validation() { assert!(normalize_host("bad host").is_err()); assert!(normalize_host("::1").is_ok()); assert!(normalize_port(Some(&json!(0))).is_ok()); assert!(normalize_port(Some(&json!(70000))).is_err()); assert!(normalize_profile(&json!({"host": "a.b", "username": "x\ny"}), None).is_err()); assert!(normalize_profile(&json!({"host": "a.b", "username": "x", "authMethod": "privateKey"}), None).is_err()); } }