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 args.push(ssh.destination.clone());
44 args.push(join_remote_command(&remote_args));
45 CommandSpec::new("ssh", args).ssh_destination(ssh.destination.clone())
46}
47
48pub fn join_remote_command(args: &[String]) -> String {
49 args.iter()
50 .map(|arg| posix_quote(arg))
51 .collect::<Vec<_>>()
52 .join(" ")
53}
54
55pub fn ssh_directory_completions(
61 ssh: &SshTarget,
62 prefix: &str,
63 executor: &impl CommandExecutor,
64) -> Result<Vec<String>> {
65 if prefix.is_empty() {
66 return Ok(Vec::new());
67 }
68 let remote_command = format!("ls -d -- {}*/ 2>/dev/null", posix_quote(prefix));
69 let mut args = ssh.ssh_args.clone();
70 args.extend([
71 "-o".into(),
72 "BatchMode=yes".into(),
73 "-o".into(),
74 "ConnectTimeout=3".into(),
75 "-o".into(),
76 "ServerAliveInterval=2".into(),
77 "-o".into(),
78 "ServerAliveCountMax=1".into(),
79 ssh.destination.clone(),
80 remote_command,
81 ]);
82 let output = executor.execute(
83 &CommandSpec::new("ssh", args)
84 .ssh_destination(ssh.destination.clone())
85 .purpose("complete remote mount directory"),
86 )?;
87 if output.status != 0 {
88 return Ok(Vec::new());
89 }
90 let mut matches = String::from_utf8_lossy(&output.stdout)
91 .lines()
92 .filter(|path| path.starts_with(prefix) && path.ends_with('/'))
93 .map(str::to_owned)
94 .collect::<Vec<_>>();
95 matches.sort();
96 matches.dedup();
97 Ok(matches)
98}
99
100pub fn ssh_directory_exists(
102 ssh: &SshTarget,
103 path: &Path,
104 executor: &impl CommandExecutor,
105) -> Result<bool> {
106 let command = ssh_validation_command(
107 ssh,
108 vec![
109 "test".into(),
110 "-d".into(),
111 path.to_string_lossy().into_owned(),
112 ],
113 "validate remote directory",
114 );
115 let output = executor.execute(&command)?;
116 match output.status {
117 0 => Ok(true),
118 1 => Ok(false),
119 status => bail!(
120 "remote directory check failed with status {status}: {}",
121 String::from_utf8_lossy(&output.stderr).trim()
122 ),
123 }
124}
125
126pub fn validate_bare_project_directory(
128 ssh: &SshTarget,
129 path: &Path,
130 executor: &impl CommandExecutor,
131) -> Result<()> {
132 validate_bare_project_path(path)?;
133 if !ssh_directory_exists(ssh, path, executor)? {
134 bail!(
135 "remote project directory {} does not exist or is not a directory",
136 path.display()
137 );
138 }
139 let output = executor.execute(&ssh_validation_command(
140 ssh,
141 vec![
142 "git".into(),
143 "-C".into(),
144 path.to_string_lossy().into_owned(),
145 "rev-parse".into(),
146 "--verify".into(),
147 "HEAD".into(),
148 ],
149 "validate bare SSH Git project",
150 ))?;
151 if output.status != 0 {
152 let detail = String::from_utf8_lossy(&output.stderr);
153 let detail = detail.trim();
154 if detail.is_empty() {
155 bail!(
156 "remote project directory {} has no valid Git HEAD",
157 path.display()
158 );
159 }
160 bail!(
161 "remote project directory {} has no valid Git HEAD: {detail}",
162 path.display()
163 );
164 }
165 Ok(())
166}
167
168pub fn validate_bare_project_path(path: &Path) -> Result<()> {
169 if !path.is_absolute()
170 || path
171 .components()
172 .any(|part| part == std::path::Component::ParentDir)
173 {
174 bail!("bare project directory must be an absolute safe path");
175 }
176 Ok(())
177}
178
179pub fn ssh_validation_command(
180 ssh: &SshTarget,
181 remote_args: Vec<String>,
182 purpose: &'static str,
183) -> CommandSpec {
184 let mut args = ssh.ssh_args.clone();
185 args.extend([
186 "-o".into(),
187 "BatchMode=yes".into(),
188 "-o".into(),
189 "ConnectTimeout=3".into(),
190 "-o".into(),
191 "ServerAliveInterval=2".into(),
192 "-o".into(),
193 "ServerAliveCountMax=1".into(),
194 ssh.destination.clone(),
195 join_remote_command(&remote_args),
196 ]);
197 CommandSpec::new("ssh", args)
198 .ssh_destination(ssh.destination.clone())
199 .purpose(purpose)
200}
201
202pub fn posix_quote(value: &str) -> String {
206 format!("'{}'", value.replace('\'', "'\\''"))
207}
208
209pub fn verify_locator(locator: &TargetLocator, session_id: &str) -> Result<()> {
210 let expected_name = resource_name(session_id)?;
211 match locator {
212 TargetLocator::LocalBare { worker_root } => {
213 let path = Path::new(worker_root);
214 if !path.is_absolute()
215 || path
216 .components()
217 .any(|part| part == std::path::Component::ParentDir)
218 || !path.ends_with(session_id)
219 {
220 bail!("refusing cleanup: invalid local bare worker root");
221 }
222 }
223 TargetLocator::LocalPodman { container_id, .. }
224 | TargetLocator::LocalDocker { container_id }
225 | TargetLocator::AppleContainer { container_id }
226 | TargetLocator::SshPodman { container_id, .. }
227 | TargetLocator::SshDocker { container_id, .. } => {
228 if container_id != &expected_name && !is_runtime_container_id(container_id) {
229 bail!(
230 "refusing cleanup: container locator is neither the generated name nor an immutable runtime ID"
231 );
232 }
233 }
234 TargetLocator::AwsEc2 {
235 instance_id,
236 workspace,
237 ..
238 } => {
239 if !valid_ec2_instance_id(instance_id) {
240 bail!("refusing cleanup: invalid EC2 instance ID");
241 }
242 verify_session_workspace(workspace, session_id)?;
243 }
244 TargetLocator::SshBare {
245 workspace,
246 worker_id,
247 ..
248 } => match worker_id {
249 Some(worker_id) => {
250 validate_session_id(worker_id)?;
251 if worker_id != session_id {
252 bail!("refusing cleanup: SSH worker identity does not match session ID");
253 }
254 validate_workspace_prefix(workspace)?;
255 }
256 None => verify_session_workspace(workspace, session_id)?,
257 },
258 }
259 Ok(())
260}
261
262pub fn verify_session_workspace(workspace: &str, session_id: &str) -> Result<()> {
263 validate_workspace_prefix(workspace)?;
264 let final_component = workspace.trim_end_matches('/').rsplit('/').next();
265 if final_component != Some(session_id) {
266 bail!("refusing cleanup: workspace does not end in the exact session ID");
267 }
268 Ok(())
269}
270
271pub fn validate_session_id(value: &str) -> Result<()> {
272 if value.len() < 8
273 || value.len() > 128
274 || !value
275 .chars()
276 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
277 {
278 bail!("session ID must be 8-128 ASCII letters, digits, '-' or '_'");
279 }
280 Ok(())
281}
282
283pub fn validate_relative_path(value: &str) -> Result<()> {
284 let path = std::path::Path::new(value);
285 if value.is_empty()
286 || path.is_absolute()
287 || path
288 .components()
289 .any(|part| !matches!(part, std::path::Component::Normal(_)))
290 {
291 bail!("unsafe relative bundle path {value:?}");
292 }
293 Ok(())
294}
295
296pub fn validate_workspace_prefix(value: &str) -> Result<()> {
297 if value.is_empty()
298 || value == "/"
299 || value == "~"
300 || value == "~/"
301 || value.contains('\0')
302 || value.split('/').any(|part| part == "..")
303 {
304 bail!("unsafe workspace path");
305 }
306 Ok(())
307}
308
309pub fn validate_container_template(template: &ContainerTemplate) -> Result<()> {
310 if template.image.trim().is_empty() || template.image.starts_with('-') {
311 bail!("invalid container image");
312 }
313 if template
314 .extra_run_args
315 .iter()
316 .any(|arg| arg == "--name" || arg.starts_with("--name="))
317 {
318 bail!("container template may not override the generated name");
319 }
320 if template.extra_run_args.iter().any(|arg| {
321 arg == "--label"
322 || [SESSION_LABEL, MANAGED_LABEL]
323 .iter()
324 .any(|label| arg.starts_with(&format!("--label={label}=")))
325 }) {
326 bail!("container template may not override Mjolnir ownership labels");
327 }
328 Ok(())
329}
330
331pub fn validate_ssh(ssh: &SshTarget) -> Result<()> {
332 if ssh.destination.trim().is_empty()
333 || ssh.destination.starts_with('-')
334 || ssh.destination.chars().any(char::is_whitespace)
335 {
336 bail!("invalid SSH destination");
337 }
338 Ok(())
339}
340
341pub fn validate_aws(aws: &AwsTemplate) -> Result<()> {
342 validate_ssh(&aws.ssh)?;
343 for (name, value) in [
344 ("AWS profile", &aws.profile),
345 ("AWS region", &aws.region),
346 ("launch template", &aws.launch_template),
347 ] {
348 if value.is_empty()
349 || value.starts_with('-')
350 || !value
351 .chars()
352 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
353 {
354 bail!("invalid {name}");
355 }
356 }
357 Ok(())
358}
359
360pub fn validate_executable(value: &str) -> Result<()> {
361 if value.is_empty() || value.starts_with('-') || value.chars().any(char::is_whitespace) {
362 bail!("invalid executable name");
363 }
364 Ok(())
365}
366
367pub fn valid_ec2_instance_id(value: &str) -> bool {
368 value
369 .strip_prefix("i-")
370 .is_some_and(|rest| rest.len() >= 8 && rest.chars().all(|c| c.is_ascii_hexdigit()))
371}
372
373pub fn is_runtime_container_id(value: &str) -> bool {
374 value.len() >= 12 && value.len() <= 128 && value.chars().all(|c| c.is_ascii_hexdigit())
375}
376
377pub const SSH_TRANSPORT_EXIT_STATUS: i32 = 255;
380
381const TRANSPORT_REJECTION_MARKERS: [&str; 4] = [
385 "Connection closed by",
386 "Connection reset by",
387 "kex_exchange_identification",
388 "Connection timed out during banner exchange",
389];
390
391pub fn is_transport_rejection(status: i32, stderr: &str) -> bool {
397 status == SSH_TRANSPORT_EXIT_STATUS
398 && TRANSPORT_REJECTION_MARKERS
399 .iter()
400 .any(|marker| stderr.contains(marker))
401}
402
403const DEFAULT_MAX_CONCURRENT_SSH: usize = 6;
412
413pub const MAX_CONCURRENT_SSH_ENV: &str = "MJ_SSH_MAX_CONCURRENT";
415
416fn max_concurrent_ssh() -> usize {
417 static LIMIT: OnceLock<usize> = OnceLock::new();
418 *LIMIT.get_or_init(|| {
419 let Some(raw) = std::env::var_os(MAX_CONCURRENT_SSH_ENV) else {
420 return DEFAULT_MAX_CONCURRENT_SSH;
421 };
422 match raw
423 .to_str()
424 .and_then(|value| value.trim().parse::<usize>().ok())
425 {
426 Some(limit) if limit > 0 => limit,
427 _ => {
428 tracing::warn!(
429 variable = MAX_CONCURRENT_SSH_ENV,
430 value = %raw.to_string_lossy(),
431 default = DEFAULT_MAX_CONCURRENT_SSH,
432 "ignoring invalid SSH concurrency limit"
433 );
434 DEFAULT_MAX_CONCURRENT_SSH
435 }
436 }
437 })
438}
439
440struct DestinationGate {
446 limit: usize,
447 in_flight: Mutex<usize>,
448 released: Condvar,
449}
450
451impl DestinationGate {
452 fn new(limit: usize) -> Arc<Self> {
453 Arc::new(Self {
454 limit,
455 in_flight: Mutex::new(0),
456 released: Condvar::new(),
457 })
458 }
459
460 fn acquire(self: &Arc<Self>) -> SshPermit {
461 let mut in_flight = self
462 .in_flight
463 .lock()
464 .unwrap_or_else(std::sync::PoisonError::into_inner);
465 while *in_flight >= self.limit {
466 in_flight = self
467 .released
468 .wait(in_flight)
469 .unwrap_or_else(std::sync::PoisonError::into_inner);
470 }
471 *in_flight += 1;
472 drop(in_flight);
473 SshPermit {
474 gate: Arc::clone(self),
475 }
476 }
477}
478
479pub struct SshPermit {
481 gate: Arc<DestinationGate>,
482}
483
484impl std::fmt::Debug for SshPermit {
485 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486 formatter.write_str("SshPermit")
487 }
488}
489
490impl Drop for SshPermit {
491 fn drop(&mut self) {
492 let mut in_flight = self
493 .gate
494 .in_flight
495 .lock()
496 .unwrap_or_else(std::sync::PoisonError::into_inner);
497 *in_flight = in_flight.saturating_sub(1);
498 drop(in_flight);
499 self.gate.released.notify_one();
500 }
501}
502
503pub struct SshAdmission;
505
506impl SshAdmission {
507 pub fn acquire(destination: &str) -> SshPermit {
510 Self::gate(destination).acquire()
511 }
512
513 fn gate(destination: &str) -> Arc<DestinationGate> {
514 static GATES: OnceLock<Mutex<BTreeMap<String, Arc<DestinationGate>>>> = OnceLock::new();
515 let mut gates = GATES
516 .get_or_init(|| Mutex::new(BTreeMap::new()))
517 .lock()
518 .unwrap_or_else(std::sync::PoisonError::into_inner);
519 Arc::clone(
520 gates
521 .entry(destination.to_owned())
522 .or_insert_with(|| DestinationGate::new(max_concurrent_ssh())),
523 )
524 }
525}
526
527pub const SSH_RETRY_ATTEMPTS: usize = 3;
529
530const SSH_RETRY_BACKOFF_MS: [(u64, u64); SSH_RETRY_ATTEMPTS - 1] = [(500, 2_000), (2_000, 4_000)];
534
535static SSH_RETRY_BACKOFF_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
538
539#[doc(hidden)]
542pub fn set_ssh_retry_backoff_for_test(delay: Option<Duration>) {
543 SSH_RETRY_BACKOFF_OVERRIDE_MS.store(
544 delay.map_or(u64::MAX, |delay| delay.as_millis() as u64),
545 Ordering::Relaxed,
546 );
547}
548
549pub fn ssh_retry_delay(attempts_made: usize) -> Duration {
555 let override_ms = SSH_RETRY_BACKOFF_OVERRIDE_MS.load(Ordering::Relaxed);
556 if override_ms != u64::MAX {
557 return Duration::from_millis(override_ms);
558 }
559 let (low, high) = SSH_RETRY_BACKOFF_MS
560 .get(attempts_made.saturating_sub(1))
561 .copied()
562 .unwrap_or(*SSH_RETRY_BACKOFF_MS.last().expect("non-empty schedule"));
563 let mut bytes = [0_u8; 8];
564 let spread = if getrandom::fill(&mut bytes).is_ok() {
566 u64::from_le_bytes(bytes) % (high - low + 1)
567 } else {
568 0
569 };
570 Duration::from_millis(low + spread)
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use std::sync::atomic::{AtomicUsize, Ordering};
577
578 #[test]
579 fn transport_rejection_matches_only_sshd_hangups() {
580 let cases: [(i32, &str, bool); 7] = [
581 (255, "Connection closed by 192.168.1.77 port 22", true),
582 (
583 255,
584 "kex_exchange_identification: read: Connection reset by peer",
585 true,
586 ),
587 (255, "ssh: Connection reset by 10.0.0.1 port 22", true),
588 (255, "Connection timed out during banner exchange", true),
589 (255, "Permission denied (publickey).", false),
590 (
591 255,
592 "ssh: connect to host h port 22: Connection refused",
593 false,
594 ),
595 (1, "Connection closed by 192.168.1.77 port 22", false),
596 ];
597 for (status, stderr, expected) in cases {
598 assert_eq!(
599 is_transport_rejection(status, stderr),
600 expected,
601 "status {status} stderr {stderr:?}"
602 );
603 }
604 }
605
606 #[test]
607 fn admission_never_admits_more_than_the_limit() {
608 let gate = DestinationGate::new(2);
609 let in_flight = Arc::new(AtomicUsize::new(0));
610 let peak = Arc::new(AtomicUsize::new(0));
611 let threads: Vec<_> = (0..12)
612 .map(|_| {
613 let gate = Arc::clone(&gate);
614 let in_flight = Arc::clone(&in_flight);
615 let peak = Arc::clone(&peak);
616 std::thread::spawn(move || {
617 for _ in 0..25 {
618 let permit = gate.acquire();
619 let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
620 peak.fetch_max(now, Ordering::SeqCst);
621 std::thread::yield_now();
622 in_flight.fetch_sub(1, Ordering::SeqCst);
623 drop(permit);
624 }
625 })
626 })
627 .collect();
628 for thread in threads {
629 thread.join().expect("admission worker must not panic");
630 }
631 assert!(
632 peak.load(Ordering::SeqCst) <= 2,
633 "admission let {} connections run against a 2-permit gate",
634 peak.load(Ordering::SeqCst)
635 );
636 assert_eq!(in_flight.load(Ordering::SeqCst), 0);
637 }
638
639 #[test]
640 fn admission_blocks_once_every_permit_is_held() {
641 let gate = DestinationGate::new(2);
642 let first = gate.acquire();
643 let second = gate.acquire();
644 let waiter = {
645 let gate = Arc::clone(&gate);
646 std::thread::spawn(move || {
647 let permit = gate.acquire();
648 drop(permit);
649 })
650 };
651 std::thread::sleep(std::time::Duration::from_millis(50));
653 assert!(!waiter.is_finished());
654 drop(first);
655 waiter
656 .join()
657 .expect("waiter must be admitted once a permit frees");
658 drop(second);
659 }
660}