1mod agent;
28mod broker;
29#[cfg(all(test, unix))]
32mod integration_tests;
33mod protocol;
34mod session_context;
35#[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
55const 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
180const 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)] pub 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 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 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 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 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 let status = run_ssh_with_ctrl_c_broker(&mut cmd, broker_session.as_deref()).await?;
321
322 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
386async 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#[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#[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
491fn 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
537fn 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 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
569fn 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 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 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
626fn 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 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 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
700fn 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
720fn 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 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 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 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 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}