Skip to main content

ssh_cli/vps/
crud.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP: VPS CRUD dispatcher extracted from vps/mod (SRP; line budget).
3#![forbid(unsafe_code)]
4//! Dispatcher for `ssh-cli vps …` subcommands.
5
6use super::config_io::{load, lock_config, resolve_config_path, validate_key_path_exists};
7use super::doctor::run_doctor_with_optional_probe;
8use super::health::run_health_check;
9use super::import_export::{run_export, run_import};
10use super::model::{self, VpsRecord};
11use super::secrets_cmd::take_auto_key_meta;
12use super::selection::HostSelection;
13use super::{read_secret_stdin, use_json};
14use crate::cli::{OutputFormat, VpsAction};
15use crate::errors::SshCliError;
16use anyhow::Result;
17use secrecy::SecretString;
18use std::path::{Path, PathBuf};
19
20/// Dispatcher dos subcomandos `vps`.
21pub async fn run_vps_command(
22    action: VpsAction,
23    config_override: Option<PathBuf>,
24    format: OutputFormat,
25) -> Result<()> {
26    let path = resolve_config_path(config_override.as_deref())?;
27
28    match action {
29        VpsAction::Add {
30            name,
31            host,
32            port,
33            user,
34            password,
35            password_stdin,
36            key,
37            key_passphrase,
38            key_passphrase_stdin,
39            use_agent,
40            agent_socket,
41            timeout,
42            max_command_chars,
43            max_output_chars,
44            max_chars,
45            sudo_password,
46            sudo_password_stdin,
47            su_password,
48            su_password_stdin,
49            disable_sudo,
50            tags,
51            tls,
52            tls_sni,
53            tls_client_cert,
54            tls_client_key,
55            check,
56        } => {
57            // GAP-SSH-VAL-001: validate na fronteira de escrita.
58            let name = crate::paths::validate_and_normalize(&name)
59                .map_err(|e| SshCliError::InvalidArgument(format!("invalid VPS name: {e}")))?;
60            let name_key = name.as_str().to_owned();
61            // Early, advisory duplicate check: fails before any stdin prompt. The
62            // authoritative check happens again under the config lock, below.
63            if load(&path)?.hosts.contains_key(&name_key) {
64                return Err(SshCliError::VpsDuplicate(name_key).into());
65            }
66            // Stdin can only be drained once, so ANY two `--*-stdin` flags conflict.
67            // D13: the old guard was `password_stdin && (sudo || su)`, which let
68            // `--sudo-password-stdin --su-password-stdin` through: the first read
69            // consumed stdin and the second silently produced an empty secret.
70            // Counting is the invariant; enumerating pairs is not.
71            let stdin_secrets = usize::from(password_stdin)
72                + usize::from(key_passphrase_stdin)
73                + usize::from(sudo_password_stdin)
74                + usize::from(su_password_stdin);
75            if stdin_secrets > 1 {
76                return Err(SshCliError::InvalidArgument(
77                    "only one --*-stdin per one-shot invocation (stdin is drained once); \
78                     use vps edit for the remaining secrets"
79                        .into(),
80                )
81                .into());
82            }
83            let password = if password_stdin {
84                read_secret_stdin()?
85            } else {
86                SecretString::from(password.unwrap_or_default())
87            };
88            let key_passphrase = if key_passphrase_stdin {
89                Some(read_secret_stdin()?)
90            } else {
91                key_passphrase.map(SecretString::from)
92            };
93            let sudo_s = if sudo_password_stdin {
94                Some(read_secret_stdin()?)
95            } else {
96                sudo_password.map(SecretString::from)
97            };
98            let su_s = if su_password_stdin {
99                Some(read_secret_stdin()?)
100            } else {
101                su_password.map(SecretString::from)
102            };
103            let key = key.map(|p| p.to_string_lossy().into_owned());
104            if let Some(ref k) = key {
105                validate_key_path_exists(k)?;
106            }
107            // legacy max_chars → command if max_command was not set explicitly
108            // (clap already parses `none`/`0`/decimal via parse_cli_char_limit)
109            let max_cmd = max_command_chars
110                .or(max_chars)
111                .unwrap_or(model::DEFAULT_MAX_COMMAND_CHARS);
112            let max_out = max_output_chars.unwrap_or(model::DEFAULT_MAX_OUTPUT_CHARS);
113            // GAP-AUD-009: timeout is milliseconds; warn agents that use "5" meaning seconds.
114            if timeout > 0 && timeout < 1000 {
115                crate::output::print_warning_fmt(format_args!(
116                    "--timeout {timeout} is only {timeout}ms (< 1s); did you mean seconds? Use e.g. --timeout 5000 for 5s"
117                ));
118            }
119            let mut record = VpsRecord::try_new(
120                name.as_str(),
121                host,
122                port,
123                user,
124                password,
125                key,
126                key_passphrase,
127                Some(timeout),
128                Some(max_cmd),
129                Some(max_out),
130                sudo_s,
131                su_s,
132                disable_sudo,
133            )
134            .map_err(SshCliError::InvalidArgument)?;
135            // G-E2E-19: registry auth triplo (password | key | agent).
136            if use_agent {
137                record.use_agent = true;
138                record.password = SecretString::from(String::new());
139                record.key_path = None;
140                record.key_passphrase = None;
141                record.agent_socket = agent_socket.map(|p| p.to_string_lossy().into_owned());
142            }
143            // G-O2: tags for fleet selection (dedupe preserve order).
144            let tag_list = crate::vps::selection::dedupe_host_names(tags);
145            record
146                .set_tags_from_raw(tag_list)
147                .map_err(SshCliError::from)?;
148            record.tls = tls;
149            record.tls_sni = tls_sni;
150            record.tls_client_cert = tls_client_cert.map(|p| p.to_string_lossy().into_owned());
151            record.tls_client_key = tls_client_key.map(|p| p.to_string_lossy().into_owned());
152            if record.tls {
153                // Validate options early (SNI empty / partial mTLS).
154                let sni = record
155                    .tls_sni
156                    .as_deref()
157                    .filter(|s| !s.trim().is_empty())
158                    .unwrap_or(record.host.as_str());
159                let _ = crate::tls::TlsConnectOptions::try_new(
160                    sni,
161                    record
162                        .tls_client_cert
163                        .as_ref()
164                        .map(std::path::PathBuf::from),
165                    record.tls_client_key.as_ref().map(std::path::PathBuf::from),
166                )?;
167            }
168            // GAP-SSH-VAL-002 / VAL-003: full domain validation on the write-path.
169            record.validate().map_err(SshCliError::from)?;
170            // Read-modify-write under one lock: a concurrent `vps add` that loaded the
171            // same snapshot would otherwise overwrite this host on save.
172            let guard = lock_config(&path)?;
173            let mut file = load(&path)?;
174            if file.hosts.contains_key(&name_key) {
175                return Err(SshCliError::VpsDuplicate(name_key).into());
176            }
177            file.hosts.insert(name_key.clone(), record);
178            file.schema_version = model::CURRENT_SCHEMA_VERSION;
179            guard.save(&path, &file)?;
180            // Release before `--check`: the lock must never span an SSH round trip.
181            drop(guard);
182            // G-E2E-04 / one-shot: single stdout document (fold auto-key into vps-added).
183            // Workload: local single-file CRUD — sequential justified (≪ SSH RTT).
184            let auto_key = take_auto_key_meta();
185            let mut data = serde_json::json!({ "name": name_key });
186            if let Some(ref meta) = auto_key {
187                data["secrets_key_auto_created"] = serde_json::Value::Bool(true);
188                data["key_file"] = serde_json::Value::String(meta.key_file.clone());
189                data["key_source"] = serde_json::Value::String(meta.key_source.to_owned());
190            } else {
191                data["secrets_key_auto_created"] = serde_json::Value::Bool(false);
192            }
193            let msg = if let Some(ref meta) = auto_key {
194                format!(
195                    "{}; primary-key auto-created at {}",
196                    crate::i18n::t(crate::i18n::Message::VpsAdded {
197                        name: name_key.clone(),
198                    }),
199                    meta.key_file
200                )
201            } else {
202                crate::i18n::t(crate::i18n::Message::VpsAdded {
203                    name: name_key.clone(),
204                })
205            };
206            crate::output::emit_success("vps-added", data, &msg, format == OutputFormat::Json)?;
207            if check {
208                run_health_check(crate::vps::HealthCheckRequest {
209                    selection: HostSelection::Single(name.clone()),
210                    config_override,
211                    format,
212                    json_local: false,
213                    password_override: None,
214                    timeout_override: None,
215                    key_override: None,
216                    key_passphrase_override: None,
217                    replace_host_key: false,
218                    // `vps add --check` probes the host the caller just registered,
219                    // so the name came from this invocation's argv.
220                    host_source: crate::json_wire::TargetSource::Argv,
221                })
222                .await?;
223            }
224        }
225        VpsAction::List { json, tags } => {
226            let file = load(&path)?;
227            let records: Vec<_> = if tags.is_empty() {
228                file.hosts.values().cloned().collect()
229            } else {
230                {
231                    let wanted = crate::domain::try_tags(&tags)
232                        .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
233                    file.hosts
234                        .values()
235                        .filter(|r| r.has_any_tag(&wanted))
236                        .cloned()
237                        .collect()
238                }
239            };
240            // GAP-SSH-IO-001: respeitar format global.
241            if use_json(json, format) {
242                crate::output::print_list_json(&records)?;
243            } else {
244                crate::output::print_list_text(&records);
245            }
246        }
247        VpsAction::Remove { name } => {
248            // Lock spans load → mutate → save so a concurrent edit is not resurrected.
249            let guard = lock_config(&path)?;
250            let mut file = load(&path)?;
251            if !file.hosts.contains_key(&name) {
252                return Err(SshCliError::VpsNotFound(name).into());
253            }
254            // C2: previewed *after* the existence check, so the plan never promises
255            // a removal that the real run would reject with exit 66. A dry-run that
256            // reports success for a host that does not exist is worse than no
257            // preview, because the agent then treats the failure as a regression.
258            if crate::cli::dry_run_stop(
259                "vps-remove",
260                &[
261                    ("name", serde_json::json!(name)),
262                    ("config_path", serde_json::json!(path.display().to_string())),
263                ],
264            )? {
265                return Ok(());
266            }
267            file.hosts.remove(&name);
268            guard.save(&path, &file)?;
269            drop(guard);
270            // GAP-SSH-STATE-001: clear orphan active marker.
271            clear_active_if_name(&path, &name)?;
272            crate::output::emit_success(
273                "vps-removed",
274                serde_json::json!({ "name": name }),
275                &crate::i18n::t(crate::i18n::Message::VpsRemoved { name: name.clone() }),
276                format == OutputFormat::Json,
277            )?;
278        }
279        VpsAction::Edit {
280            name,
281            host,
282            port,
283            user,
284            password,
285            password_stdin,
286            key,
287            key_passphrase,
288            key_passphrase_stdin,
289            use_agent,
290            agent_socket,
291            timeout,
292            max_command_chars,
293            max_output_chars,
294            max_chars,
295            sudo_password,
296            sudo_password_stdin,
297            su_password,
298            su_password_stdin,
299            disable_sudo,
300            enable_sudo,
301            tls,
302            no_tls,
303            tls_sni,
304            tls_client_cert,
305            tls_client_key,
306        } => {
307            // D13: `edit` had NO mutual-exclusion guard at all and read stdin up to
308            // three times in a row. Only the first read saw data; the rest silently
309            // stored empty secrets. Same invariant as `add`: stdin drains once.
310            let stdin_secrets = usize::from(password_stdin)
311                + usize::from(key_passphrase_stdin)
312                + usize::from(sudo_password_stdin)
313                + usize::from(su_password_stdin);
314            if stdin_secrets > 1 {
315                return Err(SshCliError::InvalidArgument(
316                    "only one --*-stdin per one-shot invocation (stdin is drained once); \
317                     run vps edit again for the remaining secrets"
318                        .into(),
319                )
320                .into());
321            }
322            // Stdin secrets are read *before* the lock: a blocking read must never hold
323            // it, or a concurrent one-shot would wait on the operator's terminal.
324            let password_stdin_value = if password_stdin {
325                Some(read_secret_stdin()?)
326            } else {
327                None
328            };
329            let key_passphrase_stdin_value = if key_passphrase_stdin {
330                Some(read_secret_stdin()?)
331            } else {
332                None
333            };
334            let sudo_stdin_value = if sudo_password_stdin {
335                Some(read_secret_stdin()?)
336            } else {
337                None
338            };
339            let su_stdin_value = if su_password_stdin {
340                Some(read_secret_stdin()?)
341            } else {
342                None
343            };
344            // Lock spans load → mutate → save (lost-update on concurrent edits).
345            let guard = lock_config(&path)?;
346            let mut file = load(&path)?;
347            let record = file
348                .hosts
349                .get_mut(&name)
350                .ok_or(SshCliError::VpsNotFound(name.clone()))?;
351            use crate::domain::{CharLimit, KeyPath, SshHost, SshPort, SshUser, TimeoutMs};
352            if let Some(h) = host {
353                record.host =
354                    SshHost::try_new(h).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
355            }
356            if let Some(p) = port {
357                record.port =
358                    SshPort::try_new(p).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
359            }
360            if let Some(u) = user {
361                record.username =
362                    SshUser::try_new(u).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
363            }
364            if use_agent {
365                record.use_agent = true;
366                record.password = SecretString::from(String::new());
367                record.key_path = None;
368                record.key_passphrase = None;
369                if let Some(s) = agent_socket {
370                    record.agent_socket = Some(s.to_string_lossy().into_owned());
371                }
372            } else {
373                if let Some(pw) = password_stdin_value {
374                    record.password = pw;
375                    record.use_agent = false;
376                } else if let Some(pw) = password {
377                    record.password = SecretString::from(pw);
378                    record.use_agent = false;
379                }
380                if let Some(k) = key {
381                    let k = k.to_string_lossy().into_owned();
382                    validate_key_path_exists(&k)?;
383                    record.key_path = Some(
384                        KeyPath::try_new(k)
385                            .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?,
386                    );
387                    record.use_agent = false;
388                }
389                if let Some(kp) = key_passphrase_stdin_value {
390                    record.key_passphrase = Some(kp);
391                } else if let Some(kp) = key_passphrase {
392                    record.key_passphrase = Some(SecretString::from(kp));
393                }
394                if let Some(s) = agent_socket {
395                    record.agent_socket = Some(s.to_string_lossy().into_owned());
396                }
397            }
398            if let Some(t) = timeout {
399                record.timeout_ms = TimeoutMs::try_new(t)
400                    .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
401            }
402            if let Some(m) = max_command_chars.or(max_chars) {
403                record.max_command_chars = CharLimit::try_new(m)
404                    .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
405            }
406            if let Some(m) = max_output_chars {
407                record.max_output_chars = CharLimit::try_new(m)
408                    .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
409            }
410            if let Some(sp) = sudo_stdin_value {
411                record.sudo_password = Some(sp);
412            } else if let Some(sp) = sudo_password {
413                record.sudo_password = Some(SecretString::from(sp));
414            }
415            if let Some(sp) = su_stdin_value {
416                record.su_password = Some(sp);
417            } else if let Some(sp) = su_password {
418                record.su_password = Some(SecretString::from(sp));
419            }
420            // G-10: tri-state edit without Option<bool> — exclusive SetTrue flags.
421            if disable_sudo {
422                record.disable_sudo = true;
423            } else if enable_sudo {
424                record.disable_sudo = false;
425            }
426            if tls {
427                record.tls = true;
428            } else if no_tls {
429                record.tls = false;
430            }
431            if let Some(sni) = tls_sni {
432                record.tls_sni = Some(sni);
433            }
434            if let Some(c) = tls_client_cert {
435                record.tls_client_cert = Some(c.to_string_lossy().into_owned());
436            }
437            if let Some(k) = tls_client_key {
438                record.tls_client_key = Some(k.to_string_lossy().into_owned());
439            }
440            if record.tls {
441                let sni = record
442                    .tls_sni
443                    .as_deref()
444                    .filter(|s| !s.trim().is_empty())
445                    .unwrap_or(record.host.as_str());
446                let _ = crate::tls::TlsConnectOptions::try_new(
447                    sni,
448                    record
449                        .tls_client_cert
450                        .as_ref()
451                        .map(std::path::PathBuf::from),
452                    record.tls_client_key.as_ref().map(std::path::PathBuf::from),
453                )?;
454            }
455            record.validate().map_err(SshCliError::from)?;
456            guard.save(&path, &file)?;
457            drop(guard);
458            crate::output::emit_success(
459                "vps-edited",
460                serde_json::json!({ "name": name }),
461                &crate::i18n::t(crate::i18n::Message::VpsEdited { name: name.clone() }),
462                format == OutputFormat::Json,
463            )?;
464        }
465        VpsAction::Show { name, json } => {
466            let file = load(&path)?;
467            let record = file
468                .hosts
469                .get(&name)
470                .ok_or(SshCliError::VpsNotFound(name.clone()))?;
471            if use_json(json, format) {
472                crate::output::print_details_json(record)?;
473            } else {
474                crate::output::print_details_text(record);
475            }
476        }
477        VpsAction::Path => {
478            // G-AUD-02: JSON envelope when format is Json; plain path in Text.
479            if use_json(false, format) {
480                let path_s = path.display().to_string();
481                crate::output::emit_success(
482                    "vps-path",
483                    serde_json::json!({ "path": path_s }),
484                    &path_s,
485                    true,
486                )?;
487            } else {
488                // G-MAC-01: format_args + write_fmt — no intermediate String for Display.
489                crate::output::write_line_fmt(format_args!("{}", path.display()))?;
490            }
491        }
492        VpsAction::Doctor {
493            json,
494            probe_ssh,
495            hosts,
496        } => {
497            // G-PAR-38/42: single envelope (local + optional ssh_probe); no dual JSON roots.
498            let as_json = use_json(json, format);
499            if hosts.is_some() && !probe_ssh {
500                return Err(SshCliError::InvalidArgument(
501                    "--hosts on vps doctor requires --probe-ssh".into(),
502                )
503                .into());
504            }
505            let selection = if probe_ssh {
506                match hosts {
507                    None => HostSelection::All,
508                    Some(raw) => {
509                        let names = crate::cli::parse_hosts_list(&raw);
510                        if names.is_empty() {
511                            return Err(SshCliError::InvalidArgument(
512                                "--hosts requires at least one host name".into(),
513                            )
514                            .into());
515                        }
516                        let names = names
517                            .into_iter()
518                            .map(crate::domain::VpsName::try_new)
519                            .collect::<Result<Vec<_>, _>>()
520                            .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
521                        HostSelection::Named(names)
522                    }
523                }
524            } else {
525                // unused when !probe_ssh
526                HostSelection::All
527            };
528            run_doctor_with_optional_probe(
529                config_override.as_deref(),
530                as_json,
531                probe_ssh,
532                if probe_ssh { Some(selection) } else { None },
533            )
534            .await?;
535        }
536        VpsAction::Export {
537            include_secrets,
538            output,
539            json,
540            i_understand_secrets_on_stdout,
541        } => {
542            // G-AUD-03: export body JSON when local --json or global format Json.
543            run_export(
544                &path,
545                include_secrets,
546                output.as_deref(),
547                json,
548                i_understand_secrets_on_stdout,
549                format,
550            )?;
551        }
552        VpsAction::Import {
553            file,
554            allow_incomplete,
555        } => {
556            run_import(&path, &file, allow_incomplete, format)?;
557        }
558    }
559    Ok(())
560}
561
562/// Removes the `active` file if its content matches the removed name (STATE-001).
563fn clear_active_if_name(config_path: &Path, name: &str) -> Result<()> {
564    let active = config_path
565        .parent()
566        .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
567        .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
568    if !active.exists() {
569        return Ok(());
570    }
571    let content = std::fs::read_to_string(&active).unwrap_or_default();
572    if content.trim() == name {
573        let _ = std::fs::remove_file(&active);
574    }
575    Ok(())
576}