diff --git a/crates/nyaterm-core/src/session_import/electerm.rs b/crates/nyaterm-core/src/session_import/electerm.rs index c392db8be..9d8e724f8 100644 --- a/crates/nyaterm-core/src/session_import/electerm.rs +++ b/crates/nyaterm-core/src/session_import/electerm.rs @@ -5,9 +5,9 @@ use serde::Deserialize; use crate::{ConnectionAuth, ConnectionType}; +use super::nyaterm_json::parse_nyaterm_json_content; use super::{ - AppError, AppResult, PreparedJsonConnection, PreparedJsonImport, parse_nyaterm_json_content, - read_text_file_limited, + AppError, AppResult, PreparedJsonConnection, PreparedJsonImport, read_text_file_limited, }; #[derive(Debug, Deserialize)] diff --git a/crates/nyaterm-core/src/session_import/mod.rs b/crates/nyaterm-core/src/session_import/mod.rs index 80a5cb4e2..cb2f42937 100644 --- a/crates/nyaterm-core/src/session_import/mod.rs +++ b/crates/nyaterm-core/src/session_import/mod.rs @@ -3,7 +3,6 @@ //! bookmarks, and Termius IndexedDB data. use crate::{AiExecutionProfile, ConnectionAuth, ConnectionType, SavedPassword, SshKey}; -use serde::Deserialize; use std::collections::HashMap; use std::io::Read; use std::path::Path; @@ -12,6 +11,7 @@ use thiserror::Error; mod electerm; mod finalshell; mod mobaxterm; +mod nyaterm_json; mod securecrt; mod termius; mod windterm; @@ -80,174 +80,6 @@ impl std::fmt::Debug for PreparedSessionImport { } } -#[derive(Deserialize)] -struct NyatermJsonImportFile { - #[serde(default = "default_import_version")] - version: u32, - #[serde(default)] - passwords: Vec, - #[serde(default)] - ssh_keys: Vec, - #[serde(default)] - groups: Vec, - #[serde(default)] - sessions: Vec, -} - -fn default_import_version() -> u32 { - 1 -} - -#[derive(Deserialize)] -struct NyatermJsonPassword { - #[serde(rename = "ref")] - ref_name: String, - name: String, - password: String, -} - -#[derive(Deserialize)] -struct NyatermJsonSshKey { - #[serde(rename = "ref")] - ref_name: String, - name: String, - private_key: String, - #[serde(default)] - certificate: Option, - #[serde(default)] - passphrase: Option, -} - -#[derive(Deserialize)] -struct NyatermJsonGroup { - path: Vec, -} - -#[derive(Deserialize)] -struct NyatermJsonSshAuth { - mode: String, - #[serde(default)] - password: Option, - #[serde(default)] - password_ref: Option, - #[serde(default)] - key_ref: Option, -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum NyatermJsonSession { - Ssh { - name: String, - #[serde(default)] - group_path: Vec, - host: String, - #[serde(default = "default_ssh_port")] - port: u16, - #[serde(default = "default_ssh_user")] - username: String, - #[serde(default)] - auth: Option, - #[serde(default)] - description: Option, - #[serde(default)] - sort_order: i32, - #[serde(default)] - icon: Option, - }, - LocalTerminal { - name: String, - #[serde(default)] - group_path: Vec, - #[serde(default)] - shell_path: String, - #[serde(default)] - shell_args: String, - #[serde(default)] - working_dir: Option, - #[serde(default)] - description: Option, - #[serde(default)] - sort_order: i32, - #[serde(default)] - icon: Option, - }, - Telnet { - name: String, - #[serde(default)] - group_path: Vec, - host: String, - #[serde(default = "default_telnet_port")] - port: u16, - #[serde(default = "default_telnet_backspace_mode")] - backspace_mode: String, - #[serde(default)] - description: Option, - #[serde(default)] - sort_order: i32, - #[serde(default)] - icon: Option, - }, - Serial { - name: String, - #[serde(default)] - group_path: Vec, - port_name: String, - #[serde(default = "default_serial_baud_rate")] - baud_rate: u32, - #[serde(default = "default_serial_data_bits")] - data_bits: u8, - #[serde(default = "default_serial_parity")] - parity: String, - #[serde(default = "default_serial_stop_bits")] - stop_bits: String, - #[serde(default = "default_serial_backspace_mode")] - backspace_mode: String, - #[serde(default)] - description: Option, - #[serde(default)] - sort_order: i32, - #[serde(default)] - icon: Option, - }, -} - -fn default_ssh_port() -> u16 { - 22 -} - -fn default_ssh_user() -> String { - "root".to_string() -} - -fn default_telnet_port() -> u16 { - 23 -} - -fn default_telnet_backspace_mode() -> String { - "del".to_string() -} - -fn default_serial_baud_rate() -> u32 { - 115_200 -} - -fn default_serial_data_bits() -> u8 { - 8 -} - -fn default_serial_parity() -> String { - "none".to_string() -} - -fn default_serial_stop_bits() -> String { - "1".to_string() -} - -fn default_serial_backspace_mode() -> String { - "ctrl_h".to_string() -} - /// Detect BOM (UTF-8/UTF-16) and decode accordingly; fall back to GBK. fn decode_bytes(raw: &[u8]) -> String { if let Some((enc, bom_len)) = encoding_rs::Encoding::for_bom(raw) { @@ -296,6 +128,17 @@ fn read_text_file_limited(path: impl AsRef, label: &str) -> AppResult) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + fn parse_ini_sections(content: &str) -> HashMap> { let mut sections: HashMap> = HashMap::new(); let mut current_section = String::new(); @@ -318,445 +161,6 @@ fn parse_ini_sections(content: &str) -> HashMap> sections } -// NyaTerm JSON (.json) - -fn parse_nyaterm_json_content(content: &str) -> AppResult { - let file: NyatermJsonImportFile = serde_json::from_str(content) - .map_err(|e| AppError::Config(format!("Invalid NyaTerm JSON: {e}")))?; - prepare_nyaterm_json_import(file) -} - -fn prepare_nyaterm_json_import(file: NyatermJsonImportFile) -> AppResult { - if file.version != 1 { - return Err(AppError::Config(format!( - "Unsupported NyaTerm JSON import version: {}", - file.version - ))); - } - - let mut password_ref_map: HashMap = HashMap::new(); - let mut passwords = Vec::new(); - for entry in file.passwords { - let ref_name = required_string(entry.ref_name, "password ref", "passwords")?; - if password_ref_map.contains_key(&ref_name) { - return Err(AppError::Config(format!( - "Duplicate password ref in import file: {ref_name}" - ))); - } - if entry.password.is_empty() { - return Err(AppError::Config(format!( - "Password entry '{ref_name}' cannot have an empty password" - ))); - } - - let id = uuid::Uuid::new_v4().to_string(); - password_ref_map.insert(ref_name, id.clone()); - passwords.push(SavedPassword { - id, - name: required_string(entry.name, "password name", "passwords")?, - password: Some(entry.password), - has_password: false, - }); - } - - let mut key_ref_map: HashMap = HashMap::new(); - let mut ssh_keys = Vec::new(); - for entry in file.ssh_keys { - let ref_name = required_string(entry.ref_name, "ssh key ref", "ssh_keys")?; - if key_ref_map.contains_key(&ref_name) { - return Err(AppError::Config(format!( - "Duplicate ssh key ref in import file: {ref_name}" - ))); - } - if entry.private_key.trim().is_empty() { - return Err(AppError::Config(format!( - "SSH key entry '{ref_name}' cannot have empty private_key" - ))); - } - - let id = uuid::Uuid::new_v4().to_string(); - key_ref_map.insert(ref_name, id.clone()); - ssh_keys.push(SshKey { - id, - name: required_string(entry.name, "ssh key name", "ssh_keys")?, - key: Some(entry.private_key), - cert: normalize_optional_string(entry.certificate), - passphrase: normalize_optional_string(entry.passphrase), - key_file_path: None, - cert_file_path: None, - has_key_data: false, - has_cert_data: false, - }); - } - - let mut groups = Vec::new(); - for group in file.groups { - let path = normalize_required_group_path(group.path, "groups.path")?; - if !groups.contains(&path) { - groups.push(path); - } - } - - let mut connections = Vec::new(); - for session in file.sessions { - connections.push(prepare_nyaterm_json_session( - session, - &password_ref_map, - &key_ref_map, - )?); - } - - Ok(PreparedSessionImport { - groups, - passwords, - ssh_keys, - connections, - }) -} - -fn prepare_nyaterm_json_session( - session: NyatermJsonSession, - password_ref_map: &HashMap, - key_ref_map: &HashMap, -) -> AppResult { - match session { - NyatermJsonSession::Ssh { - name, - group_path, - host, - port, - username, - auth, - description, - sort_order, - icon, - } => { - validate_port(port, "ssh session")?; - let context = format!("ssh session '{name}'"); - Ok(PreparedSessionConnection { - name: required_string(name, "name", "ssh session")?, - config: ConnectionType::Ssh { - host: required_string(host, "host", &context)?, - port, - username: required_string(username, "username", &context)?, - backspace_mode: "del".to_string(), - ai_execution_profile: AiExecutionProfile::Auto, - x11_forwarding: false, - agent_endpoint: Default::default(), - agent_forwarding: false, - encoding: String::new(), - }, - group_path: normalize_optional_group_path(group_path, &context)?, - description: normalize_optional_string(description), - sort_order, - icon: normalize_optional_string(icon), - auth: Some(prepare_json_ssh_auth( - auth, - password_ref_map, - key_ref_map, - &context, - )?), - }) - } - NyatermJsonSession::LocalTerminal { - name, - group_path, - shell_path, - shell_args, - working_dir, - description, - sort_order, - icon, - } => { - let context = format!("local_terminal session '{name}'"); - Ok(PreparedSessionConnection { - name: required_string(name, "name", "local_terminal session")?, - config: ConnectionType::LocalTerminal { - shell_path: required_string(shell_path, "shell_path", &context)?, - shell_args, - working_dir: normalize_optional_string(working_dir), - ai_execution_profile: AiExecutionProfile::Auto, - encoding: String::new(), - }, - group_path: normalize_optional_group_path(group_path, &context)?, - description: normalize_optional_string(description), - sort_order, - icon: normalize_optional_string(icon), - auth: None, - }) - } - NyatermJsonSession::Telnet { - name, - group_path, - host, - port, - backspace_mode, - description, - sort_order, - icon, - } => { - validate_port(port, "telnet session")?; - validate_backspace_mode(&backspace_mode, "telnet session")?; - let context = format!("telnet session '{name}'"); - Ok(PreparedSessionConnection { - name: required_string(name, "name", "telnet session")?, - config: ConnectionType::Telnet { - host: required_string(host, "host", &context)?, - port, - username: String::new(), - ai_execution_profile: AiExecutionProfile::Auto, - backspace_mode, - raw_tcp_cli: false, - enter_mode: "cr".to_string(), - local_echo: false, - local_line_edit: false, - force_character_at_a_time: false, - send_naws: true, - send_sga: true, - auto_login: Default::default(), - encoding: String::new(), - }, - group_path: normalize_optional_group_path(group_path, &context)?, - description: normalize_optional_string(description), - sort_order, - icon: normalize_optional_string(icon), - auth: None, - }) - } - NyatermJsonSession::Serial { - name, - group_path, - port_name, - baud_rate, - data_bits, - parity, - stop_bits, - backspace_mode, - description, - sort_order, - icon, - } => { - validate_serial_config(baud_rate, data_bits, &parity, &stop_bits, &backspace_mode)?; - let context = format!("serial session '{name}'"); - Ok(PreparedSessionConnection { - name: required_string(name, "name", "serial session")?, - config: ConnectionType::Serial { - port_name: required_string(port_name, "port_name", &context)?, - baud_rate, - data_bits, - parity, - stop_bits, - ai_execution_profile: AiExecutionProfile::Auto, - backspace_mode, - encoding: String::new(), - }, - group_path: normalize_optional_group_path(group_path, &context)?, - description: normalize_optional_string(description), - sort_order, - icon: normalize_optional_string(icon), - auth: None, - }) - } - } -} - -fn prepare_json_ssh_auth( - auth: Option, - password_ref_map: &HashMap, - key_ref_map: &HashMap, - context: &str, -) -> AppResult { - let Some(auth) = auth else { - return Ok(ConnectionAuth { - mode: "none".to_string(), - password_id: None, - password: None, - key_id: None, - otp_id: None, - auto_fill_otp: false, - has_password: false, - }); - }; - - match auth.mode.trim() { - "none" => { - if auth.password.is_some() || auth.password_ref.is_some() || auth.key_ref.is_some() { - return Err(AppError::Config(format!( - "{context}: auth.mode 'none' cannot include password, password_ref, or key_ref" - ))); - } - Ok(ConnectionAuth { - mode: "none".to_string(), - password_id: None, - password: None, - key_id: None, - otp_id: None, - auto_fill_otp: false, - has_password: false, - }) - } - "password" => { - let has_password = auth - .password - .as_ref() - .is_some_and(|value| !value.is_empty()); - let password_ref = normalize_optional_string(auth.password_ref); - if has_password == password_ref.is_some() { - return Err(AppError::Config(format!( - "{context}: password auth must include exactly one of password or password_ref" - ))); - } - let password_id = if let Some(ref_name) = password_ref { - Some(password_ref_map.get(&ref_name).cloned().ok_or_else(|| { - AppError::Config(format!( - "{context}: password_ref '{ref_name}' was not found" - )) - })?) - } else { - None - }; - let password = auth.password; - - Ok(ConnectionAuth { - mode: "password".to_string(), - password_id, - password, - key_id: None, - otp_id: None, - auto_fill_otp: false, - has_password: false, - }) - } - "key" => { - if auth.password.is_some() || auth.password_ref.is_some() { - return Err(AppError::Config(format!( - "{context}: key auth cannot include password or password_ref" - ))); - } - let key_ref = normalize_optional_string(auth.key_ref) - .ok_or_else(|| AppError::Config(format!("{context}: key auth requires key_ref")))?; - let key_id = key_ref_map.get(&key_ref).cloned().ok_or_else(|| { - AppError::Config(format!("{context}: key_ref '{key_ref}' was not found")) - })?; - - Ok(ConnectionAuth { - mode: "key".to_string(), - password_id: None, - password: None, - key_id: Some(key_id), - otp_id: None, - auto_fill_otp: false, - has_password: false, - }) - } - mode => Err(AppError::Config(format!( - "{context}: unsupported SSH auth mode '{mode}'" - ))), - } -} - -fn required_string(value: String, field: &str, context: &str) -> AppResult { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(AppError::Config(format!("{context}: {field} is required"))); - } - Ok(trimmed.to_string()) -} - -fn normalize_optional_string(value: Option) -> Option { - value.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -fn normalize_required_group_path(path: Vec, context: &str) -> AppResult> { - normalize_optional_group_path(path, context)?.ok_or_else(|| { - AppError::Config(format!( - "{context}: group path must contain at least one segment" - )) - }) -} - -fn normalize_optional_group_path( - path: Vec, - context: &str, -) -> AppResult>> { - if path.is_empty() { - return Ok(None); - } - - let mut segments = Vec::new(); - for segment in path { - let trimmed = segment.trim(); - if trimmed.is_empty() { - return Err(AppError::Config(format!( - "{context}: group path segments cannot be empty" - ))); - } - segments.push(trimmed.to_string()); - } - Ok(Some(segments)) -} - -fn validate_port(port: u16, context: &str) -> AppResult<()> { - if port == 0 { - return Err(AppError::Config(format!( - "{context}: port must be between 1 and 65535" - ))); - } - Ok(()) -} - -fn validate_backspace_mode(value: &str, context: &str) -> AppResult<()> { - match value { - "ctrl_h" | "del" => Ok(()), - _ => Err(AppError::Config(format!( - "{context}: backspace_mode must be 'ctrl_h' or 'del'" - ))), - } -} - -fn validate_serial_config( - baud_rate: u32, - data_bits: u8, - parity: &str, - stop_bits: &str, - backspace_mode: &str, -) -> AppResult<()> { - if baud_rate == 0 { - return Err(AppError::Config( - "serial session: baud_rate must be greater than 0".to_string(), - )); - } - if !(5..=8).contains(&data_bits) { - return Err(AppError::Config( - "serial session: data_bits must be between 5 and 8".to_string(), - )); - } - match parity { - "none" | "even" | "odd" => {} - _ => { - return Err(AppError::Config( - "serial session: parity must be 'none', 'even', or 'odd'".to_string(), - )); - } - } - match stop_bits { - "1" | "1.5" | "2" => {} - _ => { - return Err(AppError::Config( - "serial session: stop_bits must be '1', '1.5', or '2'".to_string(), - )); - } - } - validate_backspace_mode(backspace_mode, "serial session") -} - // Shared import persistence helpers fn prepare_legacy_sessions(imported: Vec) -> PreparedSessionImport { diff --git a/crates/nyaterm-core/src/session_import/nyaterm_json.rs b/crates/nyaterm-core/src/session_import/nyaterm_json.rs new file mode 100644 index 000000000..526089240 --- /dev/null +++ b/crates/nyaterm-core/src/session_import/nyaterm_json.rs @@ -0,0 +1,606 @@ +use std::collections::HashMap; + +use serde::Deserialize; + +use crate::{AiExecutionProfile, ConnectionAuth, ConnectionType, SavedPassword, SshKey}; + +use super::{ + AppError, AppResult, PreparedSessionConnection, PreparedSessionImport, + normalize_optional_string, +}; + +#[derive(Deserialize)] +struct NyatermJsonImportFile { + #[serde(default = "default_import_version")] + version: u32, + #[serde(default)] + passwords: Vec, + #[serde(default)] + ssh_keys: Vec, + #[serde(default)] + groups: Vec, + #[serde(default)] + sessions: Vec, +} + +fn default_import_version() -> u32 { + 1 +} + +#[derive(Deserialize)] +struct NyatermJsonPassword { + #[serde(rename = "ref")] + ref_name: String, + name: String, + password: String, +} + +#[derive(Deserialize)] +struct NyatermJsonSshKey { + #[serde(rename = "ref")] + ref_name: String, + name: String, + private_key: String, + #[serde(default)] + certificate: Option, + #[serde(default)] + passphrase: Option, +} + +#[derive(Deserialize)] +struct NyatermJsonGroup { + path: Vec, +} + +#[derive(Deserialize)] +struct NyatermJsonSshAuth { + mode: String, + #[serde(default)] + password: Option, + #[serde(default)] + password_ref: Option, + #[serde(default)] + key_ref: Option, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum NyatermJsonSession { + Ssh { + name: String, + #[serde(default)] + group_path: Vec, + host: String, + #[serde(default = "default_ssh_port")] + port: u16, + #[serde(default = "default_ssh_user")] + username: String, + #[serde(default)] + auth: Option, + #[serde(default)] + description: Option, + #[serde(default)] + sort_order: i32, + #[serde(default)] + icon: Option, + }, + LocalTerminal { + name: String, + #[serde(default)] + group_path: Vec, + #[serde(default)] + shell_path: String, + #[serde(default)] + shell_args: String, + #[serde(default)] + working_dir: Option, + #[serde(default)] + description: Option, + #[serde(default)] + sort_order: i32, + #[serde(default)] + icon: Option, + }, + Telnet { + name: String, + #[serde(default)] + group_path: Vec, + host: String, + #[serde(default = "default_telnet_port")] + port: u16, + #[serde(default = "default_telnet_backspace_mode")] + backspace_mode: String, + #[serde(default)] + description: Option, + #[serde(default)] + sort_order: i32, + #[serde(default)] + icon: Option, + }, + Serial { + name: String, + #[serde(default)] + group_path: Vec, + port_name: String, + #[serde(default = "default_serial_baud_rate")] + baud_rate: u32, + #[serde(default = "default_serial_data_bits")] + data_bits: u8, + #[serde(default = "default_serial_parity")] + parity: String, + #[serde(default = "default_serial_stop_bits")] + stop_bits: String, + #[serde(default = "default_serial_backspace_mode")] + backspace_mode: String, + #[serde(default)] + description: Option, + #[serde(default)] + sort_order: i32, + #[serde(default)] + icon: Option, + }, +} + +fn default_ssh_port() -> u16 { + 22 +} + +fn default_ssh_user() -> String { + "root".to_string() +} + +fn default_telnet_port() -> u16 { + 23 +} + +fn default_telnet_backspace_mode() -> String { + "del".to_string() +} + +fn default_serial_baud_rate() -> u32 { + 115_200 +} + +fn default_serial_data_bits() -> u8 { + 8 +} + +fn default_serial_parity() -> String { + "none".to_string() +} + +fn default_serial_stop_bits() -> String { + "1".to_string() +} + +fn default_serial_backspace_mode() -> String { + "ctrl_h".to_string() +} + +// NyaTerm JSON (.json) + +pub(super) fn parse_nyaterm_json_content(content: &str) -> AppResult { + let file: NyatermJsonImportFile = serde_json::from_str(content) + .map_err(|e| AppError::Config(format!("Invalid NyaTerm JSON: {e}")))?; + prepare_nyaterm_json_import(file) +} + +fn prepare_nyaterm_json_import(file: NyatermJsonImportFile) -> AppResult { + if file.version != 1 { + return Err(AppError::Config(format!( + "Unsupported NyaTerm JSON import version: {}", + file.version + ))); + } + + let mut password_ref_map: HashMap = HashMap::new(); + let mut passwords = Vec::new(); + for entry in file.passwords { + let ref_name = required_string(entry.ref_name, "password ref", "passwords")?; + if password_ref_map.contains_key(&ref_name) { + return Err(AppError::Config(format!( + "Duplicate password ref in import file: {ref_name}" + ))); + } + if entry.password.is_empty() { + return Err(AppError::Config(format!( + "Password entry '{ref_name}' cannot have an empty password" + ))); + } + + let id = uuid::Uuid::new_v4().to_string(); + password_ref_map.insert(ref_name, id.clone()); + passwords.push(SavedPassword { + id, + name: required_string(entry.name, "password name", "passwords")?, + password: Some(entry.password), + has_password: false, + }); + } + + let mut key_ref_map: HashMap = HashMap::new(); + let mut ssh_keys = Vec::new(); + for entry in file.ssh_keys { + let ref_name = required_string(entry.ref_name, "ssh key ref", "ssh_keys")?; + if key_ref_map.contains_key(&ref_name) { + return Err(AppError::Config(format!( + "Duplicate ssh key ref in import file: {ref_name}" + ))); + } + if entry.private_key.trim().is_empty() { + return Err(AppError::Config(format!( + "SSH key entry '{ref_name}' cannot have empty private_key" + ))); + } + + let id = uuid::Uuid::new_v4().to_string(); + key_ref_map.insert(ref_name, id.clone()); + ssh_keys.push(SshKey { + id, + name: required_string(entry.name, "ssh key name", "ssh_keys")?, + key: Some(entry.private_key), + cert: normalize_optional_string(entry.certificate), + passphrase: normalize_optional_string(entry.passphrase), + key_file_path: None, + cert_file_path: None, + has_key_data: false, + has_cert_data: false, + }); + } + + let mut groups = Vec::new(); + for group in file.groups { + let path = normalize_required_group_path(group.path, "groups.path")?; + if !groups.contains(&path) { + groups.push(path); + } + } + + let mut connections = Vec::new(); + for session in file.sessions { + connections.push(prepare_nyaterm_json_session( + session, + &password_ref_map, + &key_ref_map, + )?); + } + + Ok(PreparedSessionImport { + groups, + passwords, + ssh_keys, + connections, + }) +} + +fn prepare_nyaterm_json_session( + session: NyatermJsonSession, + password_ref_map: &HashMap, + key_ref_map: &HashMap, +) -> AppResult { + match session { + NyatermJsonSession::Ssh { + name, + group_path, + host, + port, + username, + auth, + description, + sort_order, + icon, + } => { + validate_port(port, "ssh session")?; + let context = format!("ssh session '{name}'"); + Ok(PreparedSessionConnection { + name: required_string(name, "name", "ssh session")?, + config: ConnectionType::Ssh { + host: required_string(host, "host", &context)?, + port, + username: required_string(username, "username", &context)?, + backspace_mode: "del".to_string(), + ai_execution_profile: AiExecutionProfile::Auto, + x11_forwarding: false, + agent_endpoint: Default::default(), + agent_forwarding: false, + encoding: String::new(), + }, + group_path: normalize_optional_group_path(group_path, &context)?, + description: normalize_optional_string(description), + sort_order, + icon: normalize_optional_string(icon), + auth: Some(prepare_json_ssh_auth( + auth, + password_ref_map, + key_ref_map, + &context, + )?), + }) + } + NyatermJsonSession::LocalTerminal { + name, + group_path, + shell_path, + shell_args, + working_dir, + description, + sort_order, + icon, + } => { + let context = format!("local_terminal session '{name}'"); + Ok(PreparedSessionConnection { + name: required_string(name, "name", "local_terminal session")?, + config: ConnectionType::LocalTerminal { + shell_path: required_string(shell_path, "shell_path", &context)?, + shell_args, + working_dir: normalize_optional_string(working_dir), + ai_execution_profile: AiExecutionProfile::Auto, + encoding: String::new(), + }, + group_path: normalize_optional_group_path(group_path, &context)?, + description: normalize_optional_string(description), + sort_order, + icon: normalize_optional_string(icon), + auth: None, + }) + } + NyatermJsonSession::Telnet { + name, + group_path, + host, + port, + backspace_mode, + description, + sort_order, + icon, + } => { + validate_port(port, "telnet session")?; + validate_backspace_mode(&backspace_mode, "telnet session")?; + let context = format!("telnet session '{name}'"); + Ok(PreparedSessionConnection { + name: required_string(name, "name", "telnet session")?, + config: ConnectionType::Telnet { + host: required_string(host, "host", &context)?, + port, + username: String::new(), + ai_execution_profile: AiExecutionProfile::Auto, + backspace_mode, + raw_tcp_cli: false, + enter_mode: "cr".to_string(), + local_echo: false, + local_line_edit: false, + force_character_at_a_time: false, + send_naws: true, + send_sga: true, + auto_login: Default::default(), + encoding: String::new(), + }, + group_path: normalize_optional_group_path(group_path, &context)?, + description: normalize_optional_string(description), + sort_order, + icon: normalize_optional_string(icon), + auth: None, + }) + } + NyatermJsonSession::Serial { + name, + group_path, + port_name, + baud_rate, + data_bits, + parity, + stop_bits, + backspace_mode, + description, + sort_order, + icon, + } => { + validate_serial_config(baud_rate, data_bits, &parity, &stop_bits, &backspace_mode)?; + let context = format!("serial session '{name}'"); + Ok(PreparedSessionConnection { + name: required_string(name, "name", "serial session")?, + config: ConnectionType::Serial { + port_name: required_string(port_name, "port_name", &context)?, + baud_rate, + data_bits, + parity, + stop_bits, + ai_execution_profile: AiExecutionProfile::Auto, + backspace_mode, + encoding: String::new(), + }, + group_path: normalize_optional_group_path(group_path, &context)?, + description: normalize_optional_string(description), + sort_order, + icon: normalize_optional_string(icon), + auth: None, + }) + } + } +} + +fn prepare_json_ssh_auth( + auth: Option, + password_ref_map: &HashMap, + key_ref_map: &HashMap, + context: &str, +) -> AppResult { + let Some(auth) = auth else { + return Ok(ConnectionAuth { + mode: "none".to_string(), + password_id: None, + password: None, + key_id: None, + otp_id: None, + auto_fill_otp: false, + has_password: false, + }); + }; + + match auth.mode.trim() { + "none" => { + if auth.password.is_some() || auth.password_ref.is_some() || auth.key_ref.is_some() { + return Err(AppError::Config(format!( + "{context}: auth.mode 'none' cannot include password, password_ref, or key_ref" + ))); + } + Ok(ConnectionAuth { + mode: "none".to_string(), + password_id: None, + password: None, + key_id: None, + otp_id: None, + auto_fill_otp: false, + has_password: false, + }) + } + "password" => { + let has_password = auth + .password + .as_ref() + .is_some_and(|value| !value.is_empty()); + let password_ref = normalize_optional_string(auth.password_ref); + if has_password == password_ref.is_some() { + return Err(AppError::Config(format!( + "{context}: password auth must include exactly one of password or password_ref" + ))); + } + let password_id = if let Some(ref_name) = password_ref { + Some(password_ref_map.get(&ref_name).cloned().ok_or_else(|| { + AppError::Config(format!( + "{context}: password_ref '{ref_name}' was not found" + )) + })?) + } else { + None + }; + let password = auth.password; + + Ok(ConnectionAuth { + mode: "password".to_string(), + password_id, + password, + key_id: None, + otp_id: None, + auto_fill_otp: false, + has_password: false, + }) + } + "key" => { + if auth.password.is_some() || auth.password_ref.is_some() { + return Err(AppError::Config(format!( + "{context}: key auth cannot include password or password_ref" + ))); + } + let key_ref = normalize_optional_string(auth.key_ref) + .ok_or_else(|| AppError::Config(format!("{context}: key auth requires key_ref")))?; + let key_id = key_ref_map.get(&key_ref).cloned().ok_or_else(|| { + AppError::Config(format!("{context}: key_ref '{key_ref}' was not found")) + })?; + + Ok(ConnectionAuth { + mode: "key".to_string(), + password_id: None, + password: None, + key_id: Some(key_id), + otp_id: None, + auto_fill_otp: false, + has_password: false, + }) + } + mode => Err(AppError::Config(format!( + "{context}: unsupported SSH auth mode '{mode}'" + ))), + } +} + +fn required_string(value: String, field: &str, context: &str) -> AppResult { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::Config(format!("{context}: {field} is required"))); + } + Ok(trimmed.to_string()) +} + +fn normalize_required_group_path(path: Vec, context: &str) -> AppResult> { + normalize_optional_group_path(path, context)?.ok_or_else(|| { + AppError::Config(format!( + "{context}: group path must contain at least one segment" + )) + }) +} + +fn normalize_optional_group_path( + path: Vec, + context: &str, +) -> AppResult>> { + if path.is_empty() { + return Ok(None); + } + + let mut segments = Vec::new(); + for segment in path { + let trimmed = segment.trim(); + if trimmed.is_empty() { + return Err(AppError::Config(format!( + "{context}: group path segments cannot be empty" + ))); + } + segments.push(trimmed.to_string()); + } + Ok(Some(segments)) +} + +fn validate_port(port: u16, context: &str) -> AppResult<()> { + if port == 0 { + return Err(AppError::Config(format!( + "{context}: port must be between 1 and 65535" + ))); + } + Ok(()) +} + +fn validate_backspace_mode(value: &str, context: &str) -> AppResult<()> { + match value { + "ctrl_h" | "del" => Ok(()), + _ => Err(AppError::Config(format!( + "{context}: backspace_mode must be 'ctrl_h' or 'del'" + ))), + } +} + +fn validate_serial_config( + baud_rate: u32, + data_bits: u8, + parity: &str, + stop_bits: &str, + backspace_mode: &str, +) -> AppResult<()> { + if baud_rate == 0 { + return Err(AppError::Config( + "serial session: baud_rate must be greater than 0".to_string(), + )); + } + if !(5..=8).contains(&data_bits) { + return Err(AppError::Config( + "serial session: data_bits must be between 5 and 8".to_string(), + )); + } + match parity { + "none" | "even" | "odd" => {} + _ => { + return Err(AppError::Config( + "serial session: parity must be 'none', 'even', or 'odd'".to_string(), + )); + } + } + match stop_bits { + "1" | "1.5" | "2" => {} + _ => { + return Err(AppError::Config( + "serial session: stop_bits must be '1', '1.5', or '2'".to_string(), + )); + } + } + validate_backspace_mode(backspace_mode, "serial session") +} diff --git a/crates/nyaterm-core/src/session_import/tests.rs b/crates/nyaterm-core/src/session_import/tests.rs index ea99d0e04..47d4e994d 100644 --- a/crates/nyaterm-core/src/session_import/tests.rs +++ b/crates/nyaterm-core/src/session_import/tests.rs @@ -1,8 +1,7 @@ -use super::{ - ConnectionType, parse_nyaterm_json_content, prepare_session_import, read_file_limited, -}; +use super::{ConnectionType, prepare_session_import, read_file_limited}; use super::{ mobaxterm::parse_moba_entry, + nyaterm_json::parse_nyaterm_json_content, windterm::{parse_windterm_content, parse_windterm_target}, xshell::parse_xsh_content, }; diff --git a/docs/architecture/gpui-migration-status.md b/docs/architecture/gpui-migration-status.md index 5b188b9cc..bf4cf8ebc 100644 --- a/docs/architecture/gpui-migration-status.md +++ b/docs/architecture/gpui-migration-status.md @@ -280,10 +280,11 @@ these as staged extraction candidates, not as formatting-only refactor targets. ## Completed -- Xshell archive, MobaXterm bookmark and WindTerm session parsing now live in - their respective `nyaterm-core::session_import` format modules. The - session-import facade retains dispatch and shared bounded helpers, while ZIP - entry limits, encoding handling, group derivation and authentication +- Xshell archive, MobaXterm bookmark, WindTerm session and NyaTerm JSON parsing + now live in their respective `nyaterm-core::session_import` format modules. + The session-import facade retains public result contracts, dispatch and + shared bounded helpers; JSON defaults and credential-reference validation, + ZIP entry limits, encoding handling, group derivation and authentication compatibility remain covered by the existing parser tests. - Composite Rust domains now consistently use directory roots (`mod.rs`) when they own child modules, and `nyaterm-core::models::workspace` owns the