Skip to main content

mj_core/targets/
ssh.rs

1use super::*;
2
3/// The connectivity probe `mj doctor` runs against an SSH target.
4///
5/// It reuses the provisioning argument order so the probe fails exactly where
6/// a real session would, with two deliberate overrides prepended. OpenSSH
7/// honours the first occurrence of an option, so these win over the
8/// provisioning defaults: `BatchMode=yes` never prompts for a password, and
9/// `StrictHostKeyChecking=yes` never accepts an unknown host key. Doctor
10/// diagnoses; the user decides whether to trust a key.
11pub fn ssh_connectivity_probe(ssh: &SshTarget) -> CommandSpec {
12    let mut probe = ssh.clone();
13    probe.ssh_args.splice(
14        0..0,
15        [
16            "-o".to_owned(),
17            "BatchMode=yes".to_owned(),
18            "-o".to_owned(),
19            "StrictHostKeyChecking=yes".to_owned(),
20        ],
21    );
22    ssh_command(&probe, ["true"]).purpose("verify SSH connectivity")
23}
24
25pub fn ssh_command(
26    ssh: &SshTarget,
27    args: impl IntoIterator<Item = impl AsRef<str>>,
28) -> CommandSpec {
29    ssh_command_owned(
30        ssh,
31        args.into_iter()
32            .map(|arg| arg.as_ref().to_owned())
33            .collect(),
34    )
35}
36
37pub fn ssh_command_owned(ssh: &SshTarget, remote_args: Vec<String>) -> CommandSpec {
38    let mut args = ssh.ssh_args.clone();
39    args.push(ssh.destination.clone());
40    args.push(join_remote_command(&remote_args));
41    CommandSpec::new("ssh", args)
42}
43
44pub fn join_remote_command(args: &[String]) -> String {
45    args.iter()
46        .map(|arg| posix_quote(arg))
47        .collect::<Vec<_>>()
48        .join(" ")
49}
50
51/// Complete remote directory paths through the configured SSH target.
52///
53/// The SSH connection timeout and noninteractive mode keep a Tab press from
54/// blocking the wizard when a host is unavailable. The quoted prefix remains
55/// literal while the trailing glob is expanded only by the remote shell.
56pub fn ssh_directory_completions(
57    ssh: &SshTarget,
58    prefix: &str,
59    executor: &impl CommandExecutor,
60) -> Result<Vec<String>> {
61    if prefix.is_empty() {
62        return Ok(Vec::new());
63    }
64    let remote_command = format!("ls -d -- {}*/ 2>/dev/null", posix_quote(prefix));
65    let mut args = ssh.ssh_args.clone();
66    args.extend([
67        "-o".into(),
68        "BatchMode=yes".into(),
69        "-o".into(),
70        "ConnectTimeout=3".into(),
71        "-o".into(),
72        "ServerAliveInterval=2".into(),
73        "-o".into(),
74        "ServerAliveCountMax=1".into(),
75        ssh.destination.clone(),
76        remote_command,
77    ]);
78    let output = executor
79        .execute(&CommandSpec::new("ssh", args).purpose("complete remote mount directory"))?;
80    if output.status != 0 {
81        return Ok(Vec::new());
82    }
83    let mut matches = String::from_utf8_lossy(&output.stdout)
84        .lines()
85        .filter(|path| path.starts_with(prefix) && path.ends_with('/'))
86        .map(str::to_owned)
87        .collect::<Vec<_>>();
88    matches.sort();
89    matches.dedup();
90    Ok(matches)
91}
92
93/// Check whether a directory exists on the configured SSH host.
94pub fn ssh_directory_exists(
95    ssh: &SshTarget,
96    path: &Path,
97    executor: &impl CommandExecutor,
98) -> Result<bool> {
99    let command = ssh_validation_command(
100        ssh,
101        vec![
102            "test".into(),
103            "-d".into(),
104            path.to_string_lossy().into_owned(),
105        ],
106        "validate remote directory",
107    );
108    let output = executor.execute(&command)?;
109    match output.status {
110        0 => Ok(true),
111        1 => Ok(false),
112        status => bail!(
113            "remote directory check failed with status {status}: {}",
114            String::from_utf8_lossy(&output.stderr).trim()
115        ),
116    }
117}
118
119/// Verify that a bare-SSH project path exists and has a committed Git HEAD.
120pub fn validate_bare_project_directory(
121    ssh: &SshTarget,
122    path: &Path,
123    executor: &impl CommandExecutor,
124) -> Result<()> {
125    validate_bare_project_path(path)?;
126    if !ssh_directory_exists(ssh, path, executor)? {
127        bail!(
128            "remote project directory {} does not exist or is not a directory",
129            path.display()
130        );
131    }
132    let output = executor.execute(&ssh_validation_command(
133        ssh,
134        vec![
135            "git".into(),
136            "-C".into(),
137            path.to_string_lossy().into_owned(),
138            "rev-parse".into(),
139            "--verify".into(),
140            "HEAD".into(),
141        ],
142        "validate bare SSH Git project",
143    ))?;
144    if output.status != 0 {
145        let detail = String::from_utf8_lossy(&output.stderr);
146        let detail = detail.trim();
147        if detail.is_empty() {
148            bail!(
149                "remote project directory {} has no valid Git HEAD",
150                path.display()
151            );
152        }
153        bail!(
154            "remote project directory {} has no valid Git HEAD: {detail}",
155            path.display()
156        );
157    }
158    Ok(())
159}
160
161pub fn validate_bare_project_path(path: &Path) -> Result<()> {
162    if !path.is_absolute()
163        || path
164            .components()
165            .any(|part| part == std::path::Component::ParentDir)
166    {
167        bail!("bare project directory must be an absolute safe path");
168    }
169    Ok(())
170}
171
172pub fn ssh_validation_command(
173    ssh: &SshTarget,
174    remote_args: Vec<String>,
175    purpose: &'static str,
176) -> CommandSpec {
177    let mut args = ssh.ssh_args.clone();
178    args.extend([
179        "-o".into(),
180        "BatchMode=yes".into(),
181        "-o".into(),
182        "ConnectTimeout=3".into(),
183        "-o".into(),
184        "ServerAliveInterval=2".into(),
185        "-o".into(),
186        "ServerAliveCountMax=1".into(),
187        ssh.destination.clone(),
188        join_remote_command(&remote_args),
189    ]);
190    CommandSpec::new("ssh", args).purpose(purpose)
191}
192
193/// Wrap a value so a POSIX shell reads it as one literal argument. Used at the
194/// SSH boundary here and when Hel rebuilds an agent's terminal command line
195/// (`terminal::shell_line`).
196pub fn posix_quote(value: &str) -> String {
197    format!("'{}'", value.replace('\'', "'\\''"))
198}
199
200pub fn verify_locator(locator: &TargetLocator, session_id: &str) -> Result<()> {
201    let expected_name = resource_name(session_id)?;
202    match locator {
203        TargetLocator::LocalBare { worker_root } => {
204            let path = Path::new(worker_root);
205            if !path.is_absolute()
206                || path
207                    .components()
208                    .any(|part| part == std::path::Component::ParentDir)
209                || !path.ends_with(session_id)
210            {
211                bail!("refusing cleanup: invalid local bare worker root");
212            }
213        }
214        TargetLocator::LocalPodman { container_id, .. }
215        | TargetLocator::LocalDocker { container_id }
216        | TargetLocator::AppleContainer { container_id }
217        | TargetLocator::SshPodman { container_id, .. }
218        | TargetLocator::SshDocker { container_id, .. } => {
219            if container_id != &expected_name && !is_runtime_container_id(container_id) {
220                bail!(
221                    "refusing cleanup: container locator is neither the generated name nor an immutable runtime ID"
222                );
223            }
224        }
225        TargetLocator::AwsEc2 {
226            instance_id,
227            workspace,
228            ..
229        } => {
230            if !valid_ec2_instance_id(instance_id) {
231                bail!("refusing cleanup: invalid EC2 instance ID");
232            }
233            verify_session_workspace(workspace, session_id)?;
234        }
235        TargetLocator::SshBare {
236            workspace,
237            worker_id,
238            ..
239        } => match worker_id {
240            Some(worker_id) => {
241                validate_session_id(worker_id)?;
242                if worker_id != session_id {
243                    bail!("refusing cleanup: SSH worker identity does not match session ID");
244                }
245                validate_workspace_prefix(workspace)?;
246            }
247            None => verify_session_workspace(workspace, session_id)?,
248        },
249    }
250    Ok(())
251}
252
253pub fn verify_session_workspace(workspace: &str, session_id: &str) -> Result<()> {
254    validate_workspace_prefix(workspace)?;
255    let final_component = workspace.trim_end_matches('/').rsplit('/').next();
256    if final_component != Some(session_id) {
257        bail!("refusing cleanup: workspace does not end in the exact session ID");
258    }
259    Ok(())
260}
261
262pub fn validate_session_id(value: &str) -> Result<()> {
263    if value.len() < 8
264        || value.len() > 128
265        || !value
266            .chars()
267            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
268    {
269        bail!("session ID must be 8-128 ASCII letters, digits, '-' or '_'");
270    }
271    Ok(())
272}
273
274pub fn validate_relative_path(value: &str) -> Result<()> {
275    let path = std::path::Path::new(value);
276    if value.is_empty()
277        || path.is_absolute()
278        || path
279            .components()
280            .any(|part| !matches!(part, std::path::Component::Normal(_)))
281    {
282        bail!("unsafe relative bundle path {value:?}");
283    }
284    Ok(())
285}
286
287pub fn validate_workspace_prefix(value: &str) -> Result<()> {
288    if value.is_empty()
289        || value == "/"
290        || value == "~"
291        || value == "~/"
292        || value.contains('\0')
293        || value.split('/').any(|part| part == "..")
294    {
295        bail!("unsafe workspace path");
296    }
297    Ok(())
298}
299
300pub fn validate_container_template(template: &ContainerTemplate) -> Result<()> {
301    if template.image.trim().is_empty() || template.image.starts_with('-') {
302        bail!("invalid container image");
303    }
304    if template
305        .extra_run_args
306        .iter()
307        .any(|arg| arg == "--name" || arg.starts_with("--name="))
308    {
309        bail!("container template may not override the generated name");
310    }
311    if template.extra_run_args.iter().any(|arg| {
312        arg == "--label"
313            || [SESSION_LABEL, MANAGED_LABEL]
314                .iter()
315                .any(|label| arg.starts_with(&format!("--label={label}=")))
316    }) {
317        bail!("container template may not override Mjolnir ownership labels");
318    }
319    Ok(())
320}
321
322pub fn validate_ssh(ssh: &SshTarget) -> Result<()> {
323    if ssh.destination.trim().is_empty()
324        || ssh.destination.starts_with('-')
325        || ssh.destination.chars().any(char::is_whitespace)
326    {
327        bail!("invalid SSH destination");
328    }
329    Ok(())
330}
331
332pub fn validate_aws(aws: &AwsTemplate) -> Result<()> {
333    validate_ssh(&aws.ssh)?;
334    for (name, value) in [
335        ("AWS profile", &aws.profile),
336        ("AWS region", &aws.region),
337        ("launch template", &aws.launch_template),
338    ] {
339        if value.is_empty()
340            || value.starts_with('-')
341            || !value
342                .chars()
343                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
344        {
345            bail!("invalid {name}");
346        }
347    }
348    Ok(())
349}
350
351pub fn validate_executable(value: &str) -> Result<()> {
352    if value.is_empty() || value.starts_with('-') || value.chars().any(char::is_whitespace) {
353        bail!("invalid executable name");
354    }
355    Ok(())
356}
357
358pub fn valid_ec2_instance_id(value: &str) -> bool {
359    value
360        .strip_prefix("i-")
361        .is_some_and(|rest| rest.len() >= 8 && rest.chars().all(|c| c.is_ascii_hexdigit()))
362}
363
364pub fn is_runtime_container_id(value: &str) -> bool {
365    value.len() >= 12 && value.len() <= 128 && value.chars().all(|c| c.is_ascii_hexdigit())
366}