CH-J Toolsspráva serverů přes SSH
Menu
Vydané zdroje

CH-J Tools

Procházejte adresáře a soubory konkrétního vydání aplikace.

Stáhnout zdroje ZIP
CH-J / Server Manager 872 souborů
core-20261030T220507Z-61e4558d889f Přidáno: · Sestaveno:
profile_transfer.rs Přidáno: Datum není evidováno 17,1 KB · 393 řádků
Kód Stáhnout soubor
1//! Portable server-profile import/export (`chj-server-profiles` v1 JSON and
2//! `name,host,username,port` CSV) — port of `profiles/profileTransfer.js`.
3//! The format is shared with SM Mobile JSON v1 and must stay byte-compatible.
5use crate::core::error::{CoreError, CoreResult};
6use crate::core::profiles::{is_ip, normalize_profile, ProfileService};
7use once_cell::sync::Lazy;
8use parking_lot::Mutex;
9use regex::Regex;
10use serde_json::{json, Value};
11use std::collections::{HashMap, HashSet};
12use std::net::Ipv6Addr;
13use std::time::{Duration, Instant};
15pub const FORMAT: &str = "chj-server-profiles";
16pub const VERSION: u64 = 1;
17pub const MAX_BYTES: usize = 5 * 1024 * 1024;
18const FIELDS: [&str; 4] = ["name", "host", "username", "port"];
20static CSV_ESCAPED: Lazy<Regex> = Lazy::new(|| Regex::new(r"^'(?:[=+@\-\t\r]|')").unwrap());
21static FORMULA_START: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[=+@\-\t\r']").unwrap());
22static DIGITS: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[0-9]+$").unwrap());
24#[derive(Clone, Debug, PartialEq)]
25pub struct PortableProfile {
26 pub name: String,
27 pub host: String,
28 pub username: String,
29 pub port: u16,
32impl PortableProfile {
33 fn to_json(&self) -> Value {
34 json!({ "name": self.name, "host": self.host, "username": self.username, "port": self.port })
35 }
38fn port_text(value: &Value) -> Option<String> {
39 match value {
40 Value::Number(n) => Some(n.to_string()),
41 Value::String(s) => Some(s.clone()),
42 Value::Bool(b) => Some(b.to_string()),
43 Value::Null => Some("null".into()),
44 _ => None,
45 }
48pub fn portable_profile(input: &Value) -> CoreResult<PortableProfile> {
49 let object = input.as_object().ok_or_else(|| CoreError::plain("Invalid server profile."))?;
50 for field in ["name", "host", "username"] {
51 match object.get(field) {
52 Some(Value::String(s)) if !s.trim().is_empty() => {}
53 _ => return Err(CoreError::plain(format!("Missing or invalid {field}."))),
54 }
55 }
56 let name = object["name"].as_str().unwrap_or("");
57 if name.encode_utf16().count() > 100 || name.contains(['\0', '\r', '\n']) {
58 return Err(CoreError::plain("Invalid server name (maximum 100 characters)."));
59 }
60 let port_value = object.get("port").filter(|v| !v.is_null() || object.contains_key("port"));
61 if let Some(port) = object.get("port") {
62 let text = port_text(port).unwrap_or_default();
63 if !DIGITS.is_match(&text) {
64 return Err(CoreError::plain("Invalid SSH port."));
65 }
66 let number: f64 = text.parse().unwrap_or(0.0);
67 if !(1.0..=65535.0).contains(&number) {
68 return Err(CoreError::plain("SSH port must be between 1 and 65535."));
69 }
70 }
71 let profile = normalize_profile(
72 &json!({ "label": name, "host": object["host"], "username": object["username"], "port": port_value.cloned().unwrap_or(json!(22)) }),
73 None,
74 )?;
75 Ok(PortableProfile {
76 name: profile["label"].as_str().unwrap_or_default().to_string(),
77 host: profile["host"].as_str().unwrap_or_default().to_string(),
78 username: profile["username"].as_str().unwrap_or_default().to_string(),
79 port: profile["port"].as_u64().unwrap_or(22) as u16,
80 })
83/// Duplicate identity: lowercased host (IPv6 canonicalized like the WHATWG URL parser), port, username.
84pub fn identity(host: &str, port: u64, username: &str) -> String {
85 let normalized = if is_ip(host) == Some(6) {
86 host.parse::<Ipv6Addr>().map(|ip| format!("[{ip}]")).unwrap_or_else(|_| host.to_lowercase())
87 } else {
88 host.to_lowercase()
89 };
90 serde_json::to_string(&json!([normalized, port, username])).unwrap_or_default()
93fn identity_of(profile: &Value) -> String {
94 identity(
95 profile.get("host").and_then(Value::as_str).unwrap_or(""),
96 profile.get("port").and_then(Value::as_u64).unwrap_or(22),
97 profile.get("username").and_then(Value::as_str).unwrap_or(""),
98 )
101/// RFC 4180 parser with embedded commas, quotes and line breaks.
102fn csv_rows(content: &str) -> CoreResult<Vec<Vec<String>>> {
103 let chars: Vec<char> = content.chars().collect();
104 let (mut rows, mut row, mut cell) = (Vec::new(), Vec::new(), String::new());
105 let (mut quoted, mut closed) = (false, false);
106 let mut i = 0;
107 while i < chars.len() {
108 let c = chars[i];
109 if quoted {
110 if c == '"' && chars.get(i + 1) == Some(&'"') {
111 cell.push('"');
112 i += 1;
113 } else if c == '"' {
114 quoted = false;
115 closed = true;
116 } else {
117 cell.push(c);
118 }
119 } else if c == '"' && cell.is_empty() && !closed {
120 quoted = true;
121 } else if c == ',' {
122 row.push(std::mem::take(&mut cell));
123 closed = false;
124 } else if c == '\n' || c == '\r' {
125 if c == '\r' && chars.get(i + 1) == Some(&'\n') {
126 i += 1;
127 }
128 row.push(std::mem::take(&mut cell));
129 rows.push(std::mem::take(&mut row));
130 closed = false;
131 } else {
132 if closed || c == '"' {
133 return Err(CoreError::plain("Malformed CSV quoting."));
134 }
135 cell.push(c);
136 }
137 i += 1;
138 }
139 if quoted {
140 return Err(CoreError::plain("Unterminated CSV field."));
141 }
142 if !cell.is_empty() || !row.is_empty() || closed {
143 row.push(cell);
144 rows.push(row);
145 }
146 Ok(rows)
149fn decode_csv_cell(value: &str) -> String {
150 if CSV_ESCAPED.is_match(value) { value[1..].to_string() } else { value.to_string() }
153pub fn parse_profiles(content: &str) -> CoreResult<(String, Vec<PortableProfile>)> {
154 if content.len() > MAX_BYTES {
155 return Err(CoreError::plain("Profile file exceeds the 5 MiB limit."));
156 }
157 let content = content.strip_prefix('\u{feff}').unwrap_or(content).trim();
158 let (format, servers): (&str, Vec<Value>) = if content.starts_with('{') || content.starts_with('[') {
159 let document: Value = serde_json::from_str(content).map_err(|e| CoreError::plain(e.to_string()))?;
160 if document.get("format").and_then(Value::as_str) != Some(FORMAT)
161 || document.get("version").and_then(Value::as_f64) != Some(VERSION as f64)
162 || !document.get("servers").map(Value::is_array).unwrap_or(false)
163 {
164 return Err(CoreError::plain("Unsupported server profile format or version."));
165 }
166 ("json", document["servers"].as_array().cloned().unwrap_or_default())
167 } else {
168 let rows = csv_rows(content)?;
169 let mut iter = rows.into_iter();
170 let header = iter.next().unwrap_or_default();
171 let unique: HashSet<&String> = header.iter().collect();
172 if header.len() != FIELDS.len() || unique.len() != FIELDS.len() || FIELDS.iter().any(|f| !header.iter().any(|h| h == f)) {
173 return Err(CoreError::plain("CSV columns must be name,host,username,port."));
174 }
175 let mut servers = Vec::new();
176 for row in iter.filter(|row| row.iter().any(|c| !c.is_empty())) {
177 if row.len() != header.len() {
178 return Err(CoreError::plain("CSV row has an unexpected number of columns."));
179 }
180 let mut object = serde_json::Map::new();
181 for (field, value) in header.iter().zip(row.iter()) {
182 object.insert(field.clone(), Value::String(decode_csv_cell(value)));
183 }
184 servers.push(Value::Object(object));
185 }
186 ("csv", servers)
187 };
188 if servers.is_empty() || servers.len() > 10000 {
189 return Err(CoreError::plain("Import must contain between 1 and 10000 servers."));
190 }
191 let mut result = Vec::with_capacity(servers.len());
192 for (index, server) in servers.iter().enumerate() {
193 result.push(portable_profile(server).map_err(|e| CoreError::plain(format!("Server {}: {}", index + 1, e.message)))?);
194 }
195 Ok((format.to_string(), result))
198pub fn serialize_profiles(profiles: &[Value], format: &str) -> CoreResult<String> {
199 let mut servers = Vec::new();
200 for profile in profiles {
201 servers.push(portable_profile(&json!({
202 "name": profile.get("label").cloned().unwrap_or(Value::Null),
203 "host": profile.get("host").cloned().unwrap_or(Value::Null),
204 "username": profile.get("username").cloned().unwrap_or(Value::Null),
205 "port": profile.get("port").cloned().unwrap_or(Value::Null)
206 }))?);
207 }
208 match format {
209 "json" => {
210 let document = json!({ "format": FORMAT, "version": VERSION, "servers": servers.iter().map(PortableProfile::to_json).collect::<Vec<_>>() });
211 Ok(serde_json::to_string_pretty(&document)? + "\n")
212 }
213 "csv" => {
214 let escape = |value: &str| -> String {
215 let mut cell = value.to_string();
216 if FORMULA_START.is_match(&cell) {
217 cell = format!("'{cell}");
218 }
219 if cell.contains(['"', ',', '\r', '\n']) {
220 format!("\"{}\"", cell.replace('"', "\"\""))
221 } else {
222 cell
223 }
224 };
225 let mut lines = vec![FIELDS.join(",")];
226 for s in &servers {
227 lines.push([escape(&s.name), escape(&s.host), escape(&s.username), escape(&s.port.to_string())].join(","));
228 }
229 Ok(format!("\u{feff}{}\r\n", lines.join("\r\n")))
230 }
231 _ => Err(CoreError::plain("Unsupported export format.")),
232 }
235struct Pending {
236 token: String,
237 servers: Vec<PortableProfile>,
238 generation: u64,
239 expires: Instant,
242pub struct ProfileTransfer {
243 pending: Mutex<Option<Pending>>,
246impl Default for ProfileTransfer {
247 fn default() -> Self {
248 Self { pending: Mutex::new(None) }
249 }
252impl ProfileTransfer {
253 pub fn preview(&self, profiles: &ProfileService, content: &str) -> CoreResult<Value> {
254 let (format, servers) = parse_profiles(content)?;
255 let mut seen: HashSet<String> = profiles.list()?.iter().map(identity_of).collect();
256 let rows: Vec<Value> = servers
257 .iter()
258 .map(|server| {
259 let key = identity(&server.host, server.port as u64, &server.username);
260 let duplicate = !seen.insert(key);
261 let mut row = server.to_json();
262 row["duplicate"] = Value::Bool(duplicate);
263 row
264 })
265 .collect();
266 let token = uuid::Uuid::new_v4().to_string();
267 *self.pending.lock() = Some(Pending {
268 token: token.clone(),
269 servers,
270 generation: profiles.vault.generation(),
271 expires: Instant::now() + Duration::from_secs(15 * 60),
272 });
273 Ok(json!({ "token": token, "format": format, "servers": rows }))
274 }
276 pub fn cancel(&self) {
277 *self.pending.lock() = None;
278 }
280 pub fn commit(&self, profiles: &ProfileService, token: &str, policy: &str) -> CoreResult<Value> {
281 let pending = {
282 let guard = self.pending.lock();
283 match guard.as_ref() {
284 Some(p) if p.token == token && p.generation == profiles.vault.generation() && p.expires >= Instant::now() => p.servers.clone(),
285 _ => return Err(CoreError::plain("Import preview expired. Select the file again.")),
286 }
287 };
288 if !["skip", "update", "create"].contains(&policy) {
289 return Err(CoreError::plain("Invalid duplicate policy."));
290 }
291 let (mut created, mut updated, mut skipped) = (0u64, 0u64, 0u64);
292 profiles.vault.update(|data| {
293 let list = data["profiles"].as_array().cloned().unwrap_or_default();
294 let mut existing: HashMap<String, Value> = list.iter().map(|p| (identity_of(p), p.clone())).collect();
295 for server in &pending {
296 let key = identity(&server.host, server.port as u64, &server.username);
297 let matched = existing.get(&key).cloned();
298 if matched.is_some() && policy == "skip" {
299 skipped += 1;
300 continue;
301 }
302 let previous = if policy == "update" { matched } else { None };
303 let tags = previous.as_ref().and_then(|p| p.get("tags").cloned()).unwrap_or(json!([]));
304 let input = json!({ "label": server.name, "name": server.name, "host": server.host, "username": server.username, "port": server.port, "tags": tags });
305 let profile = normalize_profile(&input, previous.as_ref())?;
306 let profiles_array = data["profiles"].as_array_mut().ok_or_else(|| CoreError::plain("Vault profiles are invalid."))?;
307 if let Some(previous) = &previous {
308 let previous_id = previous["id"].as_str().unwrap_or("").to_string();
309 if let Some(index) = profiles_array.iter().position(|p| p["id"].as_str() == Some(&previous_id)) {
310 profiles_array[index] = profile.clone();
311 }
312 if let Some(passwords) = data.pointer_mut("/secrets/profilePasswords").and_then(Value::as_object_mut) {
313 passwords.remove(&previous_id);
314 }
315 updated += 1;
316 } else {
317 profiles_array.push(profile.clone());
318 created += 1;
319 }
320 existing.insert(key, profile);
321 }
322 Ok(())
323 })?;
324 self.cancel();
325 Ok(json!({ "created": created, "updated": updated, "skipped": skipped }))
326 }
329#[cfg(test)]
330mod tests {
331 use super::*;
333 #[test]
334 fn non_bmp_saved_profiles_fit_portable_limits() {
335 for label in ["😀".repeat(60), format!("{}😀", "a".repeat(99))] {
336 let saved = normalize_profile(&json!({ "label": label, "host": "example.com", "username": "😀".repeat(64) }), None).unwrap();
337 let portable = portable_profile(&json!({ "name": saved["label"], "host": saved["host"], "username": saved["username"], "port": 22 })).unwrap();
338 assert!(portable.name.encode_utf16().count() <= 100);
339 assert_eq!(portable.username, "😀".repeat(64));
340 for format in ["csv", "json"] {
341 let output = serialize_profiles(&[saved.clone()], format).unwrap();
342 let (_, parsed) = parse_profiles(&output).unwrap();
343 assert_eq!(parsed, vec![portable.clone()]);
344 }
345 }
346 assert!(normalize_profile(&json!({ "host": "example.com", "username": "😀".repeat(65) }), None).is_err());
347 }
349 #[test]
350 fn csv_round_trip_with_formula_guard() {
351 let profiles = vec![json!({"label": "=cmd,\"x\"", "host": "a.example", "username": "-u", "port": 22})];
352 let csv = serialize_profiles(&profiles, "csv").unwrap();
353 assert!(csv.starts_with('\u{feff}'));
354 assert!(csv.contains("\"'=cmd,\"\"x\"\"\""));
355 let (format, parsed) = parse_profiles(&csv).unwrap();
356 assert_eq!(format, "csv");
357 assert_eq!(parsed[0].name, "=cmd,\"x\"");
358 assert_eq!(parsed[0].username, "-u");
359 }
361 #[test]
362 fn json_format_matches_mobile_v1() {
363 let json_text = serialize_profiles(&[json!({"label": "Srv", "host": "10.0.0.1", "username": "root", "port": 2222})], "json").unwrap();
364 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");
365 assert!(parse_profiles("{\"format\":\"x\",\"version\":1,\"servers\":[]}").is_err());
366 assert!(parse_profiles("{\"format\":\"chj-server-profiles\",\"version\":1,\"servers\":[{\"name\":\"a\",\"host\":\"h\",\"username\":\"u\",\"port\":\"22x\"}]}").is_err());
367 }
369 #[test]
370 fn ipv6_identity_is_canonical() {
371 assert_eq!(identity("2001:DB8::0:1", 22, "u"), identity("2001:db8::1", 22, "u"));
372 }
374 #[tokio::test]
375 async fn preview_and_commit_policies() {
376 let dir = tempfile::tempdir().unwrap();
377 let vault = std::sync::Arc::new(crate::core::vault::VaultStore::new(dir.path()));
378 vault.create("heslo123").await.unwrap();
379 let service = ProfileService::new(vault);
380 service.save(&json!({"label": "Old", "host": "a.example", "username": "root", "storePassword": true, "password": "x"})).unwrap();
381 let transfer = ProfileTransfer::default();
382 let preview = transfer.preview(&service, "name,host,username,port\nNew,a.example,root,22\nB,b.example,u,2200\n").unwrap();
383 assert_eq!(preview["servers"][0]["duplicate"], true);
384 assert_eq!(preview["servers"][1]["duplicate"], false);
385 let result = transfer.commit(&service, preview["token"].as_str().unwrap(), "update").unwrap();
386 assert_eq!(result, json!({"created": 1, "updated": 1, "skipped": 0}));
387 let list = service.list().unwrap();
388 let old = list.iter().find(|p| p["host"] == "a.example").unwrap();
389 assert_eq!(old["label"], "New");
390 assert_eq!(old["hasStoredPassword"], false);
391 assert!(transfer.commit(&service, preview["token"].as_str().unwrap(), "update").is_err());
392 }
SHA-256c9776c4a3c40971a70ab6ac96d190be9e56386955c5ece1ea07e20ddbc6b892c

CH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.

SHA-256 archivu77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Soubory