//! Portable server-profile import/export (`chj-server-profiles` v1 JSON and //! `name,host,username,port` CSV) — port of `profiles/profileTransfer.js`. //! The format is shared with SM Mobile JSON v1 and must stay byte-compatible. use crate::core::error::{CoreError, CoreResult}; use crate::core::profiles::{is_ip, normalize_profile, ProfileService}; use once_cell::sync::Lazy; use parking_lot::Mutex; use regex::Regex; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::net::Ipv6Addr; use std::time::{Duration, Instant}; pub const FORMAT: &str = "chj-server-profiles"; pub const VERSION: u64 = 1; pub const MAX_BYTES: usize = 5 * 1024 * 1024; const FIELDS: [&str; 4] = ["name", "host", "username", "port"]; static CSV_ESCAPED: Lazy = Lazy::new(|| Regex::new(r"^'(?:[=+@\-\t\r]|')").unwrap()); static FORMULA_START: Lazy = Lazy::new(|| Regex::new(r"^[=+@\-\t\r']").unwrap()); static DIGITS: Lazy = Lazy::new(|| Regex::new(r"^[0-9]+$").unwrap()); #[derive(Clone, Debug, PartialEq)] pub struct PortableProfile { pub name: String, pub host: String, pub username: String, pub port: u16, } impl PortableProfile { fn to_json(&self) -> Value { json!({ "name": self.name, "host": self.host, "username": self.username, "port": self.port }) } } fn port_text(value: &Value) -> Option { match value { Value::Number(n) => Some(n.to_string()), Value::String(s) => Some(s.clone()), Value::Bool(b) => Some(b.to_string()), Value::Null => Some("null".into()), _ => None, } } pub fn portable_profile(input: &Value) -> CoreResult { let object = input.as_object().ok_or_else(|| CoreError::plain("Invalid server profile."))?; for field in ["name", "host", "username"] { match object.get(field) { Some(Value::String(s)) if !s.trim().is_empty() => {} _ => return Err(CoreError::plain(format!("Missing or invalid {field}."))), } } let name = object["name"].as_str().unwrap_or(""); if name.encode_utf16().count() > 100 || name.contains(['\0', '\r', '\n']) { return Err(CoreError::plain("Invalid server name (maximum 100 characters).")); } let port_value = object.get("port").filter(|v| !v.is_null() || object.contains_key("port")); if let Some(port) = object.get("port") { let text = port_text(port).unwrap_or_default(); if !DIGITS.is_match(&text) { return Err(CoreError::plain("Invalid SSH port.")); } let number: f64 = text.parse().unwrap_or(0.0); if !(1.0..=65535.0).contains(&number) { return Err(CoreError::plain("SSH port must be between 1 and 65535.")); } } let profile = normalize_profile( &json!({ "label": name, "host": object["host"], "username": object["username"], "port": port_value.cloned().unwrap_or(json!(22)) }), None, )?; Ok(PortableProfile { name: profile["label"].as_str().unwrap_or_default().to_string(), host: profile["host"].as_str().unwrap_or_default().to_string(), username: profile["username"].as_str().unwrap_or_default().to_string(), port: profile["port"].as_u64().unwrap_or(22) as u16, }) } /// Duplicate identity: lowercased host (IPv6 canonicalized like the WHATWG URL parser), port, username. pub fn identity(host: &str, port: u64, username: &str) -> String { let normalized = if is_ip(host) == Some(6) { host.parse::().map(|ip| format!("[{ip}]")).unwrap_or_else(|_| host.to_lowercase()) } else { host.to_lowercase() }; serde_json::to_string(&json!([normalized, port, username])).unwrap_or_default() } fn identity_of(profile: &Value) -> String { identity( profile.get("host").and_then(Value::as_str).unwrap_or(""), profile.get("port").and_then(Value::as_u64).unwrap_or(22), profile.get("username").and_then(Value::as_str).unwrap_or(""), ) } /// RFC 4180 parser with embedded commas, quotes and line breaks. fn csv_rows(content: &str) -> CoreResult>> { let chars: Vec = content.chars().collect(); let (mut rows, mut row, mut cell) = (Vec::new(), Vec::new(), String::new()); let (mut quoted, mut closed) = (false, false); let mut i = 0; while i < chars.len() { let c = chars[i]; if quoted { if c == '"' && chars.get(i + 1) == Some(&'"') { cell.push('"'); i += 1; } else if c == '"' { quoted = false; closed = true; } else { cell.push(c); } } else if c == '"' && cell.is_empty() && !closed { quoted = true; } else if c == ',' { row.push(std::mem::take(&mut cell)); closed = false; } else if c == '\n' || c == '\r' { if c == '\r' && chars.get(i + 1) == Some(&'\n') { i += 1; } row.push(std::mem::take(&mut cell)); rows.push(std::mem::take(&mut row)); closed = false; } else { if closed || c == '"' { return Err(CoreError::plain("Malformed CSV quoting.")); } cell.push(c); } i += 1; } if quoted { return Err(CoreError::plain("Unterminated CSV field.")); } if !cell.is_empty() || !row.is_empty() || closed { row.push(cell); rows.push(row); } Ok(rows) } fn decode_csv_cell(value: &str) -> String { if CSV_ESCAPED.is_match(value) { value[1..].to_string() } else { value.to_string() } } pub fn parse_profiles(content: &str) -> CoreResult<(String, Vec)> { if content.len() > MAX_BYTES { return Err(CoreError::plain("Profile file exceeds the 5 MiB limit.")); } let content = content.strip_prefix('\u{feff}').unwrap_or(content).trim(); let (format, servers): (&str, Vec) = if content.starts_with('{') || content.starts_with('[') { let document: Value = serde_json::from_str(content).map_err(|e| CoreError::plain(e.to_string()))?; if document.get("format").and_then(Value::as_str) != Some(FORMAT) || document.get("version").and_then(Value::as_f64) != Some(VERSION as f64) || !document.get("servers").map(Value::is_array).unwrap_or(false) { return Err(CoreError::plain("Unsupported server profile format or version.")); } ("json", document["servers"].as_array().cloned().unwrap_or_default()) } else { let rows = csv_rows(content)?; let mut iter = rows.into_iter(); let header = iter.next().unwrap_or_default(); let unique: HashSet<&String> = header.iter().collect(); if header.len() != FIELDS.len() || unique.len() != FIELDS.len() || FIELDS.iter().any(|f| !header.iter().any(|h| h == f)) { return Err(CoreError::plain("CSV columns must be name,host,username,port.")); } let mut servers = Vec::new(); for row in iter.filter(|row| row.iter().any(|c| !c.is_empty())) { if row.len() != header.len() { return Err(CoreError::plain("CSV row has an unexpected number of columns.")); } let mut object = serde_json::Map::new(); for (field, value) in header.iter().zip(row.iter()) { object.insert(field.clone(), Value::String(decode_csv_cell(value))); } servers.push(Value::Object(object)); } ("csv", servers) }; if servers.is_empty() || servers.len() > 10000 { return Err(CoreError::plain("Import must contain between 1 and 10000 servers.")); } let mut result = Vec::with_capacity(servers.len()); for (index, server) in servers.iter().enumerate() { result.push(portable_profile(server).map_err(|e| CoreError::plain(format!("Server {}: {}", index + 1, e.message)))?); } Ok((format.to_string(), result)) } pub fn serialize_profiles(profiles: &[Value], format: &str) -> CoreResult { let mut servers = Vec::new(); for profile in profiles { servers.push(portable_profile(&json!({ "name": profile.get("label").cloned().unwrap_or(Value::Null), "host": profile.get("host").cloned().unwrap_or(Value::Null), "username": profile.get("username").cloned().unwrap_or(Value::Null), "port": profile.get("port").cloned().unwrap_or(Value::Null) }))?); } match format { "json" => { let document = json!({ "format": FORMAT, "version": VERSION, "servers": servers.iter().map(PortableProfile::to_json).collect::>() }); Ok(serde_json::to_string_pretty(&document)? + "\n") } "csv" => { let escape = |value: &str| -> String { let mut cell = value.to_string(); if FORMULA_START.is_match(&cell) { cell = format!("'{cell}"); } if cell.contains(['"', ',', '\r', '\n']) { format!("\"{}\"", cell.replace('"', "\"\"")) } else { cell } }; let mut lines = vec![FIELDS.join(",")]; for s in &servers { lines.push([escape(&s.name), escape(&s.host), escape(&s.username), escape(&s.port.to_string())].join(",")); } Ok(format!("\u{feff}{}\r\n", lines.join("\r\n"))) } _ => Err(CoreError::plain("Unsupported export format.")), } } struct Pending { token: String, servers: Vec, generation: u64, expires: Instant, } pub struct ProfileTransfer { pending: Mutex>, } impl Default for ProfileTransfer { fn default() -> Self { Self { pending: Mutex::new(None) } } } impl ProfileTransfer { pub fn preview(&self, profiles: &ProfileService, content: &str) -> CoreResult { let (format, servers) = parse_profiles(content)?; let mut seen: HashSet = profiles.list()?.iter().map(identity_of).collect(); let rows: Vec = servers .iter() .map(|server| { let key = identity(&server.host, server.port as u64, &server.username); let duplicate = !seen.insert(key); let mut row = server.to_json(); row["duplicate"] = Value::Bool(duplicate); row }) .collect(); let token = uuid::Uuid::new_v4().to_string(); *self.pending.lock() = Some(Pending { token: token.clone(), servers, generation: profiles.vault.generation(), expires: Instant::now() + Duration::from_secs(15 * 60), }); Ok(json!({ "token": token, "format": format, "servers": rows })) } pub fn cancel(&self) { *self.pending.lock() = None; } pub fn commit(&self, profiles: &ProfileService, token: &str, policy: &str) -> CoreResult { let pending = { let guard = self.pending.lock(); match guard.as_ref() { Some(p) if p.token == token && p.generation == profiles.vault.generation() && p.expires >= Instant::now() => p.servers.clone(), _ => return Err(CoreError::plain("Import preview expired. Select the file again.")), } }; if !["skip", "update", "create"].contains(&policy) { return Err(CoreError::plain("Invalid duplicate policy.")); } let (mut created, mut updated, mut skipped) = (0u64, 0u64, 0u64); profiles.vault.update(|data| { let list = data["profiles"].as_array().cloned().unwrap_or_default(); let mut existing: HashMap = list.iter().map(|p| (identity_of(p), p.clone())).collect(); for server in &pending { let key = identity(&server.host, server.port as u64, &server.username); let matched = existing.get(&key).cloned(); if matched.is_some() && policy == "skip" { skipped += 1; continue; } let previous = if policy == "update" { matched } else { None }; let tags = previous.as_ref().and_then(|p| p.get("tags").cloned()).unwrap_or(json!([])); let input = json!({ "label": server.name, "name": server.name, "host": server.host, "username": server.username, "port": server.port, "tags": tags }); let profile = normalize_profile(&input, previous.as_ref())?; let profiles_array = data["profiles"].as_array_mut().ok_or_else(|| CoreError::plain("Vault profiles are invalid."))?; if let Some(previous) = &previous { let previous_id = previous["id"].as_str().unwrap_or("").to_string(); if let Some(index) = profiles_array.iter().position(|p| p["id"].as_str() == Some(&previous_id)) { profiles_array[index] = profile.clone(); } if let Some(passwords) = data.pointer_mut("/secrets/profilePasswords").and_then(Value::as_object_mut) { passwords.remove(&previous_id); } updated += 1; } else { profiles_array.push(profile.clone()); created += 1; } existing.insert(key, profile); } Ok(()) })?; self.cancel(); Ok(json!({ "created": created, "updated": updated, "skipped": skipped })) } } #[cfg(test)] mod tests { use super::*; #[test] fn non_bmp_saved_profiles_fit_portable_limits() { for label in ["😀".repeat(60), format!("{}😀", "a".repeat(99))] { let saved = normalize_profile(&json!({ "label": label, "host": "example.com", "username": "😀".repeat(64) }), None).unwrap(); let portable = portable_profile(&json!({ "name": saved["label"], "host": saved["host"], "username": saved["username"], "port": 22 })).unwrap(); assert!(portable.name.encode_utf16().count() <= 100); assert_eq!(portable.username, "😀".repeat(64)); for format in ["csv", "json"] { let output = serialize_profiles(&[saved.clone()], format).unwrap(); let (_, parsed) = parse_profiles(&output).unwrap(); assert_eq!(parsed, vec![portable.clone()]); } } assert!(normalize_profile(&json!({ "host": "example.com", "username": "😀".repeat(65) }), None).is_err()); } #[test] fn csv_round_trip_with_formula_guard() { let profiles = vec![json!({"label": "=cmd,\"x\"", "host": "a.example", "username": "-u", "port": 22})]; let csv = serialize_profiles(&profiles, "csv").unwrap(); assert!(csv.starts_with('\u{feff}')); assert!(csv.contains("\"'=cmd,\"\"x\"\"\"")); let (format, parsed) = parse_profiles(&csv).unwrap(); assert_eq!(format, "csv"); assert_eq!(parsed[0].name, "=cmd,\"x\""); assert_eq!(parsed[0].username, "-u"); } #[test] fn json_format_matches_mobile_v1() { let json_text = serialize_profiles(&[json!({"label": "Srv", "host": "10.0.0.1", "username": "root", "port": 2222})], "json").unwrap(); assert_eq!(json_text, "{\n \"format\": \"chj-server-profiles\",\n \"version\": 1,\n \"servers\": [\n {\n \"name\": \"Srv\",\n \"host\": \"10.0.0.1\",\n \"username\": \"root\",\n \"port\": 2222\n }\n ]\n}\n"); assert!(parse_profiles("{\"format\":\"x\",\"version\":1,\"servers\":[]}").is_err()); assert!(parse_profiles("{\"format\":\"chj-server-profiles\",\"version\":1,\"servers\":[{\"name\":\"a\",\"host\":\"h\",\"username\":\"u\",\"port\":\"22x\"}]}").is_err()); } #[test] fn ipv6_identity_is_canonical() { assert_eq!(identity("2001:DB8::0:1", 22, "u"), identity("2001:db8::1", 22, "u")); } #[tokio::test] async fn preview_and_commit_policies() { let dir = tempfile::tempdir().unwrap(); let vault = std::sync::Arc::new(crate::core::vault::VaultStore::new(dir.path())); vault.create("heslo123").await.unwrap(); let service = ProfileService::new(vault); service.save(&json!({"label": "Old", "host": "a.example", "username": "root", "storePassword": true, "password": "x"})).unwrap(); let transfer = ProfileTransfer::default(); let preview = transfer.preview(&service, "name,host,username,port\nNew,a.example,root,22\nB,b.example,u,2200\n").unwrap(); assert_eq!(preview["servers"][0]["duplicate"], true); assert_eq!(preview["servers"][1]["duplicate"], false); let result = transfer.commit(&service, preview["token"].as_str().unwrap(), "update").unwrap(); assert_eq!(result, json!({"created": 1, "updated": 1, "skipped": 0})); let list = service.list().unwrap(); let old = list.iter().find(|p| p["host"] == "a.example").unwrap(); assert_eq!(old["label"], "New"); assert_eq!(old["hasStoredPassword"], false); assert!(transfer.commit(&service, preview["token"].as_str().unwrap(), "update").is_err()); } }