Source publiée
Télécharger la source ZIP
CH-J Tools
Parcourez les répertoires et les fichiers pour une version spécifique de l'application.
CH-J / Server Manager
1704 fichiers
0.1.2-beta
Ajouté: · Construit:
1
//! Server profiles and trusted host keys stored in the vault2
//! (port of `profiles/profileService.js`; identical vault JSON layout).4
use crate::core::error::{CoreError, CoreResult};5
use crate::core::vault::VaultStore;6
use once_cell::sync::Lazy;7
use regex::Regex;8
use serde_json::{json, Map, Value};9
use std::net::IpAddr;10
use std::sync::Arc;12
static HOSTNAME: Lazy<Regex> = Lazy::new(|| {13
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()14
});15
static FINGERPRINT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-fA-F0-9]{64}$").unwrap());16
static KEYCHAIN_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9-]{1,64}$").unwrap());18
pub fn is_ip(value: &str) -> Option<u8> {19
match value.parse::<IpAddr>() {20
Ok(IpAddr::V4(_)) => Some(4),21
Ok(IpAddr::V6(_)) => Some(6),22
Err(_) => None,23
}24
}26
pub fn normalize_host(value: &str) -> CoreResult<String> {27
let host = value.trim();28
if host.is_empty() || (is_ip(host).is_none() && (host.len() > 253 || !HOSTNAME.is_match(host))) {29
return Err(CoreError::plain("Invalid server host name or IP address."));30
}31
Ok(host.to_lowercase())32
}34
/// JS `Number(value || 22)` semantics for numbers and numeric strings.35
pub fn normalize_port(value: Option<&Value>) -> CoreResult<u16> {36
let number = match value {37
None | Some(Value::Null) | Some(Value::Bool(false)) => 22.0,38
Some(Value::Number(n)) => {39
let n = n.as_f64().unwrap_or(f64::NAN);40
if n == 0.0 { 22.0 } else { n }41
}42
Some(Value::String(s)) if s.is_empty() => 22.0,43
Some(Value::String(s)) => s.trim().parse::<f64>().unwrap_or(f64::NAN),44
_ => f64::NAN,45
};46
if number.fract() != 0.0 || !(1.0..=65535.0).contains(&number) {47
return Err(CoreError::plain("SSH port must be between 1 and 65535."));48
}49
Ok(number as u16)50
}52
fn has_forbidden(value: &str) -> bool {53
value.contains('\0') || value.contains('\r') || value.contains('\n')54
}56
fn text(input: &Value, key: &str) -> String {57
match input.get(key) {58
Some(Value::String(s)) => s.clone(),59
Some(Value::Number(n)) => n.to_string(),60
Some(Value::Bool(b)) => b.to_string(),61
_ => String::new(),62
}63
}65
fn now_iso() -> String {66
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)67
}69
pub fn normalize_profile(input: &Value, existing: Option<&Value>) -> CoreResult<Value> {70
normalize_host(&text(input, "host"))?;71
let host = text(input, "host").trim().to_string();72
let username = text(input, "username").trim().to_string();73
if username.is_empty() || username.encode_utf16().count() > 128 || has_forbidden(&username) {74
return Err(CoreError::plain("Invalid SSH username."));75
}76
let auth_method = if input.get("authMethod").and_then(Value::as_str) == Some("privateKey") { "privateKey" } else { "password" };77
let keychain_key_id = if auth_method == "privateKey" { text(input, "keychainKeyId").trim().to_string() } else { String::new() };78
if !keychain_key_id.is_empty() && !KEYCHAIN_ID.is_match(&keychain_key_id) {79
return Err(CoreError::new("PROFILE_KEYCHAIN_KEY_INVALID", "Select an SSH key from the Keychain."));80
}81
// A Keychain key replaces the file path; never keep both.82
let private_key_path = if keychain_key_id.is_empty() { text(input, "privateKeyPath").trim().to_string() } else { String::new() };83
if auth_method == "privateKey" && keychain_key_id.is_empty() && (private_key_path.is_empty() || has_forbidden(&private_key_path)) {84
return Err(CoreError::plain("Select a private key file."));85
}86
let kex_policy=input.get("kexPolicy").or_else(||existing.and_then(|p|p.get("kexPolicy"))).and_then(Value::as_str).unwrap_or("prefer-hybrid");87
if !["prefer-hybrid","hybrid-only"].contains(&kex_policy){return Err(CoreError::new("SSH_KEX_POLICY_INVALID","Select a supported SSH key exchange policy."));}88
let now = now_iso();89
let label_source = { let l = text(input, "label"); if l.is_empty() { host.clone() } else { l } };90
let mut units = 0;91
let mut label: String = label_source.trim().chars().take_while(|c| {92
units += c.len_utf16();93
units <= 10094
}).collect();95
if label.is_empty() {96
label = host.clone();97
}98
let mut tags: Vec<Value> = Vec::new();99
if let Some(Value::Array(items)) = input.get("tags") {100
for item in items {101
let tag = match item { Value::String(s) => s.trim().to_string(), Value::Null => "null".into(), other => other.to_string() };102
if !tag.is_empty() && !tags.iter().any(|t| t.as_str() == Some(tag.as_str())) {103
tags.push(Value::String(tag));104
}105
}106
tags.truncate(20);107
}108
let existing_field = |key: &str| existing.and_then(|e| e.get(key)).cloned();109
Ok(json!({110
"id": existing_field("id").unwrap_or_else(|| Value::String(uuid::Uuid::new_v4().to_string())),111
"label": label,112
"host": host,113
"port": normalize_port(input.get("port"))?,114
"username": username,115
"authMethod": auth_method,116
"kexPolicy":kex_policy,117
"privateKeyPath": if auth_method == "privateKey" { private_key_path } else { String::new() },118
"keychainKeyId": keychain_key_id,119
"tags": tags,120
"jumpProfileId": input.get("jumpProfileId").or_else(|| existing.and_then(|p| p.get("jumpProfileId"))).and_then(Value::as_str).unwrap_or("").trim(),121
"createdAt": existing_field("createdAt").unwrap_or_else(|| Value::String(now.clone())),122
"updatedAt": now,123
"lastUsedAt": existing_field("lastUsedAt").unwrap_or(Value::Null)124
}))125
}127
pub fn host_key_id(host: &str, port: Option<&Value>) -> CoreResult<String> {128
Ok(format!("{}:{}", normalize_host(host)?, normalize_port(port)?))129
}131
fn validate_stored_password(value: &str) -> CoreResult<String> {132
if value.is_empty() || value.encode_utf16().count() > 1024 || value.contains('\0') {133
return Err(CoreError::plain("Stored SSH password is invalid."));134
}135
Ok(value.to_string())136
}138
fn password_store(data: &mut Value) -> &mut Map<String, Value> {139
if !data.get("secrets").map(Value::is_object).unwrap_or(false) {140
data["secrets"] = json!({});141
}142
if !data["secrets"].get("profilePasswords").map(Value::is_object).unwrap_or(false) {143
data["secrets"]["profilePasswords"] = json!({});144
}145
data["secrets"]["profilePasswords"].as_object_mut().expect("object")146
}148
fn has_password(data: &Value, id: &str) -> bool {149
data.pointer("/secrets/profilePasswords").and_then(|p| p.get(id)).map(Value::is_string).unwrap_or(false)150
}152
fn sort_key(profile: &Value) -> String {153
profile154
.get("lastUsedAt")155
.and_then(Value::as_str)156
.filter(|s| !s.is_empty())157
.or_else(|| profile.get("updatedAt").and_then(Value::as_str))158
.unwrap_or("")159
.to_string()160
}162
fn same_connection_profile(current:&Value,profile:&Value)->bool {163
["host","port","username","authMethod","privateKeyPath","keychainKeyId","jumpProfileId"].iter().all(|field|{164
current[*field]==profile[*field] || (["privateKeyPath","keychainKeyId","jumpProfileId"].contains(field) && current[*field].as_str().unwrap_or("")==profile[*field].as_str().unwrap_or(""))165
}) && current["kexPolicy"].as_str().unwrap_or("prefer-hybrid")==profile["kexPolicy"].as_str().unwrap_or("prefer-hybrid")166
}168
pub struct ProfileService {169
pub vault: Arc<VaultStore>,170
}172
impl ProfileService {173
pub fn new(vault: Arc<VaultStore>) -> Self {174
Self { vault }175
}177
pub fn list(&self) -> CoreResult<Vec<Value>> {178
let data = self.vault.get_data()?;179
let mut profiles: Vec<Value> = data["profiles"]180
.as_array()181
.cloned()182
.unwrap_or_default()183
.into_iter()184
.map(|mut profile| {185
let id = profile.get("id").and_then(Value::as_str).unwrap_or("").to_string();186
profile["hasStoredPassword"] = Value::Bool(has_password(&data, &id));187
profile188
})189
.collect();190
profiles.sort_by(|a, b| sort_key(b).cmp(&sort_key(a)));191
Ok(profiles)192
}194
pub fn get(&self, id: &str) -> CoreResult<Value> {195
let data = self.vault.get_data()?;196
let mut profile = data["profiles"]197
.as_array()198
.and_then(|items| items.iter().find(|p| p.get("id").and_then(Value::as_str) == Some(id)).cloned())199
.ok_or_else(|| CoreError::plain("Server profile was not found."))?;200
profile["hasStoredPassword"] = Value::Bool(has_password(&data, id));201
Ok(profile)202
}204
pub fn save(&self, input: &Value) -> CoreResult<Value> {205
let id = self.vault.update(|data| {206
let profiles = data["profiles"].as_array_mut().ok_or_else(|| CoreError::plain("Vault profiles are invalid."))?;207
let input_id = input.get("id").and_then(Value::as_str);208
let index = input_id.and_then(|id| profiles.iter().position(|p| p.get("id").and_then(Value::as_str) == Some(id)));209
let saved = normalize_profile(input, index.map(|i| &profiles[i]))?;210
let saved_id = saved["id"].as_str().unwrap_or("").to_string();211
let mut seen = std::collections::HashSet::from([saved_id.clone()]);212
let mut next = saved["jumpProfileId"].as_str().unwrap_or("");213
while !next.is_empty() {214
if !seen.insert(next.to_string()) {215
return Err(CoreError::new("JUMP_LOOP", "The jump route contains a cycle."));216
}217
let hop = profiles.iter().find(|p| p["id"].as_str() == Some(next))218
.ok_or_else(|| CoreError::new("JUMP_PROFILE_NOT_FOUND", "Jump profile was not found."))?;219
next = hop["jumpProfileId"].as_str().unwrap_or("");220
}221
let keychain_key_id = saved["keychainKeyId"].as_str().unwrap_or("").to_string();222
if !keychain_key_id.is_empty() {223
let usable = data.pointer("/secrets/keychain").and_then(Value::as_array).is_some_and(|items| items.iter().any(|item|224
item["id"].as_str() == Some(keychain_key_id.as_str()) && item["kind"] == "ssh" && item["private"].is_string()));225
if !usable {226
return Err(CoreError::new("PROFILE_KEYCHAIN_KEY_INVALID", "The selected Keychain key is not an SSH key with a private part."));227
}228
}229
let profiles = data["profiles"].as_array_mut().ok_or_else(|| CoreError::plain("Vault profiles are invalid."))?;230
// The stored secret is the SSH password for password login and the key passphrase for key login.231
// Switching the method must not reuse the old secret as the new one.232
let method_changed = index.is_some_and(|i| profiles[i]["authMethod"] != saved["authMethod"]);233
match index {234
Some(i) => profiles[i] = saved,235
None => profiles.push(saved),236
}237
let store_password = input.get("storePassword").and_then(Value::as_bool);238
let password = text(input, "password");239
let passwords = password_store(data);240
if store_password == Some(false) || (method_changed && password.is_empty()) {241
passwords.remove(&saved_id);242
}243
if store_password == Some(true) {244
if !password.is_empty() {245
passwords.insert(saved_id.clone(), Value::String(validate_stored_password(&password)?));246
} else if !passwords.get(&saved_id).map(Value::is_string).unwrap_or(false) {247
return Err(CoreError::plain("Enter the SSH password to store it in the vault."));248
}249
}250
Ok(saved_id)251
})?;252
self.get(&id)253
}255
pub fn delete(&self, id: &str) -> CoreResult<Value> {256
let removed = self.vault.update(|data| {257
let profiles = data["profiles"].as_array_mut().ok_or_else(|| CoreError::plain("Vault profiles are invalid."))?;258
let before = profiles.len();259
profiles.retain(|p| p.get("id").and_then(Value::as_str) != Some(id));260
let removed = before != profiles.len();261
for profile in profiles.iter_mut() {262
if profile["jumpProfileId"].as_str() == Some(id) {263
profile["jumpProfileId"] = json!("");264
}265
}266
password_store(data).remove(id);267
Ok(removed)268
})?;269
Ok(json!({ "removed": removed }))270
}272
pub fn mark_used(&self, id: &str) -> CoreResult<()> {273
self.vault.update(|data| {274
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))) {275
profile["lastUsedAt"] = Value::String(now_iso());276
}277
Ok(())278
})279
}281
pub fn get_host_key(&self, host: &str, port: u16) -> CoreResult<Option<String>> {282
let id = host_key_id(host, Some(&json!(port)))?;283
Ok(self.vault.trusted_host_key(&id))284
}286
pub fn get_stored_password(&self, profile_id: &str) -> CoreResult<Option<String>> {287
let data = self.vault.get_data()?;288
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);289
if !exists {290
return Err(CoreError::plain("Server profile was not found."));291
}292
Ok(data.pointer("/secrets/profilePasswords").and_then(|p| p.get(profile_id)).and_then(Value::as_str).map(str::to_string))293
}295
/// Cached jump credentials must never bypass a changed saved route or KEX policy.296
/// Manual-only routes stay usable without unlocking the vault.297
pub fn assert_connection_route(&self,route:&[Value])->CoreResult<()> {298
if route.iter().all(|p|p["id"].as_str().unwrap_or("").starts_with("adhoc:")){return Ok(())}299
let generation=self.vault.unlocked_generation()?;300
self.vault.with_data_at_generation(generation,|data|{301
for profile in route.iter().filter(|p|!p["id"].as_str().unwrap_or("").starts_with("adhoc:")) {302
let current=data["profiles"].as_array().and_then(|items|items.iter().find(|p|p["id"]==profile["id"]));303
if !current.is_some_and(|p|same_connection_profile(p,profile)) {304
return Err(CoreError::new("PROFILE_CHANGED","The saved SSH route or policy changed. Reconnect before reusing its credentials."));305
}306
}307
Ok(())308
})309
}311
/// A live session may retain an old route after its saved profile is edited.312
/// Never send that profile's newly stored password to the old endpoint.313
pub fn get_stored_password_for_connection(&self, profile: &Value) -> CoreResult<Option<String>> {314
let data = self.vault.get_data()?;315
let id = profile["id"].as_str().unwrap_or("");316
let current = data["profiles"].as_array().and_then(|items| items.iter().find(|p| p["id"] == id))317
.ok_or_else(|| CoreError::new("PROFILE_CHANGED", "The saved profile changed. Reconnect before reusing its credentials."))?;318
if !same_connection_profile(current,profile){return Err(CoreError::new("PROFILE_CHANGED","The saved profile or SSH policy changed. Reconnect before reusing its credentials."));}319
Ok(data.pointer("/secrets/profilePasswords").and_then(|p| p.get(id)).and_then(Value::as_str).map(str::to_string))320
}322
/// Private OpenSSH text of the Keychain key assigned to a saved profile.323
/// Stays inside Core; the caller wraps it in `Zeroizing`.324
pub fn keychain_private_key(&self, key_id: &str) -> CoreResult<String> {325
let entry = self.keychain_ssh_entry(key_id)?;326
entry["private"].as_str().map(str::to_string)327
.ok_or_else(|| CoreError::new("PROFILE_KEYCHAIN_KEY_MISSING", "The Keychain key assigned to this server has no private part."))328
}330
/// `SHA256:…` fingerprint of the public half of a Keychain SSH key (lockout protection in Users).331
pub fn keychain_public_fingerprint(&self, key_id: &str) -> Option<String> {332
let entry = self.keychain_ssh_entry(key_id).ok()?;333
let public = russh::keys::ssh_key::PublicKey::from_openssh(entry["public"].as_str()?).ok()?;334
Some(crate::core::ssh::keys::fingerprint_of_blob(&public.to_bytes().ok()?))335
}337
fn keychain_ssh_entry(&self, key_id: &str) -> CoreResult<Value> {338
let data = self.vault.get_data()?;339
data.pointer("/secrets/keychain").and_then(Value::as_array)340
.and_then(|items| items.iter().find(|item| item["id"].as_str() == Some(key_id) && item["kind"] == "ssh").cloned())341
.ok_or_else(|| CoreError::new("PROFILE_KEYCHAIN_KEY_MISSING", "The Keychain key assigned to this server was not found."))342
}344
pub fn trust_host_key(&self, host: &str, port: u16, fingerprint: &str) -> CoreResult<Value> {345
self.write_host_key(host, port, fingerprint, None)346
}348
pub fn confirm_host_key(&self, host: &str, port: u16, fingerprint: &str, expected: Option<&str>) -> CoreResult<Value> {349
self.write_host_key(host, port, fingerprint, Some(expected))350
}352
fn write_host_key(&self, host: &str, port: u16, fingerprint: &str, expected: Option<Option<&str>>) -> CoreResult<Value> {353
let normalized = fingerprint.trim();354
if !FINGERPRINT.is_match(normalized) { return Err(CoreError::plain("Invalid SHA-256 host fingerprint.")); }355
let normalized = normalized.to_lowercase();356
let id = host_key_id(host, Some(&json!(port)))?;357
let persistent = self.vault.trust_host_key(&id, &normalized, expected)?;358
Ok(json!({ "host": normalize_host(host)?, "port": port, "fingerprint": normalized, "persistent": persistent }))359
}361
}363
#[cfg(test)]364
mod tests {365
use super::*;367
#[tokio::test]368
async fn keychain_key_profile_stores_passphrase_and_drops_it_on_method_change() {369
let root = tempfile::tempdir().unwrap();370
let vault = Arc::new(VaultStore::new(root.path()));371
vault.create("fixture-password").await.unwrap();372
let service = ProfileService::new(vault.clone());373
let key = crate::core::keychain::generate("ssh", "fixture key").unwrap();374
let public_only = crate::core::keychain::parse(key["public"].as_str().unwrap().as_bytes(), "pub", "").unwrap();375
crate::core::keychain::store(&vault, key.clone()).unwrap();376
crate::core::keychain::store(&vault, json!({"id":"pgp-1","kind":"openpgp","private":"x","fingerprint":"AA"})).unwrap();377
let key_id = key["id"].as_str().unwrap();378
// Unknown, public-only and non-SSH keys are refused.379
for bad in ["missing-id", "pgp-1"] {380
assert!(service.save(&json!({"host":"k.example","username":"root","authMethod":"privateKey","keychainKeyId":bad})).is_err());381
}382
let mut public_only = public_only; public_only["fingerprint"] = json!("other");383
crate::core::keychain::store(&vault, public_only.clone()).unwrap();384
assert!(service.save(&json!({"host":"k.example","username":"root","authMethod":"privateKey","keychainKeyId":public_only["id"]})).is_err());385
let saved = service.save(&json!({"host":"k.example","username":"root","authMethod":"privateKey","keychainKeyId":key_id,"storePassword":true,"password":"fixture-passphrase"})).unwrap();386
let id = saved["id"].as_str().unwrap();387
assert_eq!(saved["keychainKeyId"], key_id);388
assert_eq!(saved["hasStoredPassword"], true);389
assert_eq!(service.get_stored_password_for_connection(&saved).unwrap().as_deref(), Some("fixture-passphrase"));390
assert_eq!(service.keychain_private_key(key_id).unwrap(), key["private"].as_str().unwrap());391
assert!(service.keychain_public_fingerprint(key_id).unwrap().starts_with("SHA256:"));392
// Switching to password login without a new password must not reuse the passphrase.393
let switched = service.save(&json!({"id":id,"host":"k.example","username":"root","authMethod":"password","storePassword":false})).unwrap();394
assert_eq!(switched["hasStoredPassword"], false);395
assert_eq!(switched["keychainKeyId"], "");396
}398
async fn service() -> (tempfile::TempDir, ProfileService) {399
let dir = tempfile::tempdir().unwrap();400
let vault = Arc::new(VaultStore::new(dir.path()));401
vault.create("heslo123").await.unwrap();402
(dir, ProfileService::new(vault))403
}405
#[tokio::test]406
async fn save_list_password_and_host_keys() {407
let (_dir, service) = service().await;408
let saved = service409
.save(&json!({"label": "Web", "host": "Example.COM", "port": "2222", "username": "root", "authMethod": "password", "storePassword": true, "password": "pw"}))410
.unwrap();411
assert_eq!(saved["port"], 2222);412
assert_eq!(saved["host"], "Example.COM");413
assert_eq!(saved["hasStoredPassword"], true);414
let id = saved["id"].as_str().unwrap();415
assert_eq!(service.get_stored_password(id).unwrap().as_deref(), Some("pw"));416
service.trust_host_key("example.com", 2222, &"AB".repeat(32)).unwrap();417
assert_eq!(service.get_host_key("EXAMPLE.com", 2222).unwrap().unwrap(), "ab".repeat(32));418
let updated = service.save(&json!({"id": id, "host": "example.com", "username": "root", "storePassword": false})).unwrap();419
assert_eq!(updated["hasStoredPassword"], false);420
assert_eq!(updated["createdAt"], saved["createdAt"]);421
assert_eq!(service.delete(id).unwrap()["removed"], true);422
assert!(service.list().unwrap().is_empty());423
}425
#[tokio::test]426
async fn locked_manual_host_trust_is_memory_only_and_profiles_stay_protected() {427
let root = tempfile::tempdir().unwrap();428
let service = ProfileService::new(Arc::new(VaultStore::new(root.path())));429
let fingerprint = "ab".repeat(32);430
assert!(service.list().is_err());431
assert!(service.get("saved").is_err());432
assert_eq!(service.trust_host_key("example.invalid", 22, &fingerprint).unwrap()["persistent"], false);433
assert_eq!(service.get_host_key("example.invalid", 22).unwrap().as_deref(), Some(fingerprint.as_str()));434
assert!(!root.path().join("vault").exists());435
let reopened = ProfileService::new(Arc::new(VaultStore::new(root.path())));436
assert!(reopened.get_host_key("example.invalid", 22).unwrap().is_none());437
}439
#[tokio::test]440
async fn authenticated_pins_survive_lock_and_unknown_confirmation_is_compare_and_swap() {441
let root = tempfile::tempdir().unwrap(); let vault = Arc::new(VaultStore::new(root.path()));442
vault.create("fixture-password").await.unwrap(); let service = ProfileService::new(vault.clone());443
let original = "ab".repeat(32); let other = "cd".repeat(32);444
service.trust_host_key("fixture.example.invalid",22,&original).unwrap(); vault.lock();445
assert_eq!(service.get_host_key("fixture.example.invalid",22).unwrap().as_deref(),Some(original.as_str()));446
assert!(service.confirm_host_key("fixture.example.invalid",22,&other,None).is_err());447
assert_eq!(service.confirm_host_key("fixture.example.invalid",22,&other,Some(&original)).unwrap()["persistent"],false);448
// Unlock reloads authenticated on-disk trust, replacing temporary trust.449
vault.unlock("fixture-password").await.unwrap(); vault.lock();450
assert_eq!(service.get_host_key("fixture.example.invalid",22).unwrap().as_deref(),Some(original.as_str()));451
vault.reset("SMAZAT").unwrap();452
assert!(service.get_host_key("fixture.example.invalid",22).unwrap().is_none());453
}455
#[test]456
fn validation() {457
assert!(normalize_host("bad host").is_err());458
assert!(normalize_host("::1").is_ok());459
assert!(normalize_port(Some(&json!(0))).is_ok());460
assert!(normalize_port(Some(&json!(70000))).is_err());461
assert!(normalize_profile(&json!({"host": "a.b", "username": "x\ny"}), None).is_err());462
assert!(normalize_profile(&json!({"host": "a.b", "username": "x", "authMethod": "privateKey"}), None).is_err());463
let keychain = normalize_profile(&json!({"host": "a.b", "username": "x", "authMethod": "privateKey", "keychainKeyId": "abc-1", "privateKeyPath": "/ignored"}), None).unwrap();464
assert_eq!(keychain["keychainKeyId"], "abc-1");465
assert_eq!(keychain["privateKeyPath"], "");466
assert!(normalize_profile(&json!({"host": "a.b", "username": "x", "authMethod": "privateKey", "keychainKeyId": "../x"}), None).is_err());467
assert_eq!(normalize_profile(&json!({"host": "a.b", "username": "x", "authMethod": "password", "keychainKeyId": "abc"}), None).unwrap()["keychainKeyId"], "");468
}469
}SHA-256
8d90a2af48985eabede3f3c9de48d9c78b77222cc8edc095c6c58aad2240b7e0CH-J Proprietary Software License 1.14 · La source est fournie sous CH-J Proprietary Software License 1.14. Sa disponibilité ne modifie pas les termes de la licence ni n'accorde de droits supplémentaires.
Archive SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f