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.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    #[test]
806    fn wrapped_command_round_trips_through_a_real_shell() {
807        // Exercises the full nested-quoting composition end to end: run the
808        // wrapped command through `sh -c` and check the remote command's
809        // stdout, rather than reasoning about escaping by hand.
810        let wrapped = build_wrapped_remote_command(
811            "sid",
812            "tok",
813            "/tmp/shine-ssh-mod-test-sid.sock",
814            None,
815            &BTreeMap::new(),
816            &["echo".to_string(), "it's a test".to_string()],
817        );
818        let output = std::process::Command::new("sh")
819            .arg("-c")
820            .arg(&wrapped)
821            .output()
822            .expect("failed to run sh");
823        assert!(output.status.success(), "stderr: {:?}", output.stderr);
824        assert_eq!(
825            String::from_utf8_lossy(&output.stdout).trim_end(),
826            "it's a test"
827        );
828    }
829
830    #[test]
831    fn ssh_invocation_args_keep_user_options_verbatim_and_ahead_of_our_own() {
832        let args = vec!["-J".to_string(), "bastion".to_string()];
833        let (parsed_options, host, _command) =
834            split_ssh_args(&[args.clone(), vec!["dev".to_string()]].concat()).unwrap();
835
836        let invocation = build_ssh_invocation_args(
837            &parsed_options,
838            "/tmp/.shine-ssh-sid.sock",
839            "/tmp/shine-ssh-sid/local.sock",
840            None,
841            &host,
842            "wrapped-command",
843        );
844
845        assert_eq!(
846            invocation,
847            vec![
848                "-J",
849                "bastion",
850                "-t",
851                "-R",
852                "/tmp/.shine-ssh-sid.sock:/tmp/shine-ssh-sid/local.sock",
853                "dev",
854                "wrapped-command",
855            ]
856        );
857    }
858
859    #[test]
860    fn windows_ssh_invocation_has_no_transfer_or_posix_wrapper() {
861        let invocation = build_windows_ssh_invocation_args(
862            &["-p".to_string(), "2222".to_string()],
863            "windows-host",
864            "powershell.exe -NoProfile -EncodedCommand QQ==",
865        );
866
867        assert_eq!(
868            invocation,
869            vec![
870                "-p",
871                "2222",
872                "-t",
873                "windows-host",
874                "powershell.exe -NoProfile -EncodedCommand QQ==",
875            ]
876        );
877        assert!(!invocation.iter().any(|arg| arg == "-R"));
878        assert!(!invocation.iter().any(|arg| arg.contains("env ")));
879        assert!(!invocation.iter().any(|arg| arg.contains("sh -c")));
880    }
881
882    #[test]
883    fn ssh_invocation_args_inject_control_master_when_control_path_given() {
884        let (parsed_options, host, _command) = split_ssh_args(&["dev".to_string()]).unwrap();
885        let invocation = build_ssh_invocation_args(
886            &parsed_options,
887            "/tmp/.shine-ssh-sid.sock",
888            "/tmp/shine-ssh-sid/local.sock",
889            Some(std::path::Path::new("/tmp/shine-ssh-sid/ctl.sock")),
890            &host,
891            "wrapped-command",
892        );
893
894        assert_eq!(
895            invocation,
896            vec![
897                "-o",
898                "ControlMaster=auto",
899                "-o",
900                "ControlPath=/tmp/shine-ssh-sid/ctl.sock",
901                "-o",
902                "ControlPersist=60",
903                "-t",
904                "-R",
905                "/tmp/.shine-ssh-sid.sock:/tmp/shine-ssh-sid/local.sock",
906                "dev",
907                "wrapped-command",
908            ]
909        );
910    }
911
912    #[test]
913    fn ssh_invocation_args_preserve_repeated_o_options_in_order() {
914        let args = vec![
915            "-o".to_string(),
916            "ProxyJump=bastion".to_string(),
917            "-o".to_string(),
918            "ServerAliveInterval=30".to_string(),
919            "dev".to_string(),
920            "ls".to_string(),
921            "-la".to_string(),
922        ];
923        let (parsed_options, host, command) = split_ssh_args(&args).unwrap();
924        assert_eq!(command, vec!["ls", "-la"]);
925
926        let invocation = build_ssh_invocation_args(
927            &parsed_options,
928            "/tmp/.shine-ssh-sid.sock",
929            "/tmp/shine-ssh-sid/local.sock",
930            None,
931            &host,
932            "wrapped-command",
933        );
934
935        // The user's repeated -o options must appear verbatim, in order, and
936        // ahead of our own -t/-R/host/command — never reordered or merged.
937        assert_eq!(
938            invocation,
939            vec![
940                "-o",
941                "ProxyJump=bastion",
942                "-o",
943                "ServerAliveInterval=30",
944                "-t",
945                "-R",
946                "/tmp/.shine-ssh-sid.sock:/tmp/shine-ssh-sid/local.sock",
947                "dev",
948                "wrapped-command",
949            ]
950        );
951    }
952
953    #[test]
954    fn wrapped_command_defaults_to_login_shell() {
955        let wrapped = build_wrapped_remote_command(
956            "sid",
957            "tok",
958            "/tmp/.shine-ssh-sid.sock",
959            None,
960            &BTreeMap::new(),
961            &[],
962        );
963        assert!(wrapped.contains(r#"exec "$SHELL" -l"#));
964        assert!(wrapped.contains("trap \"rm -f $SHINE_SSH_REMOTE_SOCK\" EXIT"));
965    }
966
967    #[test]
968    fn wrapped_command_omits_theme_var_when_none() {
969        let wrapped = build_wrapped_remote_command(
970            "sid",
971            "tok",
972            "/tmp/.shine-ssh-sid.sock",
973            None,
974            &BTreeMap::new(),
975            &[],
976        );
977        assert!(!wrapped.contains("SHINE_TERMINAL_THEME"));
978    }
979
980    #[test]
981    fn wrapped_command_injects_quoted_theme_var_when_present() {
982        let wrapped = build_wrapped_remote_command(
983            "sid",
984            "tok",
985            "/tmp/.shine-ssh-sid.sock",
986            Some("dark"),
987            &BTreeMap::new(),
988            &[],
989        );
990        assert!(wrapped.contains("SHINE_TERMINAL_THEME='dark'"));
991        // Must appear inside the `env ...` prefix, before the `sh -c` handoff.
992        assert!(
993            wrapped.find("SHINE_TERMINAL_THEME").unwrap() < wrapped.find("sh -c").unwrap(),
994            "theme var must be part of the env prefix: {wrapped}"
995        );
996    }
997
998    #[test]
999    fn wrapped_command_theme_injection_round_trips_through_a_real_shell() {
1000        // Same rationale as wrapped_command_round_trips_through_a_real_shell:
1001        // verify the quoting composition by actually running it, rather than
1002        // reasoning about escaping by hand.
1003        let wrapped = build_wrapped_remote_command(
1004            "sid",
1005            "tok",
1006            "/tmp/shine-ssh-mod-test-theme-sid.sock",
1007            Some("dark"),
1008            &BTreeMap::new(),
1009            &["printenv".to_string(), "SHINE_TERMINAL_THEME".to_string()],
1010        );
1011        let output = std::process::Command::new("sh")
1012            .arg("-c")
1013            .arg(&wrapped)
1014            .output()
1015            .expect("failed to run sh");
1016        assert!(output.status.success(), "stderr: {:?}", output.stderr);
1017        assert_eq!(String::from_utf8_lossy(&output.stdout).trim_end(), "dark");
1018    }
1019
1020    #[test]
1021    fn wrapped_command_forwarded_env_round_trips_special_characters() {
1022        let forwarded = BTreeMap::from([(
1023            "REMOTE_VALUE".to_string(),
1024            "space ' quote $dollar\nand newline".to_string(),
1025        )]);
1026        let wrapped = build_wrapped_remote_command(
1027            "sid",
1028            "tok",
1029            "/tmp/shine-ssh-mod-test-env-sid.sock",
1030            None,
1031            &forwarded,
1032            &["printenv".to_string(), "REMOTE_VALUE".to_string()],
1033        );
1034        let output = std::process::Command::new("sh")
1035            .arg("-c")
1036            .arg(&wrapped)
1037            .output()
1038            .expect("failed to run sh");
1039        assert!(output.status.success(), "stderr: {:?}", output.stderr);
1040        assert_eq!(
1041            String::from_utf8_lossy(&output.stdout),
1042            "space ' quote $dollar\nand newline\n"
1043        );
1044    }
1045
1046    #[test]
1047    fn windows_wrapped_command_decodes_special_values_and_command_argv() {
1048        let forwarded = BTreeMap::from([(
1049            "REMOTE_VALUE".to_string(),
1050            "space ' quote \" $dollar & amp % percent ! bang\nand newline".to_string(),
1051        )]);
1052        let wrapped = build_windows_wrapped_remote_command(
1053            "sid",
1054            Some("dark"),
1055            &forwarded,
1056            &[
1057                "C:\\Program Files\\tool.exe".to_string(),
1058                "one ' $ & % !".to_string(),
1059            ],
1060        )
1061        .unwrap();
1062
1063        assert!(wrapped.starts_with("powershell.exe -NoProfile -EncodedCommand "));
1064        assert!(!wrapped.contains("REMOTE_VALUE"));
1065        assert!(!wrapped.contains("$dollar"));
1066        let bootstrap = decode_powershell_script(wrapped.rsplit_once(' ').unwrap().1);
1067        let encoded = bootstrap
1068            .split(" -EncodedCommand '")
1069            .nth(1)
1070            .unwrap()
1071            .split('\'')
1072            .next()
1073            .unwrap();
1074        let script = decode_powershell_script(encoded);
1075        assert!(!bootstrap.contains("REMOTE_VALUE"));
1076        assert!(!bootstrap.contains("$dollar"));
1077        assert!(bootstrap.contains("Get-Command pwsh.exe"));
1078        assert!(bootstrap.contains("else { 'powershell.exe' }"));
1079        assert!(bootstrap.contains("& $shell -NoProfile -EncodedCommand"));
1080        assert!(bootstrap.contains("exit $code"));
1081        assert!(script.contains("$env:SHINE_SSH_SESSION = 'sid'"));
1082        assert!(script.contains("$env:SHINE_TERMINAL_THEME = 'dark'"));
1083        assert!(script.contains(
1084            "$env:REMOTE_VALUE = 'space '' quote \" $dollar & amp % percent ! bang\nand newline'"
1085        ));
1086        assert!(script.contains("& 'C:\\Program Files\\tool.exe' 'one '' $ & % !'"));
1087        assert!(script.contains("exit $LASTEXITCODE"));
1088    }
1089
1090    fn decode_powershell_script(encoded: &str) -> String {
1091        let bytes = BASE64.decode(encoded).unwrap();
1092        let units = bytes
1093            .chunks_exact(2)
1094            .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
1095            .collect::<Vec<_>>();
1096        String::from_utf16(&units).unwrap()
1097    }
1098
1099    #[test]
1100    fn windows_wrapped_command_uses_no_exit_for_interactive_session() {
1101        let wrapped =
1102            build_windows_wrapped_remote_command("sid", None, &BTreeMap::new(), &[]).unwrap();
1103        let bootstrap = decode_powershell_script(wrapped.rsplit_once(' ').unwrap().1);
1104        assert!(bootstrap.contains("& $shell -NoExit -EncodedCommand "));
1105        assert!(!bootstrap.contains("& $shell -NoProfile"));
1106    }
1107
1108    #[test]
1109    fn windows_wrapped_command_selects_shell_before_running_payload() {
1110        let wrapped = build_windows_wrapped_remote_command(
1111            "sid",
1112            None,
1113            &BTreeMap::new(),
1114            &["exit".to_string(), "7".to_string()],
1115        )
1116        .unwrap();
1117
1118        assert!(wrapped.starts_with("powershell.exe -NoProfile -EncodedCommand "));
1119        let bootstrap = decode_powershell_script(wrapped.rsplit_once(' ').unwrap().1);
1120        assert!(bootstrap.contains("Get-Command pwsh.exe -CommandType Application"));
1121        assert!(bootstrap.contains("$pwsh.Source"));
1122        assert!(bootstrap.contains("else { 'powershell.exe' }"));
1123        assert_eq!(bootstrap.matches("& $shell").count(), 1);
1124        assert!(!wrapped.contains("&&"));
1125        assert!(!wrapped.contains("||"));
1126    }
1127
1128    #[test]
1129    fn windows_wrapped_command_rejects_nul_values() {
1130        let forwarded = BTreeMap::from([("REMOTE_VALUE".to_string(), "bad\0value".to_string())]);
1131        let error = build_windows_wrapped_remote_command("sid", None, &forwarded, &[]).unwrap_err();
1132        assert!(error.to_string().contains("NUL"));
1133    }
1134
1135    #[tokio::test]
1136    async fn forwarded_plain_env_uses_exact_key_and_alias() {
1137        let dir = std::env::temp_dir().join(format!("shine-ssh-env-{}", uuid::Uuid::new_v4()));
1138        let mut config = Config::new_for_test(&dir);
1139        config.env.insert("LOCAL_NAME".into(), "local value".into());
1140        config
1141            .env
1142            .insert("LOCAL_NAME_SECRET".into(), "encrypted value".into());
1143
1144        let resolved = resolve_forwarded_env(&config, &["LOCAL_NAME=REMOTE_NAME".to_string()], &[])
1145            .await
1146            .unwrap();
1147
1148        assert_eq!(
1149            resolved.get("REMOTE_NAME").map(String::as_str),
1150            Some("local value")
1151        );
1152    }
1153
1154    #[tokio::test]
1155    async fn forwarded_plain_env_requires_explicit_secret_opt_in() {
1156        let dir = std::env::temp_dir().join(format!("shine-ssh-env-{}", uuid::Uuid::new_v4()));
1157        let mut config = Config::new_for_test(&dir);
1158        config
1159            .env
1160            .insert("API_TOKEN_SECRET".into(), "ciphertext".into());
1161
1162        let error = resolve_forwarded_env(&config, &["API_TOKEN".to_string()], &[])
1163            .await
1164            .unwrap_err();
1165
1166        assert!(error.to_string().contains("use --with-secret API_TOKEN"));
1167    }
1168
1169    #[tokio::test]
1170    async fn forwarded_secret_requires_base_key_and_encrypted_storage() {
1171        let dir = std::env::temp_dir().join(format!("shine-ssh-env-{}", uuid::Uuid::new_v4()));
1172        let mut config = Config::new_for_test(&dir);
1173        config.env.insert("API_TOKEN".into(), "plaintext".into());
1174        config
1175            .env
1176            .insert("OTHER_SECRET".into(), "ciphertext".into());
1177
1178        let plaintext_only = resolve_forwarded_env(&config, &[], &["API_TOKEN".to_string()])
1179            .await
1180            .unwrap_err();
1181        assert!(
1182            plaintext_only
1183                .to_string()
1184                .contains("API_TOKEN_SECRET is not set")
1185        );
1186
1187        let suffixed = resolve_forwarded_env(&config, &[], &["OTHER_SECRET".to_string()])
1188            .await
1189            .unwrap_err();
1190        assert!(
1191            suffixed
1192                .to_string()
1193                .contains("expects a base key without the _SECRET suffix")
1194        );
1195    }
1196
1197    #[tokio::test]
1198    async fn forwarded_env_rejects_duplicate_and_reserved_targets() {
1199        let dir = std::env::temp_dir().join(format!("shine-ssh-env-{}", uuid::Uuid::new_v4()));
1200        let mut config = Config::new_for_test(&dir);
1201        config.env.insert("ONE".into(), "1".into());
1202        config.env.insert("TWO".into(), "2".into());
1203
1204        let duplicate = resolve_forwarded_env(
1205            &config,
1206            &["ONE=REMOTE".to_string(), "TWO=REMOTE".to_string()],
1207            &[],
1208        )
1209        .await
1210        .unwrap_err();
1211        assert!(
1212            duplicate
1213                .to_string()
1214                .contains("duplicate target variable: REMOTE")
1215        );
1216
1217        let reserved = resolve_forwarded_env(&config, &["ONE=SHINE_SSH_TOKEN".to_string()], &[])
1218            .await
1219            .unwrap_err();
1220        assert!(
1221            reserved
1222                .to_string()
1223                .contains("cannot override shine-managed SSH variable SHINE_SSH_TOKEN")
1224        );
1225    }
1226}