1pub mod model;
7
8use crate::cli::{SecretsAction, VpsAction, OutputFormat};
9use crate::erros::{SshCliError, SshCliResult};
10use crate::output;
11use crate::ssh::client::{SshClient, SshClientTrait, ConnectionConfig};
12use crate::ssh::known_hosts::KnownHosts;
13use crate::ssh::packing::{append_description, pack_su, pack_sudo};
14use anyhow::Result;
15use model::{effective_limit, parse_char_limit, VpsRecord};
16use secrecy::SecretString;
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::io::{Read, Write};
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Default, Serialize, Deserialize)]
24pub struct ConfigFile {
25 #[serde(default)]
27 pub schema_version: u32,
28 #[serde(default)]
30 pub hosts: BTreeMap<String, VpsRecord>,
31}
32
33pub fn resolve_config_path(override_path: Option<PathBuf>) -> SshCliResult<PathBuf> {
35 match override_path {
36 Some(p) => {
37 if p.is_dir() {
38 return Ok(p.join("config.toml"));
39 }
40 if p.extension().and_then(|e| e.to_str()) == Some("toml") {
41 return Ok(p);
42 }
43 Ok(p.join("config.toml"))
44 }
45 None => default_config_path(),
46 }
47}
48
49pub fn default_config_path() -> SshCliResult<PathBuf> {
51 if let Ok(home) = std::env::var("SSH_CLI_HOME") {
52 if home.contains("..") {
53 return Err(SshCliError::InvalidArgument(
54 "SSH_CLI_HOME must not contain '..'".to_string(),
55 ));
56 }
57 return Ok(PathBuf::from(home).join("config.toml"));
58 }
59
60 let dirs = directories::ProjectDirs::from("", "", "ssh-cli").ok_or_else(|| {
61 SshCliError::Generic("could not resolve config directory".to_string())
62 })?;
63 Ok(dirs.config_dir().join("config.toml"))
64}
65
66#[derive(Debug, Clone)]
68pub struct ConfigLayer {
69 pub name: &'static str,
71 pub path: PathBuf,
73}
74
75pub fn winning_layer(override_path: Option<PathBuf>) -> SshCliResult<ConfigLayer> {
77 if override_path.is_some() {
78 return Ok(ConfigLayer {
79 name: "--config-dir",
80 path: resolve_config_path(override_path)?,
81 });
82 }
83 if std::env::var("SSH_CLI_HOME").is_ok() {
84 return Ok(ConfigLayer {
85 name: "SSH_CLI_HOME",
86 path: default_config_path()?,
87 });
88 }
89 Ok(ConfigLayer {
90 name: "XDG ProjectDirs",
91 path: default_config_path()?,
92 })
93}
94
95pub fn load(path: &PathBuf) -> SshCliResult<ConfigFile> {
100 if !path.exists() {
101 return Ok(ConfigFile {
102 schema_version: model::CURRENT_SCHEMA_VERSION,
103 hosts: BTreeMap::new(),
104 });
105 }
106 let content = std::fs::read_to_string(path)?;
107 let mut file: ConfigFile = toml::from_str(&content)?;
108 for reg in file.hosts.values_mut() {
109 reg.normalize_schema();
110 }
111 if file.schema_version < model::CURRENT_SCHEMA_VERSION {
112 file.schema_version = model::CURRENT_SCHEMA_VERSION;
113 }
114 Ok(file)
115}
116
117pub fn write_atomic(path: &Path, bytes: &[u8]) -> SshCliResult<()> {
121 if let Some(parent_dir) = path.parent() {
122 std::fs::create_dir_all(parent_dir)?;
123 }
124 let parent_dir = path
125 .parent()
126 .map(Path::to_path_buf)
127 .unwrap_or_else(|| PathBuf::from("."));
128 let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir)?;
129 tmp.write_all(bytes)?;
130 tmp.as_file().sync_data()?;
131 tmp.persist(path).map_err(|e| SshCliError::Io(e.error))?;
132 apply_permissions_600(path)?;
133 #[cfg(unix)]
134 {
135 if let Ok(dir) = std::fs::File::open(&parent_dir) {
136 let _ = dir.sync_all();
137 }
138 }
139 Ok(())
140}
141
142pub fn save(path: &Path, file: &ConfigFile) -> SshCliResult<()> {
147 if let Some(parent_dir) = path.parent() {
148 std::fs::create_dir_all(parent_dir)?;
149 }
150 let text = toml::to_string_pretty(file)
151 .map_err(|e| SshCliError::Generic(format!("failed to serialize TOML: {e}")))?;
152
153 let lock_path = path.with_extension("toml.lock");
155 let lock_file = std::fs::OpenOptions::new()
156 .create(true)
157 .truncate(false)
158 .read(true)
159 .write(true)
160 .open(&lock_path)?;
161 apply_permissions_600(&lock_path)?;
163 fs2::FileExt::lock_exclusive(&lock_file)?;
164
165 write_atomic(path, text.as_bytes())?;
166
167 let _ = fs2::FileExt::unlock(&lock_file);
168 Ok(())
169}
170
171fn expand_tilde(path: &str) -> PathBuf {
173 let home = std::env::var_os("HOME")
174 .or_else(|| std::env::var_os("USERPROFILE"))
175 .map(PathBuf::from);
176 if let Some(rest) = path.strip_prefix("~/") {
177 if let Some(home) = home {
178 return home.join(rest);
179 }
180 }
181 if path == "~" {
182 if let Some(home) = home {
183 return home;
184 }
185 }
186 PathBuf::from(path)
187}
188
189fn validate_key_path_exists(key_path: &str) -> Result<(), SshCliError> {
195 validate_key_path_exists_with_passphrase(key_path, None)
196}
197
198fn validate_key_path_exists_with_passphrase(
200 key_path: &str,
201 passphrase: Option<&str>,
202) -> Result<(), SshCliError> {
203 let p = expand_tilde(key_path);
204 if !p.is_file() {
205 return Err(SshCliError::FileNotFound(format!(
206 "private key not found: {}",
207 p.display()
208 )));
209 }
210 #[cfg(feature = "ssh-real")]
211 {
212 match russh::keys::load_secret_key(&p, passphrase) {
213 Ok(_) => Ok(()),
214 Err(e) => {
215 let msg = e.to_string().to_lowercase();
216 if msg.contains("password")
218 || msg.contains("passphrase")
219 || msg.contains("encrypted")
220 || msg.contains("decrypt")
221 {
222 return Ok(());
223 }
224 Err(SshCliError::InvalidArgument(format!(
225 "invalid OpenSSH private key at {}: {e}",
226 p.display()
227 )))
228 }
229 }
230 }
231 #[cfg(not(feature = "ssh-real"))]
232 {
233 let _ = passphrase;
234 Ok(())
235 }
236}
237
238#[must_use]
240pub fn use_json(json_local: bool, format: OutputFormat) -> bool {
241 json_local || format == OutputFormat::Json
242}
243
244#[cfg(unix)]
245fn apply_permissions_600(path: &Path) -> SshCliResult<()> {
246 use std::os::unix::fs::PermissionsExt;
247 let mut permissions = std::fs::metadata(path)?.permissions();
248 permissions.set_mode(0o600);
249 std::fs::set_permissions(path, permissions)?;
250 Ok(())
251}
252
253#[cfg(not(unix))]
254fn apply_permissions_600(_caminho: &Path) -> SshCliResult<()> {
255 Ok(())
256}
257
258pub fn read_secret_stdin() -> SshCliResult<String> {
260 let mut buf = String::new();
261 std::io::stdin().read_to_string(&mut buf)?;
262 Ok(buf.trim_end_matches(['\r', '\n']).to_string())
263}
264
265pub(crate) fn apply_overrides(
269 vps: &mut VpsRecord,
270 password_override: Option<String>,
271 sudo_password_override: Option<String>,
272 su_password_override: Option<String>,
273 timeout_override: Option<u64>,
274 key_path_override: Option<String>,
275 key_passphrase_override: Option<String>,
276) {
277 if let Some(pwd) = password_override {
278 vps.password = SecretString::from(pwd);
279 }
280 if let Some(spwd) = sudo_password_override {
281 vps.sudo_password = Some(SecretString::from(spwd));
282 }
283 if let Some(sp) = su_password_override {
284 vps.su_password = Some(SecretString::from(sp));
285 }
286 if let Some(t) = timeout_override {
287 vps.timeout_ms = t;
288 }
289 if let Some(k) = key_path_override {
290 vps.key_path = Some(k);
291 }
292 if let Some(kp) = key_passphrase_override {
293 vps.key_passphrase = Some(SecretString::from(kp));
294 }
295}
296
297fn validate_command_length(command: &str, max_command_chars: usize) -> SshCliResult<()> {
298 let lim = effective_limit(max_command_chars);
299 let len = command.chars().count();
300 if len > lim {
301 return Err(SshCliError::CommandTooLong {
302 max: max_command_chars,
303 len,
304 });
305 }
306 if command.trim().is_empty() {
307 return Err(SshCliError::InvalidArgument("empty command".to_string()));
308 }
309 Ok(())
310}
311
312pub async fn run_vps_command(
314 action: VpsAction,
315 config_override: Option<PathBuf>,
316 format: OutputFormat,
317) -> Result<()> {
318 let path = resolve_config_path(config_override.clone())?;
319
320 match action {
321 VpsAction::Add {
322 name,
323 host,
324 port,
325 user,
326 password,
327 password_stdin,
328 key,
329 key_passphrase,
330 timeout,
331 max_command_chars,
332 max_output_chars,
333 max_chars,
334 sudo_password,
335 sudo_password_stdin,
336 su_password,
337 su_password_stdin,
338 disable_sudo,
339 check,
340 } => {
341 let name = crate::paths::validate_and_normalize(&name)
343 .map_err(|e| SshCliError::InvalidArgument(format!("invalid VPS name: {e}")))?;
344 let mut file = load(&path)?;
345 if file.hosts.contains_key(&name) {
346 return Err(SshCliError::VpsDuplicate(name).into());
347 }
348 if password_stdin && (sudo_password_stdin || su_password_stdin) {
349 return Err(SshCliError::InvalidArgument(
350 "only one --*-stdin per one-shot invocation; use vps edit for sudo/su".into(),
351 )
352 .into());
353 }
354 let password = if password_stdin {
355 SecretString::from(read_secret_stdin()?)
356 } else {
357 SecretString::from(password.unwrap_or_default())
358 };
359 let sudo_s = if sudo_password_stdin {
360 Some(SecretString::from(read_secret_stdin()?))
361 } else {
362 sudo_password.map(SecretString::from)
363 };
364 let su_s = if su_password_stdin {
365 Some(SecretString::from(read_secret_stdin()?))
366 } else {
367 su_password.map(SecretString::from)
368 };
369 if let Some(ref k) = key {
370 validate_key_path_exists(k)?;
371 }
372 let max_cmd = max_command_chars
374 .as_deref()
375 .or(max_chars.as_deref())
376 .map(parse_char_limit)
377 .unwrap_or(model::DEFAULT_MAX_COMMAND_CHARS);
378 let max_out = max_output_chars
379 .as_deref()
380 .map(parse_char_limit)
381 .unwrap_or(model::DEFAULT_MAX_OUTPUT_CHARS);
382 if timeout > 0 && timeout < 1000 {
384 eprintln!(
385 "warning: --timeout {timeout} is only {timeout}ms (< 1s); did you mean seconds? Use e.g. --timeout 5000 for 5s"
386 );
387 }
388 let registro = VpsRecord::new(
389 name.clone(),
390 host,
391 port,
392 user,
393 password,
394 key,
395 key_passphrase.map(SecretString::from),
396 Some(timeout),
397 Some(max_cmd),
398 Some(max_out),
399 sudo_s,
400 su_s,
401 disable_sudo,
402 );
403 registro.validate().map_err(SshCliError::InvalidArgument)?;
405 file.hosts.insert(name.clone(), registro);
406 file.schema_version = model::CURRENT_SCHEMA_VERSION;
407 save(&path, &file)?;
408 emit_auto_key_if_needed(format == OutputFormat::Json)?;
409 crate::output::emit_success(
410 "vps-added",
411 serde_json::json!({ "name": name }),
412 &crate::i18n::t(crate::i18n::Message::VpsAdded { name: name.clone() }),
413 format == OutputFormat::Json,
414 )?;
415 if check {
416 run_health_check(
417 Some(&name),
418 config_override,
419 format,
420 false,
421 None,
422 None,
423 None,
424 None,
425 false,
426 )
427 .await?;
428 }
429 }
430 VpsAction::List { json } => {
431 let file = load(&path)?;
432 let records: Vec<_> = file.hosts.values().cloned().collect();
433 if use_json(json, format) {
435 crate::output::print_list_json(&records);
436 } else {
437 crate::output::print_list_text(&records);
438 }
439 }
440 VpsAction::Remove { name } => {
441 let mut file = load(&path)?;
442 if file.hosts.remove(&name).is_none() {
443 return Err(SshCliError::VpsNotFound(name).into());
444 }
445 save(&path, &file)?;
446 clear_active_if_name(&path, &name)?;
448 crate::output::emit_success(
449 "vps-removed",
450 serde_json::json!({ "name": name }),
451 &crate::i18n::t(crate::i18n::Message::VpsRemoved { name: name.clone() }),
452 format == OutputFormat::Json,
453 )?;
454 }
455 VpsAction::Edit {
456 name,
457 host,
458 port,
459 user,
460 password,
461 password_stdin,
462 key,
463 key_passphrase,
464 timeout,
465 max_command_chars,
466 max_output_chars,
467 max_chars,
468 sudo_password,
469 sudo_password_stdin,
470 su_password,
471 su_password_stdin,
472 disable_sudo,
473 } => {
474 let mut file = load(&path)?;
475 let registro = file
476 .hosts
477 .get_mut(&name)
478 .ok_or_else(|| SshCliError::VpsNotFound(name.clone()))?;
479 if let Some(h) = host {
480 registro.host = h;
481 }
482 if let Some(p) = port {
483 registro.port = p;
484 }
485 if let Some(u) = user {
486 registro.username = u;
487 }
488 if password_stdin {
489 registro.password = SecretString::from(read_secret_stdin()?);
490 } else if let Some(pw) = password {
491 registro.password = SecretString::from(pw);
492 }
493 if let Some(k) = key {
494 validate_key_path_exists(&k)?;
495 registro.key_path = Some(k);
496 }
497 if let Some(kp) = key_passphrase {
498 registro.key_passphrase = Some(SecretString::from(kp));
499 }
500 if let Some(t) = timeout {
501 registro.timeout_ms = t;
502 }
503 if let Some(m) = max_command_chars.or(max_chars) {
504 registro.max_command_chars = parse_char_limit(&m);
505 }
506 if let Some(m) = max_output_chars {
507 registro.max_output_chars = parse_char_limit(&m);
508 }
509 if sudo_password_stdin {
510 registro.sudo_password = Some(SecretString::from(read_secret_stdin()?));
511 } else if let Some(sp) = sudo_password {
512 registro.sudo_password = Some(SecretString::from(sp));
513 }
514 if su_password_stdin {
515 registro.su_password = Some(SecretString::from(read_secret_stdin()?));
516 } else if let Some(sp) = su_password {
517 registro.su_password = Some(SecretString::from(sp));
518 }
519 if let Some(d) = disable_sudo {
520 registro.disable_sudo = d;
521 }
522 registro.validate().map_err(SshCliError::InvalidArgument)?;
523 save(&path, &file)?;
524 crate::output::emit_success(
525 "vps-edited",
526 serde_json::json!({ "name": name }),
527 &crate::i18n::t(crate::i18n::Message::VpsEdited { name: name.clone() }),
528 format == OutputFormat::Json,
529 )?;
530 }
531 VpsAction::Show { name, json } => {
532 let file = load(&path)?;
533 let registro = file
534 .hosts
535 .get(&name)
536 .ok_or_else(|| SshCliError::VpsNotFound(name.clone()))?;
537 if use_json(json, format) {
538 crate::output::print_details_json(registro);
539 } else {
540 crate::output::print_details_text(registro);
541 }
542 }
543 VpsAction::Path => {
544 crate::output::write_line(&path.display().to_string())?;
545 }
546 VpsAction::Doctor { json } => {
547 run_doctor(config_override, use_json(json, format))?;
548 }
549 VpsAction::Export {
550 include_secrets,
551 output,
552 json,
553 i_understand_secrets_on_stdout,
554 } => {
555 run_export(
557 &path,
558 include_secrets,
559 output.as_deref(),
560 json,
561 i_understand_secrets_on_stdout,
562 format,
563 )?;
564 }
565 VpsAction::Import {
566 file,
567 allow_incomplete,
568 } => {
569 run_import(&path, &file, allow_incomplete, format)?;
570 }
571 }
572 Ok(())
573}
574
575fn clear_active_if_name(caminho_config: &Path, name: &str) -> Result<()> {
577 let active = caminho_config
578 .parent()
579 .map(|p| p.join("active"))
580 .unwrap_or_else(|| PathBuf::from("active"));
581 if !active.exists() {
582 return Ok(());
583 }
584 let content = std::fs::read_to_string(&active).unwrap_or_default();
585 if content.trim() == name {
586 let _ = std::fs::remove_file(&active);
587 }
588 Ok(())
589}
590
591fn run_doctor(config_override: Option<PathBuf>, json: bool) -> Result<()> {
592 let layer = winning_layer(config_override.clone())?;
593 let path = layer.path.clone();
594 let exists = path.exists();
595 let file = load(&path)?;
596 let kh = KnownHosts::path_beside_config(&path);
597 let active = path
598 .parent()
599 .map(|p| p.join("active"))
600 .unwrap_or_else(|| PathBuf::from("active"));
601 let perms = if exists {
602 #[cfg(unix)]
603 {
604 use std::os::unix::fs::PermissionsExt;
605 format!(
606 "{:o}",
607 std::fs::metadata(&path)?.permissions().mode() & 0o777
608 )
609 }
610 #[cfg(not(unix))]
611 {
612 "n/a".to_string()
613 }
614 } else {
615 "missing".to_string()
616 };
617
618 let seg = crate::secrets::secrets_status()?;
619 if json {
620 let v = serde_json::json!({
621 "layer": layer.name,
622 "config_path": path.display().to_string(),
623 "exists": exists,
624 "permissions": perms,
625 "schema_version": file.schema_version,
626 "hosts": file.hosts.len(),
627 "known_hosts": kh.display().to_string(),
628 "active_file": active.display().to_string(),
629 "secrets_at_rest": if seg.encryption_active { "encrypted" } else { "plaintext" },
630 "secrets_key_source": seg.source.as_str(),
631 "secrets_key_file": seg.key_file_path.display().to_string(),
632 "secrets_plaintext_opt_out": seg.plaintext_opt_out,
633 "telemetry": false,
634 });
635 crate::output::print_json_value(&v)?;
637 } else {
638 let config_path_s = path.display().to_string();
639 let kh_s = kh.display().to_string();
640 let active_s = active.display().to_string();
641 let key_file_s = seg.key_file_path.display().to_string();
642 crate::output::print_doctor_text(
643 layer.name,
644 &config_path_s,
645 exists,
646 &perms,
647 file.schema_version,
648 file.hosts.len(),
649 &kh_s,
650 &active_s,
651 if seg.encryption_active {
652 "encrypted"
653 } else {
654 "plaintext"
655 },
656 seg.source.as_str(),
657 &key_file_s,
658 seg.plaintext_opt_out,
659 );
660 }
661 Ok(())
662}
663
664fn run_export(
665 path: &PathBuf,
666 include_secrets: bool,
667 output: Option<&str>,
668 json: bool,
669 i_understand_secrets_on_stdout: bool,
670 format: OutputFormat,
671) -> Result<()> {
672 if include_secrets && output.is_none() && !i_understand_secrets_on_stdout {
674 let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
675 if !stdout_is_tty {
676 return Err(SshCliError::InvalidArgument(
677 "refusing --include-secrets to a pipe/non-TTY stdout; \
678 use `--output <file>` (mode 0o600) or pass `--i-understand-secrets-on-stdout`"
679 .into(),
680 )
681 .into());
682 }
683 }
684
685 let file = load(path)?;
686 let mut export = ConfigFile {
687 schema_version: model::CURRENT_SCHEMA_VERSION,
688 hosts: BTreeMap::new(),
689 };
690 for (k, mut v) in file.hosts {
691 if !include_secrets {
692 v.password = SecretString::from(String::new());
694 v.sudo_password = None;
695 v.su_password = None;
696 v.key_passphrase = None;
697 }
698 v.schema_version = model::CURRENT_SCHEMA_VERSION;
699 export.hosts.insert(k, v);
700 }
701
702 let bytes = if json {
704 let hosts_json = crate::output::export_hosts_to_json(&export.hosts, include_secrets);
705 let v = serde_json::json!({
706 "ok": true,
707 "event": "vps-export",
708 "schema_version": export.schema_version,
709 "include_secrets": include_secrets,
710 "hosts": hosts_json,
711 });
712 let text = serde_json::to_string_pretty(&v)?;
713 text.into_bytes()
714 } else {
715 let text = toml::to_string_pretty(&export)?;
716 text.into_bytes()
717 };
718
719 if let Some(out_path) = output {
720 write_atomic(Path::new(out_path), &bytes)?;
721 crate::output::emit_success(
722 "vps-export",
723 serde_json::json!({
724 "path": out_path,
725 "include_secrets": include_secrets,
726 "format": if json { "json" } else { "toml" },
727 }),
728 &crate::i18n::t(crate::i18n::Message::ExportCompleted {
729 path: out_path.to_string(),
730 }),
731 format == OutputFormat::Json,
732 )?;
733 } else {
734 use std::io::Write;
736 let mut out = std::io::stdout().lock();
737 out.write_all(&bytes)?;
738 if !bytes.ends_with(b"\n") {
739 out.write_all(b"\n")?;
740 }
741 }
742 Ok(())
743}
744
745fn parse_import_payload(text: &str) -> SshCliResult<ConfigFile> {
747 let trimmed = text.trim_start();
748 if trimmed.starts_with('{') {
749 parse_import_json(trimmed)
750 } else {
751 toml::from_str(text).map_err(SshCliError::TomlDe)
752 }
753}
754
755fn parse_import_json(text: &str) -> SshCliResult<ConfigFile> {
756 let v: serde_json::Value = serde_json::from_str(text).map_err(SshCliError::Json)?;
757 let hosts_val = v.get("hosts").cloned().ok_or_else(|| {
759 SshCliError::InvalidArgument(
760 "JSON import requires a top-level `hosts` object (vps-export envelope or bare map)"
761 .into(),
762 )
763 })?;
764 let obj = hosts_val.as_object().ok_or_else(|| {
765 SshCliError::InvalidArgument("JSON import `hosts` must be an object map".into())
766 })?;
767 let mut hosts = BTreeMap::new();
768 for (key, entry) in obj {
769 let rec = vps_record_from_export_json(key, entry)?;
770 hosts.insert(key.clone(), rec);
771 }
772 let schema_version = v
773 .get("schema_version")
774 .and_then(|x| x.as_u64())
775 .map(|n| n as u32)
776 .unwrap_or(model::CURRENT_SCHEMA_VERSION);
777 Ok(ConfigFile {
778 schema_version,
779 hosts,
780 })
781}
782
783fn vps_record_from_export_json(key: &str, entry: &serde_json::Value) -> SshCliResult<VpsRecord> {
785 let str_field = |names: &[&str]| -> Option<String> {
786 for n in names {
787 if let Some(s) = entry.get(*n).and_then(|x| x.as_str()) {
788 return Some(s.to_string());
789 }
790 }
791 None
792 };
793 let name = str_field(&["name", "nome"]).unwrap_or_else(|| key.to_string());
794 let host = str_field(&["host"]).unwrap_or_default();
795 let port = entry
796 .get("port")
797 .or_else(|| entry.get("porta"))
798 .and_then(|x| x.as_u64())
799 .unwrap_or(22) as u16;
800 let username = str_field(&["username", "user", "usuario"]).unwrap_or_default();
801 let password = SecretString::from(str_field(&["password", "senha"]).unwrap_or_default());
802 let key_path = str_field(&["key_path"]);
803 let key_passphrase = str_field(&["key_passphrase"]).map(SecretString::from);
804 let timeout_ms = entry
805 .get("timeout_ms")
806 .and_then(|x| x.as_u64())
807 .unwrap_or(model::DEFAULT_TIMEOUT_MS);
808 let max_command_chars = entry
809 .get("max_command_chars")
810 .and_then(|x| x.as_u64())
811 .map(|n| n as usize)
812 .unwrap_or(model::DEFAULT_MAX_COMMAND_CHARS);
813 let max_output_chars = entry
814 .get("max_output_chars")
815 .or_else(|| entry.get("max_chars"))
816 .and_then(|x| x.as_u64())
817 .map(|n| n as usize)
818 .unwrap_or(model::DEFAULT_MAX_OUTPUT_CHARS);
819 let sudo_password = str_field(&["sudo_password", "senha_sudo"]).map(SecretString::from);
820 let su_password = str_field(&["su_password", "senha_su"]).map(SecretString::from);
821 let disable_sudo = entry
822 .get("disable_sudo")
823 .and_then(|x| x.as_bool())
824 .unwrap_or(false);
825 let added_at = str_field(&["added_at", "adicionado_em"])
826 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
827 let schema_version = entry
828 .get("schema_version")
829 .and_then(|x| x.as_u64())
830 .map(|n| n as u32)
831 .unwrap_or(model::CURRENT_SCHEMA_VERSION);
832 Ok(VpsRecord {
833 name,
834 host,
835 port,
836 username,
837 password,
838 key_path,
839 key_passphrase,
840 timeout_ms,
841 max_command_chars,
842 max_output_chars,
843 sudo_password,
844 su_password,
845 disable_sudo,
846 schema_version,
847 added_at,
848 })
849}
850
851fn run_import(
852 path: &PathBuf,
853 file: &Path,
854 allow_incomplete: bool,
855 format: OutputFormat,
856) -> Result<()> {
857 let text = std::fs::read_to_string(file).map_err(SshCliError::Io)?;
858 let imported = parse_import_payload(&text)?;
859 let mut current = load(path)?;
860 let mut imported_count = 0usize;
861 for (k, mut v) in imported.hosts {
862 let name = crate::paths::validate_and_normalize(&k).map_err(|e| {
864 SshCliError::InvalidArgument(format!("invalid VPS name in import '{k}': {e}"))
865 })?;
866 v.name = name.clone();
867 v.normalize_schema();
868 if let Some(ref key) = v.key_path {
869 if !key.trim().is_empty() {
870 validate_key_path_exists(key)?;
871 }
872 }
873 match v.validate() {
874 Ok(()) => {
875 current.hosts.insert(name, v);
876 imported_count += 1;
877 }
878 Err(msg) if allow_incomplete => {
879 tracing::warn!(host = %name, %msg, "import incomplete allowed");
881 current.hosts.insert(name, v);
882 imported_count += 1;
883 }
884 Err(msg) => {
885 let redacted = !v.has_password() && !v.has_key();
887 if redacted {
888 return Err(SshCliError::InvalidArgument(format!(
889 "host '{name}' looks like a redacted export (no password/key). \
890 Use `vps export --include-secrets`, complete with `vps edit`, \
891 or `vps import --allow-incomplete`. Detail: {msg}"
892 ))
893 .into());
894 }
895 return Err(SshCliError::InvalidArgument(format!(
896 "host '{name}' invalid in import: {msg}"
897 ))
898 .into());
899 }
900 }
901 }
902 current.schema_version = model::CURRENT_SCHEMA_VERSION;
903 save(path, ¤t)?;
904 crate::output::emit_success(
905 "vps-import",
906 serde_json::json!({ "imported": imported_count }),
907 &crate::i18n::t(crate::i18n::Message::ImportCompleted),
908 format == OutputFormat::Json,
909 )?;
910 Ok(())
911}
912
913pub async fn run_secrets_command(
915 action: SecretsAction,
916 config_override: Option<PathBuf>,
917 format: OutputFormat,
918) -> Result<()> {
919 crate::secrets::set_config_dir(config_override.clone());
921 match action {
922 SecretsAction::Status { json } => {
923 let seg = crate::secrets::secrets_status()?;
924 let use_json = json || format == OutputFormat::Json;
925 if use_json {
926 let v = serde_json::json!({
927 "encryption_active": seg.encryption_active,
928 "key_source": seg.source.as_str(),
929 "key_file": seg.key_file_path.display().to_string(),
930 "plaintext_opt_out": seg.plaintext_opt_out,
931 "at_rest": if seg.encryption_active { "encrypted" } else { "plaintext" },
932 });
933 crate::output::print_json_value(&v)?;
934 } else {
935 crate::output::print_success(&format!(
936 "at-rest: {} | source: {} | key_file: {} | plaintext_opt_out: {}",
937 if seg.encryption_active {
938 "encrypted"
939 } else {
940 "plaintext"
941 },
942 seg.source.as_str(),
943 seg.key_file_path.display(),
944 seg.plaintext_opt_out
945 ));
946 }
947 Ok(())
948 }
949 SecretsAction::Init {
950 keyring,
951 force,
952 json,
953 } => {
954 let path = resolve_config_path(config_override.clone())?;
958 let hosts_to_reencrypt = if force && path.is_file() {
959 Some(load(&path)?)
960 } else {
961 None
962 };
963
964 let seg = crate::secrets::init_primary_key(keyring, force)?;
965 let mut reencrypted_hosts = 0usize;
966
967 if let Some(file) = hosts_to_reencrypt {
968 reencrypted_hosts = file.hosts.len();
969 save(&path, &file).map_err(|e| {
970 SshCliError::Generic(format!(
971 "primary key was rotated but re-encrypting config failed: {e}; \
972 restore secrets.key.bak if present and re-run `secrets reencrypt`"
973 ))
974 })?;
975 }
976
977 let use_json = json || format == OutputFormat::Json;
978 crate::output::emit_success(
979 "secrets-init",
980 serde_json::json!({
981 "key_source": seg.source.as_str(),
982 "key_file": seg.key_file_path.display().to_string(),
983 "reencrypted_hosts": reencrypted_hosts,
984 "force": force,
985 }),
986 &crate::i18n::t(crate::i18n::Message::PrimaryKeyReady {
987 source: seg.source.as_str().to_string(),
988 key_file: seg.key_file_path.display().to_string(),
989 }),
990 use_json,
991 )?;
992 Ok(())
993 }
994 SecretsAction::Reencrypt { json } => {
995 let path = resolve_config_path(config_override)?;
996 run_reencrypt(&path, json || format == OutputFormat::Json)?;
997 Ok(())
998 }
999 }
1000}
1001
1002fn run_reencrypt(path: &PathBuf, json: bool) -> Result<()> {
1004 let (key, _source) = crate::secrets::ensure_key_for_write()?;
1005 if key.is_none() {
1006 return Err(SshCliError::InvalidArgument(
1007 "no primary-key; run `ssh-cli secrets init` or unset SSH_CLI_ALLOW_PLAINTEXT_SECRETS"
1008 .to_string(),
1009 )
1010 .into());
1011 }
1012 if let Some(mut k) = key {
1013 use zeroize::Zeroize;
1014 k.zeroize();
1015 }
1016 let file = load(path)?;
1017 let hosts = file.hosts.len();
1018 save(path, &file)?;
1019 crate::output::emit_success(
1020 "secrets-reencrypt",
1021 serde_json::json!({ "hosts": hosts }),
1022 &crate::i18n::t(crate::i18n::Message::ReencryptCompleted { hosts }),
1023 json,
1024 )?;
1025 Ok(())
1026}
1027
1028fn emit_auto_key_if_needed(json: bool) -> Result<()> {
1029 if crate::secrets::take_auto_key_created() {
1030 let path = crate::secrets::secrets_key_path().unwrap_or_default();
1031 crate::output::emit_success(
1032 "secrets-key-auto-created",
1033 serde_json::json!({
1034 "key_file": path.display().to_string(),
1035 "key_source": "xdg_file",
1036 }),
1037 &format!("primary-key auto-created at {}", path.display()),
1038 json,
1039 )?;
1040 }
1041 Ok(())
1042}
1043
1044pub async fn run_connect(
1046 name: &str,
1047 config_override: Option<PathBuf>,
1048 format: OutputFormat,
1049) -> Result<()> {
1050 let path = resolve_config_path(config_override)?;
1051 let file = load(&path)?;
1052 if !file.hosts.contains_key(name) {
1053 return Err(SshCliError::VpsNotFound(name.to_string()).into());
1054 }
1055
1056 let active_file = path
1057 .parent()
1058 .map(|p| p.join("active"))
1059 .unwrap_or_else(|| PathBuf::from("active"));
1060 if let Some(parent_dir) = active_file.parent() {
1061 std::fs::create_dir_all(parent_dir)?;
1062 }
1063 let parent_dir = active_file
1065 .parent()
1066 .map(Path::to_path_buf)
1067 .unwrap_or_else(|| PathBuf::from("."));
1068 let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir)?;
1069 tmp.write_all(name.as_bytes())?;
1070 tmp.as_file().sync_data()?;
1071 tmp.persist(&active_file)
1072 .map_err(|e| SshCliError::Io(e.error))?;
1073 crate::output::emit_success(
1074 "vps-connected",
1075 serde_json::json!({ "name": name }),
1076 &crate::i18n::t(crate::i18n::Message::VpsActiveSelected {
1077 name: name.to_string(),
1078 }),
1079 format == OutputFormat::Json,
1080 )?;
1081 Ok(())
1082}
1083
1084pub fn find_by_name(
1086 config_override: Option<PathBuf>,
1087 name: &str,
1088) -> SshCliResult<Option<VpsRecord>> {
1089 let path = resolve_config_path(config_override)?;
1090 let file = load(&path)?;
1091 Ok(file.hosts.get(name).cloned())
1092}
1093
1094pub fn read_active_vps(config_override: Option<PathBuf>) -> SshCliResult<Option<String>> {
1096 let path = resolve_config_path(config_override)?;
1097 let active_file = path
1098 .parent()
1099 .map(|p| p.join("active"))
1100 .unwrap_or_else(|| PathBuf::from("active"));
1101 if !active_file.exists() {
1102 return Ok(None);
1103 }
1104 let name = std::fs::read_to_string(&active_file)?;
1105 Ok(Some(name.trim().to_string()))
1106}
1107
1108pub fn build_connection_config(
1110 vps: &VpsRecord,
1111 config_toml: Option<&Path>,
1112 replace_host_key: bool,
1113) -> ConnectionConfig {
1114 let known_hosts_path = config_toml.map(KnownHosts::path_beside_config);
1115 ConnectionConfig {
1116 host: vps.host.clone(),
1117 port: vps.port,
1118 username: vps.username.clone(),
1119 password: vps.password.clone(),
1120 key_path: vps.key_path.clone(),
1121 key_passphrase: vps.key_passphrase.clone(),
1122 timeout_ms: vps.timeout_ms,
1123 known_hosts_path,
1124 replace_host_key,
1125 }
1126}
1127
1128#[derive(Debug, Default, Clone)]
1130pub struct ExecOptions {
1131 pub password: Option<String>,
1133 pub sudo_password: Option<String>,
1135 pub su_password: Option<String>,
1137 pub timeout: Option<u64>,
1139 pub key: Option<String>,
1141 pub key_passphrase: Option<String>,
1143 pub description: Option<String>,
1145 pub replace_host_key: bool,
1147 pub disable_sudo: bool,
1149}
1150
1151pub async fn run_exec(
1153 vps_name: &str,
1154 command: &str,
1155 config_override: Option<PathBuf>,
1156 format: OutputFormat,
1157 json: bool,
1158 opts: ExecOptions,
1159) -> Result<()> {
1160 if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1161 return Err(anyhow::anyhow!(crate::i18n::t(
1162 crate::i18n::Message::OperationCancelled
1163 )));
1164 }
1165 let path = resolve_config_path(config_override)?;
1166 let file = load(&path)?;
1167 let vps_base = file
1168 .hosts
1169 .get(vps_name)
1170 .ok_or_else(|| SshCliError::VpsNotFound(vps_name.to_string()))?;
1171
1172 let mut vps = vps_base.clone();
1173 apply_overrides(
1174 &mut vps,
1175 opts.password,
1176 opts.sudo_password,
1177 opts.su_password,
1178 opts.timeout,
1179 opts.key,
1180 opts.key_passphrase,
1181 );
1182 let cmd = append_description(command, opts.description.as_deref());
1183 validate_command_length(&cmd, vps.max_command_chars)?;
1184 let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
1185 let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
1186 run_exec_with_client(&vps, &cmd, client, format, json).await
1187}
1188
1189pub async fn run_exec_with_client(
1191 vps: &VpsRecord,
1192 command: &str,
1193 mut client: Box<dyn SshClientTrait>,
1194 format: OutputFormat,
1195 json: bool,
1196) -> Result<()> {
1197 if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1198 return Err(anyhow::anyhow!(crate::i18n::t(
1199 crate::i18n::Message::OperationCancelled
1200 )));
1201 }
1202 let max_out = effective_limit(vps.max_output_chars);
1203 let output = client.run_command(command, max_out, None).await?;
1204 client.disconnect().await?;
1205 if format == OutputFormat::Json || json {
1206 output::print_execution_output_json(&output);
1207 } else {
1208 output::print_execution_output(&output);
1209 }
1210 if let Some(code) = output.exit_code {
1211 if code != 0 {
1212 return Err(SshCliError::CommandFailed {
1213 exit_code: code,
1214 stderr: output.stderr.clone(),
1215 }
1216 .into());
1217 }
1218 }
1219 Ok(())
1220}
1221
1222pub async fn run_sudo_exec(
1224 vps_name: &str,
1225 command: &str,
1226 config_override: Option<PathBuf>,
1227 format: OutputFormat,
1228 json: bool,
1229 opts: ExecOptions,
1230) -> Result<()> {
1231 if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1232 return Err(anyhow::anyhow!(crate::i18n::t(
1233 crate::i18n::Message::OperationCancelled
1234 )));
1235 }
1236 let path = resolve_config_path(config_override)?;
1237 let file = load(&path)?;
1238 let vps_base = file
1239 .hosts
1240 .get(vps_name)
1241 .ok_or_else(|| SshCliError::VpsNotFound(vps_name.to_string()))?;
1242
1243 let mut vps = vps_base.clone();
1244 apply_overrides(
1245 &mut vps,
1246 opts.password.clone(),
1247 opts.sudo_password.clone(),
1248 opts.su_password.clone(),
1249 opts.timeout,
1250 opts.key.clone(),
1251 opts.key_passphrase.clone(),
1252 );
1253 if opts.disable_sudo || vps.disable_sudo {
1254 return Err(SshCliError::SudoDisabled.into());
1255 }
1256 let cmd = append_description(command, opts.description.as_deref());
1257 validate_command_length(&cmd, vps.max_command_chars)?;
1258 let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
1259 let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
1260 run_sudo_exec_with_client(&vps, &cmd, client, format, json).await
1261}
1262
1263pub async fn run_sudo_exec_with_client(
1265 vps: &VpsRecord,
1266 command: &str,
1267 mut client: Box<dyn SshClientTrait>,
1268 format: OutputFormat,
1269 json: bool,
1270) -> Result<()> {
1271 if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1272 return Err(anyhow::anyhow!(crate::i18n::t(
1273 crate::i18n::Message::OperationCancelled
1274 )));
1275 }
1276 if vps.disable_sudo {
1277 return Err(SshCliError::SudoDisabled.into());
1278 }
1279 let pack = pack_sudo(command, vps.sudo_password.as_ref());
1280 let max_out = effective_limit(vps.max_output_chars);
1281 let output = client
1282 .run_command(&pack.command, max_out, pack.stdin)
1283 .await?;
1284 client.disconnect().await?;
1285 if format == OutputFormat::Json || json {
1286 output::print_execution_output_json(&output);
1287 } else {
1288 output::print_execution_output(&output);
1289 }
1290 if let Some(code) = output.exit_code {
1291 if code != 0 {
1292 return Err(SshCliError::CommandFailed {
1293 exit_code: code,
1294 stderr: output.stderr.clone(),
1295 }
1296 .into());
1297 }
1298 }
1299 Ok(())
1300}
1301
1302pub async fn run_su_exec(
1304 vps_name: &str,
1305 command: &str,
1306 config_override: Option<PathBuf>,
1307 format: OutputFormat,
1308 json: bool,
1309 opts: ExecOptions,
1310) -> Result<()> {
1311 if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1312 return Err(anyhow::anyhow!(crate::i18n::t(
1313 crate::i18n::Message::OperationCancelled
1314 )));
1315 }
1316 let path = resolve_config_path(config_override)?;
1317 let file = load(&path)?;
1318 let vps_base = file
1319 .hosts
1320 .get(vps_name)
1321 .ok_or_else(|| SshCliError::VpsNotFound(vps_name.to_string()))?;
1322
1323 let mut vps = vps_base.clone();
1324 apply_overrides(
1325 &mut vps,
1326 opts.password,
1327 opts.sudo_password,
1328 opts.su_password,
1329 opts.timeout,
1330 opts.key,
1331 opts.key_passphrase,
1332 );
1333 if opts.disable_sudo || vps.disable_sudo {
1334 return Err(SshCliError::SudoDisabled.into());
1335 }
1336 let su_password = vps.su_password.clone().ok_or(SshCliError::SuPasswordMissing)?;
1337 let cmd = append_description(command, opts.description.as_deref());
1338 validate_command_length(&cmd, vps.max_command_chars)?;
1339 let pack = pack_su(&cmd, &su_password);
1340 let cfg = build_connection_config(&vps, Some(&path), opts.replace_host_key);
1341 let mut client: Box<dyn SshClientTrait> =
1342 <SshClient as SshClientTrait>::connect(cfg).await?;
1343 let max_out = effective_limit(vps.max_output_chars);
1344 let output = client
1345 .run_command(&pack.command, max_out, pack.stdin)
1346 .await?;
1347 client.disconnect().await?;
1348 if format == OutputFormat::Json || json {
1349 output::print_execution_output_json(&output);
1350 } else {
1351 output::print_execution_output(&output);
1352 }
1353 if let Some(code) = output.exit_code {
1354 if code != 0 {
1355 return Err(SshCliError::CommandFailed {
1356 exit_code: code,
1357 stderr: output.stderr.clone(),
1358 }
1359 .into());
1360 }
1361 }
1362 Ok(())
1363}
1364
1365#[allow(clippy::too_many_arguments)]
1368pub async fn run_health_check(
1369 vps_name: Option<&str>,
1370 config_override: Option<PathBuf>,
1371 format: OutputFormat,
1372 json_local: bool,
1373 password_override: Option<String>,
1374 timeout_override: Option<u64>,
1375 key_override: Option<String>,
1376 key_passphrase_override: Option<String>,
1377 replace_host_key: bool,
1378) -> Result<()> {
1379 if json_local || format == OutputFormat::Json {
1381 crate::output::set_json_errors(true);
1382 }
1383 if crate::signals::is_cancelled() || crate::signals::is_terminated() {
1384 return Err(anyhow::anyhow!(crate::i18n::t(
1385 crate::i18n::Message::OperationCancelled
1386 )));
1387 }
1388 let resolved_name: String = match vps_name {
1389 Some(n) => n.to_string(),
1390 None => {
1391 let ativa = read_active_vps(config_override.clone())?;
1393 ativa.ok_or(SshCliError::NoActiveVps)?
1394 }
1395 };
1396 let path = resolve_config_path(config_override)?;
1397 let file = load(&path)?;
1398 let vps_base = file
1399 .hosts
1400 .get(&resolved_name)
1401 .ok_or_else(|| SshCliError::VpsNotFound(resolved_name.clone()))?;
1402
1403 let mut vps = vps_base.clone();
1404 apply_overrides(
1407 &mut vps,
1408 password_override,
1409 None,
1410 None,
1411 timeout_override,
1412 key_override,
1413 key_passphrase_override,
1414 );
1415 let cfg = build_connection_config(&vps, Some(&path), replace_host_key);
1417 let start = std::time::Instant::now();
1418 let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
1419 let latency_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1420 client.disconnect().await?;
1421
1422 if use_json(json_local, format) {
1423 output::print_health_check_json(&resolved_name, latency_ms);
1424 } else {
1425 output::print_health_check(&resolved_name, latency_ms);
1426 }
1427 Ok(())
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432 use super::*;
1433 use secrecy::ExposeSecret;
1434 use tempfile::TempDir;
1435
1436 fn reg_min() -> VpsRecord {
1437 VpsRecord::new(
1438 "srv".into(),
1439 "host.example.com".into(),
1440 2222,
1441 "admin".into(),
1442 SecretString::from("pass".to_string()),
1443 None,
1444 None,
1445 Some(60_000),
1446 Some(1_000),
1447 Some(50_000),
1448 None,
1449 None,
1450 false,
1451 )
1452 }
1453
1454 #[test]
1455 fn empty_file_serializes_with_schema() {
1456 let arq = ConfigFile {
1457 schema_version: model::CURRENT_SCHEMA_VERSION,
1458 hosts: BTreeMap::new(),
1459 };
1460 let text = toml::to_string(&arq).unwrap();
1461 assert!(text.contains("schema_version = 3"));
1462 }
1463
1464 #[test]
1465 fn parse_limit_none() {
1466 assert_eq!(parse_char_limit("none"), 0);
1467 assert_eq!(parse_char_limit("0"), 0);
1468 assert_eq!(parse_char_limit("1000"), 1000);
1469 }
1470
1471 #[test]
1472 fn build_config_copies_fields() {
1473 let registro = reg_min();
1474 let cfg = build_connection_config(®istro, None, false);
1475 assert_eq!(cfg.host, "host.example.com");
1476 assert_eq!(cfg.port, 2222);
1477 assert_eq!(cfg.username, "admin");
1478 assert_eq!(cfg.timeout_ms, 60_000);
1479 assert!(cfg.known_hosts_path.is_none());
1480 }
1481
1482 #[test]
1483 #[serial_test::serial]
1484 fn atomic_save_roundtrip() {
1485 let tmp = TempDir::new().unwrap();
1486 crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
1487 unsafe {
1498 std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
1499 }
1500 let path = tmp.path().join("config.toml");
1501 let mut arq = ConfigFile {
1502 schema_version: 2,
1503 hosts: BTreeMap::new(),
1504 };
1505 arq.hosts.insert("a".into(), reg_min());
1506 save(&path, &arq).unwrap();
1507 let loaded = load(&path).unwrap();
1508 assert_eq!(loaded.hosts.len(), 1);
1509 assert_eq!(loaded.hosts["a"].password.expose_secret(), "pass");
1510 #[cfg(unix)]
1511 {
1512 use std::os::unix::fs::PermissionsExt;
1513 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1514 assert_eq!(mode, 0o600);
1515 let lock = path.with_extension("toml.lock");
1516 if lock.exists() {
1517 let lm = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
1518 assert_eq!(lm, 0o600);
1519 }
1520 }
1521 unsafe {
1532 std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
1533 }
1534 crate::secrets::set_config_dir(None);
1535 }
1536
1537 #[test]
1538 fn resolve_config_path_with_dir_override() {
1539 let result = resolve_config_path(Some(PathBuf::from("/tmp/test-dir")));
1540 assert_eq!(
1541 result.unwrap(),
1542 PathBuf::from("/tmp/test-dir/config.toml")
1543 );
1544 }
1545
1546 #[test]
1547 fn validate_long_command() {
1548 let err = validate_command_length(&"x".repeat(20), 10).unwrap_err();
1549 assert!(matches!(err, SshCliError::CommandTooLong { .. }));
1550 }
1551
1552 #[test]
1553 fn pack_sudo_integrated() {
1554 let pack = pack_sudo("ls -la", None);
1555 assert_eq!(pack.command, "sudo -n sh -c 'ls -la'");
1556 assert!(pack.stdin.is_none());
1557 }
1558
1559 #[test]
1560 fn apply_overrides_password() {
1561 let mut v = reg_min();
1562 apply_overrides(
1563 &mut v,
1564 Some("nova".into()),
1565 Some("sudo".into()),
1566 None,
1567 Some(1000),
1568 Some("/k".into()),
1569 None,
1570 );
1571 assert_eq!(v.password.expose_secret(), "nova");
1572 assert_eq!(v.timeout_ms, 1000);
1573 assert_eq!(v.key_path.as_deref(), Some("/k"));
1574 }
1575
1576 #[tokio::test]
1577 async fn sudo_exec_with_client_ok() {
1578 use crate::ssh::client::mocks::MockSshClient;
1579 use crate::ssh::client::ExecutionOutput;
1580 let mut mock = MockSshClient::new();
1581 mock.expect_run_command().returning(|c, _, stdin| {
1582 assert!(c.contains("sudo -n sh -c"));
1583 assert!(stdin.is_none());
1584 Ok(ExecutionOutput {
1585 stdout: "ok".into(),
1586 stderr: String::new(),
1587 exit_code: Some(0),
1588 truncated_stdout: false,
1589 truncated_stderr: false,
1590 duration_ms: 1,
1591 })
1592 });
1593 mock.expect_disconnect().returning(|| Ok(()));
1594
1595 let vps = reg_min();
1596 run_sudo_exec_with_client(&vps, "id", Box::new(mock), OutputFormat::Text, false)
1597 .await
1598 .unwrap();
1599 }
1600}