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