已发布的源代码
下载源代码 ZIP
CH-J Tools
浏览特定应用版本的目录和文件。
CH-J / Server Manager
1704 个文件
0.1.2-beta
添加时间: · 构建时间:
1
//! Portable server-profile import/export (`chj-server-profiles` v1 JSON and2
//! `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.5
use crate::core::error::{CoreError, CoreResult};6
use crate::core::profiles::{is_ip, normalize_profile, ProfileService};7
use once_cell::sync::Lazy;8
use parking_lot::Mutex;9
use regex::Regex;10
use serde_json::{json, Value};11
use std::collections::{HashMap, HashSet};12
use std::net::Ipv6Addr;13
use std::time::{Duration, Instant};15
pub const FORMAT: &str = "chj-server-profiles";16
pub const VERSION: u64 = 1;17
pub const MAX_BYTES: usize = 5 * 1024 * 1024;18
const FIELDS: [&str; 4] = ["name", "host", "username", "port"];20
static CSV_ESCAPED: Lazy<Regex> = Lazy::new(|| Regex::new(r"^'(?:[=+@\-\t\r]|')").unwrap());21
static FORMULA_START: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[=+@\-\t\r']").unwrap());22
static DIGITS: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[0-9]+$").unwrap());24
#[derive(Clone, Debug, PartialEq)]25
pub struct PortableProfile {26
pub name: String,27
pub host: String,28
pub username: String,29
pub port: u16,30
}32
impl PortableProfile {33
fn to_json(&self) -> Value {34
json!({ "name": self.name, "host": self.host, "username": self.username, "port": self.port })35
}36
}38
fn 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
}46
}48
pub 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
})81
}83
/// Duplicate identity: lowercased host (IPv6 canonicalized like the WHATWG URL parser), port, username.84
pub 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()91
}93
fn 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
)99
}101
/// RFC 4180 parser with embedded commas, quotes and line breaks.102
fn 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)147
}149
fn decode_csv_cell(value: &str) -> String {150
if CSV_ESCAPED.is_match(value) { value[1..].to_string() } else { value.to_string() }151
}153
pub 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))196
}198
pub 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
cell223
}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
}233
}235
struct Pending {236
token: String,237
servers: Vec<PortableProfile>,238
generation: u64,239
expires: Instant,240
}242
pub struct ProfileTransfer {243
pending: Mutex<Option<Pending>>,244
}246
impl Default for ProfileTransfer {247
fn default() -> Self {248
Self { pending: Mutex::new(None) }249
}250
}252
impl 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> = servers257
.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
row264
})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 (generation, 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.generation, 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_at_generation(generation, |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
}327
}329
#[cfg(test)]330
mod 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
}393
}SHA-256
8fe90249c35673218cfca95e46a81536cdb11ffba2988f332c669a47ae4391baCH-J Proprietary Software License 1.14 · 源代码依据 CH-J Proprietary Software License 1.14 提供。公开源代码不会改变许可条款,也不会授予额外权利。
压缩包 SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f