1use super::*;
2
3#[cfg(unix)]
4use std::fs;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Condvar, Mutex, OnceLock};
7use std::time::Duration;
8
9pub fn ssh_connectivity_probe(ssh: &SshTarget) -> CommandSpec {
24 let mut probe = ssh.clone();
25 probe.ssh_args.splice(
26 0..0,
27 [
28 "-o".to_owned(),
29 "BatchMode=yes".to_owned(),
30 "-o".to_owned(),
31 "StrictHostKeyChecking=yes".to_owned(),
32 ],
33 );
34 ssh_command_with_control(&probe, vec!["true".to_owned()], false)
35 .purpose("verify SSH connectivity")
36}
37
38pub fn ssh_command(
39 ssh: &SshTarget,
40 args: impl IntoIterator<Item = impl AsRef<str>>,
41) -> CommandSpec {
42 ssh_command_owned(
43 ssh,
44 args.into_iter()
45 .map(|arg| arg.as_ref().to_owned())
46 .collect(),
47 )
48}
49
50pub fn ssh_command_owned(ssh: &SshTarget, remote_args: Vec<String>) -> CommandSpec {
51 ssh_command_with_control(ssh, remote_args, true)
52}
53
54fn ssh_command_with_control(
56 ssh: &SshTarget,
57 remote_args: Vec<String>,
58 may_become_master: bool,
59) -> CommandSpec {
60 let mut args = ssh.ssh_args.clone();
61 push_control_args(&mut args, may_become_master);
62 args.push(ssh.destination.clone());
63 args.push(join_remote_command(&remote_args));
64 CommandSpec::new("ssh", args).ssh_destination(ssh.destination.clone())
65}
66
67pub const REMOTE_UPLOAD_STAGING: &str = ".cache/mjolnir/uploads";
71
72pub fn scp_upload(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
74 let mut args = scp_args(ssh);
75 if recursive {
76 args.push("-r".into());
77 }
78 args.push(source.to_string_lossy().into_owned());
79 args.push(format!("{}:{remote}", ssh.destination));
80 scp_command(ssh, args)
81}
82
83pub fn scp_download(ssh: &SshTarget, remote: &str, local: &str) -> CommandSpec {
85 let mut args = scp_args(ssh);
86 args.push(format!("{}:{remote}", ssh.destination));
87 args.push(local.into());
88 scp_command(ssh, args)
89}
90
91fn scp_args(ssh: &SshTarget) -> Vec<String> {
95 let mut args = ssh
96 .ssh_args
97 .iter()
98 .map(|argument| {
99 if argument == "-p" {
100 "-P".to_owned()
101 } else {
102 argument.clone()
103 }
104 })
105 .collect();
106 push_connection_sharing_args(&mut args);
107 args
108}
109
110fn scp_command(ssh: &SshTarget, args: Vec<String>) -> CommandSpec {
111 CommandSpec::new("scp", args).ssh_destination(ssh.destination.clone())
114}
115
116#[cfg(unix)]
121const CONTROL_PERSIST: &str = "60";
122
123pub const CONTROL_MASTER_ENV: &str = "MJ_SSH_CONTROL_MASTER";
126
127#[cfg(unix)]
130const MAX_CONTROL_PATH: usize = 103;
131
132#[cfg(unix)]
135const CONTROL_PATH_FILE: &str = "%C";
136
137#[cfg(unix)]
138fn sharing_disabled(value: Option<&std::ffi::OsStr>) -> bool {
139 let Some(value) = value else {
140 return false;
141 };
142 matches!(
143 value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
144 "0" | "off" | "false" | "no"
145 )
146}
147
148#[doc(hidden)]
151#[derive(Debug, Clone)]
152pub enum SshSharingForTest {
153 Disabled,
155 Directory(PathBuf),
157}
158
159static SHARING_OVERRIDE: Mutex<Option<SshSharingForTest>> = Mutex::new(None);
160
161#[doc(hidden)]
166pub fn set_ssh_connection_sharing_for_test(setting: Option<SshSharingForTest>) {
167 *SHARING_OVERRIDE
168 .lock()
169 .unwrap_or_else(std::sync::PoisonError::into_inner) = setting;
170}
171
172#[cfg(unix)]
173fn sharing_override() -> Option<SshSharingForTest> {
174 SHARING_OVERRIDE
175 .lock()
176 .unwrap_or_else(std::sync::PoisonError::into_inner)
177 .clone()
178}
179
180#[cfg(unix)]
186fn control_socket_path() -> Option<PathBuf> {
187 match sharing_override() {
188 Some(SshSharingForTest::Disabled) => return None,
189 Some(SshSharingForTest::Directory(dir)) => return prepare_control_path(dir),
190 None => {}
191 }
192 static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
193 DIR.get_or_init(|| {
194 if sharing_disabled(std::env::var_os(CONTROL_MASTER_ENV).as_deref()) {
195 return None;
196 }
197 let base = match std::env::var_os("XDG_RUNTIME_DIR") {
198 Some(runtime) if !runtime.is_empty() => PathBuf::from(runtime).join("mjolnir"),
199 _ => crate::config::data_dir().join("ssh"),
200 };
201 prepare_control_path(base)
202 })
203 .clone()
204}
205
206#[cfg(unix)]
209fn prepare_control_path(dir: PathBuf) -> Option<PathBuf> {
210 let socket = dir.join(CONTROL_PATH_FILE);
211 let bound_len = socket.as_os_str().len() - CONTROL_PATH_FILE.len() + 64;
214 if bound_len > MAX_CONTROL_PATH {
215 tracing::debug!(
216 directory = %dir.display(),
217 "skipping SSH connection sharing: control socket path would be too long"
218 );
219 return None;
220 }
221 if let Err(error) = fs::create_dir_all(&dir) {
222 tracing::debug!(
223 directory = %dir.display(),
224 %error,
225 "skipping SSH connection sharing: control directory is unavailable"
226 );
227 return None;
228 }
229 use std::os::unix::fs::PermissionsExt;
230 if let Err(error) = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)) {
231 tracing::debug!(
232 directory = %dir.display(),
233 %error,
234 "skipping SSH connection sharing: cannot restrict control directory"
235 );
236 return None;
237 }
238 Some(socket)
239}
240
241pub fn push_connection_sharing_args(args: &mut Vec<String>) {
254 push_control_args(args, true);
255}
256
257pub fn push_connection_reuse_args(args: &mut Vec<String>) {
270 push_control_args(args, false);
271}
272
273fn push_control_args(args: &mut Vec<String>, may_become_master: bool) {
274 #[cfg(unix)]
275 if let Some(socket) = control_socket_path() {
276 args.extend([
277 "-o".to_owned(),
278 if may_become_master {
279 "ControlMaster=auto".to_owned()
280 } else {
281 "ControlMaster=no".to_owned()
282 },
283 "-o".to_owned(),
284 format!("ControlPath={}", socket.display()),
285 ]);
286 if may_become_master {
288 args.extend(["-o".to_owned(), format!("ControlPersist={CONTROL_PERSIST}")]);
289 }
290 }
291 #[cfg(not(unix))]
292 let _ = (args, may_become_master);
293}
294
295pub fn join_remote_command(args: &[String]) -> String {
296 args.iter()
297 .map(|arg| posix_quote(arg))
298 .collect::<Vec<_>>()
299 .join(" ")
300}
301
302pub fn ssh_directory_exists(
304 ssh: &SshTarget,
305 path: &Path,
306 executor: &impl CommandExecutor,
307) -> Result<bool> {
308 let command = ssh_validation_command(
309 ssh,
310 vec![
311 "test".into(),
312 "-d".into(),
313 path.to_string_lossy().into_owned(),
314 ],
315 "validate remote directory",
316 );
317 let output = executor.execute(&command)?;
318 match output.status {
319 0 => Ok(true),
320 1 => Ok(false),
321 status => bail!(
322 "remote directory check failed with status {status}: {}",
323 String::from_utf8_lossy(&output.stderr).trim()
324 ),
325 }
326}
327
328pub fn validate_bare_project_directory(
330 ssh: &SshTarget,
331 path: &Path,
332 executor: &impl CommandExecutor,
333) -> Result<()> {
334 validate_bare_project_path(path)?;
335 if !ssh_directory_exists(ssh, path, executor)? {
336 bail!(
337 "remote project directory {} does not exist or is not a directory",
338 path.display()
339 );
340 }
341 let output = executor.execute(&ssh_validation_command(
342 ssh,
343 vec![
344 "git".into(),
345 "-C".into(),
346 path.to_string_lossy().into_owned(),
347 "rev-parse".into(),
348 "--verify".into(),
349 "HEAD".into(),
350 ],
351 "validate bare SSH Git project",
352 ))?;
353 if output.status != 0 {
354 let detail = String::from_utf8_lossy(&output.stderr);
355 let detail = detail.trim();
356 if detail.is_empty() {
357 bail!(
358 "remote project directory {} has no valid Git HEAD",
359 path.display()
360 );
361 }
362 bail!(
363 "remote project directory {} has no valid Git HEAD: {detail}",
364 path.display()
365 );
366 }
367 Ok(())
368}
369
370pub fn validate_bare_project_path(path: &Path) -> Result<()> {
371 if !path.is_absolute()
372 || path
373 .components()
374 .any(|part| part == std::path::Component::ParentDir)
375 {
376 bail!("bare project directory must be an absolute safe path");
377 }
378 Ok(())
379}
380
381pub fn ssh_validation_command(
382 ssh: &SshTarget,
383 remote_args: Vec<String>,
384 purpose: &'static str,
385) -> CommandSpec {
386 let mut args = ssh.ssh_args.clone();
387 args.extend([
388 "-o".into(),
389 "BatchMode=yes".into(),
390 "-o".into(),
391 "ConnectTimeout=3".into(),
392 "-o".into(),
393 "ServerAliveInterval=2".into(),
394 "-o".into(),
395 "ServerAliveCountMax=1".into(),
396 ]);
397 push_connection_reuse_args(&mut args);
398 args.extend([ssh.destination.clone(), join_remote_command(&remote_args)]);
399 CommandSpec::new("ssh", args)
400 .ssh_destination(ssh.destination.clone())
401 .purpose(purpose)
402}
403
404pub fn posix_quote(value: &str) -> String {
408 format!("'{}'", value.replace('\'', "'\\''"))
409}
410
411pub fn verify_locator(locator: &TargetLocator, session_id: &str) -> Result<()> {
412 let expected_name = resource_name(session_id)?;
413 match locator {
414 TargetLocator::LocalBare { worker_root } => {
415 let path = Path::new(worker_root);
416 if !path.is_absolute()
417 || path
418 .components()
419 .any(|part| part == std::path::Component::ParentDir)
420 || !path.ends_with(session_id)
421 {
422 bail!("refusing cleanup: invalid local bare worker root");
423 }
424 }
425 TargetLocator::LocalPodman {
426 container_id,
427 borrowed_from,
428 ..
429 }
430 | TargetLocator::LocalDocker {
431 container_id,
432 borrowed_from,
433 }
434 | TargetLocator::AppleContainer {
435 container_id,
436 borrowed_from,
437 }
438 | TargetLocator::SshPodman {
439 container_id,
440 borrowed_from,
441 ..
442 }
443 | TargetLocator::SshDocker {
444 container_id,
445 borrowed_from,
446 ..
447 } => match borrowed_from {
448 Some(owner) => {
449 validate_session_id(owner)?;
450 if owner == session_id {
451 bail!(
452 "refusing cleanup: a borrowed container cannot be owned by the borrowing session"
453 );
454 }
455 let owner_name = resource_name(owner)?;
456 if container_id != &owner_name && !is_runtime_container_id(container_id) {
457 bail!(
458 "refusing cleanup: borrowed container locator is neither the owning session's generated name nor an immutable runtime ID"
459 );
460 }
461 }
462 None => {
463 if container_id != &expected_name && !is_runtime_container_id(container_id) {
464 bail!(
465 "refusing cleanup: container locator is neither the generated name nor an immutable runtime ID"
466 );
467 }
468 }
469 },
470 TargetLocator::AwsEc2 {
471 instance_id,
472 workspace,
473 ..
474 } => {
475 if !valid_ec2_instance_id(instance_id) {
476 bail!("refusing cleanup: invalid EC2 instance ID");
477 }
478 verify_session_workspace(workspace, session_id)?;
479 }
480 TargetLocator::SshBare {
481 workspace,
482 worker_id,
483 ..
484 } => match worker_id {
485 Some(worker_id) => {
486 validate_session_id(worker_id)?;
487 if worker_id != session_id {
488 bail!("refusing cleanup: SSH worker identity does not match session ID");
489 }
490 validate_workspace_prefix(workspace)?;
491 }
492 None => verify_session_workspace(workspace, session_id)?,
493 },
494 }
495 Ok(())
496}
497
498pub fn is_borrowed(locator: &TargetLocator) -> bool {
502 match locator {
503 TargetLocator::LocalPodman { borrowed_from, .. }
504 | TargetLocator::LocalDocker { borrowed_from, .. }
505 | TargetLocator::AppleContainer { borrowed_from, .. }
506 | TargetLocator::SshPodman { borrowed_from, .. }
507 | TargetLocator::SshDocker { borrowed_from, .. } => borrowed_from.is_some(),
508 TargetLocator::SshBare { worker_id, .. } => worker_id.is_some(),
509 TargetLocator::LocalBare { .. } | TargetLocator::AwsEc2 { .. } => false,
510 }
511}
512
513pub fn verify_session_workspace(workspace: &str, session_id: &str) -> Result<()> {
514 validate_workspace_prefix(workspace)?;
515 let final_component = workspace.trim_end_matches('/').rsplit('/').next();
516 if final_component != Some(session_id) {
517 bail!("refusing cleanup: workspace does not end in the exact session ID");
518 }
519 Ok(())
520}
521
522pub fn validate_session_id(value: &str) -> Result<()> {
523 if value.len() < 8
524 || value.len() > 128
525 || !value
526 .chars()
527 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
528 {
529 bail!("session ID must be 8-128 ASCII letters, digits, '-' or '_'");
530 }
531 Ok(())
532}
533
534pub fn validate_relative_path(value: &str) -> Result<()> {
535 let path = std::path::Path::new(value);
536 if value.is_empty()
537 || path.is_absolute()
538 || path
539 .components()
540 .any(|part| !matches!(part, std::path::Component::Normal(_)))
541 {
542 bail!("unsafe relative bundle path {value:?}");
543 }
544 Ok(())
545}
546
547pub fn validate_workspace_prefix(value: &str) -> Result<()> {
548 if value.is_empty()
549 || value == "/"
550 || value == "~"
551 || value == "~/"
552 || value.contains('\0')
553 || value.split('/').any(|part| part == "..")
554 {
555 bail!("unsafe workspace path");
556 }
557 Ok(())
558}
559
560pub fn validate_container_template(template: &ContainerTemplate) -> Result<()> {
561 if template.image.trim().is_empty() || template.image.starts_with('-') {
562 bail!("invalid container image");
563 }
564 if template
565 .extra_run_args
566 .iter()
567 .any(|arg| arg == "--name" || arg.starts_with("--name="))
568 {
569 bail!("container template may not override the generated name");
570 }
571 if template.extra_run_args.iter().any(|arg| {
572 arg == "--label"
573 || [SESSION_LABEL, MANAGED_LABEL, INSTANCE_LABEL]
574 .iter()
575 .any(|label| arg.starts_with(&format!("--label={label}=")))
576 }) {
577 bail!("container template may not override Mjolnir ownership labels");
578 }
579 Ok(())
580}
581
582pub fn validate_ssh(ssh: &SshTarget) -> Result<()> {
583 if ssh.destination.trim().is_empty()
584 || ssh.destination.starts_with('-')
585 || ssh.destination.chars().any(char::is_whitespace)
586 {
587 bail!("invalid SSH destination");
588 }
589 Ok(())
590}
591
592pub fn validate_aws(aws: &AwsTemplate) -> Result<()> {
593 validate_ssh(&aws.ssh)?;
594 for (name, value) in [
595 ("AWS profile", &aws.profile),
596 ("AWS region", &aws.region),
597 ("launch template", &aws.launch_template),
598 ] {
599 if value.is_empty()
600 || value.starts_with('-')
601 || !value
602 .chars()
603 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
604 {
605 bail!("invalid {name}");
606 }
607 }
608 Ok(())
609}
610
611pub fn validate_executable(value: &str) -> Result<()> {
612 if value.is_empty() || value.starts_with('-') || value.chars().any(char::is_whitespace) {
613 bail!("invalid executable name");
614 }
615 Ok(())
616}
617
618pub fn valid_ec2_instance_id(value: &str) -> bool {
619 value
620 .strip_prefix("i-")
621 .is_some_and(|rest| rest.len() >= 8 && rest.chars().all(|c| c.is_ascii_hexdigit()))
622}
623
624pub fn is_runtime_container_id(value: &str) -> bool {
625 value.len() >= 12 && value.len() <= 128 && value.chars().all(|c| c.is_ascii_hexdigit())
626}
627
628pub const SSH_TRANSPORT_EXIT_STATUS: i32 = 255;
631
632const TRANSPORT_REJECTION_MARKERS: [&str; 4] = [
636 "Connection closed by",
637 "Connection reset by",
638 "kex_exchange_identification",
639 "Connection timed out during banner exchange",
640];
641
642pub fn is_transport_rejection(status: i32, stderr: &str) -> bool {
648 status == SSH_TRANSPORT_EXIT_STATUS
649 && TRANSPORT_REJECTION_MARKERS
650 .iter()
651 .any(|marker| stderr.contains(marker))
652}
653
654const DEFAULT_MAX_CONCURRENT_SSH: usize = 6;
663
664pub const MAX_CONCURRENT_SSH_ENV: &str = "MJ_SSH_MAX_CONCURRENT";
666
667fn max_concurrent_ssh() -> usize {
668 static LIMIT: OnceLock<usize> = OnceLock::new();
669 *LIMIT.get_or_init(|| {
670 let Some(raw) = std::env::var_os(MAX_CONCURRENT_SSH_ENV) else {
671 return DEFAULT_MAX_CONCURRENT_SSH;
672 };
673 match raw
674 .to_str()
675 .and_then(|value| value.trim().parse::<usize>().ok())
676 {
677 Some(limit) if limit > 0 => limit,
678 _ => {
679 tracing::warn!(
680 variable = MAX_CONCURRENT_SSH_ENV,
681 value = %raw.to_string_lossy(),
682 default = DEFAULT_MAX_CONCURRENT_SSH,
683 "ignoring invalid SSH concurrency limit"
684 );
685 DEFAULT_MAX_CONCURRENT_SSH
686 }
687 }
688 })
689}
690
691struct DestinationGate {
697 limit: usize,
698 in_flight: Mutex<usize>,
699 released: Condvar,
700}
701
702impl DestinationGate {
703 fn new(limit: usize) -> Arc<Self> {
704 Arc::new(Self {
705 limit,
706 in_flight: Mutex::new(0),
707 released: Condvar::new(),
708 })
709 }
710
711 fn acquire(self: &Arc<Self>) -> SshPermit {
712 let mut in_flight = self
713 .in_flight
714 .lock()
715 .unwrap_or_else(std::sync::PoisonError::into_inner);
716 while *in_flight >= self.limit {
717 in_flight = self
718 .released
719 .wait(in_flight)
720 .unwrap_or_else(std::sync::PoisonError::into_inner);
721 }
722 *in_flight += 1;
723 drop(in_flight);
724 SshPermit {
725 gate: Arc::clone(self),
726 }
727 }
728}
729
730pub struct SshPermit {
732 gate: Arc<DestinationGate>,
733}
734
735impl std::fmt::Debug for SshPermit {
736 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
737 formatter.write_str("SshPermit")
738 }
739}
740
741impl Drop for SshPermit {
742 fn drop(&mut self) {
743 let mut in_flight = self
744 .gate
745 .in_flight
746 .lock()
747 .unwrap_or_else(std::sync::PoisonError::into_inner);
748 *in_flight = in_flight.saturating_sub(1);
749 drop(in_flight);
750 self.gate.released.notify_one();
751 }
752}
753
754pub struct SshAdmission;
756
757impl SshAdmission {
758 pub fn acquire(destination: &str) -> SshPermit {
761 Self::gate(destination).acquire()
762 }
763
764 fn gate(destination: &str) -> Arc<DestinationGate> {
765 static GATES: OnceLock<Mutex<BTreeMap<String, Arc<DestinationGate>>>> = OnceLock::new();
766 let mut gates = GATES
767 .get_or_init(|| Mutex::new(BTreeMap::new()))
768 .lock()
769 .unwrap_or_else(std::sync::PoisonError::into_inner);
770 Arc::clone(
771 gates
772 .entry(destination.to_owned())
773 .or_insert_with(|| DestinationGate::new(max_concurrent_ssh())),
774 )
775 }
776}
777
778pub const SSH_RETRY_ATTEMPTS: usize = 3;
780
781const SSH_RETRY_BACKOFF_MS: [(u64, u64); SSH_RETRY_ATTEMPTS - 1] = [(500, 2_000), (2_000, 4_000)];
785
786static SSH_RETRY_BACKOFF_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
789
790#[doc(hidden)]
793pub fn set_ssh_retry_backoff_for_test(delay: Option<Duration>) {
794 SSH_RETRY_BACKOFF_OVERRIDE_MS.store(
795 delay.map_or(u64::MAX, |delay| delay.as_millis() as u64),
796 Ordering::Relaxed,
797 );
798}
799
800pub fn ssh_retry_delay(attempts_made: usize) -> Duration {
806 let override_ms = SSH_RETRY_BACKOFF_OVERRIDE_MS.load(Ordering::Relaxed);
807 if override_ms != u64::MAX {
808 return Duration::from_millis(override_ms);
809 }
810 let (low, high) = SSH_RETRY_BACKOFF_MS
811 .get(attempts_made.saturating_sub(1))
812 .copied()
813 .unwrap_or(*SSH_RETRY_BACKOFF_MS.last().expect("non-empty schedule"));
814 let mut bytes = [0_u8; 8];
815 let spread = if getrandom::fill(&mut bytes).is_ok() {
817 u64::from_le_bytes(bytes) % (high - low + 1)
818 } else {
819 0
820 };
821 Duration::from_millis(low + spread)
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 const BORROW_PARENT: &str = "0123456789abcdef0123456789abcdef";
829 const BORROW_CHILD: &str = "fedcba9876543210fedcba9876543210";
830
831 fn borrowed_podman(owner: &str) -> TargetLocator {
832 TargetLocator::LocalPodman {
833 container_id: crate::targets::resource_name(owner).unwrap(),
834 workspace_storage: PodmanWorkspaceLocator::default(),
835 borrowed_from: Some(owner.to_owned()),
836 }
837 }
838
839 #[test]
840 fn verify_locator_accepts_a_container_borrowed_from_its_owner() {
841 verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_CHILD)
842 .expect("a child may borrow its parent's container");
843 }
844
845 #[test]
846 fn verify_locator_rejects_a_container_borrowed_from_the_checking_session() {
847 let error = verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_PARENT)
848 .expect_err("a session cannot borrow from itself");
849 assert!(
850 format!("{error:#}").contains("cannot be owned by the borrowing session"),
851 "unexpected error: {error:#}"
852 );
853 }
854
855 #[test]
856 fn verify_locator_rejects_a_borrowed_container_naming_another_session() {
857 let locator = TargetLocator::LocalPodman {
858 container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
859 workspace_storage: PodmanWorkspaceLocator::default(),
860 borrowed_from: Some(BORROW_PARENT.to_owned()),
861 };
862 let error = verify_locator(&locator, BORROW_CHILD)
863 .expect_err("the container must belong to the recorded owner");
864 assert!(
865 format!("{error:#}").contains("borrowed container locator"),
866 "unexpected error: {error:#}"
867 );
868 }
869
870 #[test]
871 fn worker_root_of_a_borrowed_container_is_the_childs_own_directory() {
872 assert_eq!(
873 crate::targets::worker_root(&borrowed_podman(BORROW_PARENT), BORROW_CHILD).unwrap(),
874 format!("/var/lib/hel/workers/{BORROW_CHILD}")
875 );
876 }
877
878 #[test]
879 fn is_borrowed_distinguishes_borrowed_targets_from_owned_ones() {
880 assert!(is_borrowed(&borrowed_podman(BORROW_PARENT)));
881 assert!(is_borrowed(&TargetLocator::SshBare {
882 ssh: SshTarget {
883 destination: "host".to_owned(),
884 ssh_args: Vec::new(),
885 },
886 workspace: format!(".local/share/hel/workspaces/{BORROW_PARENT}"),
887 worker_id: Some(BORROW_CHILD.to_owned()),
888 }));
889 assert!(!is_borrowed(&TargetLocator::LocalPodman {
890 container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
891 workspace_storage: PodmanWorkspaceLocator::default(),
892 borrowed_from: None,
893 }));
894 }
895
896 #[test]
897 fn an_owned_container_locator_serializes_without_a_borrowed_from_key() {
898 let owned = TargetLocator::LocalDocker {
899 container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
900 borrowed_from: None,
901 };
902 let serialized = serde_json::to_string(&owned).unwrap();
903 assert!(
904 !serialized.contains("borrowed_from"),
905 "owned locators must stay byte-identical for older readers: {serialized}"
906 );
907 assert_eq!(
908 serde_json::from_str::<TargetLocator>(&serialized).unwrap(),
909 owned
910 );
911
912 let borrowed = borrowed_podman(BORROW_PARENT);
913 let serialized = serde_json::to_string(&borrowed).unwrap();
914 assert!(serialized.contains("borrowed_from"));
915 assert_eq!(
916 serde_json::from_str::<TargetLocator>(&serialized).unwrap(),
917 borrowed
918 );
919 }
920 use std::sync::atomic::{AtomicUsize, Ordering};
921
922 #[cfg(unix)]
925 static SHARING_TEST_LOCK: Mutex<()> = Mutex::new(());
926
927 #[cfg(unix)]
929 #[derive(Default)]
930 struct RecordingExecutor {
931 seen: std::cell::RefCell<Vec<CommandSpec>>,
932 }
933
934 #[cfg(unix)]
935 impl CommandExecutor for RecordingExecutor {
936 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
937 self.seen.borrow_mut().push(command.clone());
938 Ok(CommandOutput {
939 status: 0,
940 stdout: Vec::new(),
941 stderr: Vec::new(),
942 })
943 }
944 }
945
946 #[cfg(unix)]
947 fn sharing_args(ssh: &SshTarget) -> Vec<String> {
948 ssh_command(ssh, ["true"]).args
949 }
950
951 #[cfg(unix)]
952 fn sharing_socket_dir() -> tempfile::TempDir {
953 tempfile::tempdir_in("/tmp").expect("short control socket directory")
955 }
956
957 #[test]
958 #[cfg(unix)]
959 fn connection_sharing_follows_user_supplied_ssh_args() {
960 let _guard = SHARING_TEST_LOCK
961 .lock()
962 .unwrap_or_else(std::sync::PoisonError::into_inner);
963 let socket_dir = sharing_socket_dir();
964 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
965 socket_dir.path().to_path_buf(),
966 )));
967 let ssh = SshTarget {
968 destination: "host".to_owned(),
969 ssh_args: vec!["-o".to_owned(), "ControlMaster=no".to_owned()],
970 };
971 let args = sharing_args(&ssh);
972 set_ssh_connection_sharing_for_test(None);
973
974 let expected_path = format!("ControlPath={}/%C", socket_dir.path().display());
975 assert_eq!(
976 args,
977 vec![
978 "-o".to_owned(),
979 "ControlMaster=no".to_owned(),
980 "-o".to_owned(),
981 "ControlMaster=auto".to_owned(),
982 "-o".to_owned(),
983 expected_path,
984 "-o".to_owned(),
985 format!("ControlPersist={CONTROL_PERSIST}"),
986 "host".to_owned(),
987 "'true'".to_owned(),
988 ],
989 "sharing options must come after the user's own args, which OpenSSH prefers"
990 );
991 assert_eq!(
992 std::os::unix::fs::MetadataExt::mode(
993 &fs::metadata(socket_dir.path()).expect("socket directory")
994 ) & 0o777,
995 0o700
996 );
997 }
998
999 #[test]
1003 #[cfg(unix)]
1004 fn fail_fast_commands_reuse_a_master_without_becoming_one() {
1005 let _guard = SHARING_TEST_LOCK
1006 .lock()
1007 .unwrap_or_else(std::sync::PoisonError::into_inner);
1008 let socket_dir = sharing_socket_dir();
1009 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1010 socket_dir.path().to_path_buf(),
1011 )));
1012 let ssh = SshTarget {
1013 destination: "host".to_owned(),
1014 ssh_args: Vec::new(),
1015 };
1016 let validation = ssh_validation_command(&ssh, vec!["true".to_owned()], "test").args;
1017 let executor = RecordingExecutor::default();
1018 crate::path_completion::ssh_completions(
1019 &ssh,
1020 "/srv/pr",
1021 crate::path_completion::CompletionKind::Directories,
1022 &executor,
1023 )
1024 .expect("completion runs");
1025 let completion = executor.seen.borrow()[0].args.clone();
1026 set_ssh_connection_sharing_for_test(None);
1027
1028 let control_path = format!("ControlPath={}/%C", socket_dir.path().display());
1029 for args in [&validation, &completion] {
1030 assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
1031 assert!(args.contains(&control_path), "{args:?}");
1032 assert!(
1033 !args.iter().any(|arg| arg.starts_with("ControlPersist")),
1034 "a fail-fast command must not set how long a master lingers: {args:?}"
1035 );
1036 let master = args
1037 .iter()
1038 .position(|arg| arg == "ControlMaster=no")
1039 .expect("sharing options");
1040 let alive = args
1041 .iter()
1042 .position(|arg| arg == "ServerAliveCountMax=1")
1043 .expect("its own keepalive");
1044 assert!(alive < master, "{args:?}");
1045 assert!(
1046 master
1047 < args
1048 .iter()
1049 .position(|arg| arg == "host")
1050 .expect("destination"),
1051 "{args:?}"
1052 );
1053 }
1054 }
1055
1056 #[test]
1061 #[cfg(unix)]
1062 fn connectivity_probe_joins_a_master_without_becoming_one() {
1063 let _guard = SHARING_TEST_LOCK
1064 .lock()
1065 .unwrap_or_else(std::sync::PoisonError::into_inner);
1066 let socket_dir = sharing_socket_dir();
1067 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1068 socket_dir.path().to_path_buf(),
1069 )));
1070 let ssh = SshTarget {
1071 destination: "host".to_owned(),
1072 ssh_args: Vec::new(),
1073 };
1074 let args = ssh_connectivity_probe(&ssh).args;
1075 set_ssh_connection_sharing_for_test(None);
1076
1077 assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
1078 assert!(
1079 args.contains(&format!("ControlPath={}/%C", socket_dir.path().display())),
1080 "the probe must still join an existing master: {args:?}"
1081 );
1082 assert!(
1083 !args.iter().any(|arg| arg.starts_with("ControlPersist")),
1084 "a doctor probe must not set how long a master lingers: {args:?}"
1085 );
1086 let master = args
1087 .iter()
1088 .position(|arg| arg == "ControlMaster=no")
1089 .expect("sharing options");
1090 let strict = args
1091 .iter()
1092 .position(|arg| arg == "StrictHostKeyChecking=yes")
1093 .expect("its own host key policy");
1094 assert!(strict < master, "{args:?}");
1095 }
1096
1097 #[test]
1098 #[cfg(unix)]
1099 fn connection_sharing_is_absent_when_turned_off() {
1100 let _guard = SHARING_TEST_LOCK
1101 .lock()
1102 .unwrap_or_else(std::sync::PoisonError::into_inner);
1103 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
1104 let ssh = SshTarget {
1105 destination: "host".to_owned(),
1106 ssh_args: Vec::new(),
1107 };
1108 let args = sharing_args(&ssh);
1109 set_ssh_connection_sharing_for_test(None);
1110 assert_eq!(args, vec!["host".to_owned(), "'true'".to_owned()]);
1111 }
1112
1113 #[test]
1114 #[cfg(unix)]
1115 fn a_control_path_that_cannot_fit_a_socket_address_is_skipped() {
1116 let _guard = SHARING_TEST_LOCK
1117 .lock()
1118 .unwrap_or_else(std::sync::PoisonError::into_inner);
1119 let root = tempfile::tempdir().expect("temp dir");
1120 let long = root.path().join("a".repeat(MAX_CONTROL_PATH));
1121 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(long.clone())));
1122 let ssh = SshTarget {
1123 destination: "host".to_owned(),
1124 ssh_args: Vec::new(),
1125 };
1126 let args = sharing_args(&ssh);
1127 set_ssh_connection_sharing_for_test(None);
1128 assert_eq!(args, vec!["host".to_owned(), "'true'".to_owned()]);
1129 assert!(!long.exists(), "an unusable directory must not be created");
1130 }
1131
1132 #[test]
1133 #[cfg(not(unix))]
1134 fn connection_sharing_is_unix_only() {
1135 let mut args = vec!["-o".to_owned(), "BatchMode=yes".to_owned()];
1136 push_connection_sharing_args(&mut args);
1137 assert_eq!(args, vec!["-o".to_owned(), "BatchMode=yes".to_owned()]);
1138 }
1139
1140 #[test]
1141 #[cfg(unix)]
1142 fn the_escape_hatch_accepts_the_usual_off_spellings() {
1143 for value in ["0", "off", "FALSE", " no "] {
1144 assert!(
1145 sharing_disabled(Some(std::ffi::OsStr::new(value))),
1146 "{value:?} must disable connection sharing"
1147 );
1148 }
1149 for value in ["1", "auto", "", "yes"] {
1150 assert!(
1151 !sharing_disabled(Some(std::ffi::OsStr::new(value))),
1152 "{value:?} must leave connection sharing on"
1153 );
1154 }
1155 assert!(!sharing_disabled(None));
1156 }
1157
1158 #[test]
1162 #[cfg(unix)]
1163 fn sharing_leaves_a_reusable_master_on_a_real_host() {
1164 let _guard = SHARING_TEST_LOCK
1165 .lock()
1166 .unwrap_or_else(std::sync::PoisonError::into_inner);
1167 let Some(host) = std::env::var_os("MJ_E2E_SSH_HOST") else {
1168 return;
1169 };
1170 let host = host.to_string_lossy().into_owned();
1171 let socket_dir = sharing_socket_dir();
1172 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1173 socket_dir.path().to_path_buf(),
1174 )));
1175 let ssh = SshTarget {
1176 destination: host.clone(),
1177 ssh_args: vec!["-o".to_owned(), "BatchMode=yes".to_owned()],
1178 };
1179 let spec = ssh_command(&ssh, ["true"]);
1180 set_ssh_connection_sharing_for_test(None);
1181
1182 let first = std::process::Command::new(&spec.program)
1183 .args(&spec.args)
1184 .status()
1185 .expect("ssh must run");
1186 assert!(first.success(), "ssh {host} true failed");
1187
1188 let control_path = format!("{}/%C", socket_dir.path().display());
1189 let check = std::process::Command::new("ssh")
1190 .args([
1191 "-O",
1192 "check",
1193 "-o",
1194 &format!("ControlPath={control_path}"),
1195 &host,
1196 ])
1197 .output()
1198 .expect("ssh -O check must run");
1199 let exit = std::process::Command::new("ssh")
1200 .args([
1201 "-O",
1202 "exit",
1203 "-o",
1204 &format!("ControlPath={control_path}"),
1205 &host,
1206 ])
1207 .output();
1208 assert!(
1209 check.status.success(),
1210 "no master survived the first connection: {}",
1211 String::from_utf8_lossy(&check.stderr)
1212 );
1213 drop(exit);
1214 }
1215
1216 #[test]
1220 #[cfg(unix)]
1221 fn scp_translates_the_ssh_port_option_and_is_tagged_with_its_destination() {
1222 let _guard = SHARING_TEST_LOCK
1223 .lock()
1224 .unwrap_or_else(std::sync::PoisonError::into_inner);
1225 set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
1226 let ssh = SshTarget {
1227 destination: "build@10.0.0.1".into(),
1228 ssh_args: vec!["-p".into(), "2222".into()],
1229 };
1230
1231 let upload = scp_upload(&ssh, Path::new("/tmp/local"), "remote/path", true);
1232 let download = scp_download(&ssh, "remote/archive.zip", "/tmp/local.zip");
1233 set_ssh_connection_sharing_for_test(None);
1234
1235 assert_eq!(
1236 upload.args,
1237 [
1238 "-P",
1239 "2222",
1240 "-r",
1241 "/tmp/local",
1242 "build@10.0.0.1:remote/path"
1243 ]
1244 );
1245 assert_eq!(
1246 download.args,
1247 [
1248 "-P",
1249 "2222",
1250 "build@10.0.0.1:remote/archive.zip",
1251 "/tmp/local.zip"
1252 ]
1253 );
1254 for command in [upload, download] {
1255 assert_eq!(command.program, "scp");
1256 assert_eq!(command.ssh_destination.as_deref(), Some("build@10.0.0.1"));
1257 }
1258 }
1259
1260 #[test]
1261 fn transport_rejection_matches_only_sshd_hangups() {
1262 let cases: [(i32, &str, bool); 7] = [
1263 (255, "Connection closed by 192.168.1.77 port 22", true),
1264 (
1265 255,
1266 "kex_exchange_identification: read: Connection reset by peer",
1267 true,
1268 ),
1269 (255, "ssh: Connection reset by 10.0.0.1 port 22", true),
1270 (255, "Connection timed out during banner exchange", true),
1271 (255, "Permission denied (publickey).", false),
1272 (
1273 255,
1274 "ssh: connect to host h port 22: Connection refused",
1275 false,
1276 ),
1277 (1, "Connection closed by 192.168.1.77 port 22", false),
1278 ];
1279 for (status, stderr, expected) in cases {
1280 assert_eq!(
1281 is_transport_rejection(status, stderr),
1282 expected,
1283 "status {status} stderr {stderr:?}"
1284 );
1285 }
1286 }
1287
1288 #[test]
1289 fn admission_never_admits_more_than_the_limit() {
1290 let gate = DestinationGate::new(2);
1291 let in_flight = Arc::new(AtomicUsize::new(0));
1292 let peak = Arc::new(AtomicUsize::new(0));
1293 let threads: Vec<_> = (0..12)
1294 .map(|_| {
1295 let gate = Arc::clone(&gate);
1296 let in_flight = Arc::clone(&in_flight);
1297 let peak = Arc::clone(&peak);
1298 std::thread::spawn(move || {
1299 for _ in 0..25 {
1300 let permit = gate.acquire();
1301 let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1302 peak.fetch_max(now, Ordering::SeqCst);
1303 std::thread::yield_now();
1304 in_flight.fetch_sub(1, Ordering::SeqCst);
1305 drop(permit);
1306 }
1307 })
1308 })
1309 .collect();
1310 for thread in threads {
1311 thread.join().expect("admission worker must not panic");
1312 }
1313 assert!(
1314 peak.load(Ordering::SeqCst) <= 2,
1315 "admission let {} connections run against a 2-permit gate",
1316 peak.load(Ordering::SeqCst)
1317 );
1318 assert_eq!(in_flight.load(Ordering::SeqCst), 0);
1319 }
1320
1321 #[test]
1322 fn admission_blocks_once_every_permit_is_held() {
1323 let gate = DestinationGate::new(2);
1324 let first = gate.acquire();
1325 let second = gate.acquire();
1326 let waiter = {
1327 let gate = Arc::clone(&gate);
1328 std::thread::spawn(move || {
1329 let permit = gate.acquire();
1330 drop(permit);
1331 })
1332 };
1333 std::thread::sleep(std::time::Duration::from_millis(50));
1335 assert!(!waiter.is_finished());
1336 drop(first);
1337 waiter
1338 .join()
1339 .expect("waiter must be admitted once a permit frees");
1340 drop(second);
1341 }
1342}