Skip to main content

mj_core/targets/
ssh.rs

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
9/// The connectivity probe `mj doctor` runs against an SSH target.
10///
11/// It reuses the provisioning argument order so the probe fails exactly where
12/// a real session would, with two deliberate overrides prepended. OpenSSH
13/// honours the first occurrence of an option, so these win over the
14/// provisioning defaults: `BatchMode=yes` never prompts for a password, and
15/// `StrictHostKeyChecking=yes` never accepts an unknown host key. Doctor
16/// diagnoses; the user decides whether to trust a key.
17///
18/// Those overrides are also why the probe joins a shared master but never
19/// opens one (see `push_connection_reuse_args`): as the master it would hold
20/// them over every later session on that connection, and a plain `mj doctor`
21/// would leave an `ssh` process behind for the whole `ControlPersist` window
22/// even though the user asked only for a diagnosis.
23pub fn ssh_connectivity_probe(ssh: &SshTarget) -> CommandSpec {
24    let mut args = vec![
25        "-o".to_owned(),
26        "BatchMode=yes".to_owned(),
27        "-o".to_owned(),
28        "StrictHostKeyChecking=yes".to_owned(),
29    ];
30    args.extend(ssh.ssh_args.iter().cloned());
31    // The socket is named after the target as configured, not after these
32    // probe-only overrides, so the probe finds the daemon's master.
33    push_connection_reuse_args(&mut args, ssh);
34    args.push(ssh.destination.clone());
35    args.push(join_remote_command(&["true".to_owned()]));
36    CommandSpec::new("ssh", args)
37        .ssh_destination(ssh.destination.clone())
38        .purpose("verify SSH connectivity")
39}
40
41pub fn ssh_command(
42    ssh: &SshTarget,
43    args: impl IntoIterator<Item = impl AsRef<str>>,
44) -> CommandSpec {
45    ssh_command_owned(
46        ssh,
47        args.into_iter()
48            .map(|arg| arg.as_ref().to_owned())
49            .collect(),
50    )
51}
52
53/// Build an `ssh` command that runs as one session on a shared connection.
54/// The executor leases the session and adds its options just before the
55/// command is spawned; see [`CommandSpec::ssh_session`].
56pub fn ssh_command_owned(ssh: &SshTarget, remote_args: Vec<String>) -> CommandSpec {
57    let mut args = ssh.ssh_args.clone();
58    args.push(ssh.destination.clone());
59    args.push(join_remote_command(&remote_args));
60    CommandSpec::new("ssh", args).ssh_session(ssh)
61}
62
63/// Home-relative directory on an SSH host where files bound for a remote
64/// container wait before the engine copies them in. Home-relative rather than
65/// `~/`, because `ssh_command` quotes every argument while `scp` expands `~`.
66pub const REMOTE_UPLOAD_STAGING: &str = ".cache/mjolnir/uploads";
67
68/// Upload a local file or directory to the SSH host.
69pub fn scp_upload(ssh: &SshTarget, source: &Path, remote: &str, recursive: bool) -> CommandSpec {
70    let mut args = scp_args(ssh);
71    if recursive {
72        args.push("-r".into());
73    }
74    args.push(source.to_string_lossy().into_owned());
75    args.push(format!("{}:{remote}", ssh.destination));
76    scp_command(ssh, args)
77}
78
79/// Download a remote file from the SSH host.
80pub fn scp_download(ssh: &SshTarget, remote: &str, local: &str) -> CommandSpec {
81    let mut args = scp_args(ssh);
82    args.push(format!("{}:{remote}", ssh.destination));
83    args.push(local.into());
84    scp_command(ssh, args)
85}
86
87/// The connection's `ssh` arguments rewritten for `scp`, which spells the port
88/// option `-P`; to `scp`, `-p` means "preserve file times".
89fn scp_args(ssh: &SshTarget) -> Vec<String> {
90    ssh.ssh_args
91        .iter()
92        .map(|argument| {
93            if argument == "-p" {
94                "-P".to_owned()
95            } else {
96                argument.clone()
97            }
98        })
99        .collect()
100}
101
102fn scp_command(ssh: &SshTarget, args: Vec<String>) -> CommandSpec {
103    // `scp` runs `ssh` underneath, so it takes a session on a shared
104    // connection and is admitted and retried the same way.
105    CommandSpec::new("scp", args).ssh_session(ssh)
106}
107
108/// How long a shared master connection stays alive after its last channel
109/// closes. The master is an `ssh` process that outlives the daemon by this
110/// long, so it is kept short enough to be unsurprising and long enough to
111/// cover a whole provision.
112#[cfg(unix)]
113const CONTROL_PERSIST: &str = "60";
114
115/// Environment override that turns connection sharing off. Any of `0`, `off`,
116/// `false`, or `no` disables it.
117pub const CONTROL_MASTER_ENV: &str = "MJ_SSH_CONTROL_MASTER";
118
119/// Longest `ControlPath` that still fits in a Unix socket address. `sun_path`
120/// holds 108 bytes on Linux and 104 on macOS, minus the terminating NUL.
121#[cfg(unix)]
122const MAX_CONTROL_PATH: usize = 103;
123
124/// Hex digits of the connection hash in a socket name. 64 bits keeps the
125/// names short while making a collision between two targets implausible.
126#[cfg(unix)]
127const CONNECTION_HASH_HEX: usize = 16;
128
129/// Bytes reserved after the directory for a socket's name: a separator, the
130/// connection hash, `-` and a shard index of up to four digits, and the
131/// `.` plus 16 random characters `ssh` appends to the path while it binds a
132/// new master.
133#[cfg(unix)]
134const CONTROL_SOCKET_NAME_RESERVE: usize = 1 + CONNECTION_HASH_HEX + 1 + 4 + 17;
135
136#[cfg(unix)]
137fn sharing_disabled(value: Option<&std::ffi::OsStr>) -> bool {
138    let Some(value) = value else {
139        return false;
140    };
141    matches!(
142        value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
143        "0" | "off" | "false" | "no"
144    )
145}
146
147/// How a test pins connection sharing instead of letting it resolve from the
148/// environment.
149#[doc(hidden)]
150#[derive(Debug, Clone)]
151pub enum SshSharingForTest {
152    /// Behave as though the escape hatch were set.
153    Disabled,
154    /// Keep the control sockets in this directory.
155    Directory(PathBuf),
156}
157
158static SHARING_OVERRIDE: Mutex<Option<SshSharingForTest>> = Mutex::new(None);
159
160/// The connection-sharing override is process-wide, so the tests that set it
161/// take turns.
162#[cfg(all(test, unix))]
163pub(super) static SHARING_TEST_LOCK: Mutex<()> = Mutex::new(());
164
165/// Pin connection sharing for a test, or restore the real resolution with
166/// `None`. Tests must not depend on the developer's `$XDG_RUNTIME_DIR` or home
167/// directory, so every test that inspects `ssh` arguments pins it. Not part of
168/// the daemon's behaviour.
169#[doc(hidden)]
170pub fn set_ssh_connection_sharing_for_test(setting: Option<SshSharingForTest>) {
171    *SHARING_OVERRIDE
172        .lock()
173        .unwrap_or_else(std::sync::PoisonError::into_inner) = setting;
174}
175
176#[cfg(unix)]
177fn sharing_override() -> Option<SshSharingForTest> {
178    SHARING_OVERRIDE
179        .lock()
180        .unwrap_or_else(std::sync::PoisonError::into_inner)
181        .clone()
182}
183
184/// The directory holding this instance's control sockets, or `None` when
185/// sharing is off.
186///
187/// `$XDG_RUNTIME_DIR/mjolnir/<instance>` is preferred because it is short,
188/// per-user, and on tmpfs; the instance's data directory is the fallback.
189/// Each instance gets its own directory because each daemon counts only its
190/// own sessions: two daemons sharing masters would together exceed the
191/// server's per-connection session limit. Neither location is
192/// world-writable, and the directory is created 0700 because `ssh` will not
193/// create it itself.
194#[cfg(unix)]
195fn control_socket_dir() -> Option<PathBuf> {
196    match sharing_override() {
197        Some(SshSharingForTest::Disabled) => return None,
198        Some(SshSharingForTest::Directory(dir)) => return prepare_control_dir(dir),
199        None => {}
200    }
201    static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
202    DIR.get_or_init(|| {
203        if sharing_disabled(std::env::var_os(CONTROL_MASTER_ENV).as_deref()) {
204            return None;
205        }
206        prepare_control_dir(default_control_dir(
207            std::env::var_os("XDG_RUNTIME_DIR"),
208            crate::config::instance_name(),
209        ))
210    })
211    .clone()
212}
213
214/// Where an instance keeps its sockets when no test pins the directory.
215#[cfg(unix)]
216fn default_control_dir(runtime: Option<std::ffi::OsString>, instance: Option<String>) -> PathBuf {
217    match runtime {
218        Some(runtime) if !runtime.is_empty() => PathBuf::from(runtime)
219            .join("mjolnir")
220            .join(instance.as_deref().unwrap_or("default")),
221        // The data directory is already specific to the instance.
222        _ => crate::config::data_dir().join("ssh"),
223    }
224}
225
226/// Create the socket directory 0700 and reject one whose sockets would not fit
227/// in a Unix socket address. Failure means no sharing, never a failed command.
228#[cfg(unix)]
229fn prepare_control_dir(dir: PathBuf) -> Option<PathBuf> {
230    if dir.as_os_str().len() + CONTROL_SOCKET_NAME_RESERVE > MAX_CONTROL_PATH {
231        tracing::debug!(
232            directory = %dir.display(),
233            "skipping SSH connection sharing: control socket path would be too long"
234        );
235        return None;
236    }
237    if let Err(error) = fs::create_dir_all(&dir) {
238        tracing::debug!(
239            directory = %dir.display(),
240            %error,
241            "skipping SSH connection sharing: control directory is unavailable"
242        );
243        return None;
244    }
245    use std::os::unix::fs::PermissionsExt;
246    if let Err(error) = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)) {
247        tracing::debug!(
248            directory = %dir.display(),
249            %error,
250            "skipping SSH connection sharing: cannot restrict control directory"
251        );
252        return None;
253    }
254    Some(dir)
255}
256
257/// The identity of one configured connection: its destination and the
258/// user's own `ssh` arguments, which can change the port, user, or route.
259/// Two targets that differ in either get separate masters.
260#[cfg(unix)]
261fn connection_key(ssh: &SshTarget) -> String {
262    let mut key = ssh.destination.clone();
263    for argument in &ssh.ssh_args {
264        key.push('\0');
265        key.push_str(argument);
266    }
267    key
268}
269
270/// The socket file name for one shard of a connection: `<hash>-<shard>`.
271///
272/// Mjolnir names sockets itself rather than using `ssh`'s `%C` so that the
273/// name follows exactly the key the daemon counts sessions under, and so the
274/// daemon knows the concrete path when it must remove a stale socket.
275#[cfg(unix)]
276fn control_socket_name(ssh: &SshTarget, shard: usize) -> String {
277    use sha2::{Digest, Sha256};
278    let digest = Sha256::digest(connection_key(ssh).as_bytes());
279    let mut name = String::with_capacity(CONNECTION_HASH_HEX + 5);
280    for byte in digest.iter().take(CONNECTION_HASH_HEX / 2) {
281        name.push_str(&format!("{byte:02x}"));
282    }
283    name.push_str(&format!("-{shard}"));
284    name
285}
286
287/// Whether the user's own `ssh` arguments already configure connection
288/// sharing. OpenSSH keeps the first value it sees, so Mjolnir cannot add its
289/// own sharing options without either overriding the user or being
290/// overridden; a user who configures sharing owns it, and Mjolnir adds none.
291#[cfg(unix)]
292fn user_configures_sharing(ssh_args: &[String]) -> bool {
293    ssh_args.iter().any(|argument| {
294        if argument.starts_with("-S") {
295            return true;
296        }
297        let option = argument.strip_prefix("-o").unwrap_or(argument).trim_start();
298        let option = option.to_ascii_lowercase();
299        ["controlmaster", "controlpath"].iter().any(|name| {
300            option
301                .strip_prefix(name)
302                .is_some_and(|rest| rest.starts_with(['=', ' ', '\t']))
303        })
304    })
305}
306
307/// Append the options that let this command *reuse* a shared master without
308/// ever becoming one.
309///
310/// Use this for commands that carry deliberately impatient options, such as
311/// the short `ConnectTimeout` and one-miss `ServerAlive` keepalive of a
312/// validation probe or a Tab completion. Those settings belong to the one
313/// command that asked for them. If such a command opened the master, the
314/// master would enforce them for its whole lifetime and drop every later
315/// multiplexed session -- an upload, a `podman run`, the worker bootstrap --
316/// on a stall of a couple of seconds. With `ControlMaster=no` the command
317/// joins the connection's first master when one is up and otherwise opens its
318/// own direct connection, keeping its fail-fast options to itself.
319///
320/// These commands run in processes without the daemon's session ledger
321/// (`mj doctor`, completion) or only validate a target, so they are not
322/// counted and are not bound to a master with `ProxyCommand=false`: for a
323/// diagnosis, a direct connection is the stated behaviour.
324pub fn push_connection_reuse_args(args: &mut Vec<String>, ssh: &SshTarget) {
325    #[cfg(unix)]
326    if !user_configures_sharing(&ssh.ssh_args)
327        && let Some(dir) = control_socket_dir()
328    {
329        let socket = dir.join(control_socket_name(ssh, 0));
330        args.extend([
331            "-o".to_owned(),
332            "ControlMaster=no".to_owned(),
333            "-o".to_owned(),
334            format!("ControlPath={}", socket.display()),
335        ]);
336    }
337    #[cfg(not(unix))]
338    let _ = (args, ssh);
339}
340
341pub fn join_remote_command(args: &[String]) -> String {
342    args.iter()
343        .map(|arg| posix_quote(arg))
344        .collect::<Vec<_>>()
345        .join(" ")
346}
347
348/// Check whether a directory exists on the configured SSH host.
349pub fn ssh_directory_exists(
350    ssh: &SshTarget,
351    path: &Path,
352    executor: &impl CommandExecutor,
353) -> Result<bool> {
354    let command = ssh_validation_command(
355        ssh,
356        vec![
357            "test".into(),
358            "-d".into(),
359            path.to_string_lossy().into_owned(),
360        ],
361        "validate remote directory",
362    );
363    let output = executor.execute(&command)?;
364    match output.status {
365        0 => Ok(true),
366        1 => Ok(false),
367        status => bail!(
368            "remote directory check failed with status {status}: {}",
369            String::from_utf8_lossy(&output.stderr).trim()
370        ),
371    }
372}
373
374/// Verify that a bare-SSH project path exists and has a committed Git HEAD.
375pub fn validate_bare_project_directory(
376    ssh: &SshTarget,
377    path: &Path,
378    executor: &impl CommandExecutor,
379) -> Result<()> {
380    validate_bare_project_path(path)?;
381    if !ssh_directory_exists(ssh, path, executor)? {
382        bail!(
383            "remote project directory {} does not exist or is not a directory",
384            path.display()
385        );
386    }
387    let output = executor.execute(&ssh_validation_command(
388        ssh,
389        vec![
390            "git".into(),
391            "-C".into(),
392            path.to_string_lossy().into_owned(),
393            "rev-parse".into(),
394            "--verify".into(),
395            "HEAD".into(),
396        ],
397        "validate bare SSH Git project",
398    ))?;
399    if output.status != 0 {
400        let detail = String::from_utf8_lossy(&output.stderr);
401        let detail = detail.trim();
402        if detail.is_empty() {
403            bail!(
404                "remote project directory {} has no valid Git HEAD",
405                path.display()
406            );
407        }
408        bail!(
409            "remote project directory {} has no valid Git HEAD: {detail}",
410            path.display()
411        );
412    }
413    Ok(())
414}
415
416pub fn validate_bare_project_path(path: &Path) -> Result<()> {
417    if !path.is_absolute()
418        || path
419            .components()
420            .any(|part| part == std::path::Component::ParentDir)
421    {
422        bail!("bare project directory must be an absolute safe path");
423    }
424    Ok(())
425}
426
427pub fn ssh_validation_command(
428    ssh: &SshTarget,
429    remote_args: Vec<String>,
430    purpose: &'static str,
431) -> CommandSpec {
432    let mut args = ssh.ssh_args.clone();
433    args.extend([
434        "-o".into(),
435        "BatchMode=yes".into(),
436        "-o".into(),
437        "ConnectTimeout=3".into(),
438        "-o".into(),
439        "ServerAliveInterval=2".into(),
440        "-o".into(),
441        "ServerAliveCountMax=1".into(),
442    ]);
443    push_connection_reuse_args(&mut args, ssh);
444    args.extend([ssh.destination.clone(), join_remote_command(&remote_args)]);
445    CommandSpec::new("ssh", args)
446        .ssh_destination(ssh.destination.clone())
447        .purpose(purpose)
448}
449
450/// Wrap a value so a POSIX shell reads it as one literal argument. Used at the
451/// SSH boundary here and when Hel rebuilds an agent's terminal command line
452/// (`terminal::shell_line`).
453pub fn posix_quote(value: &str) -> String {
454    format!("'{}'", value.replace('\'', "'\\''"))
455}
456
457pub fn verify_locator(locator: &TargetLocator, session_id: &str) -> Result<()> {
458    let expected_name = resource_name(session_id)?;
459    match locator {
460        TargetLocator::LocalBare { worker_root } => {
461            let path = Path::new(worker_root);
462            if !path.is_absolute()
463                || path
464                    .components()
465                    .any(|part| part == std::path::Component::ParentDir)
466                || !path.ends_with(session_id)
467            {
468                bail!("refusing cleanup: invalid local bare worker root");
469            }
470        }
471        TargetLocator::LocalPodman {
472            container_id,
473            borrowed_from,
474            ..
475        }
476        | TargetLocator::LocalDocker {
477            container_id,
478            borrowed_from,
479        }
480        | TargetLocator::AppleContainer {
481            container_id,
482            borrowed_from,
483        }
484        | TargetLocator::SshPodman {
485            container_id,
486            borrowed_from,
487            ..
488        }
489        | TargetLocator::SshDocker {
490            container_id,
491            borrowed_from,
492            ..
493        } => match borrowed_from {
494            Some(owner) => {
495                validate_session_id(owner)?;
496                if owner == session_id {
497                    bail!(
498                        "refusing cleanup: a borrowed container cannot be owned by the borrowing session"
499                    );
500                }
501                let owner_name = resource_name(owner)?;
502                if container_id != &owner_name && !is_runtime_container_id(container_id) {
503                    bail!(
504                        "refusing cleanup: borrowed container locator is neither the owning session's generated name nor an immutable runtime ID"
505                    );
506                }
507            }
508            None => {
509                if container_id != &expected_name && !is_runtime_container_id(container_id) {
510                    bail!(
511                        "refusing cleanup: container locator is neither the generated name nor an immutable runtime ID"
512                    );
513                }
514            }
515        },
516        TargetLocator::AwsEc2 {
517            instance_id,
518            workspace,
519            ..
520        } => {
521            if !valid_ec2_instance_id(instance_id) {
522                bail!("refusing cleanup: invalid EC2 instance ID");
523            }
524            verify_session_workspace(workspace, session_id)?;
525        }
526        TargetLocator::SshBare {
527            workspace,
528            worker_id,
529            ..
530        } => match worker_id {
531            Some(worker_id) => {
532                validate_session_id(worker_id)?;
533                if worker_id != session_id {
534                    bail!("refusing cleanup: SSH worker identity does not match session ID");
535                }
536                validate_workspace_prefix(workspace)?;
537            }
538            None => verify_session_workspace(workspace, session_id)?,
539        },
540    }
541    Ok(())
542}
543
544/// Whether this locator names a target another session owns: a sub-agent
545/// child either borrowing its parent's container or running as its own worker
546/// inside the parent's SSH workspace.
547pub fn is_borrowed(locator: &TargetLocator) -> bool {
548    match locator {
549        TargetLocator::LocalPodman { borrowed_from, .. }
550        | TargetLocator::LocalDocker { borrowed_from, .. }
551        | TargetLocator::AppleContainer { borrowed_from, .. }
552        | TargetLocator::SshPodman { borrowed_from, .. }
553        | TargetLocator::SshDocker { borrowed_from, .. } => borrowed_from.is_some(),
554        TargetLocator::SshBare { worker_id, .. } => worker_id.is_some(),
555        TargetLocator::LocalBare { .. } | TargetLocator::AwsEc2 { .. } => false,
556    }
557}
558
559pub fn verify_session_workspace(workspace: &str, session_id: &str) -> Result<()> {
560    validate_workspace_prefix(workspace)?;
561    let final_component = workspace.trim_end_matches('/').rsplit('/').next();
562    if final_component != Some(session_id) {
563        bail!("refusing cleanup: workspace does not end in the exact session ID");
564    }
565    Ok(())
566}
567
568pub fn validate_session_id(value: &str) -> Result<()> {
569    if value.len() < 8
570        || value.len() > 128
571        || !value
572            .chars()
573            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
574    {
575        bail!("session ID must be 8-128 ASCII letters, digits, '-' or '_'");
576    }
577    Ok(())
578}
579
580pub fn validate_relative_path(value: &str) -> Result<()> {
581    let path = std::path::Path::new(value);
582    if value.is_empty()
583        || path.is_absolute()
584        || path
585            .components()
586            .any(|part| !matches!(part, std::path::Component::Normal(_)))
587    {
588        bail!("unsafe relative bundle path {value:?}");
589    }
590    Ok(())
591}
592
593pub fn validate_workspace_prefix(value: &str) -> Result<()> {
594    if value.is_empty()
595        || value == "/"
596        || value == "~"
597        || value == "~/"
598        || value.contains('\0')
599        || value.split('/').any(|part| part == "..")
600    {
601        bail!("unsafe workspace path");
602    }
603    Ok(())
604}
605
606pub fn validate_container_template(template: &ContainerTemplate) -> Result<()> {
607    if template.image.trim().is_empty() || template.image.starts_with('-') {
608        bail!("invalid container image");
609    }
610    if template
611        .extra_run_args
612        .iter()
613        .any(|arg| arg == "--name" || arg.starts_with("--name="))
614    {
615        bail!("container template may not override the generated name");
616    }
617    if template.extra_run_args.iter().any(|arg| {
618        arg == "--label"
619            || [SESSION_LABEL, MANAGED_LABEL, INSTANCE_LABEL]
620                .iter()
621                .any(|label| arg.starts_with(&format!("--label={label}=")))
622    }) {
623        bail!("container template may not override Mjolnir ownership labels");
624    }
625    Ok(())
626}
627
628pub fn validate_ssh(ssh: &SshTarget) -> Result<()> {
629    if ssh.destination.trim().is_empty()
630        || ssh.destination.starts_with('-')
631        || ssh.destination.chars().any(char::is_whitespace)
632    {
633        bail!("invalid SSH destination");
634    }
635    Ok(())
636}
637
638pub fn validate_aws(aws: &AwsTemplate) -> Result<()> {
639    validate_ssh(&aws.ssh)?;
640    for (name, value) in [
641        ("AWS profile", &aws.profile),
642        ("AWS region", &aws.region),
643        ("launch template", &aws.launch_template),
644    ] {
645        if value.is_empty()
646            || value.starts_with('-')
647            || !value
648                .chars()
649                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
650        {
651            bail!("invalid {name}");
652        }
653    }
654    Ok(())
655}
656
657pub fn validate_executable(value: &str) -> Result<()> {
658    if value.is_empty() || value.starts_with('-') || value.chars().any(char::is_whitespace) {
659        bail!("invalid executable name");
660    }
661    Ok(())
662}
663
664pub fn valid_ec2_instance_id(value: &str) -> bool {
665    value
666        .strip_prefix("i-")
667        .is_some_and(|rest| rest.len() >= 8 && rest.chars().all(|c| c.is_ascii_hexdigit()))
668}
669
670pub fn is_runtime_container_id(value: &str) -> bool {
671    value.len() >= 12 && value.len() <= 128 && value.chars().all(|c| c.is_ascii_hexdigit())
672}
673
674/// `ssh` reserves exit status 255 for its own transport failures; a remote
675/// command never produces it, so the remote side provably never ran.
676pub const SSH_TRANSPORT_EXIT_STATUS: i32 = 255;
677
678/// Stderr fragments OpenSSH prints when the server hangs up before
679/// authentication. `sshd`'s `MaxStartups` produces exactly these when it drops
680/// an unauthenticated connection, and so does a server that is still starting.
681const TRANSPORT_REJECTION_MARKERS: [&str; 4] = [
682    "Connection closed by",
683    "Connection reset by",
684    "kex_exchange_identification",
685    "Connection timed out during banner exchange",
686];
687
688/// Whether a finished `ssh` process was turned away by the transport rather
689/// than by the remote command.
690///
691/// The remote command never started in this case, so the caller may retry the
692/// whole invocation without worrying about repeating a side effect.
693pub fn is_transport_rejection(status: i32, stderr: &str) -> bool {
694    status == SSH_TRANSPORT_EXIT_STATUS
695        && TRANSPORT_REJECTION_MARKERS
696            .iter()
697            .any(|marker| stderr.contains(marker))
698}
699
700/// Default number of `ssh` processes this daemon will have in flight against
701/// one destination at a time.
702///
703/// `sshd` counts *unauthenticated* connections against `MaxStartups`, whose
704/// stock value is `10:30:100`: from the eleventh concurrent pre-auth connection
705/// it starts dropping them, and past a hundred it drops all of them. A daemon
706/// that spawns one fresh `ssh` per operation reaches that during startup, so it
707/// admits its own connections instead of letting the server refuse them.
708const DEFAULT_MAX_CONCURRENT_SSH: usize = 6;
709
710/// Environment override for [`DEFAULT_MAX_CONCURRENT_SSH`].
711pub const MAX_CONCURRENT_SSH_ENV: &str = "MJ_SSH_MAX_CONCURRENT";
712
713fn max_concurrent_ssh() -> usize {
714    static LIMIT: OnceLock<usize> = OnceLock::new();
715    *LIMIT.get_or_init(|| positive_env_limit(MAX_CONCURRENT_SSH_ENV, DEFAULT_MAX_CONCURRENT_SSH))
716}
717
718/// A positive whole number from the environment variable `name`, or
719/// `default` when it is unset or invalid.
720fn positive_env_limit(name: &str, default: usize) -> usize {
721    let Some(raw) = std::env::var_os(name) else {
722        return default;
723    };
724    match raw
725        .to_str()
726        .and_then(|value| value.trim().parse::<usize>().ok())
727    {
728        Some(limit) if limit > 0 => limit,
729        _ => {
730            tracing::warn!(
731                variable = name,
732                value = %raw.to_string_lossy(),
733                default,
734                "ignoring invalid SSH limit"
735            );
736            default
737        }
738    }
739}
740
741/// A counting semaphore per SSH destination.
742///
743/// Deliberately built on `std::sync` rather than a runtime primitive: the
744/// blocking process executors are called from plain threads as well as from
745/// `spawn_blocking`, and both must share one gate.
746struct DestinationGate {
747    limit: usize,
748    in_flight: Mutex<usize>,
749    released: Condvar,
750}
751
752impl DestinationGate {
753    fn new(limit: usize) -> Arc<Self> {
754        Arc::new(Self {
755            limit,
756            in_flight: Mutex::new(0),
757            released: Condvar::new(),
758        })
759    }
760
761    fn acquire(self: &Arc<Self>) -> SshPermit {
762        let mut in_flight = self
763            .in_flight
764            .lock()
765            .unwrap_or_else(std::sync::PoisonError::into_inner);
766        while *in_flight >= self.limit {
767            in_flight = self
768                .released
769                .wait(in_flight)
770                .unwrap_or_else(std::sync::PoisonError::into_inner);
771        }
772        *in_flight += 1;
773        drop(in_flight);
774        SshPermit {
775            gate: Arc::clone(self),
776        }
777    }
778}
779
780/// One admitted `ssh` connection. The slot is returned on drop.
781pub struct SshPermit {
782    gate: Arc<DestinationGate>,
783}
784
785impl std::fmt::Debug for SshPermit {
786    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
787        formatter.write_str("SshPermit")
788    }
789}
790
791impl Drop for SshPermit {
792    fn drop(&mut self) {
793        let mut in_flight = self
794            .gate
795            .in_flight
796            .lock()
797            .unwrap_or_else(std::sync::PoisonError::into_inner);
798        *in_flight = in_flight.saturating_sub(1);
799        drop(in_flight);
800        self.gate.released.notify_one();
801    }
802}
803
804/// Process-wide admission control for outbound `ssh` connections.
805pub struct SshAdmission;
806
807impl SshAdmission {
808    /// Block until this process may open another `ssh` connection to
809    /// `destination`. The returned permit holds the slot until it is dropped.
810    pub fn acquire(destination: &str) -> SshPermit {
811        Self::gate(destination).acquire()
812    }
813
814    fn gate(destination: &str) -> Arc<DestinationGate> {
815        static GATES: OnceLock<Mutex<BTreeMap<String, Arc<DestinationGate>>>> = OnceLock::new();
816        let mut gates = GATES
817            .get_or_init(|| Mutex::new(BTreeMap::new()))
818            .lock()
819            .unwrap_or_else(std::sync::PoisonError::into_inner);
820        Arc::clone(
821            gates
822                .entry(destination.to_owned())
823                .or_insert_with(|| DestinationGate::new(max_concurrent_ssh())),
824        )
825    }
826}
827
828/// Default number of sessions the daemon places on one shared connection.
829///
830/// A stock `sshd` refuses the eleventh session on one connection
831/// (`MaxSessions 10`). Two are left free for `ssh` commands from other
832/// Mjolnir processes on this machine, such as `mj doctor` and Tab completion,
833/// which join a master without being counted here.
834#[cfg(unix)]
835const DEFAULT_SESSIONS_PER_CONNECTION: usize = 8;
836
837/// Environment override for [`DEFAULT_SESSIONS_PER_CONNECTION`].
838pub const SESSIONS_PER_CONNECTION_ENV: &str = "MJ_SSH_SESSIONS_PER_CONNECTION";
839
840/// How long a successful `ssh -O check` of a master is trusted before the
841/// next lease on that shard checks again.
842#[cfg(unix)]
843const MASTER_CHECK_INTERVAL: Duration = Duration::from_secs(5);
844
845/// Per-command deadline for checking or opening a master from code that has
846/// no executor of its own, such as the relay and the resource pollers.
847pub const SSH_MASTER_OPEN_TIMEOUT: Duration = Duration::from_secs(60);
848
849#[cfg(unix)]
850fn sessions_per_connection() -> usize {
851    static LIMIT: OnceLock<usize> = OnceLock::new();
852    *LIMIT.get_or_init(|| {
853        positive_env_limit(SESSIONS_PER_CONNECTION_ENV, DEFAULT_SESSIONS_PER_CONNECTION)
854    })
855}
856
857/// One master connection and the sessions the daemon has placed on it.
858#[cfg(unix)]
859struct Shard {
860    leased: usize,
861    /// When `ssh -O check` last found this shard's master running.
862    verified_at: Option<Instant>,
863    /// Serializes checking and opening this shard's master, so concurrent
864    /// leases never start two openers for one socket.
865    opening: Arc<Mutex<()>>,
866}
867
868/// The daemon's count of sessions per shard, keyed by connection.
869#[cfg(unix)]
870struct SessionLedger {
871    per_connection: usize,
872    connections: Mutex<BTreeMap<String, Vec<Shard>>>,
873}
874
875#[cfg(unix)]
876impl SessionLedger {
877    fn new(per_connection: usize) -> Arc<Self> {
878        Arc::new(Self {
879            per_connection: per_connection.max(1),
880            connections: Mutex::new(BTreeMap::new()),
881        })
882    }
883
884    fn global() -> Arc<Self> {
885        static LEDGER: OnceLock<Arc<SessionLedger>> = OnceLock::new();
886        Arc::clone(LEDGER.get_or_init(|| Self::new(sessions_per_connection())))
887    }
888
889    fn connections(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Vec<Shard>>> {
890        self.connections
891            .lock()
892            .unwrap_or_else(std::sync::PoisonError::into_inner)
893    }
894
895    /// Lease a session on the lowest shard with room, opening that shard's
896    /// master first when it is not known to be running.
897    fn lease(
898        self: &Arc<Self>,
899        ssh: &SshTarget,
900        dir: &Path,
901        executor: &dyn CommandExecutor,
902    ) -> Result<SshSessionLease> {
903        let key = connection_key(ssh);
904        let (shard, opening) = {
905            let mut connections = self.connections();
906            let shards = connections.entry(key.clone()).or_default();
907            let index = match shards
908                .iter()
909                .position(|shard| shard.leased < self.per_connection)
910            {
911                Some(index) => index,
912                None => {
913                    shards.push(Shard {
914                        leased: 0,
915                        verified_at: None,
916                        opening: Arc::new(Mutex::new(())),
917                    });
918                    shards.len() - 1
919                }
920            };
921            shards[index].leased += 1;
922            (index, Arc::clone(&shards[index].opening))
923        };
924        // From here on the slot is released on drop, including on error.
925        let slot = LeasedSlot {
926            ledger: Arc::clone(self),
927            key,
928            shard,
929            socket: dir.join(control_socket_name(ssh, shard)),
930        };
931        if slot.needs_check() {
932            let _opening = opening
933                .lock()
934                .unwrap_or_else(std::sync::PoisonError::into_inner);
935            // Another lease may have checked or opened the master while this
936            // one waited for the lock.
937            if slot.needs_check() {
938                ensure_master(ssh, &slot.socket, executor)?;
939                slot.set_verified(Some(Instant::now()));
940            }
941        }
942        Ok(SshSessionLease { slot: Some(slot) })
943    }
944}
945
946/// Make sure a master is listening on `socket`, opening one if needed.
947///
948/// The master is opened explicitly, with `ControlMaster=yes`, and then
949/// checked again. Nothing else is attempted when that fails: the caller gets
950/// an error naming the destination instead of a direct connection.
951#[cfg(unix)]
952fn ensure_master(ssh: &SshTarget, socket: &Path, executor: &dyn CommandExecutor) -> Result<()> {
953    if master_running(ssh, socket, executor)? {
954        return Ok(());
955    }
956    // A master that died without cleaning up leaves its socket behind, and
957    // `ssh` will not bind over it: the opener would print "already exists,
958    // disabling multiplexing" and hold a plain connection instead.
959    match fs::remove_file(socket) {
960        Ok(()) => tracing::debug!(
961            socket = %socket.display(),
962            "removed a stale SSH control socket"
963        ),
964        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
965        Err(error) => {
966            return Err(error)
967                .with_context(|| format!("remove stale SSH control socket {}", socket.display()));
968        }
969    }
970    let opened = executor.execute(&master_open_command(ssh, socket))?;
971    if master_running(ssh, socket, executor)? {
972        tracing::info!(
973            destination = ssh.destination.as_str(),
974            socket = %socket.display(),
975            "opened a shared SSH connection"
976        );
977        return Ok(());
978    }
979    let stderr = String::from_utf8_lossy(&opened.stderr);
980    let detail = match stderr.trim() {
981        "" => format!("ssh exited with status {}", opened.status),
982        stderr => stderr.to_owned(),
983    };
984    bail!(
985        "could not open a shared SSH connection to {}: {detail}",
986        ssh.destination
987    )
988}
989
990#[cfg(unix)]
991fn master_running(ssh: &SshTarget, socket: &Path, executor: &dyn CommandExecutor) -> Result<bool> {
992    Ok(executor.execute(&master_check_command(ssh, socket))?.status == 0)
993}
994
995/// `ssh -O check` asks the master on `socket` whether it is alive. It opens
996/// no network connection, so it is not admitted like one.
997#[cfg(unix)]
998fn master_check_command(ssh: &SshTarget, socket: &Path) -> CommandSpec {
999    let mut args = ssh.ssh_args.clone();
1000    args.extend([
1001        "-o".to_owned(),
1002        format!("ControlPath={}", socket.display()),
1003        "-O".to_owned(),
1004        "check".to_owned(),
1005        ssh.destination.clone(),
1006    ]);
1007    CommandSpec::new("ssh", args).purpose("check a shared SSH connection")
1008}
1009
1010/// Open a master on `socket` and return once it is authenticated.
1011///
1012/// `-f -N` backgrounds the master after authentication without keeping the
1013/// caller's output pipes open, and `ControlPersist` stops it on its own once
1014/// its last session has been gone that long. `BatchMode=yes` keeps the daemon
1015/// from ever waiting on a password prompt. This is a real connection, so it
1016/// is admitted and retried like one.
1017#[cfg(unix)]
1018fn master_open_command(ssh: &SshTarget, socket: &Path) -> CommandSpec {
1019    let mut args = ssh.ssh_args.clone();
1020    args.extend([
1021        "-o".to_owned(),
1022        "BatchMode=yes".to_owned(),
1023        "-f".to_owned(),
1024        "-N".to_owned(),
1025        "-o".to_owned(),
1026        "ControlMaster=yes".to_owned(),
1027        "-o".to_owned(),
1028        format!("ControlPath={}", socket.display()),
1029        "-o".to_owned(),
1030        format!("ControlPersist={CONTROL_PERSIST}"),
1031        ssh.destination.clone(),
1032    ]);
1033    CommandSpec::new("ssh", args)
1034        .ssh_destination(ssh.destination.clone())
1035        .purpose("open a shared SSH connection")
1036}
1037
1038/// The ledger entry a lease holds; dropping it frees the slot.
1039#[cfg(unix)]
1040struct LeasedSlot {
1041    ledger: Arc<SessionLedger>,
1042    key: String,
1043    shard: usize,
1044    socket: PathBuf,
1045}
1046
1047#[cfg(unix)]
1048impl LeasedSlot {
1049    fn needs_check(&self) -> bool {
1050        let connections = self.ledger.connections();
1051        connections
1052            .get(&self.key)
1053            .and_then(|shards| shards.get(self.shard))
1054            .is_none_or(|shard| {
1055                shard
1056                    .verified_at
1057                    .is_none_or(|verified| verified.elapsed() >= MASTER_CHECK_INTERVAL)
1058            })
1059    }
1060
1061    fn set_verified(&self, verified_at: Option<Instant>) {
1062        let mut connections = self.ledger.connections();
1063        if let Some(shard) = connections
1064            .get_mut(&self.key)
1065            .and_then(|shards| shards.get_mut(self.shard))
1066        {
1067            shard.verified_at = verified_at;
1068        }
1069    }
1070}
1071
1072#[cfg(unix)]
1073impl Drop for LeasedSlot {
1074    fn drop(&mut self) {
1075        let mut connections = self.ledger.connections();
1076        if let Some(shard) = connections
1077            .get_mut(&self.key)
1078            .and_then(|shards| shards.get_mut(self.shard))
1079        {
1080            shard.leased = shard.leased.saturating_sub(1);
1081        }
1082    }
1083}
1084
1085/// A leased session slot on one shard of a shared connection. Dropping it
1086/// frees the slot.
1087///
1088/// A lease without a socket stands for a command that runs on its own
1089/// connection: sharing is switched off with `MJ_SSH_CONTROL_MASTER`, the
1090/// user's `ssh_args` configure sharing themselves, or the platform has no
1091/// connection sharing.
1092pub struct SshSessionLease {
1093    #[cfg(unix)]
1094    slot: Option<LeasedSlot>,
1095}
1096
1097impl std::fmt::Debug for SshSessionLease {
1098    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1099        formatter
1100            .debug_struct("SshSessionLease")
1101            .field("control_path", &self.control_path())
1102            .finish()
1103    }
1104}
1105
1106impl SshSessionLease {
1107    fn unshared() -> Self {
1108        Self {
1109            #[cfg(unix)]
1110            slot: None,
1111        }
1112    }
1113
1114    /// The control socket this session must use, or `None` when the command
1115    /// runs on its own connection.
1116    pub fn control_path(&self) -> Option<&Path> {
1117        #[cfg(unix)]
1118        return self.slot.as_ref().map(|slot| slot.socket.as_path());
1119        #[cfg(not(unix))]
1120        None
1121    }
1122
1123    /// Forget that this lease's master was verified, so the next lease on the
1124    /// shard checks it and reopens it if needed. Call this when a session
1125    /// failed in a way that suggests the master is gone.
1126    pub fn invalidate(&self) {
1127        #[cfg(unix)]
1128        if let Some(slot) = &self.slot {
1129            slot.set_verified(None);
1130        }
1131    }
1132}
1133
1134/// Process-wide placement of `ssh` sessions on shared connections.
1135///
1136/// A stock `sshd` allows ten sessions per connection. The daemon therefore
1137/// spreads its sessions for one connection across several masters (shards),
1138/// each opened explicitly, and every other command joins one of them with
1139/// options that make a direct connection impossible.
1140pub struct SshSessions;
1141
1142impl SshSessions {
1143    /// Reserve a session on a shard for `ssh`, opening that shard's master if
1144    /// it is not running. Blocks like [`SshAdmission::acquire`], so it is
1145    /// callable from plain threads and `spawn_blocking`. Returns an error only
1146    /// when a master could not be opened or verified.
1147    pub fn lease(ssh: &SshTarget, executor: &dyn CommandExecutor) -> Result<SshSessionLease> {
1148        #[cfg(unix)]
1149        {
1150            if user_configures_sharing(&ssh.ssh_args) {
1151                return Ok(SshSessionLease::unshared());
1152            }
1153            let Some(dir) = control_socket_dir() else {
1154                return Ok(SshSessionLease::unshared());
1155            };
1156            SessionLedger::global().lease(ssh, &dir, executor)
1157        }
1158        #[cfg(not(unix))]
1159        {
1160            let _ = (ssh, executor);
1161            Ok(SshSessionLease::unshared())
1162        }
1163    }
1164}
1165
1166/// Options for a command that runs as one session on an already open
1167/// master. `ProxyCommand=false` makes it impossible for `ssh` to open a
1168/// direct connection: a multiplexed client never runs the proxy command, and
1169/// a client that fails to reach the master exits 255 instead of connecting on
1170/// its own. Appends nothing for a lease without a socket.
1171pub fn push_session_args(args: &mut Vec<String>, lease: &SshSessionLease) {
1172    if let Some(socket) = lease.control_path() {
1173        args.extend([
1174            "-o".to_owned(),
1175            "ControlMaster=no".to_owned(),
1176            "-o".to_owned(),
1177            format!("ControlPath={}", socket.display()),
1178            "-o".to_owned(),
1179            "ProxyCommand=false".to_owned(),
1180        ]);
1181    }
1182}
1183
1184/// The argument list for `program` (`ssh` or `scp`) running as one session
1185/// on `lease`'s master: the session options first, then the command's own
1186/// arguments.
1187///
1188/// OpenSSH keeps the first value it sees for an option, so leading with the
1189/// session options makes them win over the user's `ssh_args` and
1190/// `ssh_config`, including any `ProxyCommand` or `ProxyJump`. `ssh` refuses
1191/// the `-J` flag after a `ProxyCommand` outright, so a `-J` in the user's own
1192/// arguments is rewritten to the equivalent `-o ProxyJump=`, which the
1193/// session's `ProxyCommand=false` then overrides; a session never connects
1194/// by itself, and the master was opened with the user's jump host. `scp`
1195/// already passes its `-J` on as `-oProxyJump=`.
1196pub fn session_command_args(
1197    program: &str,
1198    args: &[String],
1199    ssh: &SshTarget,
1200    lease: &SshSessionLease,
1201) -> Vec<String> {
1202    let mut session = Vec::with_capacity(args.len() + 6);
1203    push_session_args(&mut session, lease);
1204    if session.is_empty() {
1205        return args.to_vec();
1206    }
1207    if program == "ssh" && args.starts_with(&ssh.ssh_args) {
1208        let (user, rest) = args.split_at(ssh.ssh_args.len());
1209        let mut user = user.iter();
1210        while let Some(argument) = user.next() {
1211            match argument.strip_prefix("-J") {
1212                Some("") => match user.next() {
1213                    Some(jump) => session.extend(["-o".to_owned(), format!("ProxyJump={jump}")]),
1214                    None => session.push(argument.clone()),
1215                },
1216                Some(jump) => session.extend(["-o".to_owned(), format!("ProxyJump={jump}")]),
1217                None => session.push(argument.clone()),
1218            }
1219        }
1220        session.extend(rest.iter().cloned());
1221    } else {
1222        session.extend(args.iter().cloned());
1223    }
1224    session
1225}
1226
1227/// How many times a transport-rejected `ssh` invocation is tried in total.
1228pub const SSH_RETRY_ATTEMPTS: usize = 3;
1229
1230/// Inclusive millisecond bounds the jittered delay is drawn from, indexed by
1231/// the number of attempts already made. `sshd` sheds load for as long as its
1232/// pre-auth queue stays full, so the second wait is a multiple of the first.
1233const SSH_RETRY_BACKOFF_MS: [(u64, u64); SSH_RETRY_ATTEMPTS - 1] = [(500, 2_000), (2_000, 4_000)];
1234
1235/// Test override collapsing every retry delay to this many milliseconds.
1236/// `u64::MAX` means "no override".
1237static SSH_RETRY_BACKOFF_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
1238
1239/// Shorten the retry backoff so tests can drive the retry path without
1240/// sleeping for seconds. Not part of the daemon's behaviour.
1241#[doc(hidden)]
1242pub fn set_ssh_retry_backoff_for_test(delay: Option<Duration>) {
1243    SSH_RETRY_BACKOFF_OVERRIDE_MS.store(
1244        delay.map_or(u64::MAX, |delay| delay.as_millis() as u64),
1245        Ordering::Relaxed,
1246    );
1247}
1248
1249/// The jittered wait before retry number `attempts_made + 1`.
1250///
1251/// Jitter matters more than the mean here: every session's reconnect fails at
1252/// the same instant, so an unjittered schedule would simply re-send the whole
1253/// burst into the same full queue.
1254pub fn ssh_retry_delay(attempts_made: usize) -> Duration {
1255    let override_ms = SSH_RETRY_BACKOFF_OVERRIDE_MS.load(Ordering::Relaxed);
1256    if override_ms != u64::MAX {
1257        return Duration::from_millis(override_ms);
1258    }
1259    let (low, high) = SSH_RETRY_BACKOFF_MS
1260        .get(attempts_made.saturating_sub(1))
1261        .copied()
1262        .unwrap_or(*SSH_RETRY_BACKOFF_MS.last().expect("non-empty schedule"));
1263    let mut bytes = [0_u8; 8];
1264    // A failed draw only costs jitter, so fall back to the lower bound.
1265    let spread = if getrandom::fill(&mut bytes).is_ok() {
1266        u64::from_le_bytes(bytes) % (high - low + 1)
1267    } else {
1268        0
1269    };
1270    Duration::from_millis(low + spread)
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276
1277    const BORROW_PARENT: &str = "0123456789abcdef0123456789abcdef";
1278    const BORROW_CHILD: &str = "fedcba9876543210fedcba9876543210";
1279
1280    fn borrowed_podman(owner: &str) -> TargetLocator {
1281        TargetLocator::LocalPodman {
1282            container_id: crate::targets::resource_name(owner).unwrap(),
1283            workspace_storage: PodmanWorkspaceLocator::default(),
1284            borrowed_from: Some(owner.to_owned()),
1285        }
1286    }
1287
1288    #[test]
1289    fn verify_locator_accepts_a_container_borrowed_from_its_owner() {
1290        verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_CHILD)
1291            .expect("a child may borrow its parent's container");
1292    }
1293
1294    #[test]
1295    fn verify_locator_rejects_a_container_borrowed_from_the_checking_session() {
1296        let error = verify_locator(&borrowed_podman(BORROW_PARENT), BORROW_PARENT)
1297            .expect_err("a session cannot borrow from itself");
1298        assert!(
1299            format!("{error:#}").contains("cannot be owned by the borrowing session"),
1300            "unexpected error: {error:#}"
1301        );
1302    }
1303
1304    #[test]
1305    fn verify_locator_rejects_a_borrowed_container_naming_another_session() {
1306        let locator = TargetLocator::LocalPodman {
1307            container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
1308            workspace_storage: PodmanWorkspaceLocator::default(),
1309            borrowed_from: Some(BORROW_PARENT.to_owned()),
1310        };
1311        let error = verify_locator(&locator, BORROW_CHILD)
1312            .expect_err("the container must belong to the recorded owner");
1313        assert!(
1314            format!("{error:#}").contains("borrowed container locator"),
1315            "unexpected error: {error:#}"
1316        );
1317    }
1318
1319    #[test]
1320    fn worker_root_of_a_borrowed_container_is_the_childs_own_directory() {
1321        assert_eq!(
1322            crate::targets::worker_root(&borrowed_podman(BORROW_PARENT), BORROW_CHILD).unwrap(),
1323            format!("/var/lib/hel/workers/{BORROW_CHILD}")
1324        );
1325    }
1326
1327    #[test]
1328    fn is_borrowed_distinguishes_borrowed_targets_from_owned_ones() {
1329        assert!(is_borrowed(&borrowed_podman(BORROW_PARENT)));
1330        assert!(is_borrowed(&TargetLocator::SshBare {
1331            ssh: SshTarget {
1332                destination: "host".to_owned(),
1333                ssh_args: Vec::new(),
1334            },
1335            workspace: format!(".local/share/hel/workspaces/{BORROW_PARENT}"),
1336            worker_id: Some(BORROW_CHILD.to_owned()),
1337        }));
1338        assert!(!is_borrowed(&TargetLocator::LocalPodman {
1339            container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
1340            workspace_storage: PodmanWorkspaceLocator::default(),
1341            borrowed_from: None,
1342        }));
1343    }
1344
1345    #[test]
1346    fn an_owned_container_locator_serializes_without_a_borrowed_from_key() {
1347        let owned = TargetLocator::LocalDocker {
1348            container_id: crate::targets::resource_name(BORROW_CHILD).unwrap(),
1349            borrowed_from: None,
1350        };
1351        let serialized = serde_json::to_string(&owned).unwrap();
1352        assert!(
1353            !serialized.contains("borrowed_from"),
1354            "owned locators must stay byte-identical for older readers: {serialized}"
1355        );
1356        assert_eq!(
1357            serde_json::from_str::<TargetLocator>(&serialized).unwrap(),
1358            owned
1359        );
1360
1361        let borrowed = borrowed_podman(BORROW_PARENT);
1362        let serialized = serde_json::to_string(&borrowed).unwrap();
1363        assert!(serialized.contains("borrowed_from"));
1364        assert_eq!(
1365            serde_json::from_str::<TargetLocator>(&serialized).unwrap(),
1366            borrowed
1367        );
1368    }
1369    use std::sync::atomic::{AtomicUsize, Ordering};
1370
1371    /// Records the commands it is handed and reports an empty success.
1372    #[cfg(unix)]
1373    #[derive(Default)]
1374    struct RecordingExecutor {
1375        seen: std::cell::RefCell<Vec<CommandSpec>>,
1376    }
1377
1378    #[cfg(unix)]
1379    impl CommandExecutor for RecordingExecutor {
1380        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1381            self.seen.borrow_mut().push(command.clone());
1382            Ok(CommandOutput {
1383                status: 0,
1384                stdout: Vec::new(),
1385                stderr: Vec::new(),
1386            })
1387        }
1388    }
1389
1390    #[cfg(unix)]
1391    fn sharing_socket_dir() -> tempfile::TempDir {
1392        // macOS's default temporary path leaves too little room for SSH's hash.
1393        tempfile::tempdir_in("/tmp").expect("short control socket directory")
1394    }
1395
1396    /// Lease a session for `command` through the process-wide ledger with
1397    /// the sockets pinned to `dir`, and return the arguments it would be
1398    /// spawned with.
1399    #[cfg(unix)]
1400    fn spawned_args(
1401        command: &CommandSpec,
1402        dir: Option<&Path>,
1403        masters: &FakeMasters,
1404    ) -> Vec<String> {
1405        set_ssh_connection_sharing_for_test(Some(match dir {
1406            Some(dir) => SshSharingForTest::Directory(dir.to_path_buf()),
1407            None => SshSharingForTest::Disabled,
1408        }));
1409        let session = command.open_ssh_session(masters);
1410        set_ssh_connection_sharing_for_test(None);
1411        session.expect("session").command().args.clone()
1412    }
1413
1414    /// A built command carries a session request instead of sharing
1415    /// options; at spawn time the session options go in front of everything
1416    /// the user configured, so OpenSSH honours them over any proxy setting.
1417    #[test]
1418    #[cfg(unix)]
1419    fn session_options_lead_the_spawned_command() {
1420        let _guard = SHARING_TEST_LOCK
1421            .lock()
1422            .unwrap_or_else(std::sync::PoisonError::into_inner);
1423        let socket_dir = sharing_socket_dir();
1424        let ssh = SshTarget {
1425            destination: "session-options-host".to_owned(),
1426            ssh_args: vec![
1427                "-p".to_owned(),
1428                "2222".to_owned(),
1429                "-o".to_owned(),
1430                "ProxyCommand=nc %h %p".to_owned(),
1431            ],
1432        };
1433        let command = ssh_command(&ssh, ["true"]);
1434        assert_eq!(
1435            command.args,
1436            [
1437                "-p",
1438                "2222",
1439                "-o",
1440                "ProxyCommand=nc %h %p",
1441                "session-options-host",
1442                "'true'"
1443            ],
1444            "stored arguments never contain sharing options"
1445        );
1446        assert_eq!(command.ssh_session.as_ref(), Some(&ssh));
1447        assert_eq!(
1448            command.ssh_destination.as_deref(),
1449            Some("session-options-host")
1450        );
1451
1452        let masters = FakeMasters::default();
1453        let args = spawned_args(&command, Some(socket_dir.path()), &masters);
1454        let socket = socket_dir.path().join(control_socket_name(&ssh, 0));
1455        assert_eq!(
1456            args,
1457            [
1458                "-o".to_owned(),
1459                "ControlMaster=no".to_owned(),
1460                "-o".to_owned(),
1461                format!("ControlPath={}", socket.display()),
1462                "-o".to_owned(),
1463                "ProxyCommand=false".to_owned(),
1464                "-p".to_owned(),
1465                "2222".to_owned(),
1466                "-o".to_owned(),
1467                "ProxyCommand=nc %h %p".to_owned(),
1468                "session-options-host".to_owned(),
1469                "'true'".to_owned(),
1470            ]
1471        );
1472        assert_eq!(masters.openers(), 1);
1473        assert_eq!(
1474            std::os::unix::fs::MetadataExt::mode(
1475                &fs::metadata(socket_dir.path()).expect("socket directory")
1476            ) & 0o777,
1477            0o700
1478        );
1479    }
1480
1481    /// `ssh` refuses `-J` after a `ProxyCommand`, so a session rewrites the
1482    /// user's `-J` to the `ProxyJump` option the session's guard overrides.
1483    /// `scp` already turns its `-J` into that option.
1484    #[test]
1485    #[cfg(unix)]
1486    fn a_jump_host_flag_becomes_an_option_the_session_guard_overrides() {
1487        let _guard = SHARING_TEST_LOCK
1488            .lock()
1489            .unwrap_or_else(std::sync::PoisonError::into_inner);
1490        let socket_dir = sharing_socket_dir();
1491        let ssh = SshTarget {
1492            destination: "jump-rewrite-host".to_owned(),
1493            ssh_args: vec!["-J".to_owned(), "bastion".to_owned(), "-Jother".to_owned()],
1494        };
1495        let masters = FakeMasters::default();
1496        let args = spawned_args(
1497            &ssh_command(&ssh, ["-J"]),
1498            Some(socket_dir.path()),
1499            &masters,
1500        );
1501        assert_eq!(
1502            args[6..],
1503            [
1504                "-o",
1505                "ProxyJump=bastion",
1506                "-o",
1507                "ProxyJump=other",
1508                "jump-rewrite-host",
1509                "'-J'",
1510            ]
1511        );
1512        let upload = spawned_args(
1513            &scp_upload(&ssh, Path::new("/tmp/file"), "file", false),
1514            Some(socket_dir.path()),
1515            &masters,
1516        );
1517        assert_eq!(
1518            upload[6..],
1519            [
1520                "-J",
1521                "bastion",
1522                "-Jother",
1523                "/tmp/file",
1524                "jump-rewrite-host:file"
1525            ]
1526        );
1527    }
1528
1529    /// A user who configures sharing in `ssh_args` owns it: Mjolnir adds no
1530    /// sharing options of its own, in any spelling OpenSSH accepts, and opens
1531    /// no master.
1532    #[test]
1533    #[cfg(unix)]
1534    fn user_configured_sharing_suppresses_mjolnir_sharing() {
1535        let _guard = SHARING_TEST_LOCK
1536            .lock()
1537            .unwrap_or_else(std::sync::PoisonError::into_inner);
1538        let socket_dir = sharing_socket_dir();
1539        let spellings: [&[&str]; 5] = [
1540            &["-o", "ControlMaster=no"],
1541            &["-o", "controlpath /tmp/mine"],
1542            &["-oControlPath=/tmp/mine"],
1543            &["-S", "/tmp/mine"],
1544            &["-S/tmp/mine"],
1545        ];
1546        let masters = FakeMasters::default();
1547        for user in spellings {
1548            let ssh = SshTarget {
1549                destination: "user-sharing-host".to_owned(),
1550                ssh_args: user.iter().map(|arg| (*arg).to_owned()).collect(),
1551            };
1552            set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1553                socket_dir.path().to_path_buf(),
1554            )));
1555            let validation = ssh_validation_command(&ssh, vec!["true".to_owned()], "test");
1556            let command = ssh_command(&ssh, ["true"]);
1557            let args = spawned_args(&command, Some(socket_dir.path()), &masters);
1558            assert_eq!(args, command.args, "user args {user:?}");
1559            let socket_dir_text = socket_dir.path().display().to_string();
1560            assert!(
1561                !validation
1562                    .args
1563                    .iter()
1564                    .any(|arg| arg.contains(&socket_dir_text)),
1565                "user args {user:?}: {:?}",
1566                validation.args
1567            );
1568        }
1569        assert_eq!(masters.commands(), 0);
1570    }
1571
1572    /// Each instance keeps its own sockets, because each daemon counts only
1573    /// its own sessions against a master.
1574    #[test]
1575    #[cfg(unix)]
1576    fn control_sockets_live_in_a_directory_per_instance() {
1577        let runtime = Some(std::ffi::OsString::from("/run/user/1000"));
1578        assert_eq!(
1579            default_control_dir(runtime.clone(), Some("hel2".to_owned())),
1580            PathBuf::from("/run/user/1000/mjolnir/hel2")
1581        );
1582        assert_eq!(
1583            default_control_dir(runtime, None),
1584            PathBuf::from("/run/user/1000/mjolnir/default")
1585        );
1586    }
1587
1588    /// Sockets are named `<hash>-<shard>`, and the hash follows the whole
1589    /// configured connection, not only the destination.
1590    #[test]
1591    #[cfg(unix)]
1592    fn socket_names_identify_the_connection_and_the_shard() {
1593        let plain = SshTarget {
1594            destination: "host".to_owned(),
1595            ssh_args: Vec::new(),
1596        };
1597        let other_port = SshTarget {
1598            destination: "host".to_owned(),
1599            ssh_args: vec!["-p".to_owned(), "2222".to_owned()],
1600        };
1601        let first = control_socket_name(&plain, 0);
1602        let second = control_socket_name(&plain, 1);
1603        assert_eq!(first.len(), CONNECTION_HASH_HEX + 2, "{first}");
1604        assert!(first.ends_with("-0") && second.ends_with("-1"));
1605        assert_eq!(first[..CONNECTION_HASH_HEX], second[..CONNECTION_HASH_HEX]);
1606        assert_ne!(
1607            first[..CONNECTION_HASH_HEX],
1608            control_socket_name(&other_port, 0)[..CONNECTION_HASH_HEX]
1609        );
1610    }
1611
1612    /// A command with a two-second keepalive must join a master, never open
1613    /// one: as the master it would impose that keepalive on every later
1614    /// session sharing the connection.
1615    #[test]
1616    #[cfg(unix)]
1617    fn fail_fast_commands_reuse_a_master_without_becoming_one() {
1618        let _guard = SHARING_TEST_LOCK
1619            .lock()
1620            .unwrap_or_else(std::sync::PoisonError::into_inner);
1621        let socket_dir = sharing_socket_dir();
1622        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1623            socket_dir.path().to_path_buf(),
1624        )));
1625        let ssh = SshTarget {
1626            destination: "host".to_owned(),
1627            ssh_args: Vec::new(),
1628        };
1629        let validation = ssh_validation_command(&ssh, vec!["true".to_owned()], "test").args;
1630        let executor = RecordingExecutor::default();
1631        crate::path_completion::ssh_completions(
1632            &ssh,
1633            "/srv/pr",
1634            crate::path_completion::CompletionKind::Directories,
1635            &executor,
1636        )
1637        .expect("completion runs");
1638        let completion = executor.seen.borrow()[0].args.clone();
1639        set_ssh_connection_sharing_for_test(None);
1640
1641        let control_path = format!(
1642            "ControlPath={}/{}",
1643            socket_dir.path().display(),
1644            control_socket_name(&ssh, 0)
1645        );
1646        for args in [&validation, &completion] {
1647            assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
1648            assert!(args.contains(&control_path), "{args:?}");
1649            assert!(
1650                !args.iter().any(|arg| arg.starts_with("ControlPersist")),
1651                "a fail-fast command must not set how long a master lingers: {args:?}"
1652            );
1653            let master = args
1654                .iter()
1655                .position(|arg| arg == "ControlMaster=no")
1656                .expect("sharing options");
1657            let alive = args
1658                .iter()
1659                .position(|arg| arg == "ServerAliveCountMax=1")
1660                .expect("its own keepalive");
1661            assert!(alive < master, "{args:?}");
1662            assert!(
1663                master
1664                    < args
1665                        .iter()
1666                        .position(|arg| arg == "host")
1667                        .expect("destination"),
1668                "{args:?}"
1669            );
1670        }
1671    }
1672
1673    /// `mj doctor` diagnoses and exits. Its connectivity probe must join a
1674    /// master when one is up and otherwise open a plain connection, so a
1675    /// doctor run never leaves a `ControlPersist` master behind, and the
1676    /// probe's own strict overrides never bind a shared connection.
1677    #[test]
1678    #[cfg(unix)]
1679    fn connectivity_probe_joins_a_master_without_becoming_one() {
1680        let _guard = SHARING_TEST_LOCK
1681            .lock()
1682            .unwrap_or_else(std::sync::PoisonError::into_inner);
1683        let socket_dir = sharing_socket_dir();
1684        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1685            socket_dir.path().to_path_buf(),
1686        )));
1687        let ssh = SshTarget {
1688            destination: "host".to_owned(),
1689            ssh_args: Vec::new(),
1690        };
1691        let args = ssh_connectivity_probe(&ssh).args;
1692        set_ssh_connection_sharing_for_test(None);
1693
1694        assert!(args.contains(&"ControlMaster=no".to_owned()), "{args:?}");
1695        assert!(
1696            args.contains(&format!(
1697                "ControlPath={}/{}",
1698                socket_dir.path().display(),
1699                control_socket_name(&ssh, 0)
1700            )),
1701            "the probe must still join an existing master: {args:?}"
1702        );
1703        assert!(
1704            !args.iter().any(|arg| arg.starts_with("ControlPersist")),
1705            "a doctor probe must not set how long a master lingers: {args:?}"
1706        );
1707        let master = args
1708            .iter()
1709            .position(|arg| arg == "ControlMaster=no")
1710            .expect("sharing options");
1711        let strict = args
1712            .iter()
1713            .position(|arg| arg == "StrictHostKeyChecking=yes")
1714            .expect("its own host key policy");
1715        assert!(strict < master, "{args:?}");
1716    }
1717
1718    #[test]
1719    #[cfg(unix)]
1720    fn connection_sharing_is_absent_when_turned_off() {
1721        let _guard = SHARING_TEST_LOCK
1722            .lock()
1723            .unwrap_or_else(std::sync::PoisonError::into_inner);
1724        let ssh = SshTarget {
1725            destination: "sharing-off-host".to_owned(),
1726            ssh_args: Vec::new(),
1727        };
1728        let masters = FakeMasters::default();
1729        let args = spawned_args(&ssh_command(&ssh, ["true"]), None, &masters);
1730        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
1731        let validation = ssh_validation_command(&ssh, vec!["true".to_owned()], "test").args;
1732        set_ssh_connection_sharing_for_test(None);
1733        assert_eq!(args, ["sharing-off-host", "'true'"]);
1734        assert!(!validation.iter().any(|arg| arg.starts_with("Control")));
1735        assert_eq!(masters.commands(), 0);
1736    }
1737
1738    #[test]
1739    #[cfg(unix)]
1740    fn a_control_path_that_cannot_fit_a_socket_address_is_skipped() {
1741        let _guard = SHARING_TEST_LOCK
1742            .lock()
1743            .unwrap_or_else(std::sync::PoisonError::into_inner);
1744        let root = tempfile::tempdir().expect("temp dir");
1745        let long = root.path().join("a".repeat(MAX_CONTROL_PATH));
1746        let ssh = SshTarget {
1747            destination: "long-path-host".to_owned(),
1748            ssh_args: Vec::new(),
1749        };
1750        let masters = FakeMasters::default();
1751        let args = spawned_args(&ssh_command(&ssh, ["true"]), Some(&long), &masters);
1752        assert_eq!(args, ["long-path-host", "'true'"]);
1753        assert_eq!(masters.commands(), 0);
1754        assert!(!long.exists(), "an unusable directory must not be created");
1755    }
1756
1757    #[test]
1758    #[cfg(not(unix))]
1759    fn connection_sharing_is_unix_only() {
1760        let mut args = vec!["-o".to_owned(), "BatchMode=yes".to_owned()];
1761        let ssh = SshTarget {
1762            destination: "host".to_owned(),
1763            ssh_args: Vec::new(),
1764        };
1765        push_connection_reuse_args(&mut args, &ssh);
1766        assert_eq!(args, vec!["-o".to_owned(), "BatchMode=yes".to_owned()]);
1767    }
1768
1769    #[test]
1770    #[cfg(unix)]
1771    fn the_escape_hatch_accepts_the_usual_off_spellings() {
1772        for value in ["0", "off", "FALSE", " no "] {
1773            assert!(
1774                sharing_disabled(Some(std::ffi::OsStr::new(value))),
1775                "{value:?} must disable connection sharing"
1776            );
1777        }
1778        for value in ["1", "auto", "", "yes"] {
1779            assert!(
1780                !sharing_disabled(Some(std::ffi::OsStr::new(value))),
1781                "{value:?} must leave connection sharing on"
1782            );
1783        }
1784        assert!(!sharing_disabled(None));
1785    }
1786
1787    /// Against a real host: leasing a session opens a master that
1788    /// `ssh -O check` finds, and a command runs through it. Set
1789    /// `MJ_E2E_SSH_HOST` to a reachable destination to run it.
1790    #[test]
1791    #[cfg(unix)]
1792    fn a_leased_session_runs_through_an_opened_master_on_a_real_host() {
1793        let _guard = SHARING_TEST_LOCK
1794            .lock()
1795            .unwrap_or_else(std::sync::PoisonError::into_inner);
1796        let Some(host) = std::env::var_os("MJ_E2E_SSH_HOST") else {
1797            return;
1798        };
1799        let host = host.to_string_lossy().into_owned();
1800        let socket_dir = sharing_socket_dir();
1801        let ssh = SshTarget {
1802            destination: host.clone(),
1803            ssh_args: vec!["-o".to_owned(), "BatchMode=yes".to_owned()],
1804        };
1805        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
1806            socket_dir.path().to_path_buf(),
1807        )));
1808        let output = ProcessExecutor.execute(&ssh_command(&ssh, ["true"]));
1809        set_ssh_connection_sharing_for_test(None);
1810        let socket = socket_dir.path().join(control_socket_name(&ssh, 0));
1811        let check = ProcessExecutor
1812            .execute(&master_check_command(&ssh, &socket))
1813            .expect("ssh -O check must run");
1814        let exit = std::process::Command::new("ssh")
1815            .args([
1816                "-O",
1817                "exit",
1818                "-o",
1819                &format!("ControlPath={}", socket.display()),
1820                &host,
1821            ])
1822            .output();
1823        let output = output.expect("ssh must run");
1824        assert_eq!(
1825            output.status,
1826            0,
1827            "ssh {host} true failed: {}",
1828            String::from_utf8_lossy(&output.stderr)
1829        );
1830        assert_eq!(
1831            check.status,
1832            0,
1833            "no master is running: {}",
1834            String::from_utf8_lossy(&check.stderr)
1835        );
1836        drop(exit);
1837    }
1838
1839    /// Against a real host: with three sessions per connection, seven
1840    /// concurrent sessions open three masters, every session runs through
1841    /// its master, and a guarded session with no master fails instead of
1842    /// connecting on its own. Set `MJ_E2E_SSH_HOST` to run it.
1843    #[test]
1844    #[cfg(unix)]
1845    fn sessions_shard_across_masters_on_a_real_host() {
1846        let _guard = SHARING_TEST_LOCK
1847            .lock()
1848            .unwrap_or_else(std::sync::PoisonError::into_inner);
1849        let Some(host) = std::env::var_os("MJ_E2E_SSH_HOST") else {
1850            return;
1851        };
1852        let host = host.to_string_lossy().into_owned();
1853        let socket_dir = sharing_socket_dir();
1854        let ssh = SshTarget {
1855            destination: host.clone(),
1856            ssh_args: vec!["-o".to_owned(), "BatchMode=yes".to_owned()],
1857        };
1858        let ledger = SessionLedger::new(3);
1859        let leases: Vec<SshSessionLease> = (0..7)
1860            .map(|_| {
1861                ledger
1862                    .lease(&ssh, socket_dir.path(), &ProcessExecutor)
1863                    .expect("lease a session on a real host")
1864            })
1865            .collect();
1866        let sockets: Vec<PathBuf> = (0..4)
1867            .map(|shard| socket_dir.path().join(control_socket_name(&ssh, shard)))
1868            .collect();
1869        let exit_all = || {
1870            for socket in &sockets {
1871                let _ = std::process::Command::new("ssh")
1872                    .args([
1873                        "-O",
1874                        "exit",
1875                        "-o",
1876                        &format!("ControlPath={}", socket.display()),
1877                        &host,
1878                    ])
1879                    .output();
1880            }
1881        };
1882
1883        // Run all seven at once so they really share their masters.
1884        let base = ssh_command(&ssh, ["sleep", "2"]);
1885        let children: Vec<std::io::Result<std::process::Output>> = std::thread::scope(|scope| {
1886            let handles: Vec<_> = leases
1887                .iter()
1888                .map(|lease| {
1889                    let args = session_command_args(&base.program, &base.args, &ssh, lease);
1890                    scope.spawn(move || {
1891                        std::process::Command::new("ssh")
1892                            .args(args)
1893                            .stdin(std::process::Stdio::null())
1894                            .output()
1895                    })
1896                })
1897                .collect();
1898            handles
1899                .into_iter()
1900                .map(|handle| handle.join().expect("session thread"))
1901                .collect()
1902        });
1903        let running: Vec<bool> = sockets
1904            .iter()
1905            .map(|socket| {
1906                ProcessExecutor
1907                    .execute(&master_check_command(&ssh, socket))
1908                    .map(|output| output.status == 0)
1909                    .unwrap_or(false)
1910            })
1911            .collect();
1912        let orphan = std::process::Command::new("ssh")
1913            .args(session_command_args(
1914                &base.program,
1915                &base.args,
1916                &ssh,
1917                &SshSessionLease {
1918                    slot: Some(LeasedSlot {
1919                        ledger: Arc::clone(&ledger),
1920                        key: connection_key(&ssh),
1921                        shard: 9,
1922                        socket: socket_dir.path().join(control_socket_name(&ssh, 9)),
1923                    }),
1924                },
1925            ))
1926            .stdin(std::process::Stdio::null())
1927            .output();
1928        drop(leases);
1929        exit_all();
1930
1931        let shards: Vec<usize> = leases_per_shard(&ledger, &ssh);
1932        assert_eq!(shards, [0, 0, 0], "every slot is freed on drop");
1933        for (index, output) in children.iter().enumerate() {
1934            let output = output.as_ref().expect("ssh must run");
1935            assert_eq!(
1936                output.status.code(),
1937                Some(0),
1938                "session {index} failed: {}",
1939                String::from_utf8_lossy(&output.stderr)
1940            );
1941        }
1942        assert_eq!(
1943            running,
1944            [true, true, true, false],
1945            "seven sessions at three per master"
1946        );
1947        let orphan = orphan.expect("ssh must run");
1948        assert_eq!(
1949            orphan.status.code(),
1950            Some(255),
1951            "a guarded session with no master must not connect: {}",
1952            String::from_utf8_lossy(&orphan.stderr)
1953        );
1954    }
1955
1956    #[cfg(unix)]
1957    fn leases_per_shard(ledger: &SessionLedger, ssh: &SshTarget) -> Vec<usize> {
1958        ledger
1959            .connections()
1960            .get(&connection_key(ssh))
1961            .map(|shards| shards.iter().map(|shard| shard.leased).collect())
1962            .unwrap_or_default()
1963    }
1964
1965    /// `scp` spells the port `-P`; passing an `ssh` `-p` through would ask it
1966    /// to preserve file times and read the port as a file name. Every `scp`
1967    /// also opens a connection, so it is admitted like `ssh`.
1968    #[test]
1969    #[cfg(unix)]
1970    fn scp_translates_the_ssh_port_option_and_is_tagged_with_its_destination() {
1971        let _guard = SHARING_TEST_LOCK
1972            .lock()
1973            .unwrap_or_else(std::sync::PoisonError::into_inner);
1974        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
1975        let ssh = SshTarget {
1976            destination: "build@10.0.0.1".into(),
1977            ssh_args: vec!["-p".into(), "2222".into()],
1978        };
1979
1980        let upload = scp_upload(&ssh, Path::new("/tmp/local"), "remote/path", true);
1981        let download = scp_download(&ssh, "remote/archive.zip", "/tmp/local.zip");
1982        set_ssh_connection_sharing_for_test(None);
1983
1984        assert_eq!(
1985            upload.args,
1986            [
1987                "-P",
1988                "2222",
1989                "-r",
1990                "/tmp/local",
1991                "build@10.0.0.1:remote/path"
1992            ]
1993        );
1994        assert_eq!(
1995            download.args,
1996            [
1997                "-P",
1998                "2222",
1999                "build@10.0.0.1:remote/archive.zip",
2000                "/tmp/local.zip"
2001            ]
2002        );
2003        for command in [upload, download] {
2004            assert_eq!(command.program, "scp");
2005            assert_eq!(command.ssh_destination.as_deref(), Some("build@10.0.0.1"));
2006        }
2007    }
2008
2009    /// A hand-written stand-in for `ssh` that models masters: `-O check`
2010    /// succeeds only for a socket whose master it opened, and an opener
2011    /// starts one unless told to refuse. It records every command.
2012    #[cfg(unix)]
2013    #[derive(Default)]
2014    struct FakeMasters {
2015        running: std::cell::RefCell<BTreeSet<String>>,
2016        refuse_open: std::cell::Cell<Option<&'static str>>,
2017        socket_existed_at_open: std::cell::RefCell<Vec<bool>>,
2018        seen: std::cell::RefCell<Vec<CommandSpec>>,
2019    }
2020
2021    #[cfg(unix)]
2022    impl FakeMasters {
2023        fn socket(command: &CommandSpec) -> String {
2024            command
2025                .args
2026                .iter()
2027                .find_map(|arg| arg.strip_prefix("ControlPath="))
2028                .expect("every master command names its socket")
2029                .to_owned()
2030        }
2031
2032        fn kill(&self, socket: &Path) {
2033            self.running
2034                .borrow_mut()
2035                .remove(&socket.display().to_string());
2036        }
2037
2038        fn commands(&self) -> usize {
2039            self.seen.borrow().len()
2040        }
2041
2042        fn openers(&self) -> usize {
2043            self.seen
2044                .borrow()
2045                .iter()
2046                .filter(|command| command.args.contains(&"ControlMaster=yes".to_owned()))
2047                .count()
2048        }
2049    }
2050
2051    #[cfg(unix)]
2052    impl CommandExecutor for FakeMasters {
2053        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
2054            self.seen.borrow_mut().push(command.clone());
2055            assert_eq!(command.program, "ssh");
2056            let socket = Self::socket(command);
2057            let (status, stderr) = if command.args.windows(2).any(|pair| pair == ["-O", "check"]) {
2058                if self.running.borrow().contains(&socket) {
2059                    (0, "")
2060                } else {
2061                    (255, "Control socket connect: No such file or directory")
2062                }
2063            } else if command.args.contains(&"ControlMaster=yes".to_owned()) {
2064                self.socket_existed_at_open
2065                    .borrow_mut()
2066                    .push(Path::new(&socket).exists());
2067                match self.refuse_open.get() {
2068                    Some(stderr) => (255, stderr),
2069                    None => {
2070                        self.running.borrow_mut().insert(socket);
2071                        (0, "")
2072                    }
2073                }
2074            } else {
2075                panic!("the ledger ran an unexpected command: {command:?}");
2076            };
2077            Ok(CommandOutput {
2078                status,
2079                stdout: Vec::new(),
2080                stderr: stderr.as_bytes().to_vec(),
2081            })
2082        }
2083    }
2084
2085    #[cfg(unix)]
2086    fn shard_of(lease: &SshSessionLease) -> String {
2087        let path = lease.control_path().expect("a shared lease has a socket");
2088        let name = path.file_name().unwrap().to_string_lossy().into_owned();
2089        name.rsplit('-').next().unwrap().to_owned()
2090    }
2091
2092    #[cfg(unix)]
2093    fn plain_target(destination: &str) -> SshTarget {
2094        SshTarget {
2095            destination: destination.to_owned(),
2096            ssh_args: Vec::new(),
2097        }
2098    }
2099
2100    #[test]
2101    #[cfg(unix)]
2102    fn leases_fill_the_lowest_shard_and_open_another_at_the_cap() {
2103        let dir = sharing_socket_dir();
2104        let ledger = SessionLedger::new(2);
2105        let ssh = plain_target("host");
2106        let masters = FakeMasters::default();
2107
2108        let first = ledger.lease(&ssh, dir.path(), &masters).expect("first");
2109        // Check, open, check again.
2110        assert_eq!(masters.commands(), 3);
2111        let second = ledger.lease(&ssh, dir.path(), &masters).expect("second");
2112        assert_eq!(
2113            masters.commands(),
2114            3,
2115            "a master verified moments ago is not checked again"
2116        );
2117        let third = ledger.lease(&ssh, dir.path(), &masters).expect("third");
2118        assert_eq!(
2119            [&first, &second, &third].map(shard_of),
2120            ["0", "0", "1"].map(str::to_owned)
2121        );
2122        assert_eq!(masters.openers(), 2, "one master per shard");
2123        assert_eq!(
2124            third.control_path().unwrap(),
2125            dir.path().join(control_socket_name(&ssh, 1))
2126        );
2127
2128        drop(first);
2129        let fourth = ledger.lease(&ssh, dir.path(), &masters).expect("fourth");
2130        assert_eq!(shard_of(&fourth), "0", "a freed slot is reused first");
2131        assert_eq!(masters.openers(), 2);
2132    }
2133
2134    #[test]
2135    #[cfg(unix)]
2136    fn separate_connections_are_counted_separately() {
2137        let dir = sharing_socket_dir();
2138        let ledger = SessionLedger::new(1);
2139        let masters = FakeMasters::default();
2140        let first = ledger
2141            .lease(&plain_target("one"), dir.path(), &masters)
2142            .expect("one");
2143        let second = ledger
2144            .lease(&plain_target("two"), dir.path(), &masters)
2145            .expect("two");
2146        assert_eq!(
2147            [&first, &second].map(shard_of),
2148            ["0", "0"].map(str::to_owned)
2149        );
2150        assert_ne!(first.control_path(), second.control_path());
2151    }
2152
2153    #[test]
2154    #[cfg(unix)]
2155    fn an_invalidated_lease_makes_the_next_lease_reopen_a_dead_master() {
2156        let dir = sharing_socket_dir();
2157        let ledger = SessionLedger::new(8);
2158        let ssh = plain_target("host");
2159        let masters = FakeMasters::default();
2160        let first = ledger.lease(&ssh, dir.path(), &masters).expect("first");
2161        masters.kill(first.control_path().unwrap());
2162
2163        // Without a failure report the recent check is still trusted.
2164        drop(ledger.lease(&ssh, dir.path(), &masters).expect("trusted"));
2165        assert_eq!(masters.openers(), 1);
2166
2167        first.invalidate();
2168        let second = ledger.lease(&ssh, dir.path(), &masters).expect("reopened");
2169        assert_eq!(masters.openers(), 2);
2170        assert_eq!(first.control_path(), second.control_path());
2171    }
2172
2173    #[test]
2174    #[cfg(unix)]
2175    fn a_master_that_cannot_be_opened_is_an_error_naming_the_destination() {
2176        let dir = sharing_socket_dir();
2177        let ledger = SessionLedger::new(1);
2178        let ssh = plain_target("build@10.0.0.1");
2179        let masters = FakeMasters::default();
2180        masters
2181            .refuse_open
2182            .set(Some("Permission denied (publickey)."));
2183
2184        let error = ledger
2185            .lease(&ssh, dir.path(), &masters)
2186            .expect_err("no master means no session");
2187        let message = format!("{error:#}");
2188        assert!(message.contains("build@10.0.0.1"), "{message}");
2189        assert!(message.contains("Permission denied"), "{message}");
2190        assert_eq!(
2191            masters.openers(),
2192            1,
2193            "the opener is not retried by the ledger"
2194        );
2195
2196        // The failed lease gave its slot back: with a cap of one, the next
2197        // lease still lands on the first shard.
2198        masters.refuse_open.set(None);
2199        let lease = ledger.lease(&ssh, dir.path(), &masters).expect("opens");
2200        assert_eq!(shard_of(&lease), "0");
2201    }
2202
2203    #[test]
2204    #[cfg(unix)]
2205    fn a_stale_socket_is_removed_before_the_master_is_opened() {
2206        let dir = sharing_socket_dir();
2207        let ledger = SessionLedger::new(8);
2208        let ssh = plain_target("host");
2209        let socket = dir.path().join(control_socket_name(&ssh, 0));
2210        fs::write(&socket, b"").expect("stale socket stand-in");
2211        let masters = FakeMasters::default();
2212
2213        ledger.lease(&ssh, dir.path(), &masters).expect("opens");
2214
2215        assert_eq!(*masters.socket_existed_at_open.borrow(), [false]);
2216    }
2217
2218    #[test]
2219    #[cfg(unix)]
2220    fn the_opener_is_an_admitted_batch_master_and_the_check_is_local() {
2221        let ssh = SshTarget {
2222            destination: "host".to_owned(),
2223            ssh_args: vec!["-J".to_owned(), "jump".to_owned()],
2224        };
2225        let socket = Path::new("/run/mj/abc-0");
2226        let open = master_open_command(&ssh, socket);
2227        assert_eq!(
2228            open.args,
2229            [
2230                "-J",
2231                "jump",
2232                "-o",
2233                "BatchMode=yes",
2234                "-f",
2235                "-N",
2236                "-o",
2237                "ControlMaster=yes",
2238                "-o",
2239                "ControlPath=/run/mj/abc-0",
2240                "-o",
2241                &format!("ControlPersist={CONTROL_PERSIST}"),
2242                "host",
2243            ]
2244        );
2245        assert_eq!(open.ssh_destination.as_deref(), Some("host"));
2246
2247        let check = master_check_command(&ssh, socket);
2248        assert_eq!(
2249            check.args,
2250            [
2251                "-J",
2252                "jump",
2253                "-o",
2254                "ControlPath=/run/mj/abc-0",
2255                "-O",
2256                "check",
2257                "host"
2258            ]
2259        );
2260        assert_eq!(
2261            check.ssh_destination, None,
2262            "a check opens no connection and takes no admission permit"
2263        );
2264    }
2265
2266    #[test]
2267    #[cfg(unix)]
2268    fn session_args_forbid_a_direct_connection() {
2269        let dir = sharing_socket_dir();
2270        let ledger = SessionLedger::new(8);
2271        let masters = FakeMasters::default();
2272        let lease = ledger
2273            .lease(&plain_target("host"), dir.path(), &masters)
2274            .expect("lease");
2275        let mut args = Vec::new();
2276        push_session_args(&mut args, &lease);
2277        assert_eq!(
2278            args,
2279            [
2280                "-o".to_owned(),
2281                "ControlMaster=no".to_owned(),
2282                "-o".to_owned(),
2283                format!("ControlPath={}", lease.control_path().unwrap().display()),
2284                "-o".to_owned(),
2285                "ProxyCommand=false".to_owned(),
2286            ]
2287        );
2288    }
2289
2290    /// With sharing switched off, or configured by the user, a lease binds
2291    /// nothing and runs nothing.
2292    #[test]
2293    #[cfg(unix)]
2294    fn unshared_connections_lease_without_a_socket() {
2295        let _guard = SHARING_TEST_LOCK
2296            .lock()
2297            .unwrap_or_else(std::sync::PoisonError::into_inner);
2298        let masters = FakeMasters::default();
2299        let dir = sharing_socket_dir();
2300        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Directory(
2301            dir.path().to_path_buf(),
2302        )));
2303        let user_owned = SshSessions::lease(
2304            &SshTarget {
2305                destination: "unshared-user-host".to_owned(),
2306                ssh_args: vec!["-S".to_owned(), "/tmp/mine".to_owned()],
2307            },
2308            &masters,
2309        );
2310        set_ssh_connection_sharing_for_test(Some(SshSharingForTest::Disabled));
2311        let disabled = SshSessions::lease(&plain_target("unshared-disabled-host"), &masters);
2312        set_ssh_connection_sharing_for_test(None);
2313
2314        for lease in [user_owned, disabled] {
2315            let lease = lease.expect("an unshared lease never fails");
2316            assert_eq!(lease.control_path(), None);
2317            let mut args = Vec::new();
2318            push_session_args(&mut args, &lease);
2319            assert!(args.is_empty());
2320        }
2321        assert_eq!(masters.commands(), 0);
2322    }
2323
2324    #[test]
2325    fn transport_rejection_matches_only_sshd_hangups() {
2326        let cases: [(i32, &str, bool); 7] = [
2327            (255, "Connection closed by 192.168.1.77 port 22", true),
2328            (
2329                255,
2330                "kex_exchange_identification: read: Connection reset by peer",
2331                true,
2332            ),
2333            (255, "ssh: Connection reset by 10.0.0.1 port 22", true),
2334            (255, "Connection timed out during banner exchange", true),
2335            (255, "Permission denied (publickey).", false),
2336            (
2337                255,
2338                "ssh: connect to host h port 22: Connection refused",
2339                false,
2340            ),
2341            (1, "Connection closed by 192.168.1.77 port 22", false),
2342        ];
2343        for (status, stderr, expected) in cases {
2344            assert_eq!(
2345                is_transport_rejection(status, stderr),
2346                expected,
2347                "status {status} stderr {stderr:?}"
2348            );
2349        }
2350    }
2351
2352    #[test]
2353    fn admission_never_admits_more_than_the_limit() {
2354        let gate = DestinationGate::new(2);
2355        let in_flight = Arc::new(AtomicUsize::new(0));
2356        let peak = Arc::new(AtomicUsize::new(0));
2357        let threads: Vec<_> = (0..12)
2358            .map(|_| {
2359                let gate = Arc::clone(&gate);
2360                let in_flight = Arc::clone(&in_flight);
2361                let peak = Arc::clone(&peak);
2362                std::thread::spawn(move || {
2363                    for _ in 0..25 {
2364                        let permit = gate.acquire();
2365                        let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
2366                        peak.fetch_max(now, Ordering::SeqCst);
2367                        std::thread::yield_now();
2368                        in_flight.fetch_sub(1, Ordering::SeqCst);
2369                        drop(permit);
2370                    }
2371                })
2372            })
2373            .collect();
2374        for thread in threads {
2375            thread.join().expect("admission worker must not panic");
2376        }
2377        assert!(
2378            peak.load(Ordering::SeqCst) <= 2,
2379            "admission let {} connections run against a 2-permit gate",
2380            peak.load(Ordering::SeqCst)
2381        );
2382        assert_eq!(in_flight.load(Ordering::SeqCst), 0);
2383    }
2384
2385    #[test]
2386    fn admission_blocks_once_every_permit_is_held() {
2387        let gate = DestinationGate::new(2);
2388        let first = gate.acquire();
2389        let second = gate.acquire();
2390        let waiter = {
2391            let gate = Arc::clone(&gate);
2392            std::thread::spawn(move || {
2393                let permit = gate.acquire();
2394                drop(permit);
2395            })
2396        };
2397        // The third acquire has nothing to take until a permit comes back.
2398        std::thread::sleep(std::time::Duration::from_millis(50));
2399        assert!(!waiter.is_finished());
2400        drop(first);
2401        waiter
2402            .join()
2403            .expect("waiter must be admitted once a permit frees");
2404        drop(second);
2405    }
2406}