1use super::*;
2
3pub 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
51pub 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
93pub 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
119pub 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
193pub 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}