Skip to main content

cli/ssh/
mod.rs

1//! `shine ssh`: wraps the system `ssh` binary to establish an interactive
2//! session that also carries a session-scoped file-transfer channel back to
3//! the local machine (see docs/ssh-local-transfer-prd.md).
4//!
5//! Architecture, confirmed against a real host via `scripts/spike-ssh-forward.sh`:
6//! - We prepend our own `-R <remote-sock>:<local-forward-target>` to the
7//!   user's ssh args (safe: ssh options may appear in any order before the
8//!   destination).
9//! - We replace the remote command with a wrapper that sets
10//!   `SHINE_SSH_SESSION`/`SHINE_SSH_TOKEN`/`SHINE_SSH_REMOTE_SOCK` via `env`
11//!   (not `SetEnv`/`SendEnv`, which most sshd configs don't accept), then
12//!   `exec`s either the user's original remote command or their login shell.
13//!   Explicit `--with`/`--with-secret` values join that process environment.
14//! - sshd does NOT clean up the forwarded remote socket file on disconnect
15//!   (confirmed by the spike), so the wrapper registers its own `trap ...
16//!   EXIT` to remove it.
17//!
18//! The default remote mode is POSIX: it uses a Unix socket and a POSIX shell
19//! wrapper regardless of the *local* platform. `--remote-shell windows` is
20//! an explicit environment-forwarding-only mode: it sends a Base64-encoded
21//! PowerShell bootstrap, preferring PowerShell 7 (`pwsh.exe`) and falling
22//! back to Windows PowerShell (`powershell.exe`), and deliberately creates no
23//! transfer listener or `-R` forward. Locally, the POSIX path's
24//! `bind_local_listener` uses a Unix socket on macOS/Linux, or loopback TCP on
25//! Windows.
26
27mod agent;
28mod broker;
29// Drives the real agent over an in-process Unix socket pair, so it is
30// unix-only (Windows is the local side only and has no `UnixListener`).
31#[cfg(all(test, unix))]
32mod integration_tests;
33mod protocol;
34mod session_context;
35// `remote_client` dials the forwarded socket via a Unix stream: it only
36// ever runs on the *remote* end of a session, which is always assumed
37// Linux/macOS (see module docs), so it is unconditionally unix-only —
38// unlike `agent`, which must compile on Windows too since Windows is
39// supported as the *local* side.
40#[cfg(unix)]
41mod remote_client;
42
43use std::collections::{BTreeMap, BTreeSet};
44use std::path::PathBuf;
45
46use anyhow::{Context, Result, bail};
47use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
48
49use crate::commands::RemoteShell;
50use crate::config::Config;
51use crate::env::{EnvConfig, parse_env_specs, secret_key};
52use crate::secret;
53use crate::theme;
54
55/// Grace period given to still-running per-connection transfer tasks to
56/// notice the (by now closed) `ssh` tunnel and finish their own cleanup
57/// before the session directory is removed. See
58/// `agent::drain_connection_tasks`.
59const CONNECTION_DRAIN_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(5);
60
61#[cfg(not(unix))]
62const WINDOWS_REMOTE_UNSUPPORTED: &str = "`shine local` commands require this machine to be the \
63    remote (Linux/macOS) side of a `shine ssh` session; Windows is currently supported as the \
64    local side only";
65
66#[cfg(unix)]
67pub async fn handle_local_download(
68    remote_source: &str,
69    local_destination: Option<&str>,
70    force: bool,
71    dry_run: bool,
72    use_scp: bool,
73) -> Result<()> {
74    remote_client::handle_download(remote_source, local_destination, force, dry_run, use_scp).await
75}
76
77#[cfg(not(unix))]
78pub async fn handle_local_download(
79    _remote_source: &str,
80    _local_destination: Option<&str>,
81    _force: bool,
82    _dry_run: bool,
83    _use_scp: bool,
84) -> Result<()> {
85    bail!(WINDOWS_REMOTE_UNSUPPORTED)
86}
87
88#[cfg(unix)]
89pub async fn handle_local_upload(
90    local_source: &str,
91    remote_destination: Option<&str>,
92    force: bool,
93    dry_run: bool,
94    use_scp: bool,
95) -> Result<()> {
96    remote_client::handle_upload(local_source, remote_destination, force, dry_run, use_scp).await
97}
98
99#[cfg(not(unix))]
100pub async fn handle_local_upload(
101    _local_source: &str,
102    _remote_destination: Option<&str>,
103    _force: bool,
104    _dry_run: bool,
105    _use_scp: bool,
106) -> Result<()> {
107    bail!(WINDOWS_REMOTE_UNSUPPORTED)
108}
109
110#[cfg(unix)]
111pub async fn handle_local_status() -> Result<()> {
112    remote_client::handle_status().await
113}
114
115#[cfg(unix)]
116pub async fn request_direct_secrets(
117    specs: &[String],
118    argv: &[String],
119) -> Result<BTreeMap<String, String>> {
120    remote_client::request_direct_secrets(specs, argv).await
121}
122
123#[cfg(not(unix))]
124pub async fn request_direct_secrets(
125    _specs: &[String],
126    _argv: &[String],
127) -> Result<BTreeMap<String, String>> {
128    bail!(WINDOWS_REMOTE_UNSUPPORTED)
129}
130
131#[cfg(unix)]
132pub async fn request_workspace_secrets(
133    snapshot: crate::env::broker::WorkspaceSnapshot,
134    argv: &[String],
135) -> Result<BTreeMap<String, String>> {
136    remote_client::request_workspace_secrets(snapshot, argv).await
137}
138
139#[cfg(unix)]
140pub fn broker_session_available() -> bool {
141    remote_client::session_available()
142}
143
144#[cfg(not(unix))]
145pub fn broker_session_available() -> bool {
146    false
147}
148
149#[cfg(unix)]
150pub async fn describe_broker_workspace(
151    snapshot: crate::env::broker::WorkspaceSnapshot,
152    release: &[String],
153    argv: &[String],
154) -> Result<String> {
155    remote_client::describe_workspace(snapshot, release, argv).await
156}
157
158#[cfg(not(unix))]
159pub async fn describe_broker_workspace(
160    _snapshot: crate::env::broker::WorkspaceSnapshot,
161    _release: &[String],
162    _argv: &[String],
163) -> Result<String> {
164    bail!(WINDOWS_REMOTE_UNSUPPORTED)
165}
166
167#[cfg(not(unix))]
168pub async fn request_workspace_secrets(
169    _snapshot: crate::env::broker::WorkspaceSnapshot,
170    _argv: &[String],
171) -> Result<BTreeMap<String, String>> {
172    bail!(WINDOWS_REMOTE_UNSUPPORTED)
173}
174
175#[cfg(not(unix))]
176pub async fn handle_local_status() -> Result<()> {
177    bail!(WINDOWS_REMOTE_UNSUPPORTED)
178}
179
180/// Single-letter ssh options that consume a separate value, per ssh(1).
181/// Used only to locate the destination/command boundary in the user's
182/// argument list — never to reinterpret what the options mean.
183const VALUE_OPTION_LETTERS: &[char] = &[
184    'B', 'b', 'c', 'D', 'E', 'e', 'F', 'I', 'i', 'J', 'L', 'l', 'm', 'O', 'o', 'p', 'Q', 'R', 'S',
185    'W', 'w',
186];
187
188#[allow(clippy::too_many_arguments)] // Top-level handler mirrors the independently meaningful CLI switches.
189pub async fn handle_ssh(
190    config: &Config,
191    remote_shell: RemoteShell,
192    with: &[String],
193    with_secret: &[String],
194    secret_broker: bool,
195    secret_broker_policy: &[PathBuf],
196    allow_secret: &[String],
197    trust_remote_session: bool,
198    secret_broker_inspect: bool,
199    secret_broker_enroll: bool,
200    trust_remote_metadata: bool,
201    secret_broker_update_policy: Option<&str>,
202    args: &[String],
203) -> Result<()> {
204    let (ssh_options, host, remote_command) = split_ssh_args(args)?;
205    let forwarded_env = resolve_forwarded_env(config, with, with_secret).await?;
206
207    // Windows OpenSSH executes remote commands through cmd.exe by default.
208    // Do not create the POSIX-only transfer channel there; the encoded
209    // PowerShell command has no user-controlled syntax in cmd.exe.
210    if remote_shell == RemoteShell::Windows {
211        if secret_broker
212            || !secret_broker_policy.is_empty()
213            || !allow_secret.is_empty()
214            || trust_remote_session
215            || secret_broker_inspect
216            || secret_broker_enroll
217            || secret_broker_update_policy.is_some()
218        {
219            bail!("SSH secret broker requires the POSIX remote shell mode");
220        }
221        let session_id = uuid::Uuid::new_v4().to_string();
222        let local_theme = theme::resolve_local_terminal_theme_for_injection();
223        let wrapped_command = build_windows_wrapped_remote_command(
224            &session_id,
225            local_theme.map(theme::Theme::as_str),
226            &forwarded_env,
227            &remote_command,
228        )?;
229        let mut cmd = tokio::process::Command::new("ssh");
230        cmd.args(build_windows_ssh_invocation_args(
231            &ssh_options,
232            &host,
233            &wrapped_command,
234        ));
235        return finish_ssh_status(run_ssh_with_ctrl_c(&mut cmd).await?);
236    }
237
238    let session_id = uuid::Uuid::new_v4().to_string();
239    let token = uuid::Uuid::new_v4().to_string();
240    let broker_session = broker::BrokerSession::prepare(
241        config,
242        &host,
243        secret_broker,
244        secret_broker_policy,
245        allow_secret,
246        trust_remote_session,
247        secret_broker_inspect,
248        secret_broker_enroll,
249        trust_remote_metadata,
250        secret_broker_update_policy,
251    )
252    .await?;
253
254    let session_dir = config.shine_dir().join("run").join("ssh").join(&session_id);
255    tokio::fs::create_dir_all(&session_dir)
256        .await
257        .with_context(|| format!("creating {}", session_dir.display()))?;
258    // The remote host is always assumed Linux/macOS (see module docs), so
259    // its socket is always a Unix socket regardless of the local platform.
260    let remote_sock = format!("/tmp/.shine-ssh-{session_id}.sock");
261
262    let (listener, local_forward_target) = bind_local_listener(&session_dir).await?;
263    let session_local_dir = std::env::current_dir().context("reading current directory")?;
264
265    // Reuse the interactive connection as a control master so the rsync/scp
266    // child reconnects over it with no second authentication (ADR 0011). Skip
267    // if the user already configured their own multiplexing, so we don't fight
268    // their settings.
269    let control_options = if session_context::user_set_control_options(&ssh_options) {
270        None
271    } else {
272        Some(session_dir.join("ctl.sock"))
273    };
274
275    let context = std::sync::Arc::new(session_context::SessionContext {
276        host: host.clone(),
277        ssh_options: ssh_options.clone(),
278        local_dir: session_local_dir.clone(),
279        control_path: control_options.clone(),
280    });
281    context.save(&session_dir).await?;
282
283    let connection_tasks = agent::new_connection_tasks();
284    let agent_handle = tokio::spawn(listener.serve(
285        token.clone(),
286        context.clone(),
287        broker_session.clone(),
288        connection_tasks.clone(),
289    ));
290
291    // Query the *local* terminal — same-host, sub-millisecond round trip,
292    // no fragmentation risk unlike a remote OSC query (PRD §2.2/§6.1) — so
293    // the remote login shell never has to guess at its own theme.
294    let local_theme = theme::resolve_local_terminal_theme_for_injection();
295    let wrapped_command = build_wrapped_remote_command(
296        &session_id,
297        &token,
298        &remote_sock,
299        local_theme.map(theme::Theme::as_str),
300        &forwarded_env,
301        &remote_command,
302    );
303
304    let mut cmd = tokio::process::Command::new("ssh");
305    cmd.args(build_ssh_invocation_args(
306        &ssh_options,
307        &remote_sock,
308        &local_forward_target,
309        control_options.as_deref(),
310        &host,
311        &wrapped_command,
312    ));
313
314    // Racing against ctrl_c() (rather than just awaiting cmd.status()) is
315    // what makes the cleanup below actually run on Ctrl-C: installing this
316    // listener overrides SIGINT's default disposition for the process, so a
317    // Ctrl-C no longer kills us before we get a chance to clean up. The ssh
318    // child is in the same foreground process group and receives SIGINT
319    // independently; we still await its exit so we don't race it.
320    let status = run_ssh_with_ctrl_c_broker(&mut cmd, broker_session.as_deref()).await?;
321
322    // Stop accepting new connections, then give any still-running transfer
323    // a bounded chance to notice the tunnel is gone and run its own
324    // cleanup before we remove the session directory out from under it.
325    agent_handle.abort();
326    agent::drain_connection_tasks(&connection_tasks, CONNECTION_DRAIN_GRACE_PERIOD).await;
327    let _ = tokio::fs::remove_dir_all(&session_dir).await;
328
329    finish_ssh_status(status)
330}
331
332async fn run_ssh_with_ctrl_c_broker(
333    cmd: &mut tokio::process::Command,
334    broker: Option<&broker::BrokerSession>,
335) -> Result<std::process::ExitStatus> {
336    let mut child = cmd.spawn().context("failed to start ssh")?;
337    if let Some(broker) = broker {
338        broker.set_ssh_pid(child.id());
339    }
340    let mut wait = std::pin::pin!(child.wait());
341    let result = tokio::select! {
342        status = &mut wait => status,
343        _ = tokio::signal::ctrl_c() => wait.await,
344    }
345    .context("failed to run ssh");
346    if let Some(broker) = broker {
347        broker.set_ssh_pid(None);
348    }
349    result
350}
351
352async fn run_ssh_with_ctrl_c(
353    cmd: &mut tokio::process::Command,
354) -> Result<std::process::ExitStatus> {
355    let mut ssh_run = std::pin::pin!(cmd.status());
356    tokio::select! {
357        status = &mut ssh_run => status,
358        _ = tokio::signal::ctrl_c() => ssh_run.await,
359    }
360    .context("failed to run ssh")
361}
362
363fn finish_ssh_status(status: std::process::ExitStatus) -> Result<()> {
364    if status.success() {
365        return Ok(());
366    }
367    if let Some(code) = status.code() {
368        std::process::exit(code);
369    }
370    #[cfg(unix)]
371    {
372        use std::os::unix::process::ExitStatusExt;
373        std::process::exit(128 + status.signal().unwrap_or(1));
374    }
375    #[cfg(not(unix))]
376    std::process::exit(1);
377}
378
379const RESERVED_REMOTE_ENV: &[&str] = &[
380    "SHINE_SSH_SESSION",
381    "SHINE_SSH_TOKEN",
382    "SHINE_SSH_REMOTE_SOCK",
383    "SHINE_TERMINAL_THEME",
384];
385
386/// Resolves only explicitly selected config values. Plaintext selection is
387/// deliberately exact: unlike `shine env run --with`, it never falls through
388/// to `<KEY>_SECRET`. Sending decrypted material to another host requires the
389/// visibly distinct `--with-secret` opt-in.
390async fn resolve_forwarded_env(
391    config: &Config,
392    with: &[String],
393    with_secret: &[String],
394) -> Result<BTreeMap<String, String>> {
395    let plain_specs = parse_env_specs(with)?;
396    let secret_specs = parse_env_specs(with_secret)?;
397    let env = EnvConfig::load_or_init(config).await?;
398    let mut targets = BTreeSet::new();
399    let mut resolved = BTreeMap::new();
400
401    for spec in plain_specs {
402        validate_forward_target(&spec.target, &mut targets)?;
403        if spec.source.ends_with("_SECRET") {
404            bail!(
405                "--with does not inject secret storage key {}; use --with-secret with the base key instead",
406                spec.source
407            );
408        }
409        let value = env.get(&spec.source).with_context(|| {
410            let encrypted = secret_key(&spec.source);
411            if env.get(&encrypted).is_some() {
412                format!(
413                    "{} is stored as {encrypted}; use --with-secret {} to decrypt and inject it",
414                    spec.source, spec.source
415                )
416            } else {
417                format!("{} is not set in the active config [env]", spec.source)
418            }
419        })?;
420        resolved.insert(spec.target, value.to_string());
421    }
422
423    for spec in secret_specs {
424        validate_forward_target(&spec.target, &mut targets)?;
425        if spec.source.ends_with("_SECRET") {
426            bail!(
427                "--with-secret expects a base key without the _SECRET suffix: {}",
428                spec.source
429            );
430        }
431        let encrypted = secret_key(&spec.source);
432        let ciphertext = env
433            .get(&encrypted)
434            .with_context(|| format!("{encrypted} is not set in the active config [env]"))?;
435        let value = secret::decrypt_secret(ciphertext, &config.resolved_age_identities())
436            .await
437            .with_context(|| format!("decrypting {encrypted}"))?;
438        resolved.insert(spec.target, value);
439    }
440
441    Ok(resolved)
442}
443
444fn validate_forward_target(target: &str, targets: &mut BTreeSet<String>) -> Result<()> {
445    if RESERVED_REMOTE_ENV.contains(&target) {
446        bail!("cannot override shine-managed SSH variable {target}");
447    }
448    if !targets.insert(target.to_string()) {
449        bail!("duplicate target variable: {target}");
450    }
451    Ok(())
452}
453
454/// Binds the local end of the session's transfer channel and returns it
455/// together with the target to embed in `ssh`'s `-R <remote-sock>:<target>`
456/// argument.
457#[cfg(unix)]
458async fn bind_local_listener(
459    session_dir: &std::path::Path,
460) -> Result<(agent::LocalListener, String)> {
461    let local_sock = session_dir.join("local.sock");
462    let listener = tokio::net::UnixListener::bind(&local_sock)
463        .with_context(|| format!("binding local transfer socket {}", local_sock.display()))?;
464    Ok((
465        agent::LocalListener::Unix(listener),
466        local_sock.display().to_string(),
467    ))
468}
469
470/// Windows lacks the mature, well-tested Unix-domain-socket support that
471/// macOS/Linux have, so the local end uses a loopback TCP socket instead;
472/// `ssh -R` supports mixing this with the remote's Unix-socket endpoint
473/// (verified via `scripts/spike-ssh-forward-windows.ps1`).
474#[cfg(windows)]
475async fn bind_local_listener(
476    _session_dir: &std::path::Path,
477) -> Result<(agent::LocalListener, String)> {
478    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
479        .await
480        .context("binding local transfer TCP listener")?;
481    let port = listener
482        .local_addr()
483        .context("reading local TCP listener port")?
484        .port();
485    Ok((
486        agent::LocalListener::Tcp(listener),
487        format!("127.0.0.1:{port}"),
488    ))
489}
490
491/// Splits a raw `shine ssh` argument list into ssh options, the destination,
492/// and an optional remote command — mirroring what `ssh` itself would infer,
493/// without reinterpreting the options' meaning (see module docs). An
494/// explicit `--` may be used to disambiguate; it is consumed here and not
495/// forwarded to the real `ssh` invocation.
496fn split_ssh_args(args: &[String]) -> Result<(Vec<String>, String, Vec<String>)> {
497    let mut ssh_options = Vec::new();
498    let mut i = 0;
499    while i < args.len() {
500        let token = &args[i];
501        if token == "--" {
502            i += 1;
503            break;
504        }
505        if token == "-" || !token.starts_with('-') {
506            let host = token.clone();
507            let remote_command = args[i + 1..].to_vec();
508            return Ok((ssh_options, host, remote_command));
509        }
510
511        ssh_options.push(token.clone());
512        let letters: Vec<char> = token.chars().skip(1).collect();
513        let mut consumes_next = false;
514        for (idx, letter) in letters.iter().enumerate() {
515            if VALUE_OPTION_LETTERS.contains(letter) {
516                consumes_next = idx == letters.len() - 1;
517                break;
518            }
519        }
520        i += 1;
521        if consumes_next {
522            let Some(value) = args.get(i) else {
523                bail!("ssh option {token} requires a value");
524            };
525            ssh_options.push(value.clone());
526            i += 1;
527        }
528    }
529
530    let Some(host) = args.get(i) else {
531        bail!("no SSH destination given; usage: shine ssh [SSH_ARGS]... <HOST> [COMMAND]");
532    };
533    let remote_command = args[i + 1..].to_vec();
534    Ok((ssh_options, host.clone(), remote_command))
535}
536
537/// Assembles the argument list passed to the `ssh` binary: the user's own
538/// options first (untouched, per module docs), then our `-t`/`-R` forward,
539/// the destination, and the wrapped remote command. Kept as a pure function
540/// so the composition can be unit-tested without spawning a real `ssh`.
541fn build_ssh_invocation_args(
542    ssh_options: &[String],
543    remote_sock: &str,
544    local_forward_target: &str,
545    control_path: Option<&std::path::Path>,
546    host: &str,
547    wrapped_command: &str,
548) -> Vec<String> {
549    let mut args = ssh_options.to_vec();
550    // Enable connection multiplexing so a later `rsync`/`scp` child can reuse
551    // this authenticated master connection (ADR 0011). Only injected when the
552    // user didn't set their own ControlMaster/ControlPath.
553    if let Some(control_path) = control_path {
554        args.push("-o".to_string());
555        args.push("ControlMaster=auto".to_string());
556        args.push("-o".to_string());
557        args.push(format!("ControlPath={}", control_path.display()));
558        args.push("-o".to_string());
559        args.push("ControlPersist=60".to_string());
560    }
561    args.push("-t".to_string());
562    args.push("-R".to_string());
563    args.push(format!("{remote_sock}:{local_forward_target}"));
564    args.push(host.to_string());
565    args.push(wrapped_command.to_string());
566    args
567}
568
569/// Windows does not receive a transfer listener, so its SSH invocation is a
570/// deliberately small normal TTY session followed by one opaque PowerShell
571/// command. Keeping the encoded payload as a single argv item prevents CMD
572/// from seeing secret values or PowerShell metacharacters.
573fn build_windows_ssh_invocation_args(
574    ssh_options: &[String],
575    host: &str,
576    wrapped_command: &str,
577) -> Vec<String> {
578    let mut args = ssh_options.to_vec();
579    args.push("-t".to_string());
580    args.push(host.to_string());
581    args.push(wrapped_command.to_string());
582    args
583}
584
585fn build_wrapped_remote_command(
586    session_id: &str,
587    token: &str,
588    remote_sock: &str,
589    local_theme: Option<&str>,
590    forwarded_env: &BTreeMap<String, String>,
591    remote_command: &[String],
592) -> String {
593    let inner_exec = if remote_command.is_empty() {
594        r#"exec "$SHELL" -l"#.to_string()
595    } else {
596        let quoted = remote_command
597            .iter()
598            .map(|token| single_quote(token))
599            .collect::<Vec<_>>()
600            .join(" ");
601        format!("exec {quoted}")
602    };
603    // Double quotes here are safe: this text is only ever embedded through
604    // `single_quote`, which POSIX-escapes it as one opaque literal for the
605    // outer shell, so nothing inside (single or double quotes, `$`, etc.)
606    // is interpreted until the inner `sh -c` re-parses it.
607    let inner_script = format!(r#"trap "rm -f $SHINE_SSH_REMOTE_SOCK" EXIT; {inner_exec}"#);
608
609    let mut env_prefix = format!(
610        "SHINE_SSH_SESSION={session_id} SHINE_SSH_TOKEN={token} SHINE_SSH_REMOTE_SOCK={remote_sock}"
611    );
612    // Unlike the three values above (internally generated UUIDs/hex/paths,
613    // never user input), this one is quoted defensively per
614    // docs/terminal-theme-sync-prd.md §6.1/§10 even though its source
615    // (`Theme::as_str`) only ever produces the literal `light` or `dark`.
616    if let Some(theme) = local_theme {
617        env_prefix.push_str(&format!(" SHINE_TERMINAL_THEME={}", single_quote(theme)));
618    }
619    for (key, value) in forwarded_env {
620        env_prefix.push_str(&format!(" {key}={}", single_quote(value)));
621    }
622
623    format!("env {env_prefix} sh -c {}", single_quote(&inner_script))
624}
625
626/// Builds an opaque Windows PowerShell remote command. OpenSSH passes this
627/// through cmd.exe on typical Windows servers, so only the Base64 alphabet is
628/// allowed to carry user-controlled values across that boundary. A small
629/// Windows PowerShell bootstrap probes for PowerShell 7 before launching the
630/// real encoded payload in the selected shell. Keeping the outer command free
631/// of CMD operators also works when the SSH server's default shell has already
632/// been changed from CMD to PowerShell.
633fn build_windows_wrapped_remote_command(
634    session_id: &str,
635    local_theme: Option<&str>,
636    forwarded_env: &BTreeMap<String, String>,
637    remote_command: &[String],
638) -> Result<String> {
639    let mut script = String::new();
640    push_powershell_env_assignment(&mut script, "SHINE_SSH_SESSION", session_id)?;
641    if let Some(theme) = local_theme {
642        push_powershell_env_assignment(&mut script, "SHINE_TERMINAL_THEME", theme)?;
643    }
644    for (key, value) in forwarded_env {
645        push_powershell_env_assignment(&mut script, key, value)?;
646    }
647
648    let interactive = remote_command.is_empty();
649    if !interactive {
650        // Reset the native-program status so a PowerShell command does not
651        // accidentally inherit a status from profile/startup execution.
652        script.push_str("$global:LASTEXITCODE = 0\n& ");
653        for (index, argument) in remote_command.iter().enumerate() {
654            if index > 0 {
655                script.push(' ');
656            }
657            script.push_str(&powershell_single_quoted_literal(argument)?);
658        }
659        script.push_str(
660            "\nif ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\nif (-not $?) { exit 1 }\n",
661        );
662    }
663
664    let payload_encoded = BASE64.encode(utf16le_bytes(&script)?);
665    let no_profile = if interactive { "" } else { " -NoProfile" };
666    let no_exit = if interactive { " -NoExit" } else { "" };
667    let bootstrap = format!(
668        "$pwsh = Get-Command pwsh.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1\n\
669         $shell = if ($null -ne $pwsh) {{ $pwsh.Source }} else {{ 'powershell.exe' }}\n\
670         & $shell{no_profile}{no_exit} -EncodedCommand '{payload_encoded}'\n\
671         $ok = $?\n\
672         $code = $LASTEXITCODE\n\
673         if ($null -ne $code -and $code -ne 0) {{ exit $code }}\n\
674         if (-not $ok) {{ exit 1 }}\n"
675    );
676    let bootstrap_encoded = BASE64.encode(utf16le_bytes(&bootstrap)?);
677    Ok(format!(
678        "powershell.exe -NoProfile -EncodedCommand {bootstrap_encoded}"
679    ))
680}
681
682fn push_powershell_env_assignment(script: &mut String, key: &str, value: &str) -> Result<()> {
683    // Targets have already been parsed as environment identifiers by
684    // `parse_env_specs`; keep this check local so this builder remains safe
685    // if it is reused independently later.
686    if !key
687        .bytes()
688        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
689    {
690        bail!("cannot safely represent Windows environment variable name {key}");
691    }
692    script.push_str("$env:");
693    script.push_str(key);
694    script.push_str(" = ");
695    script.push_str(&powershell_single_quoted_literal(value)?);
696    script.push('\n');
697    Ok(())
698}
699
700/// Produces a PowerShell single-quoted string. PowerShell represents a literal
701/// apostrophe by doubling it; NUL is rejected because Windows environment
702/// values and command-line APIs cannot represent it safely.
703fn powershell_single_quoted_literal(value: &str) -> Result<String> {
704    if value.contains('\0') {
705        bail!("cannot forward values containing NUL bytes to Windows PowerShell");
706    }
707    Ok(format!("'{}'", value.replace('\'', "''")))
708}
709
710fn utf16le_bytes(value: &str) -> Result<Vec<u8>> {
711    if value.contains('\0') {
712        bail!("cannot encode PowerShell commands containing NUL bytes");
713    }
714    Ok(value
715        .encode_utf16()
716        .flat_map(u16::to_le_bytes)
717        .collect::<Vec<_>>())
718}
719
720/// POSIX single-quotes `s` for safe embedding as one shell word, escaping
721/// any literal `'` via the standard `'\''` idiom (close quote, escaped
722/// quote, reopen quote). Applying this at each nesting level independently
723/// composes correctly regardless of how many quoting layers are involved.
724fn single_quote(s: &str) -> String {
725    format!("'{}'", s.replace('\'', r"'\''"))
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    #[test]
733    fn plain_host_with_no_options_or_command() {
734        let (options, host, command) = split_ssh_args(&["dev".to_string()]).unwrap();
735        assert!(options.is_empty());
736        assert_eq!(host, "dev");
737        assert!(command.is_empty());
738    }
739
740    #[test]
741    fn host_followed_by_a_remote_command() {
742        let args = vec!["dev".to_string(), "ls".to_string(), "-la".to_string()];
743        let (options, host, command) = split_ssh_args(&args).unwrap();
744        assert!(options.is_empty());
745        assert_eq!(host, "dev");
746        assert_eq!(command, vec!["ls", "-la"]);
747    }
748
749    #[test]
750    fn value_option_with_separate_token() {
751        let args = vec!["-p".to_string(), "2222".to_string(), "dev".to_string()];
752        let (options, host, command) = split_ssh_args(&args).unwrap();
753        assert_eq!(options, vec!["-p", "2222"]);
754        assert_eq!(host, "dev");
755        assert!(command.is_empty());
756    }
757
758    #[test]
759    fn value_option_with_attached_value() {
760        let args = vec!["-p2222".to_string(), "dev".to_string()];
761        let (options, host, _command) = split_ssh_args(&args).unwrap();
762        assert_eq!(options, vec!["-p2222"]);
763        assert_eq!(host, "dev");
764    }
765
766    #[test]
767    fn repeated_o_option() {
768        let args = vec![
769            "-o".to_string(),
770            "ProxyJump=bastion".to_string(),
771            "dev".to_string(),
772        ];
773        let (options, host, _command) = split_ssh_args(&args).unwrap();
774        assert_eq!(options, vec!["-o", "ProxyJump=bastion"]);
775        assert_eq!(host, "dev");
776    }
777
778    #[test]
779    fn bundled_boolean_flags_consume_no_value() {
780        let args = vec!["-vvv".to_string(), "dev".to_string()];
781        let (options, host, _command) = split_ssh_args(&args).unwrap();
782        assert_eq!(options, vec!["-vvv"]);
783        assert_eq!(host, "dev");
784    }
785
786    #[test]
787    fn explicit_double_dash_separator() {
788        let args = vec!["--".to_string(), "dev".to_string(), "ls".to_string()];
789        let (options, host, command) = split_ssh_args(&args).unwrap();
790        assert!(options.is_empty());
791        assert_eq!(host, "dev");
792        assert_eq!(command, vec!["ls"]);
793    }
794
795    #[test]
796    fn no_destination_is_an_error() {
797        assert!(split_ssh_args(&[]).is_err());
798    }
799
800    #[test]
801    fn dangling_value_option_is_an_error() {
802        assert!(split_ssh_args(&["-p".to_string()]).is_err());
803    }
804
805    #[cfg(unix)]
806    #[test]
807    fn wrapped_command_round_trips_through_a_real_shell() {
808        // Exercises the full nested-quoting composition end to end: run the
809        // wrapped command through `sh -c` and check the remote command's
810        // stdout, rather than reasoning about escaping by hand.
811        let wrapped = build_wrapped_remote_command(
812            "sid",
813            "tok",
814            "/tmp/shine-ssh-mod-test-sid.sock",
815            None,
816            &BTreeMap::new(),
817            &["echo".to_string(), "it's a test".to_string()],
818        );
819        let output = std::process::Command::new("sh")
820            .arg("-c")
821            .arg(&wrapped)
822            .output()
823            .expect("failed to run sh");
824        assert!(output.status.success(), "stderr: {:?}", output.stderr);
825        assert_eq!(
826            String::from_utf8_lossy(&output.stdout).trim_end(),
827            "it's a test"
828        );
829    }
830
831    #[test]
832    fn ssh_invocation_args_keep_user_options_verbatim_and_ahead_of_our_own() {
833        let args = vec!["-J".to_string(), "bastion".to_string()];
834        let (parsed_options, host, _command) =
835            split_ssh_args(&[args.clone(), vec!["dev".to_string()]].concat()).unwrap();
836
837        let invocation = build_ssh_invocation_args(
838            &parsed_options,
839            "/tmp/.shine-ssh-sid.sock",
840            "/tmp/shine-ssh-sid/local.sock",
841            None,
842            &host,
843            "wrapped-command",
844        );
845
846        assert_eq!(
847            invocation,
848            vec![
849                "-J",
850                "bastion",
851                "-t",
852                "-R",
853                "/tmp/.shine-ssh-sid.sock:/tmp/shine-ssh-sid/local.sock",
854                "dev",
855                "wrapped-command",
856            ]
857        );
858    }
859
860    #[test]
861    fn windows_ssh_invocation_has_no_transfer_or_posix_wrapper() {
862        let invocation = build_windows_ssh_invocation_args(
863            &["-p".to_string(), "2222".to_string()],
864            "windows-host",
865            "powershell.exe -NoProfile -EncodedCommand QQ==",
866        );
867
868        assert_eq!(
869            invocation,
870            vec![
871                "-p",
872                "2222",
873                "-t",
874                "windows-host",
875                "powershell.exe -NoProfile -EncodedCommand QQ==",
876            ]
877        );
878        assert!(!invocation.iter().any(|arg| arg == "-R"));
879        assert!(!invocation.iter().any(|arg| arg.contains("env ")));
880        assert!(!invocation.iter().any(|arg| arg.contains("sh -c")));
881    }
882
883    #[test]
884    fn ssh_invocation_args_inject_control_master_when_control_path_given() {
885        let (parsed_options, host, _command) = split_ssh_args(&["dev".to_string()]).unwrap();
886        let invocation = build_ssh_invocation_args(
887            &parsed_options,
888            "/tmp/.shine-ssh-sid.sock",
889            "/tmp/shine-ssh-sid/local.sock",
890            Some(std::path::Path::new("/tmp/shine-ssh-sid/ctl.sock")),
891            &host,
892            "wrapped-command",
893        );
894
895        assert_eq!(
896            invocation,
897            vec![
898                "-o",
899                "ControlMaster=auto",
900                "-o",
901                "ControlPath=/tmp/shine-ssh-sid/ctl.sock",
902                "-o",
903                "ControlPersist=60",
904                "-t",
905                "-R",
906                "/tmp/.shine-ssh-sid.sock:/tmp/shine-ssh-sid/local.sock",
907                "dev",
908                "wrapped-command",
909            ]
910        );
911    }
912
913    #[test]
914    fn ssh_invocation_args_preserve_repeated_o_options_in_order() {
915        let args = vec![
916            "-o".to_string(),
917            "ProxyJump=bastion".to_string(),
918            "-o".to_string(),
919            "ServerAliveInterval=30".to_string(),
920            "dev".to_string(),
921            "ls".to_string(),
922            "-la".to_string(),
923        ];
924        let (parsed_options, host, command) = split_ssh_args(&args).unwrap();
925        assert_eq!(command, vec!["ls", "-la"]);
926
927        let invocation = build_ssh_invocation_args(
928            &parsed_options,
929            "/tmp/.shine-ssh-sid.sock",
930            "/tmp/shine-ssh-sid/local.sock",
931            None,
932            &host,
933            "wrapped-command",
934        );
935
936        // The user's repeated -o options must appear verbatim, in order, and
937        // ahead of our own -t/-R/host/command — never reordered or merged.
938        assert_eq!(
939            invocation,
940            vec![
941                "-o",
942                "ProxyJump=bastion",
943                "-o",
944                "ServerAliveInterval=30",
945                "-t",
946                "-R",
947                "/tmp/.shine-ssh-sid.sock:/tmp/shine-ssh-sid/local.sock",
948                "dev",
949                "wrapped-command",
950            ]
951        );
952    }
953
954    #[test]
955    fn wrapped_command_defaults_to_login_shell() {
956        let wrapped = build_wrapped_remote_command(
957            "sid",
958            "tok",
959            "/tmp/.shine-ssh-sid.sock",
960            None,
961            &BTreeMap::new(),
962            &[],
963        );
964        assert!(wrapped.contains(r#"exec "$SHELL" -l"#));
965        assert!(wrapped.contains("trap \"rm -f $SHINE_SSH_REMOTE_SOCK\" EXIT"));
966    }
967
968    #[test]
969    fn wrapped_command_omits_theme_var_when_none() {
970        let wrapped = build_wrapped_remote_command(
971            "sid",
972            "tok",
973            "/tmp/.shine-ssh-sid.sock",
974            None,
975            &BTreeMap::new(),
976            &[],
977        );
978        assert!(!wrapped.contains("SHINE_TERMINAL_THEME"));
979    }
980
981    #[test]
982    fn wrapped_command_injects_quoted_theme_var_when_present() {
983        let wrapped = build_wrapped_remote_command(
984            "sid",
985            "tok",
986            "/tmp/.shine-ssh-sid.sock",
987            Some("dark"),
988            &BTreeMap::new(),
989            &[],
990        );
991        assert!(wrapped.contains("SHINE_TERMINAL_THEME='dark'"));
992        // Must appear inside the `env ...` prefix, before the `sh -c` handoff.
993        assert!(
994            wrapped.find("SHINE_TERMINAL_THEME").unwrap() < wrapped.find("sh -c").unwrap(),
995            "theme var must be part of the env prefix: {wrapped}"
996        );
997    }
998
999    #[cfg(unix)]
1000    #[test]
1001    fn wrapped_command_theme_injection_round_trips_through_a_real_shell() {
1002        // Same rationale as wrapped_command_round_trips_through_a_real_shell:
1003        // verify the quoting composition by actually running it, rather than
1004        // reasoning about escaping by hand.
1005        let wrapped = build_wrapped_remote_command(
1006            "sid",
1007            "tok",
1008            "/tmp/shine-ssh-mod-test-theme-sid.sock",
1009            Some("dark"),
1010            &BTreeMap::new(),
1011            &["printenv".to_string(), "SHINE_TERMINAL_THEME".to_string()],
1012        );
1013        let output = std::process::Command::new("sh")
1014            .arg("-c")
1015            .arg(&wrapped)
1016            .output()
1017            .expect("failed to run sh");
1018        assert!(output.status.success(), "stderr: {:?}", output.stderr);
1019        assert_eq!(String::from_utf8_lossy(&output.stdout).trim_end(), "dark");
1020    }
1021
1022    #[cfg(unix)]
1023    #[test]
1024    fn wrapped_command_forwarded_env_round_trips_special_characters() {
1025        let forwarded = BTreeMap::from([(
1026            "REMOTE_VALUE".to_string(),
1027            "space ' quote $dollar\nand newline".to_string(),
1028        )]);
1029        let wrapped = build_wrapped_remote_command(
1030            "sid",
1031            "tok",
1032            "/tmp/shine-ssh-mod-test-env-sid.sock",
1033            None,
1034            &forwarded,
1035            &["printenv".to_string(), "REMOTE_VALUE".to_string()],
1036        );
1037        let output = std::process::Command::new("sh")
1038            .arg("-c")
1039            .arg(&wrapped)
1040            .output()
1041            .expect("failed to run sh");
1042        assert!(output.status.success(), "stderr: {:?}", output.stderr);
1043        assert_eq!(
1044            String::from_utf8_lossy(&output.stdout),
1045            "space ' quote $dollar\nand newline\n"
1046        );
1047    }
1048
1049    #[test]
1050    fn windows_wrapped_command_decodes_special_values_and_command_argv() {
1051        let forwarded = BTreeMap::from([(
1052            "REMOTE_VALUE".to_string(),
1053            "space ' quote \" $dollar & amp % percent ! bang\nand newline".to_string(),
1054        )]);
1055        let wrapped = build_windows_wrapped_remote_command(
1056            "sid",
1057            Some("dark"),
1058            &forwarded,
1059            &[
1060                "C:\\Program Files\\tool.exe".to_string(),
1061                "one ' $ & % !".to_string(),
1062            ],
1063        )
1064        .unwrap();
1065
1066        assert!(wrapped.starts_with("powershell.exe -NoProfile -EncodedCommand "));
1067        assert!(!wrapped.contains("REMOTE_VALUE"));
1068        assert!(!wrapped.contains("$dollar"));
1069        let bootstrap = decode_powershell_script(wrapped.rsplit_once(' ').unwrap().1);
1070        let encoded = bootstrap
1071            .split(" -EncodedCommand '")
1072            .nth(1)
1073            .unwrap()
1074            .split('\'')
1075            .next()
1076            .unwrap();
1077        let script = decode_powershell_script(encoded);
1078        assert!(!bootstrap.contains("REMOTE_VALUE"));
1079        assert!(!bootstrap.contains("$dollar"));
1080        assert!(bootstrap.contains("Get-Command pwsh.exe"));
1081        assert!(bootstrap.contains("else { 'powershell.exe' }"));
1082        assert!(bootstrap.contains("& $shell -NoProfile -EncodedCommand"));
1083        assert!(bootstrap.contains("exit $code"));
1084        assert!(script.contains("$env:SHINE_SSH_SESSION = 'sid'"));
1085        assert!(script.contains("$env:SHINE_TERMINAL_THEME = 'dark'"));
1086        assert!(script.contains(
1087            "$env:REMOTE_VALUE = 'space '' quote \" $dollar & amp % percent ! bang\nand newline'"
1088        ));
1089        assert!(script.contains("& 'C:\\Program Files\\tool.exe' 'one '' $ & % !'"));
1090        assert!(script.contains("exit $LASTEXITCODE"));
1091    }
1092
1093    fn decode_powershell_script(encoded: &str) -> String {
1094        let bytes = BASE64.decode(encoded).unwrap();
1095        let units = bytes
1096            .chunks_exact(2)
1097            .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
1098            .collect::<Vec<_>>();
1099        String::from_utf16(&units).unwrap()
1100    }
1101
1102    #[test]
1103    fn windows_wrapped_command_uses_no_exit_for_interactive_session() {
1104        let wrapped =
1105            build_windows_wrapped_remote_command("sid", None, &BTreeMap::new(), &[]).unwrap();
1106        let bootstrap = decode_powershell_script(wrapped.rsplit_once(' ').unwrap().1);
1107        assert!(bootstrap.contains("& $shell -NoExit -EncodedCommand "));
1108        assert!(!bootstrap.contains("& $shell -NoProfile"));
1109    }
1110
1111    #[test]
1112    fn windows_wrapped_command_selects_shell_before_running_payload() {
1113        let wrapped = build_windows_wrapped_remote_command(
1114            "sid",
1115            None,
1116            &BTreeMap::new(),
1117            &["exit".to_string(), "7".to_string()],
1118        )
1119        .unwrap();
1120
1121        assert!(wrapped.starts_with("powershell.exe -NoProfile -EncodedCommand "));
1122        let bootstrap = decode_powershell_script(wrapped.rsplit_once(' ').unwrap().1);
1123        assert!(bootstrap.contains("Get-Command pwsh.exe -CommandType Application"));
1124        assert!(bootstrap.contains("$pwsh.Source"));
1125        assert!(bootstrap.contains("else { 'powershell.exe' }"));
1126        assert_eq!(bootstrap.matches("& $shell").count(), 1);
1127        assert!(!wrapped.contains("&&"));
1128        assert!(!wrapped.contains("||"));
1129    }
1130
1131    #[test]
1132    fn windows_wrapped_command_rejects_nul_values() {
1133        let forwarded = BTreeMap::from([("REMOTE_VALUE".to_string(), "bad\0value".to_string())]);
1134        let error = build_windows_wrapped_remote_command("sid", None, &forwarded, &[]).unwrap_err();
1135        assert!(error.to_string().contains("NUL"));
1136    }
1137
1138    #[tokio::test]
1139    async fn forwarded_plain_env_uses_exact_key_and_alias() {
1140        let dir = std::env::temp_dir().join(format!("shine-ssh-env-{}", uuid::Uuid::new_v4()));
1141        let mut config = Config::new_for_test(&dir);
1142        config.env.insert("LOCAL_NAME".into(), "local value".into());
1143        config
1144            .env
1145            .insert("LOCAL_NAME_SECRET".into(), "encrypted value".into());
1146
1147        let resolved = resolve_forwarded_env(&config, &["LOCAL_NAME=REMOTE_NAME".to_string()], &[])
1148            .await
1149            .unwrap();
1150
1151        assert_eq!(
1152            resolved.get("REMOTE_NAME").map(String::as_str),
1153            Some("local value")
1154        );
1155    }
1156
1157    #[tokio::test]
1158    async fn forwarded_plain_env_requires_explicit_secret_opt_in() {
1159        let dir = std::env::temp_dir().join(format!("shine-ssh-env-{}", uuid::Uuid::new_v4()));
1160        let mut config = Config::new_for_test(&dir);
1161        config
1162            .env
1163            .insert("API_TOKEN_SECRET".into(), "ciphertext".into());
1164
1165        let error = resolve_forwarded_env(&config, &["API_TOKEN".to_string()], &[])
1166            .await
1167            .unwrap_err();
1168
1169        assert!(error.to_string().contains("use --with-secret API_TOKEN"));
1170    }
1171
1172    #[tokio::test]
1173    async fn forwarded_secret_requires_base_key_and_encrypted_storage() {
1174        let dir = std::env::temp_dir().join(format!("shine-ssh-env-{}", uuid::Uuid::new_v4()));
1175        let mut config = Config::new_for_test(&dir);
1176        config.env.insert("API_TOKEN".into(), "plaintext".into());
1177        config
1178            .env
1179            .insert("OTHER_SECRET".into(), "ciphertext".into());
1180
1181        let plaintext_only = resolve_forwarded_env(&config, &[], &["API_TOKEN".to_string()])
1182            .await
1183            .unwrap_err();
1184        assert!(
1185            plaintext_only
1186                .to_string()
1187                .contains("API_TOKEN_SECRET is not set")
1188        );
1189
1190        let suffixed = resolve_forwarded_env(&config, &[], &["OTHER_SECRET".to_string()])
1191            .await
1192            .unwrap_err();
1193        assert!(
1194            suffixed
1195                .to_string()
1196                .contains("expects a base key without the _SECRET suffix")
1197        );
1198    }
1199
1200    #[tokio::test]
1201    async fn forwarded_env_rejects_duplicate_and_reserved_targets() {
1202        let dir = std::env::temp_dir().join(format!("shine-ssh-env-{}", uuid::Uuid::new_v4()));
1203        let mut config = Config::new_for_test(&dir);
1204        config.env.insert("ONE".into(), "1".into());
1205        config.env.insert("TWO".into(), "2".into());
1206
1207        let duplicate = resolve_forwarded_env(
1208            &config,
1209            &["ONE=REMOTE".to_string(), "TWO=REMOTE".to_string()],
1210            &[],
1211        )
1212        .await
1213        .unwrap_err();
1214        assert!(
1215            duplicate
1216                .to_string()
1217                .contains("duplicate target variable: REMOTE")
1218        );
1219
1220        let reserved = resolve_forwarded_env(&config, &["ONE=SHINE_SSH_TOKEN".to_string()], &[])
1221            .await
1222            .unwrap_err();
1223        assert!(
1224            reserved
1225                .to_string()
1226                .contains("cannot override shine-managed SSH variable SHINE_SSH_TOKEN")
1227        );
1228    }
1229}