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 #[cfg(unix)]
806 #[test]
807 fn wrapped_command_round_trips_through_a_real_shell() {
808 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 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 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 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}