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